web-comments 0.1.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +895 -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
package/dist/server.js
CHANGED
|
@@ -1,501 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CommentsError,
|
|
3
|
+
createComments,
|
|
4
|
+
newId
|
|
5
|
+
} from "./chunks/chunk-RRSB326L.js";
|
|
1
6
|
import "./chunks/chunk-WSFCRVEQ.js";
|
|
2
|
-
|
|
3
|
-
// src/server/index.ts
|
|
4
|
-
import { Hono } from "hono";
|
|
5
|
-
|
|
6
|
-
// src/server/auth.ts
|
|
7
|
-
async function identify(plugins, request, env) {
|
|
8
|
-
for (const plugin of plugins) {
|
|
9
|
-
const who = await plugin.authenticate(request, env);
|
|
10
|
-
if (who) return who;
|
|
11
|
-
}
|
|
12
|
-
return null;
|
|
13
|
-
}
|
|
14
|
-
function loginUrlFor(plugins, request) {
|
|
15
|
-
for (const plugin of plugins) {
|
|
16
|
-
const url = plugin.loginUrl?.(request);
|
|
17
|
-
if (url) return url;
|
|
18
|
-
}
|
|
19
|
-
return void 0;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// src/server/validate.ts
|
|
23
|
-
var InvalidInput = class extends Error {
|
|
24
|
-
};
|
|
25
|
-
var MAX_BODY = 1e4;
|
|
26
|
-
var MAX_PAGE = 512;
|
|
27
|
-
var MAX_URL = 4096;
|
|
28
|
-
var MAX_SELECTOR = 2048;
|
|
29
|
-
var MAX_TEXT = 200;
|
|
30
|
-
function record(value, what) {
|
|
31
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
32
|
-
throw new InvalidInput(`${what} must be an object`);
|
|
33
|
-
}
|
|
34
|
-
return value;
|
|
35
|
-
}
|
|
36
|
-
function string(value, what, max) {
|
|
37
|
-
if (typeof value !== "string") throw new InvalidInput(`${what} must be a string`);
|
|
38
|
-
if (value.length > max) throw new InvalidInput(`${what} is longer than ${max} characters`);
|
|
39
|
-
return value;
|
|
40
|
-
}
|
|
41
|
-
function fraction(value, what) {
|
|
42
|
-
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
43
|
-
throw new InvalidInput(`${what} must be a number from 0 to 1`);
|
|
44
|
-
}
|
|
45
|
-
return value;
|
|
46
|
-
}
|
|
47
|
-
function commentBody(value) {
|
|
48
|
-
const body = string(value, "body", MAX_BODY).trim();
|
|
49
|
-
if (!body) throw new InvalidInput("body is empty");
|
|
50
|
-
return body;
|
|
51
|
-
}
|
|
52
|
-
function reply(value) {
|
|
53
|
-
return commentBody(record(value, "request body").body);
|
|
54
|
-
}
|
|
55
|
-
function anchor(value) {
|
|
56
|
-
const input = record(value, "anchor");
|
|
57
|
-
const selector = string(input.selector, "anchor.selector", MAX_SELECTOR);
|
|
58
|
-
if (!selector) throw new InvalidInput("anchor.selector is empty");
|
|
59
|
-
const viewportWidth = input.viewportWidth;
|
|
60
|
-
if (typeof viewportWidth !== "number" || !Number.isFinite(viewportWidth) || viewportWidth < 0) {
|
|
61
|
-
throw new InvalidInput("anchor.viewportWidth must be a positive number");
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
selector,
|
|
65
|
-
tag: string(input.tag, "anchor.tag", 64).toLowerCase(),
|
|
66
|
-
x: fraction(input.x, "anchor.x"),
|
|
67
|
-
y: fraction(input.y, "anchor.y"),
|
|
68
|
-
viewportWidth: Math.round(viewportWidth),
|
|
69
|
-
...input.text === void 0 ? {} : { text: string(input.text, "anchor.text", MAX_TEXT) }
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
function finite(value, what) {
|
|
73
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
74
|
-
throw new InvalidInput(`${what} must be a number`);
|
|
75
|
-
}
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
function size(value, what) {
|
|
79
|
-
const input = record(value, what);
|
|
80
|
-
const width = finite(input.width, `${what}.width`);
|
|
81
|
-
const height = finite(input.height, `${what}.height`);
|
|
82
|
-
if (width <= 0 || height <= 0 || width > 32768 || height > 32768) {
|
|
83
|
-
throw new InvalidInput(`${what} must be between 1 and 32768 pixels each way`);
|
|
84
|
-
}
|
|
85
|
-
return { width: Math.round(width), height: Math.round(height) };
|
|
86
|
-
}
|
|
87
|
-
function context(value) {
|
|
88
|
-
const input = record(value, "context");
|
|
89
|
-
const element = record(input.element, "context.element");
|
|
90
|
-
const rect = record(element.rect, "context.element.rect");
|
|
91
|
-
const page = record(input.page, "context.page");
|
|
92
|
-
const viewport = size(page.viewport, "context.page.viewport");
|
|
93
|
-
const scroll = record(page.scroll, "context.page.scroll");
|
|
94
|
-
return {
|
|
95
|
-
element: {
|
|
96
|
-
html: string(element.html, "context.element.html", 4096),
|
|
97
|
-
rect: {
|
|
98
|
-
x: finite(rect.x, "context.element.rect.x"),
|
|
99
|
-
y: finite(rect.y, "context.element.rect.y"),
|
|
100
|
-
width: finite(rect.width, "context.element.rect.width"),
|
|
101
|
-
height: finite(rect.height, "context.element.rect.height")
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
page: {
|
|
105
|
-
title: string(page.title, "context.page.title", 512),
|
|
106
|
-
viewport,
|
|
107
|
-
scroll: {
|
|
108
|
-
x: finite(scroll.x, "context.page.scroll.x"),
|
|
109
|
-
y: finite(scroll.y, "context.page.scroll.y")
|
|
110
|
-
},
|
|
111
|
-
devicePixelRatio: finite(page.devicePixelRatio, "context.page.devicePixelRatio"),
|
|
112
|
-
userAgent: string(page.userAgent, "context.page.userAgent", 512)
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
var SCREENSHOT_KINDS = ["element", "page"];
|
|
117
|
-
function createThread(value) {
|
|
118
|
-
const input = record(value, "request body");
|
|
119
|
-
const page = string(input.page, "page", MAX_PAGE);
|
|
120
|
-
if (!page) throw new InvalidInput("page is empty");
|
|
121
|
-
const url = string(input.url, "url", MAX_URL);
|
|
122
|
-
if (!URL.canParse(url)) throw new InvalidInput("url is not an absolute URL");
|
|
123
|
-
const screenshots = {};
|
|
124
|
-
if (input.screenshots !== void 0) {
|
|
125
|
-
const given = record(input.screenshots, "screenshots");
|
|
126
|
-
for (const kind of SCREENSHOT_KINDS) {
|
|
127
|
-
if (given[kind] !== void 0) screenshots[kind] = size(given[kind], `screenshots.${kind}`);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
page,
|
|
132
|
-
url,
|
|
133
|
-
anchor: anchor(input.anchor),
|
|
134
|
-
body: commentBody(input.body),
|
|
135
|
-
...input.context === void 0 ? {} : { context: context(input.context) },
|
|
136
|
-
screenshots
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
function threadUpdate(value) {
|
|
140
|
-
const input = record(value, "request body");
|
|
141
|
-
const update = {};
|
|
142
|
-
if (input.status !== void 0) {
|
|
143
|
-
if (input.status !== "open" && input.status !== "resolved") {
|
|
144
|
-
throw new InvalidInput(`status must be "open" or "resolved"`);
|
|
145
|
-
}
|
|
146
|
-
update.status = input.status;
|
|
147
|
-
}
|
|
148
|
-
if (input.anchor !== void 0) update.anchor = anchor(input.anchor);
|
|
149
|
-
if (!update.status && !update.anchor)
|
|
150
|
-
throw new InvalidInput("nothing to change: send status or anchor");
|
|
151
|
-
return update;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// src/server/index.ts
|
|
155
|
-
var MAX_JSON_BYTES = 64 * 1024;
|
|
156
|
-
var SEEN_EVERY_MS = 10 * 60 * 1e3;
|
|
157
|
-
var SCREENSHOT_TYPES = {
|
|
158
|
-
"image/png": "png",
|
|
159
|
-
"image/jpeg": "jpg",
|
|
160
|
-
"image/webp": "webp"
|
|
161
|
-
};
|
|
162
|
-
var ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
163
|
-
function newId() {
|
|
164
|
-
const bytes = crypto.getRandomValues(new Uint8Array(14));
|
|
165
|
-
return Array.from(bytes, (b) => ID_ALPHABET[b % ID_ALPHABET.length]).join("");
|
|
166
|
-
}
|
|
167
|
-
var lastTime = 0;
|
|
168
|
-
function now() {
|
|
169
|
-
lastTime = Math.max(Date.now(), lastTime + 1);
|
|
170
|
-
return new Date(lastTime).toISOString();
|
|
171
|
-
}
|
|
172
|
-
function authorOf(who) {
|
|
173
|
-
return {
|
|
174
|
-
id: who.id,
|
|
175
|
-
name: who.name,
|
|
176
|
-
kind: who.kind,
|
|
177
|
-
...who.avatarUrl ? { avatarUrl: who.avatarUrl } : {}
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
function createComments(options) {
|
|
181
|
-
const basePath = (options.basePath ?? "/__comments").replace(/\/+$/, "");
|
|
182
|
-
const allowedOrigins = new Set(options.allowedOrigins ?? []);
|
|
183
|
-
const maxScreenshotBytes = options.maxScreenshotBytes ?? 5 * 1024 * 1024;
|
|
184
|
-
const stores = /* @__PURE__ */ new WeakMap();
|
|
185
|
-
const lastSeen = /* @__PURE__ */ new Map();
|
|
186
|
-
const app = new Hono().basePath(basePath);
|
|
187
|
-
function storeFor(env) {
|
|
188
|
-
let entry = stores.get(env);
|
|
189
|
-
if (!entry) {
|
|
190
|
-
const store = options.store(env);
|
|
191
|
-
const ready = Promise.resolve(store.setup?.());
|
|
192
|
-
entry = { store, ready };
|
|
193
|
-
stores.set(env, entry);
|
|
194
|
-
ready.catch(() => stores.delete(env));
|
|
195
|
-
}
|
|
196
|
-
return entry;
|
|
197
|
-
}
|
|
198
|
-
function background(c, work) {
|
|
199
|
-
const logged = work.catch((error) => console.error("web-comments:", error));
|
|
200
|
-
try {
|
|
201
|
-
c.executionCtx.waitUntil(logged);
|
|
202
|
-
} catch {
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
function emit(c, event) {
|
|
206
|
-
if (options.onEvent) background(c, Promise.resolve(options.onEvent(event, c.env)));
|
|
207
|
-
}
|
|
208
|
-
function fail(status, code, message) {
|
|
209
|
-
return Response.json({ error: { code, message } }, { status });
|
|
210
|
-
}
|
|
211
|
-
function present(thread) {
|
|
212
|
-
const { site: _, screenshots, ...rest } = thread;
|
|
213
|
-
const shown = {};
|
|
214
|
-
for (const [kind, shot] of Object.entries(screenshots)) {
|
|
215
|
-
shown[kind] = {
|
|
216
|
-
url: `${basePath}/assets/${shot.key.split("/").map(encodeURIComponent).join("/")}`,
|
|
217
|
-
contentType: shot.contentType,
|
|
218
|
-
width: shot.width,
|
|
219
|
-
height: shot.height
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
return { ...rest, screenshots: shown };
|
|
223
|
-
}
|
|
224
|
-
app.use("*", async (c, next) => {
|
|
225
|
-
const origin = c.req.header("origin");
|
|
226
|
-
const ownOrigin = new URL(c.req.url).origin;
|
|
227
|
-
const foreign = origin !== void 0 && origin !== ownOrigin;
|
|
228
|
-
if (foreign && !allowedOrigins.has(origin)) {
|
|
229
|
-
return fail(403, "origin_not_allowed", `requests from ${origin} are not allowed`);
|
|
230
|
-
}
|
|
231
|
-
if (foreign) {
|
|
232
|
-
c.header("access-control-allow-origin", origin);
|
|
233
|
-
c.header("access-control-allow-credentials", "true");
|
|
234
|
-
c.header("vary", "origin");
|
|
235
|
-
if (c.req.method === "OPTIONS") {
|
|
236
|
-
c.header("access-control-allow-methods", "GET, POST, PATCH, DELETE");
|
|
237
|
-
c.header("access-control-allow-headers", "authorization, content-type");
|
|
238
|
-
c.header("access-control-max-age", "86400");
|
|
239
|
-
return c.body(null, 204);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
await next();
|
|
243
|
-
});
|
|
244
|
-
if (options.widgetScript !== void 0) {
|
|
245
|
-
const script = options.widgetScript;
|
|
246
|
-
let etag;
|
|
247
|
-
app.get("/widget.js", async (c) => {
|
|
248
|
-
etag ??= crypto.subtle.digest("SHA-256", new TextEncoder().encode(script)).then(
|
|
249
|
-
(hash) => `"${Array.from(new Uint8Array(hash).slice(0, 12), (b) => b.toString(16).padStart(2, "0")).join("")}"`
|
|
250
|
-
);
|
|
251
|
-
const tag = await etag;
|
|
252
|
-
c.header("etag", tag);
|
|
253
|
-
c.header("cache-control", "no-cache");
|
|
254
|
-
if (c.req.header("if-none-match") === tag) return c.body(null, 304);
|
|
255
|
-
c.header("content-type", "text/javascript; charset=utf-8");
|
|
256
|
-
return c.body(script);
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
app.use("*", async (c, next) => {
|
|
260
|
-
const who = await identify(options.auth, c.req.raw, c.env);
|
|
261
|
-
if (!who) {
|
|
262
|
-
const loginUrl = loginUrlFor(options.auth, c.req.raw);
|
|
263
|
-
return Response.json(
|
|
264
|
-
{ error: { code: "unauthenticated", message: "sign in to use comments" }, loginUrl },
|
|
265
|
-
{ status: 401 }
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
if (options.authorize && !await options.authorize(who, c.req.raw, c.env)) {
|
|
269
|
-
return fail(403, "forbidden", `${who.email ?? who.name} may not use comments here`);
|
|
270
|
-
}
|
|
271
|
-
const { store, ready } = storeFor(c.env);
|
|
272
|
-
await ready;
|
|
273
|
-
const seen = Date.now();
|
|
274
|
-
if (store.saveUser && seen - (lastSeen.get(who.id) ?? 0) > SEEN_EVERY_MS) {
|
|
275
|
-
lastSeen.set(who.id, seen);
|
|
276
|
-
background(c, store.saveUser(who, new Date(seen).toISOString()));
|
|
277
|
-
}
|
|
278
|
-
c.set("who", who);
|
|
279
|
-
c.set("site", options.site?.(c.req.raw, c.env) ?? "default");
|
|
280
|
-
c.set("store", store);
|
|
281
|
-
c.set("assets", options.assets?.(c.env) ?? null);
|
|
282
|
-
await next();
|
|
283
|
-
});
|
|
284
|
-
async function readJson(request) {
|
|
285
|
-
if (!request.headers.get("content-type")?.toLowerCase().startsWith("application/json")) {
|
|
286
|
-
throw new UnsupportedMedia("send the body as application/json");
|
|
287
|
-
}
|
|
288
|
-
const text = await request.text();
|
|
289
|
-
if (text.length > MAX_JSON_BYTES) throw new TooLarge();
|
|
290
|
-
return parseJson(text);
|
|
291
|
-
}
|
|
292
|
-
app.onError((error) => {
|
|
293
|
-
if (error instanceof NotConfigured) {
|
|
294
|
-
return fail(501, "not_configured", "this server keeps no screenshots");
|
|
295
|
-
}
|
|
296
|
-
if (error instanceof InvalidInput) return fail(400, "invalid_input", error.message);
|
|
297
|
-
if (error instanceof UnsupportedMedia) {
|
|
298
|
-
return fail(415, "unsupported_media_type", error.message);
|
|
299
|
-
}
|
|
300
|
-
if (error instanceof TooLarge) return fail(413, "too_large", "request body is too large");
|
|
301
|
-
console.error("web-comments:", error);
|
|
302
|
-
return Response.json(
|
|
303
|
-
{ error: { code: "internal", message: "something went wrong" } },
|
|
304
|
-
{ status: 500 }
|
|
305
|
-
);
|
|
306
|
-
});
|
|
307
|
-
app.get("/me", (c) => c.json({ me: c.var.who }));
|
|
308
|
-
app.get("/threads", async (c) => {
|
|
309
|
-
const page = c.req.query("page");
|
|
310
|
-
const threads = await c.var.store.listThreads(c.var.site, {
|
|
311
|
-
...page === void 0 ? {} : { page },
|
|
312
|
-
limit: page === void 0 ? 200 : 500
|
|
313
|
-
});
|
|
314
|
-
return c.json({ threads: threads.map(present) });
|
|
315
|
-
});
|
|
316
|
-
app.get("/threads/:id", async (c) => {
|
|
317
|
-
const thread = await c.var.store.getThread(c.var.site, c.req.param("id"));
|
|
318
|
-
return thread ? c.json({ thread: present(thread) }) : fail(404, "not_found", "no such thread");
|
|
319
|
-
});
|
|
320
|
-
app.post("/threads", async (c) => {
|
|
321
|
-
const type = c.req.header("content-type")?.toLowerCase() ?? "";
|
|
322
|
-
let input;
|
|
323
|
-
const files = {};
|
|
324
|
-
if (type.startsWith("multipart/form-data")) {
|
|
325
|
-
const length = Number(c.req.header("content-length") ?? 0);
|
|
326
|
-
if (length > MAX_JSON_BYTES + 2 * maxScreenshotBytes) throw new TooLarge();
|
|
327
|
-
const form = await c.req.raw.formData();
|
|
328
|
-
const thread2 = form.get("thread");
|
|
329
|
-
if (typeof thread2 !== "string") throw new InvalidInput('missing the "thread" field');
|
|
330
|
-
if (thread2.length > MAX_JSON_BYTES) throw new TooLarge();
|
|
331
|
-
input = createThread(parseJson(thread2));
|
|
332
|
-
for (const kind of SCREENSHOT_KINDS) {
|
|
333
|
-
const file = form.get(kind);
|
|
334
|
-
if (file instanceof File) files[kind] = file;
|
|
335
|
-
}
|
|
336
|
-
} else {
|
|
337
|
-
input = createThread(await readJson(c.req.raw));
|
|
338
|
-
}
|
|
339
|
-
const time = now();
|
|
340
|
-
const author = authorOf(c.var.who);
|
|
341
|
-
const threadId = newId();
|
|
342
|
-
const screenshots = await saveScreenshots(c.var, threadId, files, input.screenshots);
|
|
343
|
-
const stored = {
|
|
344
|
-
id: threadId,
|
|
345
|
-
site: c.var.site,
|
|
346
|
-
page: input.page,
|
|
347
|
-
url: input.url,
|
|
348
|
-
anchor: input.anchor,
|
|
349
|
-
context: input.context ?? null,
|
|
350
|
-
screenshots,
|
|
351
|
-
status: "open",
|
|
352
|
-
author,
|
|
353
|
-
createdAt: time,
|
|
354
|
-
updatedAt: time,
|
|
355
|
-
resolvedAt: null,
|
|
356
|
-
resolvedBy: null,
|
|
357
|
-
comments: []
|
|
358
|
-
};
|
|
359
|
-
const first = { id: newId(), threadId, body: input.body, author, createdAt: time };
|
|
360
|
-
await c.var.store.insertThread(stored, first);
|
|
361
|
-
const thread = present({ ...stored, comments: [first] });
|
|
362
|
-
emit(c, { type: "thread.created", thread, actor: c.var.who });
|
|
363
|
-
return c.json({ thread }, 201);
|
|
364
|
-
});
|
|
365
|
-
async function saveScreenshots(vars, threadId, files, sizes) {
|
|
366
|
-
const saved = {};
|
|
367
|
-
const kinds = Object.keys(files);
|
|
368
|
-
if (kinds.length === 0) return saved;
|
|
369
|
-
if (!vars.assets) throw new NotConfigured();
|
|
370
|
-
for (const kind of kinds) {
|
|
371
|
-
const file = files[kind];
|
|
372
|
-
const extension = SCREENSHOT_TYPES[file.type];
|
|
373
|
-
if (!extension) throw new UnsupportedMedia("screenshots must be PNG, JPEG or WebP");
|
|
374
|
-
if (file.size > maxScreenshotBytes) throw new TooLarge();
|
|
375
|
-
const size2 = sizes[kind];
|
|
376
|
-
if (!size2) throw new InvalidInput(`screenshots.${kind} needs a width and height`);
|
|
377
|
-
const key = `${vars.site}/${threadId}/${kind}-${newId()}.${extension}`;
|
|
378
|
-
await vars.assets.put(key, await file.arrayBuffer(), file.type);
|
|
379
|
-
saved[kind] = { key, contentType: file.type, ...size2 };
|
|
380
|
-
}
|
|
381
|
-
return saved;
|
|
382
|
-
}
|
|
383
|
-
app.get("/assets/*", async (c) => {
|
|
384
|
-
if (!c.var.assets) return fail(404, "not_found", "no such screenshot");
|
|
385
|
-
const key = decodeURIComponent(c.req.path.slice(`${basePath}/assets/`.length));
|
|
386
|
-
if (!key.startsWith(`${c.var.site}/`) || key.includes("..")) {
|
|
387
|
-
return fail(404, "not_found", "no such screenshot");
|
|
388
|
-
}
|
|
389
|
-
const asset = await c.var.assets.get(key);
|
|
390
|
-
if (!asset) return fail(404, "not_found", "no such screenshot");
|
|
391
|
-
return new Response(asset.body, {
|
|
392
|
-
headers: {
|
|
393
|
-
"content-type": asset.contentType,
|
|
394
|
-
// Keys never change content; the API checks who's asking before serving.
|
|
395
|
-
"cache-control": "private, max-age=31536000, immutable"
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
app.post("/threads/:id/comments", async (c) => {
|
|
400
|
-
const body = reply(await readJson(c.req.raw));
|
|
401
|
-
const thread = await c.var.store.getThread(c.var.site, c.req.param("id"));
|
|
402
|
-
if (!thread) return fail(404, "not_found", "no such thread");
|
|
403
|
-
const time = now();
|
|
404
|
-
const comment = {
|
|
405
|
-
id: newId(),
|
|
406
|
-
threadId: thread.id,
|
|
407
|
-
body,
|
|
408
|
-
author: authorOf(c.var.who),
|
|
409
|
-
createdAt: time
|
|
410
|
-
};
|
|
411
|
-
await c.var.store.insertComment(c.var.site, comment);
|
|
412
|
-
await c.var.store.updateThread(c.var.site, thread.id, { updatedAt: time });
|
|
413
|
-
const updated = present({
|
|
414
|
-
...thread,
|
|
415
|
-
updatedAt: time,
|
|
416
|
-
comments: [...thread.comments, comment]
|
|
417
|
-
});
|
|
418
|
-
emit(c, { type: "comment.created", thread: updated, comment, actor: c.var.who });
|
|
419
|
-
return c.json({ thread: updated, comment }, 201);
|
|
420
|
-
});
|
|
421
|
-
app.patch("/threads/:id", async (c) => {
|
|
422
|
-
const update = threadUpdate(await readJson(c.req.raw));
|
|
423
|
-
const before = await c.var.store.getThread(c.var.site, c.req.param("id"));
|
|
424
|
-
if (!before) return fail(404, "not_found", "no such thread");
|
|
425
|
-
if (update.anchor && before.author.id !== c.var.who.id) {
|
|
426
|
-
return fail(403, "forbidden", "only the author can move a comment");
|
|
427
|
-
}
|
|
428
|
-
const time = now();
|
|
429
|
-
const events = [];
|
|
430
|
-
const changes = {};
|
|
431
|
-
if (update.anchor) {
|
|
432
|
-
changes.anchor = update.anchor;
|
|
433
|
-
events.push("thread.moved");
|
|
434
|
-
}
|
|
435
|
-
if (update.status && update.status !== before.status) {
|
|
436
|
-
const resolved = update.status === "resolved";
|
|
437
|
-
changes.status = update.status;
|
|
438
|
-
changes.resolvedAt = resolved ? time : null;
|
|
439
|
-
changes.resolvedBy = resolved ? authorOf(c.var.who) : null;
|
|
440
|
-
events.push(resolved ? "thread.resolved" : "thread.reopened");
|
|
441
|
-
}
|
|
442
|
-
if (events.length === 0) return c.json({ thread: present(before) });
|
|
443
|
-
changes.updatedAt = time;
|
|
444
|
-
await c.var.store.updateThread(c.var.site, before.id, changes);
|
|
445
|
-
const thread = present({ ...before, ...changes });
|
|
446
|
-
for (const type of events) emit(c, { type, thread, actor: c.var.who });
|
|
447
|
-
return c.json({ thread });
|
|
448
|
-
});
|
|
449
|
-
app.delete("/comments/:id", async (c) => {
|
|
450
|
-
const commentId = c.req.param("id");
|
|
451
|
-
const comment = await c.var.store.getComment(c.var.site, commentId);
|
|
452
|
-
if (!comment) return fail(404, "not_found", "no such comment");
|
|
453
|
-
if (comment.author.id !== c.var.who.id) {
|
|
454
|
-
return fail(403, "forbidden", "only the author can delete a comment");
|
|
455
|
-
}
|
|
456
|
-
const thread = await c.var.store.getThread(c.var.site, comment.threadId);
|
|
457
|
-
if (!thread) return fail(404, "not_found", "no such comment");
|
|
458
|
-
if (thread.comments[0]?.id === commentId) {
|
|
459
|
-
await c.var.store.deleteThread(c.var.site, thread.id);
|
|
460
|
-
const assets = c.var.assets;
|
|
461
|
-
if (assets?.delete) {
|
|
462
|
-
const keys = Object.values(thread.screenshots).map((shot) => shot.key);
|
|
463
|
-
background(c, Promise.all(keys.map((key) => assets.delete?.(key))));
|
|
464
|
-
}
|
|
465
|
-
emit(c, { type: "thread.deleted", threadId: thread.id, actor: c.var.who });
|
|
466
|
-
return c.json({ deleted: "thread", threadId: thread.id });
|
|
467
|
-
}
|
|
468
|
-
await c.var.store.deleteComment(c.var.site, commentId);
|
|
469
|
-
emit(c, { type: "comment.deleted", threadId: thread.id, commentId, actor: c.var.who });
|
|
470
|
-
return c.json({ deleted: "comment", threadId: thread.id });
|
|
471
|
-
});
|
|
472
|
-
return {
|
|
473
|
-
app,
|
|
474
|
-
/** Answers requests under basePath; anything else gets a 404. */
|
|
475
|
-
fetch(request, env, ctx) {
|
|
476
|
-
return Promise.resolve(app.fetch(request, env, ctx));
|
|
477
|
-
},
|
|
478
|
-
/** Whether a request belongs to the comments API, to route it from a larger worker. */
|
|
479
|
-
matches(request) {
|
|
480
|
-
const { pathname } = new URL(request.url);
|
|
481
|
-
return pathname === basePath || pathname.startsWith(`${basePath}/`);
|
|
482
|
-
}
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
function parseJson(text) {
|
|
486
|
-
try {
|
|
487
|
-
return JSON.parse(text);
|
|
488
|
-
} catch {
|
|
489
|
-
throw new InvalidInput("request body is not valid JSON");
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
var UnsupportedMedia = class extends Error {
|
|
493
|
-
};
|
|
494
|
-
var TooLarge = class extends Error {
|
|
495
|
-
};
|
|
496
|
-
var NotConfigured = class extends Error {
|
|
497
|
-
};
|
|
498
7
|
export {
|
|
8
|
+
CommentsError,
|
|
499
9
|
createComments,
|
|
500
10
|
newId
|
|
501
11
|
};
|
package/dist/store/d1.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
drizzleStore
|
|
3
|
-
} from "../chunks/chunk-
|
|
4
|
-
import "../chunks/chunk-
|
|
5
|
-
import "../chunks/chunk-
|
|
6
|
-
import "../chunks/chunk-
|
|
3
|
+
} from "../chunks/chunk-T4WN3STA.js";
|
|
4
|
+
import "../chunks/chunk-7K7K7M5O.js";
|
|
5
|
+
import "../chunks/chunk-WY5E2C47.js";
|
|
6
|
+
import "../chunks/chunk-644PTNSO.js";
|
|
7
7
|
import "../chunks/chunk-WSFCRVEQ.js";
|
|
8
8
|
|
|
9
9
|
// src/store/d1.ts
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
comments,
|
|
3
3
|
createTablesSql,
|
|
4
|
+
links,
|
|
4
5
|
threads,
|
|
5
|
-
users
|
|
6
|
-
|
|
6
|
+
users,
|
|
7
|
+
values
|
|
8
|
+
} from "../../chunks/chunk-WY5E2C47.js";
|
|
7
9
|
import "../../chunks/chunk-WSFCRVEQ.js";
|
|
8
10
|
export {
|
|
9
11
|
comments,
|
|
10
12
|
createTablesSql,
|
|
13
|
+
links,
|
|
11
14
|
threads,
|
|
12
|
-
users
|
|
15
|
+
users,
|
|
16
|
+
values
|
|
13
17
|
};
|
package/dist/store/drizzle/pg.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
comments,
|
|
3
3
|
createTablesSql,
|
|
4
|
+
links,
|
|
4
5
|
threads,
|
|
5
|
-
users
|
|
6
|
-
|
|
6
|
+
users,
|
|
7
|
+
values
|
|
8
|
+
} from "../../chunks/chunk-7K7K7M5O.js";
|
|
7
9
|
import "../../chunks/chunk-WSFCRVEQ.js";
|
|
8
10
|
export {
|
|
9
11
|
comments,
|
|
10
12
|
createTablesSql,
|
|
13
|
+
links,
|
|
11
14
|
threads,
|
|
12
|
-
users
|
|
15
|
+
users,
|
|
16
|
+
values
|
|
13
17
|
};
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import {
|
|
2
2
|
comments,
|
|
3
3
|
createTablesSql,
|
|
4
|
+
links,
|
|
4
5
|
threads,
|
|
5
|
-
users
|
|
6
|
-
|
|
6
|
+
users,
|
|
7
|
+
values
|
|
8
|
+
} from "../../chunks/chunk-644PTNSO.js";
|
|
7
9
|
import "../../chunks/chunk-WSFCRVEQ.js";
|
|
8
10
|
export {
|
|
9
11
|
comments,
|
|
10
12
|
createTablesSql,
|
|
13
|
+
links,
|
|
11
14
|
threads,
|
|
12
|
-
users
|
|
15
|
+
users,
|
|
16
|
+
values
|
|
13
17
|
};
|
package/dist/store/drizzle.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
drizzleStore
|
|
3
|
-
} from "../chunks/chunk-
|
|
4
|
-
import "../chunks/chunk-
|
|
5
|
-
import "../chunks/chunk-
|
|
6
|
-
import "../chunks/chunk-
|
|
3
|
+
} from "../chunks/chunk-T4WN3STA.js";
|
|
4
|
+
import "../chunks/chunk-7K7K7M5O.js";
|
|
5
|
+
import "../chunks/chunk-WY5E2C47.js";
|
|
6
|
+
import "../chunks/chunk-644PTNSO.js";
|
|
7
7
|
import "../chunks/chunk-WSFCRVEQ.js";
|
|
8
8
|
export {
|
|
9
9
|
drizzleStore
|
package/dist/store/memory.js
CHANGED
|
@@ -4,6 +4,9 @@ import "../chunks/chunk-WSFCRVEQ.js";
|
|
|
4
4
|
function memoryStore() {
|
|
5
5
|
const threads = /* @__PURE__ */ new Map();
|
|
6
6
|
const comments = /* @__PURE__ */ new Map();
|
|
7
|
+
const links = /* @__PURE__ */ new Map();
|
|
8
|
+
const values = /* @__PURE__ */ new Map();
|
|
9
|
+
const linkKey = (site, connector, externalId) => JSON.stringify([site, connector, externalId]);
|
|
7
10
|
function withComments(thread) {
|
|
8
11
|
const own = [...comments.values()].filter((c) => c.threadId === thread.id).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
9
12
|
return structuredClone({ ...thread, comments: own });
|
|
@@ -50,6 +53,37 @@ function memoryStore() {
|
|
|
50
53
|
if (!find(site, id)) return;
|
|
51
54
|
threads.delete(id);
|
|
52
55
|
for (const [key, comment] of comments) if (comment.threadId === id) comments.delete(key);
|
|
56
|
+
for (const [key, link] of links) {
|
|
57
|
+
if (link.site === site && link.threadId === id) links.delete(key);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
async saveLink(site, link) {
|
|
61
|
+
links.set(linkKey(site, link.connector, link.externalId), { ...structuredClone(link), site });
|
|
62
|
+
},
|
|
63
|
+
async findLink(site, connector, externalId) {
|
|
64
|
+
const link = links.get(linkKey(site, connector, externalId));
|
|
65
|
+
if (!link) return null;
|
|
66
|
+
const { site: _, ...rest } = link;
|
|
67
|
+
return structuredClone(rest);
|
|
68
|
+
},
|
|
69
|
+
async listLinks(site, threadIds) {
|
|
70
|
+
const wanted = new Set(threadIds);
|
|
71
|
+
return [...links.values()].filter((link) => link.site === site && wanted.has(link.threadId)).map(({ site: _, ...rest }) => structuredClone(rest));
|
|
72
|
+
},
|
|
73
|
+
async getValue(name) {
|
|
74
|
+
const entry = values.get(name);
|
|
75
|
+
if (!entry) return null;
|
|
76
|
+
if (entry.expiresAt !== null && entry.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
|
|
77
|
+
values.delete(name);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return entry.value;
|
|
81
|
+
},
|
|
82
|
+
async setValue(name, value, expiresAt) {
|
|
83
|
+
values.set(name, { value, expiresAt });
|
|
84
|
+
},
|
|
85
|
+
async deleteValue(name) {
|
|
86
|
+
values.delete(name);
|
|
53
87
|
}
|
|
54
88
|
};
|
|
55
89
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Connector, Thread } from "../../server";
|
|
2
|
+
export interface LinearOptions {
|
|
3
|
+
/**
|
|
4
|
+
* How to call Linear:
|
|
5
|
+
* - `{ apiKey }`: a personal API key (lin_api_...). Issues and comments are
|
|
6
|
+
* that person's; who said what is written into them.
|
|
7
|
+
* - `{ accessToken }`: an OAuth token.
|
|
8
|
+
* - `{ clientId, clientSecret }`: an OAuth app with client credentials
|
|
9
|
+
* enabled. Issues and comments are the app's, shown under each
|
|
10
|
+
* commenter's name and picture.
|
|
11
|
+
*/
|
|
12
|
+
auth: {
|
|
13
|
+
apiKey: string;
|
|
14
|
+
} | {
|
|
15
|
+
accessToken: string;
|
|
16
|
+
} | {
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
};
|
|
20
|
+
/** The team new issues go to: its key ("ENG") or id. */
|
|
21
|
+
team: string;
|
|
22
|
+
/**
|
|
23
|
+
* The signing secret of a Linear webhook (Issues and Comments) pointed at
|
|
24
|
+
* `{basePath}/linear/webhook`. With it, Linear's side comes back: comments
|
|
25
|
+
* there become replies, and finishing or reopening the issue resolves or
|
|
26
|
+
* reopens the thread.
|
|
27
|
+
*/
|
|
28
|
+
webhookSecret?: string;
|
|
29
|
+
/** Labels to put on new issues, by id. */
|
|
30
|
+
labelIds?: string[];
|
|
31
|
+
/** The project new issues go into, by id. */
|
|
32
|
+
projectId?: string;
|
|
33
|
+
/** Which threads become issues. All of them, by default. */
|
|
34
|
+
filter?: (thread: Thread) => boolean;
|
|
35
|
+
/** Linear's GraphQL endpoint; for tests. */
|
|
36
|
+
apiUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every thread becomes a Linear issue, with its screenshots, and stays in
|
|
40
|
+
* step with it: replies go both ways, and resolving one resolves the other.
|
|
41
|
+
* Options can depend on the environment; returning null leaves Linear out
|
|
42
|
+
* of that one (say, until its secrets are set).
|
|
43
|
+
*
|
|
44
|
+
* connectors: [linear((env) => ({ auth: { apiKey: env.LINEAR_API_KEY }, team: "ENG", webhookSecret: env.LINEAR_WEBHOOK_SECRET }))]
|
|
45
|
+
*/
|
|
46
|
+
export declare function linear<Env = unknown>(options: LinearOptions | ((env: Env) => LinearOptions | null)): Connector<Env>;
|