visual-comments 0.0.0-stage → 0.2.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +77 -2
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +477 -0
  5. package/dist/core/aim.d.ts +21 -0
  6. package/dist/core/aim.js +21 -0
  7. package/dist/core/api.d.ts +5 -0
  8. package/dist/core/api.js +11 -0
  9. package/dist/core/metaball.d.ts +15 -0
  10. package/dist/core/metaball.js +42 -0
  11. package/dist/core/presence.d.ts +13 -0
  12. package/dist/core/presence.js +25 -0
  13. package/dist/core/surfaces.d.ts +95 -0
  14. package/dist/core/surfaces.js +197 -0
  15. package/dist/core/target.d.ts +59 -0
  16. package/dist/core/target.js +382 -0
  17. package/dist/core/threads.svelte.d.ts +80 -0
  18. package/dist/core/threads.svelte.js +336 -0
  19. package/dist/core/time.d.ts +3 -0
  20. package/dist/core/time.js +25 -0
  21. package/dist/index.d.ts +5 -0
  22. package/dist/index.js +6 -0
  23. package/dist/server/env.d.ts +78 -0
  24. package/dist/server/env.js +94 -0
  25. package/dist/server/index.d.ts +34 -0
  26. package/dist/server/index.js +74 -0
  27. package/dist/server/variables.d.ts +28 -0
  28. package/dist/server/variables.js +73 -0
  29. package/dist/shared/limits.d.ts +9 -0
  30. package/dist/shared/limits.js +10 -0
  31. package/dist/shared/protocol.d.ts +6 -0
  32. package/dist/shared/protocol.js +7 -0
  33. package/dist/shared/types.d.ts +157 -0
  34. package/dist/shared/types.js +2 -0
  35. package/dist/sveltekit/CommentTools.svelte +98 -0
  36. package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
  37. package/dist/sveltekit/Comments.svelte +104 -0
  38. package/dist/sveltekit/Comments.svelte.d.ts +25 -0
  39. package/dist/sveltekit/Toolbar.svelte +64 -0
  40. package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
  41. package/dist/sveltekit/ToolbarButton.svelte +66 -0
  42. package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
  43. package/dist/sveltekit/context.d.ts +16 -0
  44. package/dist/sveltekit/context.js +9 -0
  45. package/dist/sveltekit/index.d.ts +6 -0
  46. package/dist/sveltekit/index.js +6 -0
  47. package/dist/ui/Account.svelte +106 -0
  48. package/dist/ui/Account.svelte.d.ts +8 -0
  49. package/dist/ui/App.svelte +77 -0
  50. package/dist/ui/App.svelte.d.ts +7 -0
  51. package/dist/ui/Avatar.svelte +64 -0
  52. package/dist/ui/Avatar.svelte.d.ts +11 -0
  53. package/dist/ui/Composer.svelte +267 -0
  54. package/dist/ui/Composer.svelte.d.ts +20 -0
  55. package/dist/ui/Connect.svelte +128 -0
  56. package/dist/ui/Connect.svelte.d.ts +6 -0
  57. package/dist/ui/Icon.svelte +44 -0
  58. package/dist/ui/Icon.svelte.d.ts +29 -0
  59. package/dist/ui/Menu.svelte +69 -0
  60. package/dist/ui/Menu.svelte.d.ts +15 -0
  61. package/dist/ui/MenuItem.svelte +38 -0
  62. package/dist/ui/MenuItem.svelte.d.ts +12 -0
  63. package/dist/ui/Overlay.svelte +502 -0
  64. package/dist/ui/Overlay.svelte.d.ts +3 -0
  65. package/dist/ui/Panel.svelte +220 -0
  66. package/dist/ui/Panel.svelte.d.ts +10 -0
  67. package/dist/ui/Pin.svelte +156 -0
  68. package/dist/ui/Pin.svelte.d.ts +15 -0
  69. package/dist/ui/Presence.svelte +43 -0
  70. package/dist/ui/Presence.svelte.d.ts +8 -0
  71. package/dist/ui/ThreadCard.svelte +201 -0
  72. package/dist/ui/ThreadCard.svelte.d.ts +13 -0
  73. package/dist/ui/Toolbar.svelte +35 -0
  74. package/dist/ui/Toolbar.svelte.d.ts +18 -0
  75. package/dist/ui/context.d.ts +3 -0
  76. package/dist/ui/context.js +8 -0
  77. package/dist/ui/generated/styles.css +2 -0
  78. package/dist/ui/generated/styles.d.ts +2 -0
  79. package/dist/ui/generated/styles.js +3 -0
  80. package/dist/ui/styles.css +113 -0
  81. package/dist/ui/text.d.ts +5 -0
  82. package/dist/ui/text.js +11 -0
  83. package/dist/visual-comments.svelte.d.ts +230 -0
  84. package/dist/visual-comments.svelte.js +693 -0
  85. package/dist/vite/index.d.ts +20 -0
  86. package/dist/vite/index.js +78 -0
  87. package/dist-widget/visual-comments.js +58 -0
  88. package/package.json +86 -3
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The space's threads, kept current with the server's event stream. Actions
3
+ * show their result at once and resync with the server when they fail.
4
+ */
5
+ export class Threads {
6
+ viewer = $state(null);
7
+ threads = $state([]);
8
+ spaceName = $state('');
9
+ loading = $state(true);
10
+ /** Whether `viewer` is known: from the server's answer, or given when the page loaded. */
11
+ viewerKnown = $state(false);
12
+ /** A failed action, shown until dismissed. */
13
+ error = $state(null);
14
+ /** Ticks every 30 seconds so relative times stay current. */
15
+ now = $state(Date.now());
16
+ /** This tab's event stream, as the server named it, while it is open. */
17
+ connection = $state();
18
+ /** Everyone present in the space, this tab included, while the event stream is open. */
19
+ participants = $state.raw([]);
20
+ /** The space's agents once listed; undefined until then, or when the space has them turned off. */
21
+ agents = $state.raw();
22
+ api;
23
+ refreshing;
24
+ queued = false;
25
+ listening;
26
+ nextPending = -1;
27
+ presence;
28
+ /** The last presence sent, with its connection, so an unchanged one is not sent again. */
29
+ posted = '';
30
+ /** The participants as last received, so an unchanged list does not wake every listener. */
31
+ received = '[]';
32
+ /** `viewer`: who is signed in, when the page already knows it; undefined to wait for the server. */
33
+ constructor(api, viewer) {
34
+ this.api = api;
35
+ if (viewer !== undefined) {
36
+ this.viewer = viewer;
37
+ this.viewerKnown = true;
38
+ }
39
+ }
40
+ get unreadCount() {
41
+ return this.threads.filter((thread) => thread.unread && !thread.resolvedAt).length;
42
+ }
43
+ start() {
44
+ void this.refresh();
45
+ const onfocus = () => { void this.refresh(); };
46
+ window.addEventListener('focus', onfocus);
47
+ const timer = setInterval(() => { this.now = Date.now(); }, 30_000);
48
+ return () => {
49
+ window.removeEventListener('focus', onfocus);
50
+ clearInterval(timer);
51
+ this.stopListening();
52
+ };
53
+ }
54
+ /** Coalesces overlapping refreshes into at most one more request. */
55
+ refresh() {
56
+ if (this.refreshing) {
57
+ this.queued = true;
58
+ return this.refreshing;
59
+ }
60
+ this.refreshing = (async () => {
61
+ try {
62
+ const response = await this.api.fetch('');
63
+ if (!response.ok)
64
+ throw new Error();
65
+ const snapshot = (await response.json());
66
+ this.viewer = snapshot.viewer;
67
+ this.viewerKnown = true;
68
+ this.spaceName = snapshot.space.name;
69
+ // Keep replies that are still on their way to the server.
70
+ const pending = new Map(this.threads.map((t) => [t.id, t.comments.filter((c) => c.pending)]));
71
+ this.threads = snapshot.threads.map((thread) => {
72
+ const waiting = pending.get(thread.id);
73
+ return waiting?.length ? { ...thread, comments: [...thread.comments, ...waiting] } : thread;
74
+ });
75
+ if (this.viewer)
76
+ this.listen();
77
+ else
78
+ this.stopListening();
79
+ }
80
+ catch {
81
+ // Keep what is shown; the next event or focus retries.
82
+ }
83
+ finally {
84
+ this.loading = false;
85
+ this.refreshing = undefined;
86
+ if (this.queued) {
87
+ this.queued = false;
88
+ void this.refresh();
89
+ }
90
+ }
91
+ })();
92
+ return this.refreshing;
93
+ }
94
+ stopListening() {
95
+ this.listening?.abort();
96
+ this.listening = undefined;
97
+ this.disconnected();
98
+ }
99
+ /** Without the stream, who is present is unknown, and the server has already let this tab go. */
100
+ disconnected() {
101
+ this.connection = undefined;
102
+ this.setParticipants([]);
103
+ }
104
+ setParticipants(participants) {
105
+ const json = JSON.stringify(participants);
106
+ if (json === this.received)
107
+ return;
108
+ this.received = json;
109
+ this.participants = participants;
110
+ }
111
+ /** Follows the event stream; after a dropped connection, catches up on anything missed. */
112
+ listen() {
113
+ if (this.listening)
114
+ return;
115
+ const controller = new AbortController();
116
+ this.listening = controller;
117
+ void (async () => {
118
+ let connected = false;
119
+ while (!controller.signal.aborted && this.viewer) {
120
+ try {
121
+ const response = await this.api.fetch('/events', { signal: controller.signal });
122
+ if (!response.ok || !response.body)
123
+ break;
124
+ if (connected)
125
+ void this.refresh();
126
+ connected = true;
127
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
128
+ let buffer = '';
129
+ for (;;) {
130
+ const { value, done } = await reader.read();
131
+ if (done)
132
+ break;
133
+ buffer += value;
134
+ let end;
135
+ while ((end = buffer.indexOf('\n\n')) !== -1) {
136
+ this.receive(buffer.slice(0, end));
137
+ buffer = buffer.slice(end + 2);
138
+ }
139
+ }
140
+ }
141
+ catch {
142
+ // Dropped; try again shortly.
143
+ }
144
+ if (controller.signal.aborted)
145
+ break;
146
+ this.disconnected();
147
+ await new Promise((done) => setTimeout(done, 3000));
148
+ }
149
+ if (this.listening === controller)
150
+ this.listening = undefined;
151
+ })();
152
+ }
153
+ /** One message of the event stream. Servers without presence send only `change`. */
154
+ receive(message) {
155
+ const event = /^event: (.*)$/m.exec(message)?.[1];
156
+ if (event === 'change')
157
+ return void this.refresh();
158
+ if (event !== 'hello' && event !== 'presence')
159
+ return;
160
+ let data;
161
+ try {
162
+ data = JSON.parse(message.match(/^data: ?(.*)$/gm)?.map((line) => line.replace(/^data: ?/, '')).join('\n') ?? '');
163
+ }
164
+ catch {
165
+ return;
166
+ }
167
+ if (event === 'hello' && typeof data?.connection === 'string') {
168
+ this.connection = data.connection;
169
+ // A new connection is a new participant: tell the server what this tab is doing.
170
+ void this.postPresence();
171
+ }
172
+ else if (event === 'presence' && Array.isArray(data?.participants)) {
173
+ this.setParticipants(data.participants);
174
+ }
175
+ }
176
+ /**
177
+ * Says what this tab is doing, unless it said so already. `again` sends it
178
+ * even so, as the heartbeat that keeps `typing` from lapsing.
179
+ */
180
+ setPresence(presence, again = false) {
181
+ this.presence = presence;
182
+ void this.postPresence(again);
183
+ }
184
+ async postPresence(again = false) {
185
+ const connection = this.connection;
186
+ if (!connection || !this.presence)
187
+ return;
188
+ const body = JSON.stringify({ connection, ...this.presence });
189
+ if (body === this.posted && !again)
190
+ return;
191
+ this.posted = body;
192
+ try {
193
+ const response = await this.api.fetch('/presence', {
194
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body
195
+ });
196
+ if (!response.ok && this.posted === body)
197
+ this.posted = '';
198
+ }
199
+ catch {
200
+ // The next change, or the next connection, sends it again.
201
+ if (this.posted === body)
202
+ this.posted = '';
203
+ }
204
+ }
205
+ async request(method, path, body) {
206
+ const response = await this.api.fetch(path, {
207
+ method,
208
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
209
+ body: body === undefined ? undefined : JSON.stringify(body)
210
+ });
211
+ const result = await response.json().catch(() => ({}));
212
+ if (!response.ok) {
213
+ throw new Error(result.error ?? 'Something went wrong. Try again.');
214
+ }
215
+ return result;
216
+ }
217
+ apply(result) {
218
+ if (result.thread) {
219
+ const thread = result.thread;
220
+ const index = this.threads.findIndex((t) => t.id === thread.id);
221
+ if (index === -1)
222
+ this.threads.push(thread);
223
+ else
224
+ this.threads[index] = thread;
225
+ }
226
+ if (result.deleted !== undefined)
227
+ this.threads = this.threads.filter((t) => t.id !== result.deleted);
228
+ }
229
+ /** Runs an action, showing its error and resyncing with the server if it fails. */
230
+ async run(action, rethrow = false) {
231
+ try {
232
+ this.apply(await action());
233
+ }
234
+ catch (error) {
235
+ this.error = error.message;
236
+ void this.refresh();
237
+ if (rethrow)
238
+ throw error;
239
+ }
240
+ }
241
+ update(id, change) {
242
+ const index = this.threads.findIndex((t) => t.id === id);
243
+ if (index !== -1)
244
+ this.threads[index] = change(this.threads[index]);
245
+ }
246
+ imageUrl(image) {
247
+ return `${this.api.endpoint}/images/${image.id}/${image.key}`;
248
+ }
249
+ /** Uploads an image for a comment being written. Send its id with the comment. */
250
+ async upload(file, width, height) {
251
+ const response = await this.api.fetch(`/images?width=${width}&height=${height}`, {
252
+ method: 'POST',
253
+ headers: { 'Content-Type': file.type },
254
+ body: file
255
+ });
256
+ const result = await response.json().catch(() => ({}));
257
+ if (!response.ok) {
258
+ throw new Error(result.error ?? (response.status === 413 ? 'This image is too large.' : 'The image could not be uploaded.'));
259
+ }
260
+ return result.image;
261
+ }
262
+ async create(input, images = []) {
263
+ const { thread } = await this.request('POST', '/threads', { ...input, images: images.map((i) => i.id) });
264
+ this.apply({ thread });
265
+ return thread;
266
+ }
267
+ /** Shows the reply at once; throws if it could not be sent. */
268
+ async reply(threadId, body, images = []) {
269
+ const pending = {
270
+ id: this.nextPending--, author: this.viewer, body, images, createdAt: Date.now(), editedAt: null, pending: true
271
+ };
272
+ this.update(threadId, (t) => ({ ...t, resolvedAt: null, resolvedBy: null, comments: [...t.comments, pending] }));
273
+ try {
274
+ await this.run(() => this.request('POST', `/threads/${threadId}/comments`, { body, images: images.map((i) => i.id) }), true);
275
+ }
276
+ finally {
277
+ this.update(threadId, (t) => ({ ...t, comments: t.comments.filter((c) => c.id !== pending.id) }));
278
+ }
279
+ }
280
+ async edit(commentId, body, images) {
281
+ await this.run(() => this.request('PATCH', `/comments/${commentId}`, { body, images: images.map((i) => i.id) }), true);
282
+ }
283
+ async removeComment(commentId) {
284
+ await this.run(() => this.request('DELETE', `/comments/${commentId}`));
285
+ }
286
+ async removeThread(threadId) {
287
+ this.threads = this.threads.filter((t) => t.id !== threadId);
288
+ await this.run(() => this.request('DELETE', `/threads/${threadId}`));
289
+ }
290
+ async resolve(threadId, resolved) {
291
+ this.update(threadId, (t) => ({
292
+ ...t, resolvedAt: resolved ? Date.now() : null, resolvedBy: resolved ? this.viewer : null
293
+ }));
294
+ await this.run(() => this.request('PATCH', `/threads/${threadId}`, { resolved }));
295
+ }
296
+ async markRead(threadId, read) {
297
+ this.update(threadId, (t) => ({ ...t, unread: !read && t.comments.some((c) => c.author.sub !== this.viewer?.sub) }));
298
+ await this.run(() => this.request('POST', `/threads/${threadId}/read`, { read }));
299
+ }
300
+ async markAllRead() {
301
+ await Promise.all(this.threads.filter((t) => t.unread).map((t) => this.markRead(t.id, true)));
302
+ }
303
+ // Agents.
304
+ async loadAgents() {
305
+ try {
306
+ this.agents = (await this.request('GET', '/agents')).agents;
307
+ }
308
+ catch {
309
+ this.agents = undefined;
310
+ }
311
+ }
312
+ /** Takes an agent out of the space at once: its token stops working. */
313
+ async removeAgent(id) {
314
+ this.agents = this.agents?.filter((agent) => agent.id !== id);
315
+ try {
316
+ await this.request('DELETE', `/agents/${encodeURIComponent(id)}`);
317
+ }
318
+ catch (error) {
319
+ this.error = error.message;
320
+ void this.loadAgents();
321
+ }
322
+ }
323
+ /** An agent asking to join, for the person approving it. Throws when it has expired or was answered. */
324
+ async agentRequest(id) {
325
+ return (await this.request('GET', `/agents/requests/${encodeURIComponent(id)}`)).request;
326
+ }
327
+ async approveAgent(id) {
328
+ const { agent } = await this.request('POST', `/agents/requests/${encodeURIComponent(id)}/approve`);
329
+ if (this.agents)
330
+ this.agents = [...this.agents.filter((a) => a.id !== agent.id), agent];
331
+ return agent;
332
+ }
333
+ async denyAgent(id) {
334
+ await this.request('DELETE', `/agents/requests/${encodeURIComponent(id)}`);
335
+ }
336
+ }
@@ -0,0 +1,3 @@
1
+ /** "just now", "5 minutes ago", "yesterday", then a date. */
2
+ export declare function ago(time: number, now: number): string;
3
+ export declare function fullDate(time: number): string;
@@ -0,0 +1,25 @@
1
+ const relative = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
2
+ const UNITS = [
3
+ ['minute', 60_000],
4
+ ['hour', 3_600_000],
5
+ ['day', 86_400_000]
6
+ ];
7
+ /** "just now", "5 minutes ago", "yesterday", then a date. */
8
+ export function ago(time, now) {
9
+ const elapsed = Math.max(0, now - time);
10
+ if (elapsed < 60_000)
11
+ return 'just now';
12
+ if (elapsed < 7 * 86_400_000) {
13
+ const [unit, size] = UNITS.findLast(([, size]) => elapsed >= size);
14
+ return relative.format(-Math.floor(elapsed / size), unit);
15
+ }
16
+ const date = new Date(time);
17
+ return date.toLocaleDateString(undefined, {
18
+ day: 'numeric',
19
+ month: 'short',
20
+ year: date.getFullYear() === new Date(now).getFullYear() ? undefined : 'numeric'
21
+ });
22
+ }
23
+ export function fullDate(time) {
24
+ return new Date(time).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
25
+ }
@@ -0,0 +1,5 @@
1
+ export { VisualComments, pageOf, type VisualCommentsOptions, type SurfaceInfo, type CommentFilters, type PresenceLocation } from './visual-comments.svelte.js';
2
+ export type * from './shared/types.js';
3
+ import { VisualComments, type VisualCommentsOptions } from './visual-comments.svelte.js';
4
+ /** Adds comments to the page. Call once, in the browser. */
5
+ export declare function createVisualComments(options: VisualCommentsOptions, doc?: Document): VisualComments;
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { VisualComments, pageOf } from './visual-comments.svelte.js';
2
+ import { VisualComments } from './visual-comments.svelte.js';
3
+ /** Adds comments to the page. Call once, in the browser. */
4
+ export function createVisualComments(options, doc) {
5
+ return new VisualComments(options, doc);
6
+ }
@@ -0,0 +1,78 @@
1
+ import type { Viewer } from '../shared/types.js';
2
+ import { type CommentsUser, type Handle } from './index.js';
3
+ import { type CommentsEnv } from './variables.js';
4
+ export { commentsVariables, type CommentsEnv, type CommentsEnvName } from './variables.js';
5
+ export interface CommentsSignInOptions {
6
+ /**
7
+ * More host names the development sign-in works on, besides localhost: e.g.
8
+ * the machine's address on a private network, to open the dev server from
9
+ * another machine. Whoever reaches the site there can sign in as anyone.
10
+ */
11
+ devHosts?: string[];
12
+ }
13
+ export interface CommentsEnvOptions {
14
+ /**
15
+ * Whether the dev server is running: `dev` from `$app/env` (`$app/environment`
16
+ * before SvelteKit 3). Needed for `VISUAL_COMMENTS_ENABLED=dev`, and for the
17
+ * development sign-in.
18
+ */
19
+ dev?: boolean;
20
+ /**
21
+ * Sign people in to the comments apart from the site's own accounts, with
22
+ * `@cnvx/sign-in` (install it beside this package): at the provider of
23
+ * the `VISUAL_COMMENTS_OIDC_*` variables, or, while the dev server runs without
24
+ * them, with any name on localhost. Its routes are at `VISUAL_COMMENTS_AUTH`;
25
+ * its session is the `visual_comments_session` cookie. Without it, the site's
26
+ * own sign-in says who comments (`sveltekit(user)`).
27
+ */
28
+ signIn?: boolean | CommentsSignInOptions;
29
+ }
30
+ /** What the browser needs to reach the comments, for `<Comments config>` of `visual-comments/sveltekit`. */
31
+ export interface CommentsClientConfig {
32
+ /** Where this site answers comment requests, e.g. `/_comments`. */
33
+ endpoint: string;
34
+ /** Where this site's sign-in routes are, e.g. `/auth`. */
35
+ auth: string;
36
+ /**
37
+ * Who is signed in to the comments, as the server knows it when the page
38
+ * loads (`load`): null for nobody, so the toolbar shows them at once.
39
+ * Undefined: the browser asks.
40
+ */
41
+ viewer?: Viewer | null;
42
+ }
43
+ export interface CommentsFromEnv {
44
+ /** Whether comments are on. */
45
+ readonly enabled: boolean;
46
+ /** For the browser, e.g. returned by the root layout's `load`. Null while comments are off. */
47
+ readonly client: CommentsClientConfig | null;
48
+ /**
49
+ * `client` with who is signed in to the comments' own sign-in, for the root
50
+ * layout's `load`: `load: (event) => comments.load(event)`. Without the own
51
+ * sign-in, or while off, it is `client`.
52
+ */
53
+ load(event: {
54
+ request: Request;
55
+ }): Promise<CommentsClientConfig | null>;
56
+ /** Answers comment requests as `user`; undefined for any other request, and for every request while off. */
57
+ handle(request: Request, user: CommentsUser): Promise<Response | undefined>;
58
+ /**
59
+ * SvelteKit's `handle` hook. With the comments' own sign-in, it answers its
60
+ * routes and comments as the person signed in there; put it first. Otherwise
61
+ * put it after the site's sign-in: by default the person is
62
+ * `event.locals.user`. While off it passes every request on.
63
+ */
64
+ sveltekit<E extends {
65
+ request: Request;
66
+ locals: object;
67
+ }>(user?: (event: E) => CommentsUser | Promise<CommentsUser>): Handle<E>;
68
+ }
69
+ /**
70
+ * Sets up comments from `VISUAL_COMMENTS_*` variables. They are off unless
71
+ * `VISUAL_COMMENTS_ENABLED` is `true`, or `dev` while the dev server runs;
72
+ * on, `VISUAL_COMMENTS_SERVER`, `VISUAL_COMMENTS_SPACE` and
73
+ * `VISUAL_COMMENTS_SECRET` must be set, and with `signIn` outside the dev
74
+ * server the `VISUAL_COMMENTS_OIDC_*` ones and `VISUAL_COMMENTS_SESSION_SECRET`.
75
+ * Throws on a missing or invalid value, so a deployment fails at start rather
76
+ * than when someone comments.
77
+ */
78
+ export declare function commentsFromEnv(env: CommentsEnv, options?: CommentsEnvOptions): CommentsFromEnv;
@@ -0,0 +1,94 @@
1
+ // Comments set up from environment variables, so each deployment turns them on
2
+ // or off without a new build: on in development and staging, say, and absent
3
+ // in production. Off, the site answers no comment requests and the browser
4
+ // never loads the comments' code. (To leave them out of a build altogether, see
5
+ // `visual-comments/vite`.)
6
+ import { commentsProxy } from './index.js';
7
+ import { invalid, read } from './variables.js';
8
+ export { commentsVariables } from './variables.js';
9
+ /** The comments' own sign-in's cookie. */
10
+ const COOKIE = 'visual_comments_session';
11
+ const OIDC = ['VISUAL_COMMENTS_OIDC_ISSUER', 'VISUAL_COMMENTS_OIDC_CLIENT_ID', 'VISUAL_COMMENTS_OIDC_CLIENT_SECRET'];
12
+ /**
13
+ * Sets up comments from `VISUAL_COMMENTS_*` variables. They are off unless
14
+ * `VISUAL_COMMENTS_ENABLED` is `true`, or `dev` while the dev server runs;
15
+ * on, `VISUAL_COMMENTS_SERVER`, `VISUAL_COMMENTS_SPACE` and
16
+ * `VISUAL_COMMENTS_SECRET` must be set, and with `signIn` outside the dev
17
+ * server the `VISUAL_COMMENTS_OIDC_*` ones and `VISUAL_COMMENTS_SESSION_SECRET`.
18
+ * Throws on a missing or invalid value, so a deployment fails at start rather
19
+ * than when someone comments.
20
+ */
21
+ export function commentsFromEnv(env, options = {}) {
22
+ const flag = read(env, 'VISUAL_COMMENTS_ENABLED') ?? 'false';
23
+ if (flag === 'dev' && options.dev === undefined) {
24
+ throw invalid('VISUAL_COMMENTS_ENABLED', 'is dev, so commentsFromEnv needs { dev }: `dev` from $app/env');
25
+ }
26
+ const enabled = flag === 'true' || (flag === 'dev' && options.dev === true);
27
+ if (!enabled) {
28
+ return {
29
+ enabled: false,
30
+ client: null,
31
+ load: async () => null,
32
+ handle: async () => undefined,
33
+ sveltekit: () => ({ event, resolve }) => Promise.resolve(resolve(event))
34
+ };
35
+ }
36
+ const required = (name) => read(env, name) ?? (() => { throw invalid(name, 'must be set while comments are on'); })();
37
+ const path = (read(env, 'VISUAL_COMMENTS_PATH') ?? '/_comments').replace(/(.)\/+$/, '$1');
38
+ const auth = (read(env, 'VISUAL_COMMENTS_AUTH') ?? '/auth').replace(/(.)\/+$/, '$1');
39
+ const proxy = commentsProxy({
40
+ server: required('VISUAL_COMMENTS_SERVER'),
41
+ space: required('VISUAL_COMMENTS_SPACE'),
42
+ secret: required('VISUAL_COMMENTS_SECRET'),
43
+ path
44
+ });
45
+ const client = { endpoint: path, auth };
46
+ if (!options.signIn)
47
+ return { enabled: true, client, load: async () => client, handle: proxy.handle, sveltekit: proxy.sveltekit };
48
+ // The comments' own sign-in: at the provider, or in development with any name.
49
+ const provider = OIDC.some((name) => read(env, name)) || !options.dev;
50
+ const [issuer, clientId, clientSecret] = OIDC.map((name) => (provider ? required(name) : undefined));
51
+ // In development the space's secret makes the key when there is no session secret, so the
52
+ // sessions outlast restarts of the dev server.
53
+ const secret = provider
54
+ ? required('VISUAL_COMMENTS_SESSION_SECRET')
55
+ : read(env, 'VISUAL_COMMENTS_SESSION_SECRET') ?? `visual-comments dev sign-in:${required('VISUAL_COMMENTS_SECRET')}`;
56
+ const devHosts = typeof options.signIn === 'object' ? options.signIn.devHosts : undefined;
57
+ let module;
58
+ // Loaded on the first request, so sites without it can use the rest of this module.
59
+ const load = () => (module ??= import('@cnvx/sign-in/server').then(({ createSignIn, withCookies }) => ({
60
+ signIn: createSignIn({ issuer, clientId, clientSecret, secret, path: auth, cookie: COOKIE, devLogin: !provider, devHosts }),
61
+ withCookies
62
+ }), (error) => { throw new Error('The comments’ own sign-in needs @cnvx/sign-in: install it beside visual-comments.', { cause: error }); }));
63
+ // One check per request: the layout's `load` and the hook share it, and the hook sets the
64
+ // cookies it renews.
65
+ const sessions = new WeakMap();
66
+ const check = (request) => {
67
+ let session = sessions.get(request);
68
+ if (!session)
69
+ sessions.set(request, (session = load().then(({ signIn }) => signIn.check(request))));
70
+ return session;
71
+ };
72
+ return {
73
+ enabled: true,
74
+ client,
75
+ load: async ({ request }) => ({ ...client, viewer: (await check(request)).user ?? null }),
76
+ handle: proxy.handle,
77
+ sveltekit: () => async ({ event, resolve }) => {
78
+ const { signIn, withCookies } = await load();
79
+ const answered = await signIn.handle(event.request);
80
+ if (answered)
81
+ return answered;
82
+ const { pathname } = new URL(event.request.url);
83
+ if (pathname === path || pathname.startsWith(`${path}/`)) {
84
+ const session = await check(event.request);
85
+ const response = await proxy.handle(event.request, session.user);
86
+ if (response)
87
+ return withCookies(response, session.cookies);
88
+ }
89
+ const response = await resolve(event);
90
+ const session = sessions.get(event.request);
91
+ return session ? withCookies(response, (await session).cookies) : response;
92
+ }
93
+ };
94
+ }
@@ -0,0 +1,34 @@
1
+ export interface CommentsProxyOptions {
2
+ /** The comment server, e.g. `https://comments.convex.works`. */
3
+ server: string;
4
+ /** The site's space on it. */
5
+ space: string;
6
+ /** The space's secret. Keep it on the server. */
7
+ secret: string;
8
+ /** Where the browser reaches the comments on this site. Default `/_comments`. */
9
+ path?: string;
10
+ }
11
+ /** The person signed in on the site, as its sign-in knows them. Null or undefined when nobody is. */
12
+ export type CommentsUser = {
13
+ sub: string;
14
+ name: string;
15
+ email?: string;
16
+ picture?: string;
17
+ } | null | undefined;
18
+ /** Matches SvelteKit's `Handle` without depending on SvelteKit. */
19
+ export type Handle<E extends {
20
+ request: Request;
21
+ locals: object;
22
+ }> = (input: {
23
+ event: E;
24
+ resolve: (event: E) => Response | Promise<Response>;
25
+ }) => Promise<Response>;
26
+ export declare function commentsProxy(options: CommentsProxyOptions): {
27
+ path: string;
28
+ handle: (request: Request, user: CommentsUser) => Promise<Response | undefined>;
29
+ sveltekit: <E extends {
30
+ request: Request;
31
+ locals: object;
32
+ }>(user?: (event: E) => CommentsUser | Promise<CommentsUser>) => Handle<E>;
33
+ };
34
+ export { commentsFromEnv, commentsVariables, type CommentsClientConfig, type CommentsEnv, type CommentsEnvName, type CommentsEnvOptions, type CommentsFromEnv, type CommentsSignInOptions } from './env.js';
@@ -0,0 +1,74 @@
1
+ // For the site's server: passes the browser's comment requests on to the
2
+ // comment server, with the space's secret and the person signed in on the site,
3
+ // or the token of an agent of the space.
4
+ // Uses only the Fetch API, so it runs in Node, Bun, Deno and Workers.
5
+ import { AGENT_HEADER, AGENT_TOKEN_PREFIX, USER_HEADER } from '../shared/protocol.js';
6
+ const REQUEST_HEADERS = ['content-type', 'accept', 'last-event-id'];
7
+ const RESPONSE_HEADERS = ['content-type', 'cache-control', 'content-security-policy', 'x-content-type-options', 'x-accel-buffering'];
8
+ export function commentsProxy(options) {
9
+ const server = options.server.replace(/\/$/, '');
10
+ const path = (options.path ?? '/_comments').replace(/\/$/, '');
11
+ const space = encodeURIComponent(options.space);
12
+ /** Answers requests under `path`; undefined for any other request. */
13
+ async function handle(request, user) {
14
+ const url = new URL(request.url);
15
+ if (url.pathname !== path && !url.pathname.startsWith(`${path}/`))
16
+ return undefined;
17
+ const headers = new Headers({ Authorization: `Bearer ${options.secret}` });
18
+ for (const name of REQUEST_HEADERS) {
19
+ const value = request.headers.get(name);
20
+ if (value)
21
+ headers.set(name, value);
22
+ }
23
+ // An agent's token speaks for the agent, even from a browser where someone is signed in.
24
+ // Any other bearer token is the site's business and stays here.
25
+ const token = /^Bearer (\S+)$/.exec(request.headers.get('authorization') ?? '')?.[1];
26
+ if (token?.startsWith(AGENT_TOKEN_PREFIX))
27
+ headers.set(AGENT_HEADER, token);
28
+ else if (user) {
29
+ const { sub, name, email, picture } = user;
30
+ headers.set(USER_HEADER, base64url(JSON.stringify({ sub, name, email, picture })));
31
+ }
32
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
33
+ let response;
34
+ try {
35
+ response = await fetch(`${server}/v1/spaces/${space}${url.pathname.slice(path.length)}${url.search}`, {
36
+ method: request.method,
37
+ headers,
38
+ body: hasBody ? await request.arrayBuffer() : undefined,
39
+ signal: request.signal,
40
+ redirect: 'manual'
41
+ });
42
+ }
43
+ catch {
44
+ return Response.json({ error: 'The comments cannot be reached. Try again.' }, { status: 502, headers: { 'Cache-Control': 'no-store' } });
45
+ }
46
+ const out = new Headers();
47
+ for (const name of RESPONSE_HEADERS) {
48
+ const value = response.headers.get(name);
49
+ if (value)
50
+ out.set(name, value);
51
+ }
52
+ return new Response(response.body, { status: response.status, headers: out });
53
+ }
54
+ /**
55
+ * SvelteKit's `handle` hook. Put it after the sign-in's hook; by default the
56
+ * user is `event.locals.user`.
57
+ */
58
+ function sveltekit(user = (event) => event.locals.user) {
59
+ return async ({ event, resolve }) => {
60
+ const url = new URL(event.request.url);
61
+ if (url.pathname !== path && !url.pathname.startsWith(`${path}/`))
62
+ return resolve(event);
63
+ return (await handle(event.request, await user(event)));
64
+ };
65
+ }
66
+ return { path, handle, sveltekit };
67
+ }
68
+ function base64url(text) {
69
+ let binary = '';
70
+ for (const byte of new TextEncoder().encode(text))
71
+ binary += String.fromCharCode(byte);
72
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
73
+ }
74
+ export { commentsFromEnv, commentsVariables } from './env.js';