kanna-code 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BvhI1JYs.css +32 -0
- package/dist/client/assets/index-CLqK0AWC.js +606 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/agent.test.ts +64 -2
- package/src/server/agent.ts +45 -3
- package/src/server/paths.ts +4 -0
- package/src/server/server.ts +178 -1
- package/src/server/uploads.test.ts +282 -0
- package/src/server/uploads.ts +127 -0
- package/src/shared/protocol.ts +2 -0
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +76 -0
- package/src/shared/types.ts +33 -1
- package/dist/client/assets/index-CtKZIZdD.js +0 -533
- package/dist/client/assets/index-Cwex1U8S.css +0 -32
package/dist/client/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>Kanna</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CLqK0AWC.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BvhI1JYs.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanna-code",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"@xterm/xterm": "^6.0.0",
|
|
55
55
|
"cloudflared": "^0.7.1",
|
|
56
56
|
"default-shell": "^2.2.0",
|
|
57
|
+
"file-type": "^22.0.0",
|
|
57
58
|
"qrcode": "^1.5.4",
|
|
58
59
|
"react-resizable-panels": "^4.7.3"
|
|
59
60
|
},
|
package/src/server/agent.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
|
|
2
|
+
import { AgentCoordinator, buildAttachmentHintText, buildPromptText, normalizeClaudeStreamMessage } from "./agent"
|
|
3
3
|
import type { HarnessTurn } from "./harness-types"
|
|
4
|
-
import type { TranscriptEntry } from "../shared/types"
|
|
4
|
+
import type { ChatAttachment, TranscriptEntry } from "../shared/types"
|
|
5
5
|
|
|
6
6
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
|
|
7
7
|
return {
|
|
@@ -63,6 +63,68 @@ describe("normalizeClaudeStreamMessage", () => {
|
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
describe("attachment prompt helpers", () => {
|
|
67
|
+
test("appends a structured attachment hint block for all attachment kinds", () => {
|
|
68
|
+
const attachments: ChatAttachment[] = [
|
|
69
|
+
{
|
|
70
|
+
id: "image-1",
|
|
71
|
+
kind: "image",
|
|
72
|
+
displayName: "shot.png",
|
|
73
|
+
absolutePath: "/tmp/project/.kanna/uploads/shot.png",
|
|
74
|
+
relativePath: "./.kanna/uploads/shot.png",
|
|
75
|
+
contentUrl: "/api/projects/project-1/uploads/shot.png/content",
|
|
76
|
+
mimeType: "image/png",
|
|
77
|
+
size: 512,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "file-1",
|
|
81
|
+
kind: "file",
|
|
82
|
+
displayName: "spec.pdf",
|
|
83
|
+
absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
|
|
84
|
+
relativePath: "./.kanna/uploads/spec.pdf",
|
|
85
|
+
contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
|
|
86
|
+
mimeType: "application/pdf",
|
|
87
|
+
size: 1234,
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
const prompt = buildPromptText("Review these", attachments)
|
|
92
|
+
expect(prompt).toContain("<kanna-attachments>")
|
|
93
|
+
expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
|
|
94
|
+
expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("supports attachment-only prompts", () => {
|
|
98
|
+
const attachments: ChatAttachment[] = [{
|
|
99
|
+
id: "file-1",
|
|
100
|
+
kind: "file",
|
|
101
|
+
displayName: "todo.txt",
|
|
102
|
+
absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
|
|
103
|
+
relativePath: "./.kanna/uploads/todo.txt",
|
|
104
|
+
contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
|
|
105
|
+
mimeType: "text/plain",
|
|
106
|
+
size: 32,
|
|
107
|
+
}]
|
|
108
|
+
|
|
109
|
+
expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("escapes xml attribute values for attachment hint markup", () => {
|
|
113
|
+
const hint = buildAttachmentHintText([{
|
|
114
|
+
id: "file-1",
|
|
115
|
+
kind: "file",
|
|
116
|
+
displayName: "\"report\" <draft>.txt",
|
|
117
|
+
absolutePath: "/tmp/project/.kanna/uploads/report.txt",
|
|
118
|
+
relativePath: "./.kanna/uploads/report.txt",
|
|
119
|
+
contentUrl: "/api/projects/project-1/uploads/report.txt/content",
|
|
120
|
+
mimeType: "text/plain",
|
|
121
|
+
size: 64,
|
|
122
|
+
}])
|
|
123
|
+
|
|
124
|
+
expect(hint).toContain(""report" <draft>.txt")
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
66
128
|
describe("AgentCoordinator codex integration", () => {
|
|
67
129
|
test("generates a chat title in the background on the first user message", async () => {
|
|
68
130
|
const fakeCodexManager = {
|
package/src/server/agent.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
|
|
2
2
|
import type {
|
|
3
3
|
AgentProvider,
|
|
4
|
+
ChatAttachment,
|
|
4
5
|
NormalizedToolCall,
|
|
5
6
|
PendingToolSnapshot,
|
|
6
7
|
KannaStatus,
|
|
@@ -89,6 +90,41 @@ function stringFromUnknown(value: unknown) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
function escapeXmlAttribute(value: string) {
|
|
94
|
+
return value
|
|
95
|
+
.replaceAll("&", "&")
|
|
96
|
+
.replaceAll("\"", """)
|
|
97
|
+
.replaceAll("<", "<")
|
|
98
|
+
.replaceAll(">", ">")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildAttachmentHintText(attachments: ChatAttachment[]) {
|
|
102
|
+
if (attachments.length === 0) return ""
|
|
103
|
+
|
|
104
|
+
const lines = attachments.map((attachment) => (
|
|
105
|
+
`<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
"<kanna-attachments>",
|
|
110
|
+
...lines,
|
|
111
|
+
"</kanna-attachments>",
|
|
112
|
+
].join("\n")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function buildPromptText(content: string, attachments: ChatAttachment[]) {
|
|
116
|
+
const attachmentHint = buildAttachmentHintText(attachments)
|
|
117
|
+
if (!attachmentHint) {
|
|
118
|
+
return content.trim()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const trimmed = content.trim()
|
|
122
|
+
return [
|
|
123
|
+
trimmed || "Please inspect the attached files.",
|
|
124
|
+
attachmentHint,
|
|
125
|
+
].join("\n\n").trim()
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
function discardedToolResult(
|
|
93
129
|
tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
|
|
94
130
|
) {
|
|
@@ -389,6 +425,7 @@ export class AgentCoordinator {
|
|
|
389
425
|
chatId: string
|
|
390
426
|
provider: AgentProvider
|
|
391
427
|
content: string
|
|
428
|
+
attachments: ChatAttachment[]
|
|
392
429
|
model: string
|
|
393
430
|
effort?: string
|
|
394
431
|
serviceTier?: "fast"
|
|
@@ -409,7 +446,10 @@ export class AgentCoordinator {
|
|
|
409
446
|
const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
|
|
410
447
|
|
|
411
448
|
if (args.appendUserPrompt) {
|
|
412
|
-
await this.store.appendMessage(
|
|
449
|
+
await this.store.appendMessage(
|
|
450
|
+
args.chatId,
|
|
451
|
+
timestamped({ kind: "user_prompt", content: args.content, attachments: args.attachments }, Date.now())
|
|
452
|
+
)
|
|
413
453
|
}
|
|
414
454
|
await this.store.recordTurnStarted(args.chatId)
|
|
415
455
|
|
|
@@ -443,7 +483,7 @@ export class AgentCoordinator {
|
|
|
443
483
|
let turn: HarnessTurn
|
|
444
484
|
if (args.provider === "claude") {
|
|
445
485
|
turn = await startClaudeTurn({
|
|
446
|
-
content: args.content,
|
|
486
|
+
content: buildPromptText(args.content, args.attachments),
|
|
447
487
|
localPath: project.localPath,
|
|
448
488
|
model: args.model,
|
|
449
489
|
effort: args.effort,
|
|
@@ -461,7 +501,7 @@ export class AgentCoordinator {
|
|
|
461
501
|
})
|
|
462
502
|
turn = await this.codexManager.startTurn({
|
|
463
503
|
chatId: args.chatId,
|
|
464
|
-
content: args.content,
|
|
504
|
+
content: buildPromptText(args.content, args.attachments),
|
|
465
505
|
model: args.model,
|
|
466
506
|
effort: args.effort as any,
|
|
467
507
|
serviceTier: args.serviceTier,
|
|
@@ -519,6 +559,7 @@ export class AgentCoordinator {
|
|
|
519
559
|
chatId,
|
|
520
560
|
provider,
|
|
521
561
|
content: command.content,
|
|
562
|
+
attachments: command.attachments ?? [],
|
|
522
563
|
model: settings.model,
|
|
523
564
|
effort: settings.effort,
|
|
524
565
|
serviceTier: settings.serviceTier,
|
|
@@ -600,6 +641,7 @@ export class AgentCoordinator {
|
|
|
600
641
|
chatId: active.chatId,
|
|
601
642
|
provider: active.provider,
|
|
602
643
|
content: active.postToolFollowUp.content,
|
|
644
|
+
attachments: [],
|
|
603
645
|
model: active.model,
|
|
604
646
|
effort: active.effort,
|
|
605
647
|
serviceTier: active.serviceTier,
|
package/src/server/paths.ts
CHANGED
|
@@ -25,3 +25,7 @@ export async function ensureProjectDirectory(localPath: string) {
|
|
|
25
25
|
throw new Error("Project path must be a directory")
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
export function getProjectUploadDir(localPath: string) {
|
|
30
|
+
return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
|
|
31
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
+
import { stat } from "node:fs/promises"
|
|
2
3
|
import { APP_NAME, getRuntimeProfile } from "../shared/branding"
|
|
4
|
+
import type { ChatAttachment } from "../shared/types"
|
|
3
5
|
import { EventStore } from "./event-store"
|
|
4
6
|
import { AgentCoordinator } from "./agent"
|
|
5
7
|
import { discoverProjects, type DiscoveredProject } from "./discovery"
|
|
@@ -9,6 +11,45 @@ import { TerminalManager } from "./terminal-manager"
|
|
|
9
11
|
import { UpdateManager } from "./update-manager"
|
|
10
12
|
import type { UpdateInstallAttemptResult } from "./cli-runtime"
|
|
11
13
|
import { createWsRouter, type ClientState } from "./ws-router"
|
|
14
|
+
import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
|
|
15
|
+
import { getProjectUploadDir } from "./paths"
|
|
16
|
+
|
|
17
|
+
const MAX_UPLOAD_FILES = 10
|
|
18
|
+
const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
export async function persistUploadedFiles(args: {
|
|
21
|
+
projectId: string
|
|
22
|
+
localPath: string
|
|
23
|
+
files: File[]
|
|
24
|
+
persistUpload?: typeof persistProjectUpload
|
|
25
|
+
}): Promise<ChatAttachment[]> {
|
|
26
|
+
const persistUpload = args.persistUpload ?? persistProjectUpload
|
|
27
|
+
const attachments: ChatAttachment[] = []
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
for (const file of args.files) {
|
|
31
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
32
|
+
const attachment = await persistUpload({
|
|
33
|
+
projectId: args.projectId,
|
|
34
|
+
localPath: args.localPath,
|
|
35
|
+
fileName: file.name,
|
|
36
|
+
bytes,
|
|
37
|
+
fallbackMimeType: file.type || undefined,
|
|
38
|
+
})
|
|
39
|
+
attachments.push(attachment)
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await Promise.allSettled(
|
|
43
|
+
attachments.map((attachment) => deleteProjectUpload({
|
|
44
|
+
localPath: args.localPath,
|
|
45
|
+
storedName: path.basename(attachment.absolutePath),
|
|
46
|
+
}))
|
|
47
|
+
)
|
|
48
|
+
throw error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return attachments
|
|
52
|
+
}
|
|
12
53
|
|
|
13
54
|
export interface StartKannaServerOptions {
|
|
14
55
|
port?: number
|
|
@@ -79,7 +120,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
79
120
|
server = Bun.serve<ClientState>({
|
|
80
121
|
port: actualPort,
|
|
81
122
|
hostname,
|
|
82
|
-
fetch(req, serverInstance) {
|
|
123
|
+
async fetch(req, serverInstance) {
|
|
83
124
|
const url = new URL(req.url)
|
|
84
125
|
|
|
85
126
|
if (url.pathname === "/ws") {
|
|
@@ -95,6 +136,21 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
95
136
|
return Response.json({ ok: true, port: actualPort })
|
|
96
137
|
}
|
|
97
138
|
|
|
139
|
+
const uploadResponse = await handleProjectUpload(req, url, store)
|
|
140
|
+
if (uploadResponse) {
|
|
141
|
+
return uploadResponse
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const deleteUploadResponse = await handleProjectUploadDelete(req, url, store)
|
|
145
|
+
if (deleteUploadResponse) {
|
|
146
|
+
return deleteUploadResponse
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const attachmentContentResponse = await handleAttachmentContent(req, url, store)
|
|
150
|
+
if (attachmentContentResponse) {
|
|
151
|
+
return attachmentContentResponse
|
|
152
|
+
}
|
|
153
|
+
|
|
98
154
|
return serveStatic(distDir, url.pathname)
|
|
99
155
|
},
|
|
100
156
|
websocket: {
|
|
@@ -140,6 +196,127 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
140
196
|
}
|
|
141
197
|
}
|
|
142
198
|
|
|
199
|
+
async function handleProjectUpload(req: Request, url: URL, store: EventStore) {
|
|
200
|
+
if (req.method !== "POST") {
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads$/)
|
|
205
|
+
if (!match) {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const project = store.getProject(match[1])
|
|
210
|
+
if (!project) {
|
|
211
|
+
return Response.json({ error: "Project not found" }, { status: 404 })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const formData = await req.formData()
|
|
215
|
+
const files = formData
|
|
216
|
+
.getAll("files")
|
|
217
|
+
.filter((value): value is File => value instanceof File)
|
|
218
|
+
|
|
219
|
+
if (files.length === 0) {
|
|
220
|
+
return Response.json({ error: "No files uploaded" }, { status: 400 })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (files.length > MAX_UPLOAD_FILES) {
|
|
224
|
+
return Response.json({ error: `You can upload up to ${MAX_UPLOAD_FILES} files at a time.` }, { status: 400 })
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
if (file.size > MAX_UPLOAD_SIZE_BYTES) {
|
|
229
|
+
return Response.json(
|
|
230
|
+
{ error: `File "${file.name}" exceeds the ${Math.floor(MAX_UPLOAD_SIZE_BYTES / (1024 * 1024))} MB limit.` },
|
|
231
|
+
{ status: 413 }
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const attachments = await persistUploadedFiles({
|
|
238
|
+
projectId: project.id,
|
|
239
|
+
localPath: project.localPath,
|
|
240
|
+
files,
|
|
241
|
+
})
|
|
242
|
+
return Response.json({ attachments })
|
|
243
|
+
} catch (error) {
|
|
244
|
+
console.error("[uploads] Upload failed:", error)
|
|
245
|
+
return Response.json({ error: "Upload failed" }, { status: 500 })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function handleAttachmentContent(req: Request, url: URL, store: EventStore) {
|
|
250
|
+
const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)\/content$/)
|
|
251
|
+
if (!match) {
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (req.method !== "GET") {
|
|
256
|
+
return new Response(null, {
|
|
257
|
+
status: 405,
|
|
258
|
+
headers: {
|
|
259
|
+
Allow: "GET",
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const project = store.getProject(match[1])
|
|
265
|
+
if (!project) {
|
|
266
|
+
return Response.json({ error: "Project not found" }, { status: 404 })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const storedName = decodeURIComponent(match[2])
|
|
270
|
+
if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
|
|
271
|
+
return Response.json({ error: "Invalid attachment path" }, { status: 400 })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const filePath = path.join(getProjectUploadDir(project.localPath), storedName)
|
|
275
|
+
const file = Bun.file(filePath)
|
|
276
|
+
try {
|
|
277
|
+
const info = await stat(filePath)
|
|
278
|
+
if (!info.isFile()) {
|
|
279
|
+
return Response.json({ error: "Attachment not found" }, { status: 404 })
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
return Response.json({ error: "Attachment not found" }, { status: 404 })
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return new Response(file, {
|
|
286
|
+
headers: {
|
|
287
|
+
"Content-Type": inferAttachmentContentType(storedName, file.type),
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function handleProjectUploadDelete(req: Request, url: URL, store: EventStore) {
|
|
293
|
+
if (req.method !== "DELETE") {
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)$/)
|
|
298
|
+
if (!match) {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const project = store.getProject(match[1])
|
|
303
|
+
if (!project) {
|
|
304
|
+
return Response.json({ error: "Project not found" }, { status: 404 })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const storedName = decodeURIComponent(match[2])
|
|
308
|
+
if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
|
|
309
|
+
return Response.json({ error: "Invalid attachment path" }, { status: 400 })
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const deleted = await deleteProjectUpload({
|
|
313
|
+
localPath: project.localPath,
|
|
314
|
+
storedName,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return Response.json({ ok: deleted })
|
|
318
|
+
}
|
|
319
|
+
|
|
143
320
|
async function serveStatic(distDir: string, pathname: string) {
|
|
144
321
|
const requestedPath = pathname === "/" ? "/index.html" : pathname
|
|
145
322
|
const filePath = path.join(distDir, requestedPath)
|