@miphamai/cli 0.31.1 → 0.32.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/bin/daemon.ts +40 -0
- package/bin/mipham.ts +796 -1
- package/package.json +1 -1
- package/src/agent/background-registry.ts +80 -1
- package/src/daemon/agent-manager.ts +110 -0
- package/src/daemon/attach-protocol.ts +74 -0
- package/src/daemon/auth.ts +104 -0
- package/src/daemon/cors.ts +68 -0
- package/src/daemon/database.ts +449 -0
- package/src/daemon/goal-manager.ts +53 -0
- package/src/daemon/index.ts +307 -0
- package/src/daemon/message-bus.ts +84 -0
- package/src/daemon/rate-limiter.ts +70 -0
- package/src/daemon/remote-engine.ts +434 -0
- package/src/daemon/schedule-manager.ts +159 -0
- package/src/daemon/server.ts +632 -0
- package/src/daemon/session-manager.ts +45 -0
- package/src/daemon/session-worker.ts +418 -0
- package/src/daemon/types.ts +98 -0
- package/src/daemon/worker-pool.ts +174 -0
- package/src/index.tsx +44 -4
- package/src/ui/app.tsx +6 -2
package/package.json
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* Each background task runs as a detached Promise with an AbortController
|
|
5
5
|
* for cancellation. The registry tracks status and stores results, which
|
|
6
6
|
* can be queried via the Task tool (output/stop actions) or the Agent View.
|
|
7
|
+
*
|
|
8
|
+
* When a database is provided via setDatabase(), agent records are persisted
|
|
9
|
+
* to the daemon's SQLite database for cross-session visibility.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
export type BackgroundTaskKind = 'interactive' | 'forked' | 'attached' | 'unattended'
|
|
@@ -29,17 +32,53 @@ export interface BackgroundTask {
|
|
|
29
32
|
abortController: AbortController
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/** Minimal database interface for agent persistence — avoids circular imports
|
|
36
|
+
* on the DaemonDatabase class. */
|
|
37
|
+
interface AgentDatabase {
|
|
38
|
+
createAgent(agent: {
|
|
39
|
+
id: string
|
|
40
|
+
sessionId: string
|
|
41
|
+
parentId: string | null
|
|
42
|
+
agentType: string
|
|
43
|
+
description: string
|
|
44
|
+
status: string
|
|
45
|
+
kind: string
|
|
46
|
+
worktree: string | null
|
|
47
|
+
branch: string | null
|
|
48
|
+
prUrl: string | null
|
|
49
|
+
createdAt: string
|
|
50
|
+
completedAt: string | null
|
|
51
|
+
result: string | null
|
|
52
|
+
error: string | null
|
|
53
|
+
}): unknown
|
|
54
|
+
updateAgentStatus(id: string, status: string, result?: string, error?: string): void
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
type CompleteCallback = (task: BackgroundTask) => void
|
|
33
58
|
|
|
34
59
|
export class BackgroundAgentRegistry {
|
|
35
60
|
private tasks: Map<string, BackgroundTask> = new Map()
|
|
36
61
|
private completeCallbacks: Map<string, CompleteCallback[]> = new Map()
|
|
37
62
|
private idCounter = 0
|
|
63
|
+
private db: AgentDatabase | null = null
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Set the daemon database for optional SQLite persistence.
|
|
67
|
+
* When set, spawned agents are persisted to the database and their
|
|
68
|
+
* status updates are kept in sync.
|
|
69
|
+
*
|
|
70
|
+
* Uses a structural interface to avoid circular imports on DaemonDatabase.
|
|
71
|
+
*/
|
|
72
|
+
setDatabase(db: AgentDatabase): void {
|
|
73
|
+
this.db = db
|
|
74
|
+
}
|
|
38
75
|
|
|
39
76
|
/**
|
|
40
77
|
* Spawn a background task. Returns the task ID immediately.
|
|
41
78
|
* The executor function runs asynchronously; its result is stored in the task.
|
|
42
79
|
*
|
|
80
|
+
* If a database is configured, the agent record is also persisted to SQLite.
|
|
81
|
+
*
|
|
43
82
|
* @param description - Human-readable description
|
|
44
83
|
* @param agentType - Sub-agent type (general, explore, plan, code-review)
|
|
45
84
|
* @param executor - Async function that performs the work
|
|
@@ -51,6 +90,7 @@ export class BackgroundAgentRegistry {
|
|
|
51
90
|
kind: BackgroundTaskKind = 'interactive',
|
|
52
91
|
): string {
|
|
53
92
|
const id = `bg-${++this.idCounter}-${Date.now().toString(36)}`
|
|
93
|
+
const now = new Date()
|
|
54
94
|
|
|
55
95
|
const task: BackgroundTask = {
|
|
56
96
|
id,
|
|
@@ -58,18 +98,43 @@ export class BackgroundAgentRegistry {
|
|
|
58
98
|
agentType,
|
|
59
99
|
status: 'running',
|
|
60
100
|
kind,
|
|
61
|
-
startedAt:
|
|
101
|
+
startedAt: now,
|
|
62
102
|
abortController: new AbortController(),
|
|
63
103
|
}
|
|
64
104
|
|
|
65
105
|
this.tasks.set(id, task)
|
|
66
106
|
|
|
107
|
+
// Persist to SQLite when database is available
|
|
108
|
+
if (this.db) {
|
|
109
|
+
try {
|
|
110
|
+
this.db.createAgent({
|
|
111
|
+
id,
|
|
112
|
+
sessionId: 'background',
|
|
113
|
+
parentId: null,
|
|
114
|
+
agentType,
|
|
115
|
+
description,
|
|
116
|
+
status: 'running',
|
|
117
|
+
kind,
|
|
118
|
+
worktree: task.worktree ?? null,
|
|
119
|
+
branch: task.branch ?? null,
|
|
120
|
+
prUrl: task.prUrl ?? null,
|
|
121
|
+
createdAt: now.toISOString(),
|
|
122
|
+
completedAt: null,
|
|
123
|
+
result: null,
|
|
124
|
+
error: null,
|
|
125
|
+
})
|
|
126
|
+
} catch {
|
|
127
|
+
// Database persistence is best-effort — don't fail the task
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
67
131
|
// Execute in background — do NOT await
|
|
68
132
|
executor(task.abortController.signal)
|
|
69
133
|
.then((result) => {
|
|
70
134
|
task.status = 'completed'
|
|
71
135
|
task.completedAt = new Date()
|
|
72
136
|
task.result = result
|
|
137
|
+
this.syncDbStatus(id, 'completed', result)
|
|
73
138
|
this.fireComplete(id)
|
|
74
139
|
})
|
|
75
140
|
.catch((err) => {
|
|
@@ -82,6 +147,7 @@ export class BackgroundAgentRegistry {
|
|
|
82
147
|
task.error = String(err)
|
|
83
148
|
}
|
|
84
149
|
task.completedAt = new Date()
|
|
150
|
+
this.syncDbStatus(id, task.status, undefined, task.error)
|
|
85
151
|
this.fireComplete(id)
|
|
86
152
|
})
|
|
87
153
|
|
|
@@ -174,6 +240,19 @@ export class BackgroundAgentRegistry {
|
|
|
174
240
|
return counts
|
|
175
241
|
}
|
|
176
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Sync task completion/failure status to the daemon database.
|
|
245
|
+
* Best-effort — errors are silently ignored.
|
|
246
|
+
*/
|
|
247
|
+
private syncDbStatus(id: string, status: string, result?: string, error?: string): void {
|
|
248
|
+
if (!this.db) return
|
|
249
|
+
try {
|
|
250
|
+
this.db.updateAgentStatus(id, status, result, error)
|
|
251
|
+
} catch {
|
|
252
|
+
// Database sync is best-effort
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
177
256
|
private fireComplete(id: string): void {
|
|
178
257
|
const task = this.tasks.get(id)
|
|
179
258
|
if (!task) return
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// apps/cli/src/daemon/agent-manager.ts
|
|
2
|
+
// AgentManager — service layer wrapping the agents DB table from Phase 1.
|
|
3
|
+
// Delegates persistence to DaemonDatabase; adds ID generation on create.
|
|
4
|
+
// Pattern matches SessionManager from Phase 1.
|
|
5
|
+
//
|
|
6
|
+
// Phase 3: Added lifecycle event callbacks for WebSocket broadcast.
|
|
7
|
+
|
|
8
|
+
import type { DaemonDatabase } from './database'
|
|
9
|
+
import type { DaemonAgent, AgentKind } from './types'
|
|
10
|
+
|
|
11
|
+
export interface AgentLifecycleEvent {
|
|
12
|
+
type: 'created' | 'completed' | 'failed'
|
|
13
|
+
agent: DaemonAgent
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type AgentLifecycleCallback = (event: AgentLifecycleEvent) => void
|
|
17
|
+
|
|
18
|
+
export class AgentManager {
|
|
19
|
+
private db: DaemonDatabase
|
|
20
|
+
private lifecycleCallbacks: AgentLifecycleCallback[] = []
|
|
21
|
+
|
|
22
|
+
constructor(db: DaemonDatabase) {
|
|
23
|
+
this.db = db
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
createAgent(
|
|
27
|
+
sessionId: string,
|
|
28
|
+
agentType: string,
|
|
29
|
+
description: string,
|
|
30
|
+
kind: AgentKind = 'interactive',
|
|
31
|
+
parentId: string | null = null,
|
|
32
|
+
): DaemonAgent {
|
|
33
|
+
const id = `agent-${crypto.randomUUID().slice(0, 8)}`
|
|
34
|
+
const now = new Date().toISOString()
|
|
35
|
+
const agent = this.db.createAgent({
|
|
36
|
+
id,
|
|
37
|
+
sessionId,
|
|
38
|
+
parentId,
|
|
39
|
+
agentType,
|
|
40
|
+
description,
|
|
41
|
+
status: 'running',
|
|
42
|
+
kind,
|
|
43
|
+
worktree: null,
|
|
44
|
+
branch: null,
|
|
45
|
+
prUrl: null,
|
|
46
|
+
createdAt: now,
|
|
47
|
+
completedAt: null,
|
|
48
|
+
result: null,
|
|
49
|
+
error: null,
|
|
50
|
+
})
|
|
51
|
+
this.emit({ type: 'created', agent })
|
|
52
|
+
return agent
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getAgent(id: string): DaemonAgent | null {
|
|
56
|
+
return this.db.getAgent(id)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
listAgents(sessionId?: string): DaemonAgent[] {
|
|
60
|
+
return this.db.listAgents(sessionId)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
listRunningAgents(): DaemonAgent[] {
|
|
64
|
+
return this.listAgents().filter((a) => a.status === 'running')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
completeAgent(id: string, result: string): void {
|
|
68
|
+
this.db.updateAgentStatus(id, 'completed', result)
|
|
69
|
+
const updated = this.db.getAgent(id)
|
|
70
|
+
if (updated) {
|
|
71
|
+
this.emit({ type: 'completed', agent: updated })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
failAgent(id: string, error: string): void {
|
|
76
|
+
this.db.updateAgentStatus(id, 'failed', undefined, error)
|
|
77
|
+
const updated = this.db.getAgent(id)
|
|
78
|
+
if (updated) {
|
|
79
|
+
this.emit({ type: 'failed', agent: updated })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
stopAgent(id: string): DaemonAgent | null {
|
|
84
|
+
const agent = this.db.getAgent(id)
|
|
85
|
+
if (!agent) return null
|
|
86
|
+
if (agent.status !== 'running') return agent
|
|
87
|
+
|
|
88
|
+
this.db.updateAgentStatus(id, 'completed')
|
|
89
|
+
const updated = this.db.getAgent(id)
|
|
90
|
+
if (updated) {
|
|
91
|
+
this.emit({ type: 'completed', agent: updated })
|
|
92
|
+
}
|
|
93
|
+
return updated
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Subscribe to agent lifecycle events (created / completed / failed). */
|
|
97
|
+
onLifecycleEvent(callback: AgentLifecycleCallback): void {
|
|
98
|
+
this.lifecycleCallbacks.push(callback)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private emit(event: AgentLifecycleEvent): void {
|
|
102
|
+
for (const cb of this.lifecycleCallbacks) {
|
|
103
|
+
try {
|
|
104
|
+
cb(event)
|
|
105
|
+
} catch {
|
|
106
|
+
// Callback errors must not propagate
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// apps/cli/src/daemon/attach-protocol.ts
|
|
2
|
+
// Client → Daemon: prompt, interrupt
|
|
3
|
+
// Daemon → Client: text, tool_use, tool_result, usage, task_notification, done, error, session_state
|
|
4
|
+
|
|
5
|
+
export interface ClientPromptMessage {
|
|
6
|
+
type: 'prompt'
|
|
7
|
+
sessionId: string
|
|
8
|
+
prompt: string
|
|
9
|
+
}
|
|
10
|
+
export interface ClientInterruptMessage {
|
|
11
|
+
type: 'interrupt'
|
|
12
|
+
sessionId: string
|
|
13
|
+
}
|
|
14
|
+
export type ClientMessage = ClientPromptMessage | ClientInterruptMessage
|
|
15
|
+
|
|
16
|
+
export interface ServerTextMessage {
|
|
17
|
+
type: 'text'
|
|
18
|
+
sessionId: string
|
|
19
|
+
content: string
|
|
20
|
+
}
|
|
21
|
+
export interface ServerToolUseMessage {
|
|
22
|
+
type: 'tool_use'
|
|
23
|
+
sessionId: string
|
|
24
|
+
toolName: string
|
|
25
|
+
toolInput: Record<string, unknown>
|
|
26
|
+
toolId: string
|
|
27
|
+
}
|
|
28
|
+
export interface ServerToolResultMessage {
|
|
29
|
+
type: 'tool_result'
|
|
30
|
+
sessionId: string
|
|
31
|
+
toolId: string
|
|
32
|
+
content: string
|
|
33
|
+
isError?: boolean
|
|
34
|
+
}
|
|
35
|
+
export interface ServerUsageMessage {
|
|
36
|
+
type: 'usage'
|
|
37
|
+
sessionId: string
|
|
38
|
+
inputTokens: number
|
|
39
|
+
outputTokens: number
|
|
40
|
+
}
|
|
41
|
+
export interface ServerTaskNotificationMessage {
|
|
42
|
+
type: 'task_notification'
|
|
43
|
+
sessionId: string
|
|
44
|
+
taskId: string
|
|
45
|
+
status: string
|
|
46
|
+
}
|
|
47
|
+
export interface ServerDoneMessage {
|
|
48
|
+
type: 'done'
|
|
49
|
+
sessionId: string
|
|
50
|
+
stopReason: string
|
|
51
|
+
}
|
|
52
|
+
export interface ServerErrorMessage {
|
|
53
|
+
type: 'error'
|
|
54
|
+
sessionId: string
|
|
55
|
+
message: string
|
|
56
|
+
}
|
|
57
|
+
export interface ServerSessionStateMessage {
|
|
58
|
+
type: 'session_state'
|
|
59
|
+
sessionId: string
|
|
60
|
+
messages: unknown[]
|
|
61
|
+
provider: string
|
|
62
|
+
model: string
|
|
63
|
+
turnCount: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ServerMessage =
|
|
67
|
+
| ServerTextMessage
|
|
68
|
+
| ServerToolUseMessage
|
|
69
|
+
| ServerToolResultMessage
|
|
70
|
+
| ServerUsageMessage
|
|
71
|
+
| ServerTaskNotificationMessage
|
|
72
|
+
| ServerDoneMessage
|
|
73
|
+
| ServerErrorMessage
|
|
74
|
+
| ServerSessionStateMessage
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// apps/cli/src/daemon/auth.ts
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { dirname } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* bun-types@1.3.14 lacks constantTimeCompare in type definitions,
|
|
8
|
+
* though the method exists at Bun 1.2+ runtime.
|
|
9
|
+
*/
|
|
10
|
+
interface PasswordWithCompare {
|
|
11
|
+
constantTimeCompare(a: Buffer, b: Buffer): boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a 64-character hex token using cryptographically secure random bytes.
|
|
16
|
+
*/
|
|
17
|
+
export function generateToken(): string {
|
|
18
|
+
return randomBytes(32).toString('hex')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load an existing token from disk, or create one if it doesn't exist.
|
|
23
|
+
* The token file is created with 0o600 permissions.
|
|
24
|
+
*/
|
|
25
|
+
export function loadOrCreateToken(tokenPath: string): string {
|
|
26
|
+
if (existsSync(tokenPath)) {
|
|
27
|
+
return readFileSync(tokenPath, 'utf-8').trim()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const token = generateToken()
|
|
31
|
+
mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 })
|
|
32
|
+
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
33
|
+
return token
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify a provided token against the expected token.
|
|
38
|
+
* Uses Bun's constant-time comparison to prevent timing attacks.
|
|
39
|
+
*/
|
|
40
|
+
export function verifyToken(expected: string, provided: string): boolean {
|
|
41
|
+
if (!provided || !expected) return false
|
|
42
|
+
return (Bun.password as unknown as PasswordWithCompare).constantTimeCompare(
|
|
43
|
+
Buffer.from(expected),
|
|
44
|
+
Buffer.from(provided),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Generate a new API token, overwrite the token file, and return the new token.
|
|
50
|
+
*/
|
|
51
|
+
export function rotateToken(tokenPath: string): string {
|
|
52
|
+
const token = generateToken()
|
|
53
|
+
writeFileSync(tokenPath, token, { mode: 0o600 })
|
|
54
|
+
return token
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read all tokens from the token file.
|
|
59
|
+
* Currently supports a single token per file; returns it as a single-element array.
|
|
60
|
+
* Returns an empty array if the file does not exist.
|
|
61
|
+
*/
|
|
62
|
+
export function listTokens(tokenPath: string): string[] {
|
|
63
|
+
if (!existsSync(tokenPath)) return []
|
|
64
|
+
const token = readFileSync(tokenPath, 'utf-8').trim()
|
|
65
|
+
return token ? [token] : []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create an auth middleware for Bun.serve that checks the Authorization header.
|
|
70
|
+
* Returns a Response if auth fails, or null if auth passes.
|
|
71
|
+
*
|
|
72
|
+
* In the default 127.0.0.1-only configuration, all requests are implicitly
|
|
73
|
+
* trusted and auth is bypassed. Auth enforcement activates when
|
|
74
|
+
* MIPHAM_BIND=0.0.0.0 for remote access.
|
|
75
|
+
*/
|
|
76
|
+
export function authMiddleware(request: Request, validToken: string): Response | null {
|
|
77
|
+
// Allow health endpoint without auth
|
|
78
|
+
const url = new URL(request.url)
|
|
79
|
+
if (url.pathname === '/api/v1/health') return null
|
|
80
|
+
|
|
81
|
+
// localhost requests skip auth
|
|
82
|
+
const host = request.headers.get('host') || ''
|
|
83
|
+
if (host.startsWith('127.0.0.1') || host.startsWith('localhost') || host.startsWith('[::1]')) {
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const auth = request.headers.get('authorization')
|
|
88
|
+
if (!auth || !auth.startsWith('Bearer ')) {
|
|
89
|
+
return new Response(JSON.stringify({ ok: false, error: 'Missing authorization header' }), {
|
|
90
|
+
status: 401,
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const token = auth.slice(7)
|
|
96
|
+
if (!verifyToken(validToken, token)) {
|
|
97
|
+
return new Response(JSON.stringify({ ok: false, error: 'Invalid token' }), {
|
|
98
|
+
status: 403,
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// apps/cli/src/daemon/cors.ts
|
|
2
|
+
//
|
|
3
|
+
// CORS middleware for the daemon HTTP API.
|
|
4
|
+
// Handles preflight OPTIONS requests and provides helpers for
|
|
5
|
+
// adding CORS headers to responses from external origins.
|
|
6
|
+
|
|
7
|
+
const ALLOWED_HEADERS = 'Authorization, Content-Type'
|
|
8
|
+
const ALLOWED_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check whether an origin string refers to localhost.
|
|
12
|
+
*/
|
|
13
|
+
export function isLocalhostOrigin(origin: string): boolean {
|
|
14
|
+
return origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('[::1]')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle CORS preflight (OPTIONS) requests.
|
|
19
|
+
*
|
|
20
|
+
* Returns a Response with appropriate CORS headers for preflight,
|
|
21
|
+
* or null if the request does not need CORS handling (not a preflight,
|
|
22
|
+
* or origin is localhost).
|
|
23
|
+
*
|
|
24
|
+
* For non-preflight requests with an external Origin, the caller should
|
|
25
|
+
* use `addCorsHeaders` to attach CORS headers to the response.
|
|
26
|
+
*/
|
|
27
|
+
export function corsMiddleware(request: Request): Response | null {
|
|
28
|
+
const origin = request.headers.get('origin')
|
|
29
|
+
if (!origin) return null
|
|
30
|
+
|
|
31
|
+
// Skip localhost origins — no CORS needed
|
|
32
|
+
if (isLocalhostOrigin(origin)) return null
|
|
33
|
+
|
|
34
|
+
// Handle preflight
|
|
35
|
+
if (request.method === 'OPTIONS') {
|
|
36
|
+
return new Response(null, {
|
|
37
|
+
status: 204,
|
|
38
|
+
headers: {
|
|
39
|
+
'Access-Control-Allow-Origin': origin,
|
|
40
|
+
'Access-Control-Allow-Methods': ALLOWED_METHODS,
|
|
41
|
+
'Access-Control-Allow-Headers': ALLOWED_HEADERS,
|
|
42
|
+
'Access-Control-Max-Age': '86400',
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Add CORS headers to a response when the request has an external origin.
|
|
52
|
+
* If the origin is localhost or absent, the response is returned unchanged.
|
|
53
|
+
*/
|
|
54
|
+
export function addCorsHeaders(response: Response, request: Request): Response {
|
|
55
|
+
const origin = request.headers.get('origin')
|
|
56
|
+
if (!origin || isLocalhostOrigin(origin)) return response
|
|
57
|
+
|
|
58
|
+
const headers = new Headers(response.headers)
|
|
59
|
+
headers.set('Access-Control-Allow-Origin', origin)
|
|
60
|
+
headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS)
|
|
61
|
+
headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS)
|
|
62
|
+
|
|
63
|
+
return new Response(response.body, {
|
|
64
|
+
status: response.status,
|
|
65
|
+
statusText: response.statusText,
|
|
66
|
+
headers,
|
|
67
|
+
})
|
|
68
|
+
}
|