@kabmo-labs/kabmo 0.3.1 → 0.4.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 (135) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * SQLite store for waiting prompts (`waiting_prompts`).
8
+ *
9
+ * Two rules shape this file:
10
+ *
11
+ * - **The claim is a write.** `claimWaitingPrompt` flips `waiting →
12
+ * dispatched` in one statement and reports whether it won, so two sweeps —
13
+ * or two Kabmo instances on the shared database — can never both spawn a
14
+ * session for one prompt.
15
+ * - **A terminal row is kept for a while.** The client learns that a prompt
16
+ * was dispatched (and which session it became) by reading this list, so a
17
+ * row that vanished the instant it was claimed would leave a sidebar row
18
+ * with nothing to adopt. Rows are pruned by age instead.
19
+ */
20
+
21
+ import { getDb } from '@/server/db.server';
22
+ import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
23
+ import { normalizeModel } from '@/server/lib/queue/store.server';
24
+ import type { Attachment, QueuedMessageModel } from '@/shared/types/chat';
25
+ import type { WaitingPrompt, WaitingPromptModes, WaitingPromptStatus } from '@/shared/types/waiting';
26
+
27
+ /** How long a finished (dispatched/cancelled/error) row stays readable. */
28
+ export const WAITING_RETENTION_MS = 24 * 60 * 60 * 1000;
29
+
30
+ interface WaitingRow {
31
+ id: string;
32
+ pending_id: string | null;
33
+ depends_on: string;
34
+ depends_on_title: string | null;
35
+ message: string;
36
+ attachments: string;
37
+ provider: string | null;
38
+ model_id: string | null;
39
+ thinking_level: string | null;
40
+ access_mode: string | null;
41
+ modes: string | null;
42
+ cwd: string;
43
+ folder_id: number | null;
44
+ status: string;
45
+ session_id: string | null;
46
+ error: string | null;
47
+ created_at: number;
48
+ updated_at: number;
49
+ }
50
+
51
+ const STATUSES: readonly WaitingPromptStatus[] = ['waiting', 'dispatched', 'cancelled', 'error'];
52
+
53
+ function isStatus(value: unknown): value is WaitingPromptStatus {
54
+ return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
55
+ }
56
+
57
+ /** A JSON column, or null when the text is absent or unparseable. */
58
+ function parseColumn(value: string | null): unknown {
59
+ if (!value) return null;
60
+ try {
61
+ return JSON.parse(value);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function readModel(row: WaitingRow): QueuedMessageModel | null {
68
+ if (!row.provider || !row.model_id) return null;
69
+ return {
70
+ provider: row.provider,
71
+ modelId: row.model_id,
72
+ thinkingLevel: row.thinking_level ?? 'auto',
73
+ accessMode: isApprovalMode(row.access_mode) ? row.access_mode : 'always-ask',
74
+ };
75
+ }
76
+
77
+ function readModes(raw: string | null): WaitingPromptModes | null {
78
+ const parsed = parseColumn(raw);
79
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
80
+ const modes = parsed as Record<string, unknown>;
81
+ return { plan: modes.plan === true, goal: modes.goal === true };
82
+ }
83
+
84
+ function rowToWaitingPrompt(row: WaitingRow): WaitingPrompt {
85
+ const attachments = parseColumn(row.attachments);
86
+ return {
87
+ id: row.id,
88
+ pendingId: row.pending_id,
89
+ dependsOn: row.depends_on,
90
+ dependsOnTitle: row.depends_on_title,
91
+ prompt: row.message,
92
+ attachments: Array.isArray(attachments) ? (attachments as Attachment[]) : [],
93
+ model: readModel(row),
94
+ modes: readModes(row.modes),
95
+ cwd: row.cwd,
96
+ folderId: row.folder_id,
97
+ status: isStatus(row.status) ? row.status : 'error',
98
+ sessionId: row.session_id,
99
+ error: row.error,
100
+ createdAt: row.created_at,
101
+ updatedAt: row.updated_at,
102
+ };
103
+ }
104
+
105
+ export interface WaitingPromptInput {
106
+ pendingId?: string | null;
107
+ dependsOn: string;
108
+ dependsOnTitle?: string | null;
109
+ prompt: string;
110
+ attachments?: unknown;
111
+ model?: unknown;
112
+ modes?: WaitingPromptModes | null;
113
+ cwd: string;
114
+ folderId?: number | null;
115
+ }
116
+
117
+ /** Every row a client may still need: all `waiting` plus recent terminal ones. */
118
+ export async function listWaitingPrompts(now = Date.now()): Promise<WaitingPrompt[]> {
119
+ const db = await getDb();
120
+ const rows = (await db.all(
121
+ `SELECT * FROM waiting_prompts
122
+ WHERE status = 'waiting' OR updated_at >= ?
123
+ ORDER BY created_at ASC`,
124
+ [now - WAITING_RETENTION_MS],
125
+ )) as WaitingRow[];
126
+ return rows.map(rowToWaitingPrompt);
127
+ }
128
+
129
+ export async function getWaitingPrompt(id: string): Promise<WaitingPrompt | null> {
130
+ const db = await getDb();
131
+ const row = (await db.get('SELECT * FROM waiting_prompts WHERE id = ?', [id])) as WaitingRow | undefined;
132
+ return row ? rowToWaitingPrompt(row) : null;
133
+ }
134
+
135
+ /** Rows still waiting on `sessionId` (used when that session is deleted). */
136
+ export async function listWaitingOn(sessionId: string): Promise<WaitingPrompt[]> {
137
+ const db = await getDb();
138
+ const rows = (await db.all(
139
+ "SELECT * FROM waiting_prompts WHERE status = 'waiting' AND depends_on = ? ORDER BY created_at ASC",
140
+ [sessionId],
141
+ )) as WaitingRow[];
142
+ return rows.map(rowToWaitingPrompt);
143
+ }
144
+
145
+ /** Insert one prompt. Returns the stored row (id and clock filled in). */
146
+ export async function createWaitingPrompt(input: WaitingPromptInput): Promise<WaitingPrompt> {
147
+ const db = await getDb();
148
+ const id = `wait-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
149
+ const model = normalizeModel(input.model);
150
+ const now = Date.now();
151
+ await db.run(
152
+ `INSERT INTO waiting_prompts
153
+ (id, pending_id, depends_on, depends_on_title, message, attachments,
154
+ provider, model_id, thinking_level, access_mode, modes, cwd, folder_id,
155
+ status, session_id, error, created_at, updated_at)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, ?, ?)`,
157
+ [
158
+ id,
159
+ input.pendingId ?? null,
160
+ input.dependsOn,
161
+ input.dependsOnTitle ?? null,
162
+ input.prompt,
163
+ JSON.stringify(Array.isArray(input.attachments) ? input.attachments : []),
164
+ model?.provider ?? null,
165
+ model?.modelId ?? null,
166
+ model?.thinkingLevel ?? null,
167
+ model?.accessMode ?? null,
168
+ input.modes ? JSON.stringify({ plan: input.modes.plan === true, goal: input.modes.goal === true }) : null,
169
+ input.cwd,
170
+ input.folderId ?? null,
171
+ now,
172
+ now,
173
+ ],
174
+ );
175
+ const created = await getWaitingPrompt(id);
176
+ if (!created) throw new Error('Waiting prompt was not stored');
177
+ return created;
178
+ }
179
+
180
+ /**
181
+ * Claim one prompt for dispatch. True only for the caller that moved it.
182
+ *
183
+ * This is the whole concurrency story: the sweep may run in several processes,
184
+ * and a prompt must produce exactly one session.
185
+ */
186
+ export async function claimWaitingPrompt(id: string): Promise<boolean> {
187
+ const db = await getDb();
188
+ const result = await db.run(
189
+ "UPDATE waiting_prompts SET status = 'dispatched', updated_at = ? WHERE id = ? AND status = 'waiting'",
190
+ [Date.now(), id],
191
+ );
192
+ return result.changes > 0;
193
+ }
194
+
195
+ /** Record the session a claimed prompt became. */
196
+ export async function markWaitingDispatched(id: string, sessionId: string): Promise<void> {
197
+ const db = await getDb();
198
+ await db.run(
199
+ "UPDATE waiting_prompts SET session_id = ?, updated_at = ? WHERE id = ? AND status = 'dispatched'",
200
+ [sessionId, Date.now(), id],
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Return a claimed prompt to `waiting` — the dispatch threw before a session
206
+ * existed, so the prompt has not been delivered and is still wanted.
207
+ */
208
+ export async function requeueWaitingPrompt(id: string, error: string): Promise<void> {
209
+ const db = await getDb();
210
+ await db.run(
211
+ "UPDATE waiting_prompts SET status = 'waiting', error = ?, updated_at = ? WHERE id = ? AND status = 'dispatched'",
212
+ [error, Date.now(), id],
213
+ );
214
+ }
215
+
216
+ /**
217
+ * Terminal failure: the prompt will not be sent, and the client must say why.
218
+ *
219
+ * Covers BOTH a prompt still waiting and one already claimed: a dispatch that
220
+ * spawned a session and then failed to deliver into it has still ended in
221
+ * failure, and leaving the row at `dispatched` would tell the client a prompt
222
+ * arrived when it did not.
223
+ */
224
+ export async function failWaitingPrompt(id: string, error: string): Promise<void> {
225
+ const db = await getDb();
226
+ await db.run(
227
+ "UPDATE waiting_prompts SET status = 'error', error = ?, updated_at = ? WHERE id = ? AND status IN ('waiting', 'dispatched')",
228
+ [error, Date.now(), id],
229
+ );
230
+ }
231
+
232
+ /** Count one dispatch attempt; returns the new count. */
233
+ export async function countWaitingAttempt(id: string): Promise<number> {
234
+ const db = await getDb();
235
+ await db.run('UPDATE waiting_prompts SET attempts = attempts + 1 WHERE id = ?', [id]);
236
+ const row = (await db.get('SELECT attempts FROM waiting_prompts WHERE id = ?', [id])) as { attempts?: number } | undefined;
237
+ return row?.attempts ?? 1;
238
+ }
239
+
240
+ /** Fail every prompt waiting on a session that no longer exists. */
241
+ export async function failWaitingOn(sessionId: string, reason: string): Promise<number> {
242
+ const db = await getDb();
243
+ const result = await db.run(
244
+ "UPDATE waiting_prompts SET status = 'error', error = ?, updated_at = ? WHERE status = 'waiting' AND depends_on = ?",
245
+ [reason, Date.now(), sessionId],
246
+ );
247
+ return result.changes;
248
+ }
249
+
250
+ /** Cancel one prompt. False when it was already claimed (or is unknown). */
251
+ export async function cancelWaitingPrompt(id: string): Promise<boolean> {
252
+ const db = await getDb();
253
+ const result = await db.run(
254
+ "UPDATE waiting_prompts SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'waiting'",
255
+ [Date.now(), id],
256
+ );
257
+ return result.changes > 0;
258
+ }
259
+
260
+ /** Drop rows past retention; returns how many went. */
261
+ export async function pruneWaitingPrompts(now = Date.now()): Promise<number> {
262
+ const db = await getDb();
263
+ const result = await db.run(
264
+ "DELETE FROM waiting_prompts WHERE status <> 'waiting' AND updated_at < ?",
265
+ [now - WAITING_RETENTION_MS],
266
+ );
267
+ return result.changes;
268
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * "Something that a held prompt may be waiting on just moved."
8
+ *
9
+ * A one-function slot rather than a direct import, because the producer is the
10
+ * follow-up queue's delivery path and the consumer is the waiting-prompt
11
+ * runtime — and the runtime already imports the queue (it has to ask whether a
12
+ * dependency still holds a queued item, and whether a delivery is armed). A
13
+ * direct edge back would close that cycle.
14
+ *
15
+ * Anchored on `globalThis` for the reason the other process-wide slots are: a
16
+ * `bun --hot` reload re-evaluates the runtime module, and a module-level binding
17
+ * would leave the previous generation's callback installed.
18
+ */
19
+
20
+ const SLOT = '__kabmoWaitingSweepRequest';
21
+
22
+ type SweepRequest = (delayMs: number) => void;
23
+
24
+ /** Install (or clear) the runtime's sweep request. Called by the runtime. */
25
+ export function setWaitingSweepHandler(handler: SweepRequest | null): void {
26
+ (globalThis as unknown as Record<string, SweepRequest | null | undefined>)[SLOT] = handler;
27
+ }
28
+
29
+ /**
30
+ * Ask the waiting runtime for a sweep. A no-op when no runtime is installed —
31
+ * which only happens before boot finished, when there is nothing to sweep yet.
32
+ */
33
+ export function requestWaitingSweep(delayMs = 1_600): void {
34
+ (globalThis as unknown as Record<string, SweepRequest | undefined>)[SLOT]?.(delayMs);
35
+ }
@@ -19,14 +19,25 @@ import { brotliCompressSync, constants } from 'node:zlib';
19
19
  * 0.007 ms for gzip at 2 KiB). Brotli remains for a client that accepts `br`
20
20
  * but not `zstd`, and gzip is the floor every client has.
21
21
  */
22
- const MIN_BYTES = 1024;
22
+ export const MIN_BYTES = 1024;
23
23
 
24
24
  const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|wasm|svg))/;
25
25
 
26
26
  /** Compression encodings this server can produce, best first. */
27
27
  const ENCODINGS = ['zstd', 'br', 'gzip'] as const;
28
28
 
29
- type Encoding = (typeof ENCODINGS)[number];
29
+ export type Encoding = (typeof ENCODINGS)[number];
30
+
31
+ /**
32
+ * Whether a content type is worth compressing at all.
33
+ *
34
+ * Exported because the static asset route decides the same thing before it
35
+ * reads a file: buffering a PNG or a woff2 to discover it compresses to itself
36
+ * is work that buys nothing.
37
+ */
38
+ export function isCompressible(contentType: string): boolean {
39
+ return COMPRESSIBLE.test(contentType);
40
+ }
30
41
 
31
42
  /**
32
43
  * The best encoding the client accepts, or null.
@@ -35,14 +46,15 @@ type Encoding = (typeof ENCODINGS)[number];
35
46
  * browser's `Accept-Encoding` is an explicit set of alternatives, not a
36
47
  * preference ordering this server has to weigh.
37
48
  */
38
- function negotiateEncoding(acceptEncoding: string): Encoding | null {
49
+ export function pickEncoding(acceptEncoding: string | null): Encoding | null {
50
+ if (!acceptEncoding) return null;
39
51
  for (const encoding of ENCODINGS) {
40
52
  if (acceptEncoding.includes(encoding)) return encoding;
41
53
  }
42
54
  return null;
43
55
  }
44
56
 
45
- function compress(body: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array {
57
+ export function compressBytes(body: Uint8Array<ArrayBuffer>, encoding: Encoding): Uint8Array {
46
58
  if (encoding === 'zstd') return Bun.zstdCompressSync(body, { level: 3 });
47
59
  if (encoding === 'br') {
48
60
  return brotliCompressSync(body, { params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
@@ -57,7 +69,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
57
69
  if (!COMPRESSIBLE.test(contentType)) return response;
58
70
  if (response.status === 204 || response.status === 304) return response;
59
71
 
60
- const encoding = negotiateEncoding(acceptEncoding);
72
+ const encoding = pickEncoding(acceptEncoding);
61
73
  if (!encoding) return response;
62
74
 
63
75
  const buffer = new Uint8Array(await response.arrayBuffer()) as Uint8Array<ArrayBuffer>;
@@ -66,7 +78,7 @@ export async function maybeCompress(response: Response, acceptEncoding: string |
66
78
  return rebuild(response, buffer);
67
79
  }
68
80
 
69
- const compressed = compress(buffer, encoding);
81
+ const compressed = compressBytes(buffer, encoding);
70
82
  const headers = new Headers(response.headers);
71
83
  headers.set('content-encoding', encoding);
72
84
  headers.set('content-length', String(compressed.byteLength));
@@ -2,6 +2,7 @@ import { Elysia } from 'elysia';
2
2
  import { getDb } from '@/server/db.server';
3
3
  import { isAuthEnabled, isAuthenticatedRequest } from '@/server/lib/auth/guard';
4
4
  import { tryServeStatic } from '@/server/plugins/static';
5
+ import { maybeCompress } from '@/server/plugins/compress';
5
6
  import { renderShell, SHELL_MARKER } from '@/server/plugins/shell.server';
6
7
  import {
7
8
  DEV_ASSETS_ENABLED,
@@ -199,13 +200,20 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
199
200
  // so a `/_bun/` string inside the settings payload is left alone.
200
201
  const shellHtml = DEV_ASSETS_ENABLED ? rewriteDevAssetUrls(rendered.html) : rendered.html;
201
202
 
202
- return new Response(
203
- shellHtml
204
- .replace('data-theme="paper"', `data-theme="${theme.id}"`)
205
- .replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
206
- .replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${chrome}" />`)
207
- .replace('<!--app-head-->', themeStyle + fontLink)
208
- .replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
209
- { headers: HTML_HEADERS },
203
+ return maybeCompress(
204
+ new Response(
205
+ shellHtml
206
+ .replace('data-theme="paper"', `data-theme="${theme.id}"`)
207
+ .replace('data-theme-variant="light"', `data-theme-variant="${theme.variant}"`)
208
+ .replace('<meta name="theme-color" content="#faf8f3" />', `<meta name="theme-color" content="${chrome}" />`)
209
+ .replace('<!--app-head-->', themeStyle + fontLink)
210
+ .replace(SHELL_MARKER, `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
211
+ { headers: HTML_HEADERS },
212
+ ),
213
+ // 24 KB of markup the browser can never store (`no-store`), so every
214
+ // navigation pays for it again — compressed, it is about a fifth of that.
215
+ // `maybeCompress` adds the `Vary` this response needs for the same reason
216
+ // the static route does.
217
+ request.headers.get('accept-encoding'),
210
218
  );
211
219
  });
@@ -17,6 +17,11 @@ beforeAll(() => {
17
17
  writeFileSync(join(PUBLIC_DIR, 'manifest.webmanifest'), '{"name":"Kabmo"}');
18
18
  writeFileSync(join(PUBLIC_DIR, 'sw.js'), 'self.addEventListener("fetch", () => {});');
19
19
  writeFileSync(join(PUBLIC_DIR, 'index.html'), '<!doctype html><title>shell</title>');
20
+ // Above the compression floor, and compressible: the production bundle is the
21
+ // asset this rule exists for, so a fixture too small to compress would pass
22
+ // while every real client still downloaded 10 MB.
23
+ writeFileSync(join(PUBLIC_DIR, 'bundle.js'), `const x = 1;\n${'// padding\n'.repeat(200)}`);
24
+ writeFileSync(join(PUBLIC_DIR, 'photo.png'), new Uint8Array(2048));
20
25
  });
21
26
 
22
27
  afterAll(() => {
@@ -58,3 +63,57 @@ describe('servePublicAsset', () => {
58
63
  expect(await servePublicAsset('/nope.svg', ROOTS)).toBeNull();
59
64
  });
60
65
  });
66
+
67
+ describe('servePublicAsset compression', () => {
68
+ const accept = (encoding: string) =>
69
+ new Request('http://127.0.0.1/bundle.js', { headers: { 'accept-encoding': encoding } });
70
+
71
+ test('sends a compressible asset compressed, and says so', async () => {
72
+ const response = await servePublicAsset('/bundle.js', ROOTS, accept('gzip, zstd'));
73
+ expect(response?.headers.get('content-encoding')).toBe('zstd');
74
+ expect(response?.headers.get('vary')).toBe('accept-encoding');
75
+ // The declared length is the COMPRESSED one, or a client that trusts it
76
+ // would wait for bytes that are never sent.
77
+ const body = new Uint8Array(await response!.arrayBuffer());
78
+ expect(Number(response?.headers.get('content-length'))).toBe(body.byteLength);
79
+ expect(body.byteLength).toBeLessThan(2000);
80
+ });
81
+
82
+ // The body must decode back to the file's own bytes, not merely be smaller.
83
+ test('the compressed body decompresses to the file', async () => {
84
+ const response = await servePublicAsset('/bundle.js', ROOTS, accept('gzip'));
85
+ const body = new Uint8Array(await response!.arrayBuffer());
86
+ expect(Bun.gunzipSync(body)).toEqual(new Uint8Array(await Bun.file(join(PUBLIC_DIR, 'bundle.js')).arrayBuffer()));
87
+ });
88
+
89
+ test('a client that accepts nothing gets the raw bytes, with no Vary claim to honour', async () => {
90
+ const response = await servePublicAsset('/bundle.js', ROOTS, new Request('http://127.0.0.1/bundle.js'));
91
+ expect(response?.headers.get('content-encoding')).toBeNull();
92
+ expect(response?.headers.get('vary')).toBe('accept-encoding');
93
+ expect(Number(response?.headers.get('content-length'))).toBe(
94
+ (await Bun.file(join(PUBLIC_DIR, 'bundle.js')).stat()).size,
95
+ );
96
+ });
97
+
98
+ // A PNG compresses to itself, so buffering one to find that out is work that
99
+ // buys nothing — and `Vary` would fragment its cache entry for no reason.
100
+ test('leaves an already-compressed type alone', async () => {
101
+ const response = await servePublicAsset('/photo.png', ROOTS, accept('gzip, zstd'));
102
+ expect(response?.headers.get('content-encoding')).toBeNull();
103
+ expect(response?.headers.get('vary')).toBeNull();
104
+ });
105
+
106
+ // The `304` must carry `Vary` too: a shared cache that stored the identity
107
+ // variant would otherwise hand it to a client that accepts gzip.
108
+ test('the 304 carries the same Vary as the body it stands in for', async () => {
109
+ const first = await servePublicAsset('/bundle.js', ROOTS, accept('gzip'));
110
+ const etag = first!.headers.get('etag')!;
111
+ const second = await servePublicAsset(
112
+ '/bundle.js',
113
+ ROOTS,
114
+ new Request('http://127.0.0.1/bundle.js', { headers: { 'accept-encoding': 'gzip', 'if-none-match': etag } }),
115
+ );
116
+ expect(second?.status).toBe(304);
117
+ expect(second?.headers.get('vary')).toBe('accept-encoding');
118
+ });
119
+ });
@@ -1,22 +1,34 @@
1
1
  import { extname, join, normalize, resolve, sep } from 'path';
2
2
 
3
3
  import { notModified } from '@/server/lib/assets/conditional.server';
4
+ import { compressedAsset } from '@/server/lib/assets/compressed-cache.server';
4
5
  import { FONT_STYLESHEET_ROUTE, serveFontFile, serveFontStylesheet } from '@/server/lib/assets/font-css.server';
5
6
  import { FONT_ROUTE_PREFIX } from '@/server/lib/assets/fonts.server';
6
7
  import { packageDir } from '@/server/lib/fs/package-root';
8
+ import { compressBytes, isCompressible, maybeCompress, MIN_BYTES, pickEncoding } from '@/server/plugins/compress';
7
9
 
8
10
  /**
9
- * File serving for `public/` and the font routes.
11
+ * File serving for `public/`, the font routes and the production build's own
12
+ * assets.
10
13
  *
11
- * The client bundle is NOT served from here. Bun's own routing owns the assets
12
- * it emits — `/_bun/asset/*` and `/_bun/client/*` in development, `/chunk-*` in
13
- * production — and answers them from its bundle table before a request reaches
14
- * Elysia. In development those responses are reached a second way, through
15
- * `lib/assets/dev-assets.server.ts`: Bun's table wins for the paths it declares,
16
- * so the shell points at `/_dev-assets/*` and that module fetches the bytes back
17
- * from the listener to add the `Cache-Control` and `ETag`/`304` handling Bun's
18
- * dev asset routes lack. `dist/client` no longer exists, so the `/static/`
19
- * branch and its content-hashed caching went with it.
14
+ * Which layer serves the client bundle depends on how the server was started,
15
+ * and both shapes are real:
16
+ *
17
+ * - `kabmo serve` (and `bun run start` after `bun run build`) runs the AOT
18
+ * bundle from `dist/client/index.js`, whose assets are `/static/*` FILES — the
19
+ * `/static/` branch below serves them, with the compression and validators
20
+ * this module adds.
21
+ * - Running `src/server/index.ts` with `NODE_ENV=production` makes Bun bundle
22
+ * the HTML route itself and answer `/chunk-*` from its own routing table,
23
+ * before a request reaches Elysia. Those responses cannot be compressed from
24
+ * here: Bun's table wins for the paths it declares, and `Bun.serve` has no
25
+ * compression option (verified — `compression: 'gzip'` left a 650 KB body
26
+ * untouched). This is the source-run shape, not the installed product.
27
+ * - In development the same table serves `/_bun/asset/*` and `/_bun/client/*`,
28
+ * and the shell points at `/_dev-assets/*` so
29
+ * `lib/assets/dev-assets.server.ts` can fetch the bytes back from the
30
+ * listener and add the `Cache-Control` and `ETag`/`304` handling Bun's dev
31
+ * asset routes lack.
20
32
  *
21
33
  * Hand-rolled rather than `@elysiajs/static`: that plugin registers its own
22
34
  * catch-all, which would shadow this app's SSR route. Exposing one lookup
@@ -80,6 +92,14 @@ function resolveWithin(root: string, pathname: string): string | null {
80
92
  * Size+mtime is what `plugins/static.ts` can produce from the `stat` it already
81
93
  * performs, and a wrong `304` from it needs both a same-size edit and a
82
94
  * preserved mtime, which is not something a build or an editor does.
95
+ *
96
+ * A compressible asset is sent compressed, because production served it raw:
97
+ * the client bundle is 10.4 MB and reached every first visit at full size.
98
+ * `lib/assets/compressed-cache.server.ts` keeps the result per ETag, so the
99
+ * ~20 ms zstd pass happens once per build rather than once per client. The
100
+ * `Vary` header is set on EVERY response for such a type, the `304` included —
101
+ * a shared cache that stored the identity variant would otherwise hand it to a
102
+ * client that accepts gzip.
83
103
  */
84
104
  async function serveFile(filePath: string, cacheControl: string, request?: Request): Promise<Response | null> {
85
105
  let stat;
@@ -95,20 +115,36 @@ async function serveFile(filePath: string, cacheControl: string, request?: Reque
95
115
  // Weak on purpose: the validator identifies the file as the same version, and
96
116
  // byte equality is not what it promises.
97
117
  const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs / 1000)}"`;
98
- const headers = {
118
+ const compressible = isCompressible(type);
119
+ const headers: Record<string, string> = {
99
120
  'content-type': type,
100
121
  'cache-control': cacheControl,
101
122
  'last-modified': lastModified,
102
123
  etag,
103
124
  };
125
+ if (compressible) headers.vary = 'accept-encoding';
104
126
 
105
127
  if (request && notModified(request, etag, stat.mtimeMs)) {
106
128
  // No `content-length`: a 304 describes the validator, not a body.
107
129
  return new Response(null, { status: 304, headers });
108
130
  }
109
131
 
110
- return new Response(Bun.file(filePath).stream(), {
111
- headers: { ...headers, 'content-length': String(stat.size) },
132
+ const encoding = request && compressible && stat.size >= MIN_BYTES
133
+ ? pickEncoding(request.headers.get('accept-encoding'))
134
+ : null;
135
+ if (!encoding) {
136
+ return new Response(Bun.file(filePath).stream(), {
137
+ headers: { ...headers, 'content-length': String(stat.size) },
138
+ });
139
+ }
140
+
141
+ const bytes = await compressedAsset(`${etag}:${encoding}`, async () =>
142
+ compressBytes(
143
+ new Uint8Array(await Bun.file(filePath).arrayBuffer()) as Uint8Array<ArrayBuffer>,
144
+ encoding,
145
+ ));
146
+ return new Response(bytes, {
147
+ headers: { ...headers, 'content-encoding': encoding, 'content-length': String(bytes.byteLength) },
112
148
  });
113
149
  }
114
150
 
@@ -156,7 +192,13 @@ export async function tryServeStatic(pathname: string, request?: Request): Promi
156
192
  // Neither is given a conditional here: the stylesheet is `no-store` (nothing
157
193
  // is ever stored to revalidate) and a font file is `immutable` (the browser
158
194
  // does not ask again). A validator on either would be dead code.
159
- if (pathname === FONT_STYLESHEET_ROUTE) return serveFontStylesheet();
195
+ //
196
+ // The stylesheet IS compressed, though: it is 4 KB of text served on every
197
+ // page load because it cannot be stored, which is exactly the kind of asset
198
+ // a phone pays for repeatedly.
199
+ if (pathname === FONT_STYLESHEET_ROUTE) {
200
+ return maybeCompress(await serveFontStylesheet(), request?.headers.get('accept-encoding') ?? null);
201
+ }
160
202
  if (pathname.startsWith(FONT_ROUTE_PREFIX)) {
161
203
  return serveFontFile(decodeURIComponent(pathname.slice(FONT_ROUTE_PREFIX.length)));
162
204
  }
@@ -8,6 +8,7 @@ import { chamberBindings } from '@/server/routes/chamber';
8
8
  import { chatBindings } from '@/server/routes/chat';
9
9
  import { sessionsBindings } from '@/server/routes/sessions';
10
10
  import { scheduleBindings } from '@/server/routes/schedule/bindings';
11
+ import { waitingBindings } from '@/server/routes/waiting/bindings';
11
12
  import { settingsBindings } from '@/server/routes/settings';
12
13
  import { fsBindings } from '@/server/routes/fs';
13
14
  import { foldersBindings } from '@/server/routes/folders';
@@ -37,6 +38,7 @@ const allBindings: HandlerBinding[] = [
37
38
  ...chatBindings,
38
39
  ...sessionsBindings,
39
40
  ...scheduleBindings,
41
+ ...waitingBindings,
40
42
  ...settingsBindings,
41
43
  ...fsBindings,
42
44
  ...foldersBindings,
@@ -0,0 +1,14 @@
1
+ import { actionBindings, type HandlerBinding } from '@/server/lib/route-adapter';
2
+ import * as waiting from '@/server/routes/waiting/index';
3
+
4
+ /**
5
+ * `/api/waiting` — the collection (GET/POST) and cancel (DELETE `?id=`).
6
+ *
7
+ * One path, two mutating verbs: cancel names its row in the QUERY, so no `:id`
8
+ * segment shares a position with anything else in the tree.
9
+ */
10
+ export const waitingBindings: HandlerBinding[] = actionBindings(
11
+ waiting.action,
12
+ '/api/waiting',
13
+ waiting.loader,
14
+ );