web-comments 0.1.0 → 1.0.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 +77 -2
- package/dist/chunks/{chunk-GHNSLFY2.js → chunk-644PTNSO.js} +32 -3
- package/dist/chunks/{chunk-6VWNCJ3A.js → chunk-7K7K7M5O.js} +32 -3
- package/dist/chunks/chunk-RRSB326L.js +720 -0
- package/dist/chunks/{chunk-LBIURYPH.js → chunk-T4WN3STA.js} +55 -8
- package/dist/chunks/{chunk-PLAC65HG.js → chunk-WY5E2C47.js} +31 -3
- package/dist/connectors/linear.js +318 -0
- package/dist/connectors/mcp.js +890 -0
- package/dist/index.js +28 -20
- package/dist/server.js +6 -496
- package/dist/store/d1.js +4 -4
- package/dist/store/drizzle/mysql.js +7 -3
- package/dist/store/drizzle/pg.js +7 -3
- package/dist/store/drizzle/sqlite.js +7 -3
- package/dist/store/drizzle.js +4 -4
- package/dist/store/memory.js +34 -0
- package/dist/types/connectors/linear/index.d.ts +46 -0
- package/dist/types/connectors/mcp/index.d.ts +21 -0
- package/dist/types/connectors/mcp/oauth.d.ts +50 -0
- package/dist/types/connectors/mcp/pages.d.ts +14 -0
- package/dist/types/server/connector.d.ts +102 -0
- package/dist/types/server/events.d.ts +31 -0
- package/dist/types/server/index.d.ts +16 -37
- package/dist/types/server/service.d.ts +53 -0
- package/dist/types/server/store.d.ts +28 -2
- package/dist/types/shared/types.d.ts +9 -0
- package/dist/types/store/drizzle/mysql.d.ts +194 -0
- package/dist/types/store/drizzle/pg.d.ts +183 -0
- package/dist/types/store/drizzle/sqlite.d.ts +194 -0
- package/dist/types/widget/styles.d.ts +1 -1
- package/dist/widget.global.js +28 -20
- package/package.json +21 -9
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
|
|
4
|
+
// src/server/auth.ts
|
|
5
|
+
async function identify(plugins, request, env) {
|
|
6
|
+
for (const plugin of plugins) {
|
|
7
|
+
const who = await plugin.authenticate(request, env);
|
|
8
|
+
if (who) return who;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
function loginUrlFor(plugins, request) {
|
|
13
|
+
for (const plugin of plugins) {
|
|
14
|
+
const url = plugin.loginUrl?.(request);
|
|
15
|
+
if (url) return url;
|
|
16
|
+
}
|
|
17
|
+
return void 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/server/validate.ts
|
|
21
|
+
var InvalidInput = class extends Error {
|
|
22
|
+
};
|
|
23
|
+
var MAX_BODY = 1e4;
|
|
24
|
+
var MAX_PAGE = 512;
|
|
25
|
+
var MAX_URL = 4096;
|
|
26
|
+
var MAX_SELECTOR = 2048;
|
|
27
|
+
var MAX_TEXT = 200;
|
|
28
|
+
function record(value, what) {
|
|
29
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
30
|
+
throw new InvalidInput(`${what} must be an object`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function string(value, what, max) {
|
|
35
|
+
if (typeof value !== "string") throw new InvalidInput(`${what} must be a string`);
|
|
36
|
+
if (value.length > max) throw new InvalidInput(`${what} is longer than ${max} characters`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function fraction(value, what) {
|
|
40
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
41
|
+
throw new InvalidInput(`${what} must be a number from 0 to 1`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function commentBody(value) {
|
|
46
|
+
const body = string(value, "body", MAX_BODY).trim();
|
|
47
|
+
if (!body) throw new InvalidInput("body is empty");
|
|
48
|
+
return body;
|
|
49
|
+
}
|
|
50
|
+
function reply(value) {
|
|
51
|
+
return commentBody(record(value, "request body").body);
|
|
52
|
+
}
|
|
53
|
+
function anchor(value) {
|
|
54
|
+
const input = record(value, "anchor");
|
|
55
|
+
const selector = string(input.selector, "anchor.selector", MAX_SELECTOR);
|
|
56
|
+
if (!selector) throw new InvalidInput("anchor.selector is empty");
|
|
57
|
+
const viewportWidth = input.viewportWidth;
|
|
58
|
+
if (typeof viewportWidth !== "number" || !Number.isFinite(viewportWidth) || viewportWidth < 0) {
|
|
59
|
+
throw new InvalidInput("anchor.viewportWidth must be a positive number");
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
selector,
|
|
63
|
+
tag: string(input.tag, "anchor.tag", 64).toLowerCase(),
|
|
64
|
+
x: fraction(input.x, "anchor.x"),
|
|
65
|
+
y: fraction(input.y, "anchor.y"),
|
|
66
|
+
viewportWidth: Math.round(viewportWidth),
|
|
67
|
+
...input.text === void 0 ? {} : { text: string(input.text, "anchor.text", MAX_TEXT) }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function finite(value, what) {
|
|
71
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
72
|
+
throw new InvalidInput(`${what} must be a number`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function size(value, what) {
|
|
77
|
+
const input = record(value, what);
|
|
78
|
+
const width = finite(input.width, `${what}.width`);
|
|
79
|
+
const height = finite(input.height, `${what}.height`);
|
|
80
|
+
if (width <= 0 || height <= 0 || width > 32768 || height > 32768) {
|
|
81
|
+
throw new InvalidInput(`${what} must be between 1 and 32768 pixels each way`);
|
|
82
|
+
}
|
|
83
|
+
return { width: Math.round(width), height: Math.round(height) };
|
|
84
|
+
}
|
|
85
|
+
function context(value) {
|
|
86
|
+
const input = record(value, "context");
|
|
87
|
+
const element = record(input.element, "context.element");
|
|
88
|
+
const rect = record(element.rect, "context.element.rect");
|
|
89
|
+
const page = record(input.page, "context.page");
|
|
90
|
+
const viewport = size(page.viewport, "context.page.viewport");
|
|
91
|
+
const scroll = record(page.scroll, "context.page.scroll");
|
|
92
|
+
return {
|
|
93
|
+
element: {
|
|
94
|
+
html: string(element.html, "context.element.html", 4096),
|
|
95
|
+
rect: {
|
|
96
|
+
x: finite(rect.x, "context.element.rect.x"),
|
|
97
|
+
y: finite(rect.y, "context.element.rect.y"),
|
|
98
|
+
width: finite(rect.width, "context.element.rect.width"),
|
|
99
|
+
height: finite(rect.height, "context.element.rect.height")
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
page: {
|
|
103
|
+
title: string(page.title, "context.page.title", 512),
|
|
104
|
+
viewport,
|
|
105
|
+
scroll: {
|
|
106
|
+
x: finite(scroll.x, "context.page.scroll.x"),
|
|
107
|
+
y: finite(scroll.y, "context.page.scroll.y")
|
|
108
|
+
},
|
|
109
|
+
devicePixelRatio: finite(page.devicePixelRatio, "context.page.devicePixelRatio"),
|
|
110
|
+
userAgent: string(page.userAgent, "context.page.userAgent", 512)
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
var SCREENSHOT_KINDS = ["element", "page"];
|
|
115
|
+
function createThread(value) {
|
|
116
|
+
const input = record(value, "request body");
|
|
117
|
+
const page = string(input.page, "page", MAX_PAGE);
|
|
118
|
+
if (!page) throw new InvalidInput("page is empty");
|
|
119
|
+
const url = string(input.url, "url", MAX_URL);
|
|
120
|
+
if (!URL.canParse(url)) throw new InvalidInput("url is not an absolute URL");
|
|
121
|
+
const screenshots = {};
|
|
122
|
+
if (input.screenshots !== void 0) {
|
|
123
|
+
const given = record(input.screenshots, "screenshots");
|
|
124
|
+
for (const kind of SCREENSHOT_KINDS) {
|
|
125
|
+
if (given[kind] !== void 0) screenshots[kind] = size(given[kind], `screenshots.${kind}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
page,
|
|
130
|
+
url,
|
|
131
|
+
anchor: anchor(input.anchor),
|
|
132
|
+
body: commentBody(input.body),
|
|
133
|
+
...input.context === void 0 ? {} : { context: context(input.context) },
|
|
134
|
+
screenshots
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function threadUpdate(value) {
|
|
138
|
+
const input = record(value, "request body");
|
|
139
|
+
const update = {};
|
|
140
|
+
if (input.status !== void 0) {
|
|
141
|
+
if (input.status !== "open" && input.status !== "resolved") {
|
|
142
|
+
throw new InvalidInput(`status must be "open" or "resolved"`);
|
|
143
|
+
}
|
|
144
|
+
update.status = input.status;
|
|
145
|
+
}
|
|
146
|
+
if (input.anchor !== void 0) update.anchor = anchor(input.anchor);
|
|
147
|
+
if (!update.status && !update.anchor)
|
|
148
|
+
throw new InvalidInput("nothing to change: send status or anchor");
|
|
149
|
+
return update;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/server/service.ts
|
|
153
|
+
var CommentsError = class extends Error {
|
|
154
|
+
constructor(status, code, message) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.status = status;
|
|
157
|
+
this.code = code;
|
|
158
|
+
}
|
|
159
|
+
status;
|
|
160
|
+
code;
|
|
161
|
+
};
|
|
162
|
+
var SCREENSHOT_TYPES = {
|
|
163
|
+
"image/png": "png",
|
|
164
|
+
"image/jpeg": "jpg",
|
|
165
|
+
"image/webp": "webp"
|
|
166
|
+
};
|
|
167
|
+
var ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
168
|
+
function newId() {
|
|
169
|
+
const bytes = crypto.getRandomValues(new Uint8Array(14));
|
|
170
|
+
return Array.from(bytes, (b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
|
|
171
|
+
}
|
|
172
|
+
var lastTime = 0;
|
|
173
|
+
function now() {
|
|
174
|
+
lastTime = Math.max(Date.now(), lastTime + 1);
|
|
175
|
+
return new Date(lastTime).toISOString();
|
|
176
|
+
}
|
|
177
|
+
function authorOf(who) {
|
|
178
|
+
return {
|
|
179
|
+
id: who.id,
|
|
180
|
+
name: who.name,
|
|
181
|
+
kind: who.kind,
|
|
182
|
+
...who.avatarUrl ? { avatarUrl: who.avatarUrl } : {}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
var notFound = (what) => new CommentsError(404, "not_found", `no such ${what}`);
|
|
186
|
+
function createService(options) {
|
|
187
|
+
const { basePath, maxScreenshotBytes } = options;
|
|
188
|
+
function present(thread, links = []) {
|
|
189
|
+
const { site: _, screenshots, ...rest } = thread;
|
|
190
|
+
const shown = {};
|
|
191
|
+
for (const [kind, shot] of Object.entries(screenshots)) {
|
|
192
|
+
shown[kind] = {
|
|
193
|
+
url: `${basePath}/assets/${shot.key.split("/").map(encodeURIComponent).join("/")}`,
|
|
194
|
+
contentType: shot.contentType,
|
|
195
|
+
width: shot.width,
|
|
196
|
+
height: shot.height
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
...rest,
|
|
201
|
+
screenshots: shown,
|
|
202
|
+
links: links.filter((link) => link.threadId === thread.id && link.commentId === null && link.url).map((link) => ({ connector: link.connector, url: link.url, title: link.title }))
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
async function presentAll(scope, threads) {
|
|
206
|
+
const links = threads.length > 0 && scope.store.listLinks ? await scope.store.listLinks(
|
|
207
|
+
scope.site,
|
|
208
|
+
threads.map((thread) => thread.id)
|
|
209
|
+
) : [];
|
|
210
|
+
return threads.map((thread) => present(thread, links));
|
|
211
|
+
}
|
|
212
|
+
async function presentOne(scope, thread) {
|
|
213
|
+
const links = scope.store.listLinks ? await scope.store.listLinks(scope.site, [thread.id]) : [];
|
|
214
|
+
return present(thread, links);
|
|
215
|
+
}
|
|
216
|
+
async function stored(scope, id) {
|
|
217
|
+
const thread = await scope.store.getThread(scope.site, id);
|
|
218
|
+
if (!thread) throw notFound("thread");
|
|
219
|
+
return thread;
|
|
220
|
+
}
|
|
221
|
+
async function list(scope, query = {}) {
|
|
222
|
+
const limit = Math.min(query.limit ?? (query.page === void 0 ? 200 : 500), 500);
|
|
223
|
+
const threads = await scope.store.listThreads(scope.site, {
|
|
224
|
+
...query.page === void 0 ? {} : { page: query.page },
|
|
225
|
+
limit: query.status ? 500 : limit
|
|
226
|
+
});
|
|
227
|
+
const kept = query.status ? threads.filter((t) => t.status === query.status) : threads;
|
|
228
|
+
return presentAll(scope, kept.slice(0, limit));
|
|
229
|
+
}
|
|
230
|
+
async function get(scope, id) {
|
|
231
|
+
const thread = await scope.store.getThread(scope.site, id);
|
|
232
|
+
return thread ? presentOne(scope, thread) : null;
|
|
233
|
+
}
|
|
234
|
+
async function saveScreenshots(scope, threadId, files, sizes) {
|
|
235
|
+
const saved = {};
|
|
236
|
+
const kinds = Object.keys(files);
|
|
237
|
+
if (kinds.length === 0) return saved;
|
|
238
|
+
if (!scope.assets) {
|
|
239
|
+
throw new CommentsError(501, "not_configured", "this server keeps no screenshots");
|
|
240
|
+
}
|
|
241
|
+
for (const kind of kinds) {
|
|
242
|
+
const file = files[kind];
|
|
243
|
+
const extension = SCREENSHOT_TYPES[file.type];
|
|
244
|
+
if (!extension) {
|
|
245
|
+
throw new CommentsError(
|
|
246
|
+
415,
|
|
247
|
+
"unsupported_media_type",
|
|
248
|
+
"screenshots must be PNG, JPEG or WebP"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (file.size > maxScreenshotBytes) {
|
|
252
|
+
throw new CommentsError(413, "too_large", "request body is too large");
|
|
253
|
+
}
|
|
254
|
+
const size2 = sizes[kind];
|
|
255
|
+
if (!size2) throw new InvalidInput(`screenshots.${kind} needs a width and height`);
|
|
256
|
+
const key = `${scope.site}/${threadId}/${kind}-${newId()}.${extension}`;
|
|
257
|
+
await scope.assets.put(key, await file.arrayBuffer(), file.type);
|
|
258
|
+
saved[kind] = { key, contentType: file.type, ...size2 };
|
|
259
|
+
}
|
|
260
|
+
return saved;
|
|
261
|
+
}
|
|
262
|
+
async function create(scope, actor, input, files = {}) {
|
|
263
|
+
const time = now();
|
|
264
|
+
const author = authorOf(actor);
|
|
265
|
+
const threadId = newId();
|
|
266
|
+
const screenshots = await saveScreenshots(scope, threadId, files, input.screenshots);
|
|
267
|
+
const thread = {
|
|
268
|
+
id: threadId,
|
|
269
|
+
site: scope.site,
|
|
270
|
+
page: input.page,
|
|
271
|
+
url: input.url,
|
|
272
|
+
anchor: input.anchor,
|
|
273
|
+
context: input.context ?? null,
|
|
274
|
+
screenshots,
|
|
275
|
+
status: "open",
|
|
276
|
+
author,
|
|
277
|
+
createdAt: time,
|
|
278
|
+
updatedAt: time,
|
|
279
|
+
resolvedAt: null,
|
|
280
|
+
resolvedBy: null,
|
|
281
|
+
comments: []
|
|
282
|
+
};
|
|
283
|
+
const first = { id: newId(), threadId, body: input.body, author, createdAt: time };
|
|
284
|
+
await scope.store.insertThread(thread, first);
|
|
285
|
+
const shown = present({ ...thread, comments: [first] });
|
|
286
|
+
scope.announce({ type: "thread.created", thread: shown, actor });
|
|
287
|
+
return shown;
|
|
288
|
+
}
|
|
289
|
+
async function reply2(scope, actor, threadId, body) {
|
|
290
|
+
const text = commentBody(body);
|
|
291
|
+
const thread = await stored(scope, threadId);
|
|
292
|
+
const time = now();
|
|
293
|
+
const comment = {
|
|
294
|
+
id: newId(),
|
|
295
|
+
threadId,
|
|
296
|
+
body: text,
|
|
297
|
+
author: authorOf(actor),
|
|
298
|
+
createdAt: time
|
|
299
|
+
};
|
|
300
|
+
await scope.store.insertComment(scope.site, comment);
|
|
301
|
+
await scope.store.updateThread(scope.site, threadId, { updatedAt: time });
|
|
302
|
+
const shown = await presentOne(scope, {
|
|
303
|
+
...thread,
|
|
304
|
+
updatedAt: time,
|
|
305
|
+
comments: [...thread.comments, comment]
|
|
306
|
+
});
|
|
307
|
+
scope.announce({ type: "comment.created", thread: shown, comment, actor });
|
|
308
|
+
return { thread: shown, comment };
|
|
309
|
+
}
|
|
310
|
+
async function update(scope, actor, threadId, change) {
|
|
311
|
+
const before = await stored(scope, threadId);
|
|
312
|
+
if (change.anchor && before.author.id !== actor.id) {
|
|
313
|
+
throw new CommentsError(403, "forbidden", "only the author can move a comment");
|
|
314
|
+
}
|
|
315
|
+
const time = now();
|
|
316
|
+
const events = [];
|
|
317
|
+
const changes = {};
|
|
318
|
+
if (change.anchor) {
|
|
319
|
+
changes.anchor = change.anchor;
|
|
320
|
+
events.push("thread.moved");
|
|
321
|
+
}
|
|
322
|
+
if (change.status && change.status !== before.status) {
|
|
323
|
+
const resolved = change.status === "resolved";
|
|
324
|
+
changes.status = change.status;
|
|
325
|
+
changes.resolvedAt = resolved ? time : null;
|
|
326
|
+
changes.resolvedBy = resolved ? authorOf(actor) : null;
|
|
327
|
+
events.push(resolved ? "thread.resolved" : "thread.reopened");
|
|
328
|
+
}
|
|
329
|
+
if (events.length === 0) return presentOne(scope, before);
|
|
330
|
+
changes.updatedAt = time;
|
|
331
|
+
await scope.store.updateThread(scope.site, before.id, changes);
|
|
332
|
+
const thread = await presentOne(scope, { ...before, ...changes });
|
|
333
|
+
for (const type of events) scope.announce({ type, thread, actor });
|
|
334
|
+
return thread;
|
|
335
|
+
}
|
|
336
|
+
async function deleteComment(scope, actor, commentId) {
|
|
337
|
+
const comment = await scope.store.getComment(scope.site, commentId);
|
|
338
|
+
if (!comment) throw notFound("comment");
|
|
339
|
+
if (comment.author.id !== actor.id) {
|
|
340
|
+
throw new CommentsError(403, "forbidden", "only the author can delete a comment");
|
|
341
|
+
}
|
|
342
|
+
const thread = await scope.store.getThread(scope.site, comment.threadId);
|
|
343
|
+
if (!thread) throw notFound("comment");
|
|
344
|
+
if (thread.comments[0]?.id === commentId) {
|
|
345
|
+
await scope.store.deleteThread(scope.site, thread.id);
|
|
346
|
+
const assets = scope.assets;
|
|
347
|
+
if (assets?.delete) {
|
|
348
|
+
const keys = Object.values(thread.screenshots).map((shot) => shot.key);
|
|
349
|
+
scope.waitUntil(Promise.all(keys.map((key) => assets.delete?.(key))));
|
|
350
|
+
}
|
|
351
|
+
scope.announce({ type: "thread.deleted", threadId: thread.id, actor });
|
|
352
|
+
return { deleted: "thread", threadId: thread.id };
|
|
353
|
+
}
|
|
354
|
+
await scope.store.deleteComment(scope.site, commentId);
|
|
355
|
+
scope.announce({ type: "comment.deleted", threadId: thread.id, commentId, actor });
|
|
356
|
+
return { deleted: "comment", threadId: thread.id };
|
|
357
|
+
}
|
|
358
|
+
function assetKey(scope, path) {
|
|
359
|
+
const prefix = `${basePath}/assets/`;
|
|
360
|
+
if (!path.startsWith(prefix)) return null;
|
|
361
|
+
const key = decodeURIComponent(path.slice(prefix.length));
|
|
362
|
+
if (!key.startsWith(`${scope.site}/`) || key.includes("..")) return null;
|
|
363
|
+
return key;
|
|
364
|
+
}
|
|
365
|
+
return { list, get, create, reply: reply2, update, deleteComment, assetKey };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// src/server/index.ts
|
|
369
|
+
var MAX_JSON_BYTES = 64 * 1024;
|
|
370
|
+
var SEEN_EVERY_MS = 10 * 60 * 1e3;
|
|
371
|
+
var RESERVED = /* @__PURE__ */ new Set(["me", "threads", "comments", "assets", "widget.js"]);
|
|
372
|
+
function createComments(options) {
|
|
373
|
+
const basePath = (options.basePath ?? "/__comments").replace(/\/+$/, "");
|
|
374
|
+
const allowedOrigins = new Set(options.allowedOrigins ?? []);
|
|
375
|
+
const linkParam = options.linkParam ?? "comment";
|
|
376
|
+
const connectors = options.connectors ?? [];
|
|
377
|
+
const service = createService({
|
|
378
|
+
basePath,
|
|
379
|
+
maxScreenshotBytes: options.maxScreenshotBytes ?? 5 * 1024 * 1024
|
|
380
|
+
});
|
|
381
|
+
const stores = /* @__PURE__ */ new WeakMap();
|
|
382
|
+
const lastSeen = /* @__PURE__ */ new Map();
|
|
383
|
+
const names = /* @__PURE__ */ new Set();
|
|
384
|
+
for (const connector of connectors) {
|
|
385
|
+
if (!/^[a-z][a-z0-9-]*$/.test(connector.name) || RESERVED.has(connector.name)) {
|
|
386
|
+
throw new Error(`web-comments: a connector can't be called "${connector.name}"`);
|
|
387
|
+
}
|
|
388
|
+
if (names.has(connector.name)) {
|
|
389
|
+
throw new Error(`web-comments: two connectors are called "${connector.name}"`);
|
|
390
|
+
}
|
|
391
|
+
names.add(connector.name);
|
|
392
|
+
}
|
|
393
|
+
const rootPaths = /* @__PURE__ */ new Map();
|
|
394
|
+
for (const connector of connectors) {
|
|
395
|
+
for (const path of connector.rootPaths?.(basePath) ?? []) rootPaths.set(path, connector);
|
|
396
|
+
}
|
|
397
|
+
async function storeFor(env) {
|
|
398
|
+
let entry = stores.get(env);
|
|
399
|
+
if (!entry) {
|
|
400
|
+
const store = options.store(env);
|
|
401
|
+
const ready = Promise.resolve(store.setup?.());
|
|
402
|
+
entry = { store, ready };
|
|
403
|
+
stores.set(env, entry);
|
|
404
|
+
ready.catch(() => stores.delete(env));
|
|
405
|
+
}
|
|
406
|
+
await entry.ready;
|
|
407
|
+
return entry.store;
|
|
408
|
+
}
|
|
409
|
+
function siteOf(request, env) {
|
|
410
|
+
return options.site?.(request, env) ?? "default";
|
|
411
|
+
}
|
|
412
|
+
function keepAlive(ctx, work) {
|
|
413
|
+
const logged = work.catch((error) => console.error("web-comments:", error));
|
|
414
|
+
try {
|
|
415
|
+
ctx?.waitUntil(logged);
|
|
416
|
+
} catch {
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function executionOf(c) {
|
|
420
|
+
try {
|
|
421
|
+
return c.executionCtx;
|
|
422
|
+
} catch {
|
|
423
|
+
return void 0;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
async function scopeFor(env, site, ctx, source) {
|
|
427
|
+
const store = await storeFor(env);
|
|
428
|
+
const scope = {
|
|
429
|
+
site,
|
|
430
|
+
store,
|
|
431
|
+
assets: options.assets?.(env) ?? null,
|
|
432
|
+
waitUntil: (work) => keepAlive(ctx, work),
|
|
433
|
+
announce: (event) => {
|
|
434
|
+
const announced = source ? { ...event, source } : event;
|
|
435
|
+
if (options.onEvent) keepAlive(ctx, Promise.resolve(options.onEvent(announced, env)));
|
|
436
|
+
for (const connector of connectors) {
|
|
437
|
+
if (!connector.onEvent || connector.name === source) continue;
|
|
438
|
+
const context2 = contextFor(connector, env, scope, ctx);
|
|
439
|
+
keepAlive(
|
|
440
|
+
ctx,
|
|
441
|
+
Promise.resolve().then(() => connector.onEvent?.(announced, context2))
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
return scope;
|
|
447
|
+
}
|
|
448
|
+
function actionsFor(scope, actor) {
|
|
449
|
+
return {
|
|
450
|
+
listThreads: (query) => service.list(scope, query),
|
|
451
|
+
getThread: (id) => service.get(scope, id),
|
|
452
|
+
reply: (threadId, body) => service.reply(scope, actor, threadId, body),
|
|
453
|
+
setStatus: (threadId, status) => service.update(scope, actor, threadId, { status }),
|
|
454
|
+
deleteComment: async (commentId) => {
|
|
455
|
+
await service.deleteComment(scope, actor, commentId);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function threadUrl(thread) {
|
|
460
|
+
const url = new URL(thread.url);
|
|
461
|
+
url.searchParams.set(linkParam, thread.id);
|
|
462
|
+
return url.href;
|
|
463
|
+
}
|
|
464
|
+
function contextFor(connector, env, scope, ctx) {
|
|
465
|
+
const { store, site } = scope;
|
|
466
|
+
const needs = (method) => {
|
|
467
|
+
const fn = store[method];
|
|
468
|
+
if (typeof fn !== "function") {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`web-comments: the ${connector.name} connector needs a store with ${method}()`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return fn.bind(store);
|
|
474
|
+
};
|
|
475
|
+
const key = (name) => `${connector.name}:${name}`;
|
|
476
|
+
let own;
|
|
477
|
+
return {
|
|
478
|
+
env,
|
|
479
|
+
site,
|
|
480
|
+
basePath,
|
|
481
|
+
as(actor) {
|
|
482
|
+
own ??= scopeFor(env, site, ctx, connector.name);
|
|
483
|
+
const scope2 = own;
|
|
484
|
+
const act = (pick) => async (...args) => pick(actionsFor(await scope2, actor))(...args);
|
|
485
|
+
return {
|
|
486
|
+
listThreads: act((a) => a.listThreads),
|
|
487
|
+
getThread: act((a) => a.getThread),
|
|
488
|
+
reply: act((a) => a.reply),
|
|
489
|
+
setStatus: act((a) => a.setStatus),
|
|
490
|
+
deleteComment: act((a) => a.deleteComment)
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
links: {
|
|
494
|
+
find: (externalId) => needs("findLink")(site, connector.name, externalId),
|
|
495
|
+
forThread: async (threadId) => (await needs("listLinks")(site, [threadId])).filter(
|
|
496
|
+
(link) => link.connector === connector.name
|
|
497
|
+
),
|
|
498
|
+
save: (link) => needs("saveLink")(site, {
|
|
499
|
+
connector: connector.name,
|
|
500
|
+
externalId: link.externalId,
|
|
501
|
+
threadId: link.threadId,
|
|
502
|
+
commentId: link.commentId ?? null,
|
|
503
|
+
url: link.url ?? null,
|
|
504
|
+
title: link.title ?? null,
|
|
505
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
506
|
+
})
|
|
507
|
+
},
|
|
508
|
+
storage: {
|
|
509
|
+
get: (name) => needs("getValue")(key(name)),
|
|
510
|
+
set: (name, value, { ttlSeconds } = {}) => needs("setValue")(
|
|
511
|
+
key(name),
|
|
512
|
+
value,
|
|
513
|
+
ttlSeconds === void 0 ? null : new Date(Date.now() + ttlSeconds * 1e3).toISOString()
|
|
514
|
+
),
|
|
515
|
+
delete: (name) => needs("deleteValue")(key(name))
|
|
516
|
+
},
|
|
517
|
+
async screenshot(thread, kind) {
|
|
518
|
+
const shot = thread.screenshots[kind];
|
|
519
|
+
const assetKey = shot && service.assetKey(scope, shot.url);
|
|
520
|
+
if (!assetKey || !scope.assets) return null;
|
|
521
|
+
const asset = await scope.assets.get(assetKey);
|
|
522
|
+
if (!asset) return null;
|
|
523
|
+
const body = asset.body instanceof ArrayBuffer ? asset.body : await new Response(asset.body).arrayBuffer();
|
|
524
|
+
return { body, contentType: asset.contentType };
|
|
525
|
+
},
|
|
526
|
+
threadUrl,
|
|
527
|
+
waitUntil: (work) => keepAlive(ctx, work)
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async function identifyAllowed(request, env) {
|
|
531
|
+
const who = await identify(options.auth, request, env);
|
|
532
|
+
if (!who) return null;
|
|
533
|
+
if (options.authorize && !await options.authorize(who, request, env)) return null;
|
|
534
|
+
return who;
|
|
535
|
+
}
|
|
536
|
+
function fail(status, code, message) {
|
|
537
|
+
return Response.json({ error: { code, message } }, { status });
|
|
538
|
+
}
|
|
539
|
+
const app = new Hono();
|
|
540
|
+
async function toConnector(connector, c) {
|
|
541
|
+
if (!connector.fetch) return fail(404, "not_found", "no such route");
|
|
542
|
+
const request = c.req.raw;
|
|
543
|
+
const ctx = executionOf(c);
|
|
544
|
+
const scope = await scopeFor(c.env, siteOf(request, c.env), ctx);
|
|
545
|
+
const context2 = {
|
|
546
|
+
...contextFor(connector, c.env, scope, ctx),
|
|
547
|
+
url: `${new URL(request.url).origin}${basePath}/${connector.name}`,
|
|
548
|
+
identify: (other) => identifyAllowed(other, c.env),
|
|
549
|
+
loginUrl: (other) => loginUrlFor(options.auth, other)
|
|
550
|
+
};
|
|
551
|
+
return connector.fetch(request, context2);
|
|
552
|
+
}
|
|
553
|
+
for (const connector of connectors) {
|
|
554
|
+
app.all(`${basePath}/${connector.name}`, (c) => toConnector(connector, c));
|
|
555
|
+
app.all(`${basePath}/${connector.name}/*`, (c) => toConnector(connector, c));
|
|
556
|
+
}
|
|
557
|
+
for (const [path, connector] of rootPaths) app.all(path, (c) => toConnector(connector, c));
|
|
558
|
+
const api = new Hono();
|
|
559
|
+
api.use("*", async (c, next) => {
|
|
560
|
+
const origin = c.req.header("origin");
|
|
561
|
+
const ownOrigin = new URL(c.req.url).origin;
|
|
562
|
+
const foreign = origin !== void 0 && origin !== ownOrigin;
|
|
563
|
+
if (foreign && !allowedOrigins.has(origin)) {
|
|
564
|
+
return fail(403, "origin_not_allowed", `requests from ${origin} are not allowed`);
|
|
565
|
+
}
|
|
566
|
+
if (foreign) {
|
|
567
|
+
c.header("access-control-allow-origin", origin);
|
|
568
|
+
c.header("access-control-allow-credentials", "true");
|
|
569
|
+
c.header("vary", "origin");
|
|
570
|
+
if (c.req.method === "OPTIONS") {
|
|
571
|
+
c.header("access-control-allow-methods", "GET, POST, PATCH, DELETE");
|
|
572
|
+
c.header("access-control-allow-headers", "authorization, content-type");
|
|
573
|
+
c.header("access-control-max-age", "86400");
|
|
574
|
+
return c.body(null, 204);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
await next();
|
|
578
|
+
});
|
|
579
|
+
if (options.widgetScript !== void 0) {
|
|
580
|
+
const script = options.widgetScript;
|
|
581
|
+
let etag;
|
|
582
|
+
api.get("/widget.js", async (c) => {
|
|
583
|
+
etag ??= crypto.subtle.digest("SHA-256", new TextEncoder().encode(script)).then(
|
|
584
|
+
(hash) => `"${Array.from(new Uint8Array(hash).slice(0, 12), (b) => b.toString(16).padStart(2, "0")).join("")}"`
|
|
585
|
+
);
|
|
586
|
+
const tag = await etag;
|
|
587
|
+
c.header("etag", tag);
|
|
588
|
+
c.header("cache-control", "no-cache");
|
|
589
|
+
if (c.req.header("if-none-match") === tag) return c.body(null, 304);
|
|
590
|
+
c.header("content-type", "text/javascript; charset=utf-8");
|
|
591
|
+
return c.body(script);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
api.use("*", async (c, next) => {
|
|
595
|
+
const who = await identify(options.auth, c.req.raw, c.env);
|
|
596
|
+
if (!who) {
|
|
597
|
+
const loginUrl = loginUrlFor(options.auth, c.req.raw);
|
|
598
|
+
return Response.json(
|
|
599
|
+
{ error: { code: "unauthenticated", message: "sign in to use comments" }, loginUrl },
|
|
600
|
+
{ status: 401 }
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
if (options.authorize && !await options.authorize(who, c.req.raw, c.env)) {
|
|
604
|
+
return fail(403, "forbidden", `${who.email ?? who.name} may not use comments here`);
|
|
605
|
+
}
|
|
606
|
+
const ctx = executionOf(c);
|
|
607
|
+
const scope = await scopeFor(c.env, siteOf(c.req.raw, c.env), ctx);
|
|
608
|
+
const seen = Date.now();
|
|
609
|
+
if (scope.store.saveUser && seen - (lastSeen.get(who.id) ?? 0) > SEEN_EVERY_MS) {
|
|
610
|
+
lastSeen.set(who.id, seen);
|
|
611
|
+
keepAlive(ctx, scope.store.saveUser(who, new Date(seen).toISOString()));
|
|
612
|
+
}
|
|
613
|
+
c.set("who", who);
|
|
614
|
+
c.set("scope", scope);
|
|
615
|
+
await next();
|
|
616
|
+
});
|
|
617
|
+
async function readJson(request) {
|
|
618
|
+
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
619
|
+
throw new CommentsError(415, "unsupported_media_type", "send the body as application/json");
|
|
620
|
+
}
|
|
621
|
+
const text = await request.text();
|
|
622
|
+
if (text.length > MAX_JSON_BYTES) throw tooLarge();
|
|
623
|
+
return parseJson(text);
|
|
624
|
+
}
|
|
625
|
+
api.onError((error) => {
|
|
626
|
+
if (error instanceof CommentsError) return fail(error.status, error.code, error.message);
|
|
627
|
+
if (error instanceof InvalidInput) return fail(400, "invalid_input", error.message);
|
|
628
|
+
console.error("web-comments:", error);
|
|
629
|
+
return fail(500, "internal", "something went wrong");
|
|
630
|
+
});
|
|
631
|
+
api.get("/me", (c) => c.json({ me: c.var.who }));
|
|
632
|
+
api.get("/threads", async (c) => {
|
|
633
|
+
const page = c.req.query("page");
|
|
634
|
+
const threads = await service.list(c.var.scope, page === void 0 ? {} : { page });
|
|
635
|
+
return c.json({ threads });
|
|
636
|
+
});
|
|
637
|
+
api.get("/threads/:id", async (c) => {
|
|
638
|
+
const thread = await service.get(c.var.scope, c.req.param("id"));
|
|
639
|
+
return thread ? c.json({ thread }) : fail(404, "not_found", "no such thread");
|
|
640
|
+
});
|
|
641
|
+
api.post("/threads", async (c) => {
|
|
642
|
+
const type = c.req.header("content-type")?.toLowerCase() ?? "";
|
|
643
|
+
let input;
|
|
644
|
+
const files = {};
|
|
645
|
+
if (type.startsWith("multipart/form-data")) {
|
|
646
|
+
const length = Number(c.req.header("content-length") ?? 0);
|
|
647
|
+
const maxScreenshotBytes = options.maxScreenshotBytes ?? 5 * 1024 * 1024;
|
|
648
|
+
if (length > MAX_JSON_BYTES + 2 * maxScreenshotBytes) throw tooLarge();
|
|
649
|
+
const form = await c.req.raw.formData();
|
|
650
|
+
const thread2 = form.get("thread");
|
|
651
|
+
if (typeof thread2 !== "string") throw new InvalidInput('missing the "thread" field');
|
|
652
|
+
if (thread2.length > MAX_JSON_BYTES) throw tooLarge();
|
|
653
|
+
input = createThread(parseJson(thread2));
|
|
654
|
+
for (const kind of SCREENSHOT_KINDS) {
|
|
655
|
+
const file = form.get(kind);
|
|
656
|
+
if (file instanceof File) files[kind] = file;
|
|
657
|
+
}
|
|
658
|
+
} else {
|
|
659
|
+
input = createThread(await readJson(c.req.raw));
|
|
660
|
+
}
|
|
661
|
+
const thread = await service.create(c.var.scope, c.var.who, input, files);
|
|
662
|
+
return c.json({ thread }, 201);
|
|
663
|
+
});
|
|
664
|
+
api.get("/assets/*", async (c) => {
|
|
665
|
+
const { assets } = c.var.scope;
|
|
666
|
+
const key = service.assetKey(c.var.scope, c.req.path);
|
|
667
|
+
const asset = key && assets ? await assets.get(key) : null;
|
|
668
|
+
if (!asset) return fail(404, "not_found", "no such screenshot");
|
|
669
|
+
return new Response(asset.body, {
|
|
670
|
+
headers: {
|
|
671
|
+
"content-type": asset.contentType,
|
|
672
|
+
// Keys never change content; the API checks who's asking before serving.
|
|
673
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
api.post("/threads/:id/comments", async (c) => {
|
|
678
|
+
const body = reply(await readJson(c.req.raw));
|
|
679
|
+
const result = await service.reply(c.var.scope, c.var.who, c.req.param("id"), body);
|
|
680
|
+
return c.json(result, 201);
|
|
681
|
+
});
|
|
682
|
+
api.patch("/threads/:id", async (c) => {
|
|
683
|
+
const update = threadUpdate(await readJson(c.req.raw));
|
|
684
|
+
const thread = await service.update(c.var.scope, c.var.who, c.req.param("id"), update);
|
|
685
|
+
return c.json({ thread });
|
|
686
|
+
});
|
|
687
|
+
api.delete("/comments/:id", async (c) => {
|
|
688
|
+
const result = await service.deleteComment(c.var.scope, c.var.who, c.req.param("id"));
|
|
689
|
+
return c.json(result);
|
|
690
|
+
});
|
|
691
|
+
app.route(basePath, api);
|
|
692
|
+
return {
|
|
693
|
+
app,
|
|
694
|
+
/** Answers requests under basePath (and connectors' root paths); anything else gets a 404. */
|
|
695
|
+
fetch(request, env, ctx) {
|
|
696
|
+
return Promise.resolve(app.fetch(request, env, ctx));
|
|
697
|
+
},
|
|
698
|
+
/** Whether a request belongs here, to route it from a larger worker. */
|
|
699
|
+
matches(request) {
|
|
700
|
+
const { pathname } = new URL(request.url);
|
|
701
|
+
return pathname === basePath || pathname.startsWith(`${basePath}/`) || rootPaths.has(pathname);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function parseJson(text) {
|
|
706
|
+
try {
|
|
707
|
+
return JSON.parse(text);
|
|
708
|
+
} catch {
|
|
709
|
+
throw new InvalidInput("request body is not valid JSON");
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function tooLarge() {
|
|
713
|
+
return new CommentsError(413, "too_large", "request body is too large");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export {
|
|
717
|
+
CommentsError,
|
|
718
|
+
newId,
|
|
719
|
+
createComments
|
|
720
|
+
};
|