lopata 0.20.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/bindings/ai-search.d.ts +44 -0
- package/dist/types/bindings/artifacts-git-http.d.ts +19 -0
- package/dist/types/bindings/artifacts.d.ts +108 -0
- package/dist/types/bindings/flagship.d.ts +37 -0
- package/dist/types/bindings/vpc-network.d.ts +22 -0
- package/dist/types/bindings/worker-loader-entry.d.ts +71 -0
- package/dist/types/bindings/worker-loader.d.ts +81 -0
- package/dist/types/config.d.ts +23 -0
- package/dist/types/env.d.ts +3 -1
- package/dist/types/generation-manager.d.ts +6 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/dev-server-plugin.d.ts +8 -0
- package/dist/types/worker-thread/executor.d.ts +2 -0
- package/dist/types/worker-thread/protocol.d.ts +2 -0
- package/dist/types/worker-thread/thread-env.d.ts +3 -1
- package/package.json +1 -1
- package/src/bindings/ai-search.ts +185 -0
- package/src/bindings/artifacts-git-http.ts +249 -0
- package/src/bindings/artifacts.ts +453 -0
- package/src/bindings/flagship.ts +117 -0
- package/src/bindings/vpc-network.ts +40 -0
- package/src/bindings/worker-loader-entry.ts +143 -0
- package/src/bindings/worker-loader.ts +325 -0
- package/src/cli/dev.ts +25 -3
- package/src/config.ts +20 -0
- package/src/db.ts +54 -0
- package/src/env.ts +71 -0
- package/src/generation-manager.ts +5 -1
- package/src/generation.ts +20 -3
- package/src/vite-plugin/dev-server-plugin.ts +18 -2
- package/src/worker-thread/entry.ts +1 -0
- package/src/worker-thread/executor.ts +3 -0
- package/src/worker-thread/protocol.ts +2 -0
- package/src/worker-thread/thread-env.ts +68 -1
|
@@ -28,4 +28,12 @@ interface DevServerPluginOptions {
|
|
|
28
28
|
* via link:), so dynamic imports here run through Bun's native loader.
|
|
29
29
|
*/
|
|
30
30
|
export declare function devServerPlugin(options: DevServerPluginOptions): Plugin;
|
|
31
|
+
/**
|
|
32
|
+
* Convert Response headers to a node writeHead record. `set-cookie` must go
|
|
33
|
+
* through `getSetCookie()`: Headers iteration yields it once per cookie (and a
|
|
34
|
+
* keyed record would keep only the last one), so a multi-cookie response —
|
|
35
|
+
* e.g. better-auth's session_token + session_data — would silently lose
|
|
36
|
+
* cookies. Exported for tests.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildNodeHeaders(response: Response): Record<string, string | string[]>;
|
|
31
39
|
export {};
|
|
@@ -24,6 +24,8 @@ export interface WorkerThreadExecutorOptions {
|
|
|
24
24
|
executablePath?: string;
|
|
25
25
|
headless?: boolean;
|
|
26
26
|
};
|
|
27
|
+
/** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
|
|
28
|
+
artifactsBaseUrl?: string;
|
|
27
29
|
/** Main-thread env holding the stateful binding instances the worker calls into via RPC. */
|
|
28
30
|
mainEnv: Record<string, unknown>;
|
|
29
31
|
}
|
|
@@ -92,6 +92,8 @@ export interface WorkerInitConfig {
|
|
|
92
92
|
executablePath?: string;
|
|
93
93
|
headless?: boolean;
|
|
94
94
|
};
|
|
95
|
+
/** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
|
|
96
|
+
artifactsBaseUrl?: string;
|
|
95
97
|
}
|
|
96
98
|
/** Names of the worker handlers we know how to invoke via RPC. */
|
|
97
99
|
export type WorkerHandlerName = 'fetch' | 'scheduled' | 'email' | 'queue';
|
|
@@ -26,6 +26,8 @@ export interface ThreadEnvOptions {
|
|
|
26
26
|
executablePath?: string;
|
|
27
27
|
headless?: boolean;
|
|
28
28
|
};
|
|
29
|
+
/** Base URL for the Artifacts git remote (main's `/__artifacts/git` endpoint). */
|
|
30
|
+
artifactsBaseUrl?: string;
|
|
29
31
|
}
|
|
30
32
|
export interface ThreadEnvBuilt {
|
|
31
33
|
env: Record<string, unknown>;
|
|
@@ -39,4 +41,4 @@ export interface ThreadEnvBuilt {
|
|
|
39
41
|
binding: SqliteWorkflowBinding;
|
|
40
42
|
}[];
|
|
41
43
|
}
|
|
42
|
-
export declare function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, browserConfig }: ThreadEnvOptions): ThreadEnvBuilt;
|
|
44
|
+
export declare function buildThreadEnv({ config, baseDir, dataDir, rpc, envWsBridge, browserConfig, artifactsBaseUrl }: ThreadEnvOptions): ThreadEnvBuilt;
|
package/package.json
CHANGED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local implementation of the Cloudflare AI Search namespace binding
|
|
3
|
+
* (`ai_search_namespaces`).
|
|
4
|
+
*
|
|
5
|
+
* Running vector search + RAG locally is impractical, so this binding
|
|
6
|
+
* proxies to the real Cloudflare AI Search REST API — same strategy as
|
|
7
|
+
* `AiBinding`. Every proxied call is logged to SQLite for the dashboard.
|
|
8
|
+
*
|
|
9
|
+
* Requires `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` in `.dev.vars`
|
|
10
|
+
* or the environment.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUIDv7 } from 'bun'
|
|
13
|
+
import type { Database } from 'bun:sqlite'
|
|
14
|
+
|
|
15
|
+
const MAX_LOG_SIZE = 1024
|
|
16
|
+
|
|
17
|
+
function truncate(value: unknown): string {
|
|
18
|
+
const str = typeof value === 'string' ? value : JSON.stringify(value)
|
|
19
|
+
if (!str) return ''
|
|
20
|
+
return str.length > MAX_LOG_SIZE ? str.slice(0, MAX_LOG_SIZE) + '…' : str
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CreateInstanceOptions {
|
|
24
|
+
id: string
|
|
25
|
+
type?: string
|
|
26
|
+
source?: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SearchOptions {
|
|
30
|
+
messages: { role: string; content: string }[]
|
|
31
|
+
ai_search_options?: {
|
|
32
|
+
instance_ids?: string[]
|
|
33
|
+
[key: string]: unknown
|
|
34
|
+
}
|
|
35
|
+
stream?: boolean
|
|
36
|
+
[key: string]: unknown
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class AiSearchInstance {
|
|
40
|
+
private readonly binding: AiSearchNamespaceBinding
|
|
41
|
+
readonly id: string
|
|
42
|
+
readonly metadata: Record<string, unknown>
|
|
43
|
+
|
|
44
|
+
constructor(binding: AiSearchNamespaceBinding, id: string, metadata: Record<string, unknown> = {}) {
|
|
45
|
+
this.binding = binding
|
|
46
|
+
this.id = id
|
|
47
|
+
this.metadata = metadata
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async search(options: SearchOptions): Promise<unknown> {
|
|
51
|
+
return this.binding._proxyInstance('search', this.id, options)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async chatCompletions(options: SearchOptions): Promise<unknown> {
|
|
55
|
+
return this.binding._proxyInstance('chatCompletions', this.id, options)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class AiSearchNamespaceBinding {
|
|
60
|
+
private readonly db: Database
|
|
61
|
+
private readonly namespace: string
|
|
62
|
+
private readonly accountId?: string
|
|
63
|
+
private readonly apiToken?: string
|
|
64
|
+
|
|
65
|
+
constructor(db: Database, namespace: string, accountId?: string, apiToken?: string) {
|
|
66
|
+
this.db = db
|
|
67
|
+
this.namespace = namespace
|
|
68
|
+
this.accountId = accountId
|
|
69
|
+
this.apiToken = apiToken
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private ensureCredentials(): { accountId: string; apiToken: string } {
|
|
73
|
+
if (!this.accountId || !this.apiToken) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
'AI Search requires CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN in .dev.vars',
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return { accountId: this.accountId, apiToken: this.apiToken }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async create(opts: CreateInstanceOptions): Promise<AiSearchInstance> {
|
|
82
|
+
const body = await this.proxy('POST', '/ai-search/instances', opts, 'create')
|
|
83
|
+
const result = (body as { result?: Record<string, unknown> }).result ?? {}
|
|
84
|
+
return new AiSearchInstance(this, opts.id, result)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async get(id: string): Promise<AiSearchInstance> {
|
|
88
|
+
const body = await this.proxy('GET', `/ai-search/instances/${encodeURIComponent(id)}`, undefined, 'get')
|
|
89
|
+
const result = (body as { result?: Record<string, unknown> }).result ?? {}
|
|
90
|
+
return new AiSearchInstance(this, id, result)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async list(opts?: Record<string, string>): Promise<unknown> {
|
|
94
|
+
const query = opts ? '?' + new URLSearchParams(opts).toString() : ''
|
|
95
|
+
return this.proxy('GET', `/ai-search/instances${query}`, undefined, 'list')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async delete(id: string): Promise<boolean> {
|
|
99
|
+
await this.proxy('DELETE', `/ai-search/instances/${encodeURIComponent(id)}`, undefined, 'delete')
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async search(options: SearchOptions): Promise<unknown> {
|
|
104
|
+
return this.proxy('POST', `/ai-search/namespaces/${encodeURIComponent(this.namespace)}/search`, options, 'search')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async chatCompletions(options: SearchOptions): Promise<unknown> {
|
|
108
|
+
return this.proxy(
|
|
109
|
+
'POST',
|
|
110
|
+
`/ai-search/namespaces/${encodeURIComponent(this.namespace)}/chat/completions`,
|
|
111
|
+
options,
|
|
112
|
+
'chatCompletions',
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @internal — called by AiSearchInstance */
|
|
117
|
+
async _proxyInstance(method: 'search' | 'chatCompletions', instanceId: string, options: SearchOptions): Promise<unknown> {
|
|
118
|
+
const path = method === 'search'
|
|
119
|
+
? `/ai-search/instances/${encodeURIComponent(instanceId)}/search`
|
|
120
|
+
: `/ai-search/instances/${encodeURIComponent(instanceId)}/chat/completions`
|
|
121
|
+
return this.proxy('POST', path, options, `instance.${method}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async proxy(
|
|
125
|
+
httpMethod: string,
|
|
126
|
+
apiPath: string,
|
|
127
|
+
body: unknown,
|
|
128
|
+
operation: string,
|
|
129
|
+
): Promise<unknown> {
|
|
130
|
+
const { accountId, apiToken } = this.ensureCredentials()
|
|
131
|
+
const id = randomUUIDv7()
|
|
132
|
+
const start = Date.now()
|
|
133
|
+
let status = 'ok'
|
|
134
|
+
let error: string | undefined
|
|
135
|
+
let outputSummary = ''
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}${apiPath}`
|
|
139
|
+
const response = await fetch(url, {
|
|
140
|
+
method: httpMethod,
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${apiToken}`,
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
},
|
|
145
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
const text = await response.text()
|
|
150
|
+
status = 'error'
|
|
151
|
+
error = `HTTP ${response.status}: ${text}`
|
|
152
|
+
throw new Error(error)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if ((body as SearchOptions | undefined)?.stream) {
|
|
156
|
+
outputSummary = '<streaming>'
|
|
157
|
+
return response.body
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ct = response.headers.get('content-type') ?? ''
|
|
161
|
+
if (ct.includes('application/json')) {
|
|
162
|
+
const json = await response.json()
|
|
163
|
+
outputSummary = truncate(json)
|
|
164
|
+
return json
|
|
165
|
+
}
|
|
166
|
+
const text = await response.text()
|
|
167
|
+
outputSummary = truncate(text)
|
|
168
|
+
return text
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (status !== 'error') {
|
|
171
|
+
status = 'error'
|
|
172
|
+
error = err instanceof Error ? err.message : String(err)
|
|
173
|
+
}
|
|
174
|
+
throw err
|
|
175
|
+
} finally {
|
|
176
|
+
const duration = Date.now() - start
|
|
177
|
+
this.db
|
|
178
|
+
.prepare(
|
|
179
|
+
`INSERT INTO ai_search_requests (id, namespace, operation, input_summary, output_summary, duration_ms, status, error, created_at)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
181
|
+
)
|
|
182
|
+
.run(id, this.namespace, operation, truncate(body), outputSummary, duration, status, error ?? null, start)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git HTTP protocol handler for the Artifacts binding.
|
|
3
|
+
*
|
|
4
|
+
* Proxies `/__artifacts/git/<repo-id>.git/<path>` requests to the `git http-backend`
|
|
5
|
+
* CGI binary. Authentication is via HTTP Basic (username ignored, password = token
|
|
6
|
+
* from `artifacts_tokens`). Read operations (`git-upload-pack`) accept any non-revoked
|
|
7
|
+
* token; write operations (`git-receive-pack`) require a `write`-scoped token.
|
|
8
|
+
*
|
|
9
|
+
* Repos marked read-only (`read_only = 1`) reject write requests regardless of scope.
|
|
10
|
+
*/
|
|
11
|
+
import type { Database } from 'bun:sqlite'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
interface RepoRow {
|
|
16
|
+
id: string
|
|
17
|
+
read_only: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TokenRow {
|
|
21
|
+
scope: string
|
|
22
|
+
expires_at: number | null
|
|
23
|
+
revoked_at: number | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const GIT_PATH_RE = /^\/__artifacts\/git\/([^/]+)\.git(\/.*)?$/
|
|
27
|
+
|
|
28
|
+
export interface ArtifactsGitHandlerOptions {
|
|
29
|
+
db: Database
|
|
30
|
+
artifactsDir: string
|
|
31
|
+
gitBinary?: string
|
|
32
|
+
/** When true (tests), skip token validation. */
|
|
33
|
+
skipAuth?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function handleArtifactsGitRequest(
|
|
37
|
+
request: Request,
|
|
38
|
+
opts: ArtifactsGitHandlerOptions,
|
|
39
|
+
): Promise<Response | null> {
|
|
40
|
+
const url = new URL(request.url)
|
|
41
|
+
const match = url.pathname.match(GIT_PATH_RE)
|
|
42
|
+
if (!match) return null
|
|
43
|
+
|
|
44
|
+
const repoId = match[1]!
|
|
45
|
+
const subPath = match[2] ?? '/'
|
|
46
|
+
const repo = opts.db
|
|
47
|
+
.query<RepoRow, [string]>('SELECT id, read_only FROM artifacts_repos WHERE id = ?')
|
|
48
|
+
.get(repoId)
|
|
49
|
+
if (!repo) {
|
|
50
|
+
return new Response('Not found', { status: 404 })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const repoDir = join(opts.artifactsDir, `${repoId}.git`)
|
|
54
|
+
if (!existsSync(repoDir)) {
|
|
55
|
+
return new Response('Repo directory missing', { status: 500 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Decide if this is a write request
|
|
59
|
+
const isWrite = isWriteRequest(request.method, subPath, url.searchParams.get('service'))
|
|
60
|
+
if (isWrite && repo.read_only) {
|
|
61
|
+
return new Response('Repo is read-only', { status: 403 })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!opts.skipAuth) {
|
|
65
|
+
const authResult = authenticate(request, opts.db, repoId, isWrite)
|
|
66
|
+
if (!authResult.ok) {
|
|
67
|
+
return new Response(authResult.message, {
|
|
68
|
+
status: 401,
|
|
69
|
+
headers: {
|
|
70
|
+
'WWW-Authenticate': 'Basic realm="Artifacts"',
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return proxyToGitHttpBackend(request, subPath, repoDir, opts.gitBinary ?? 'git')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isWriteRequest(method: string, subPath: string, service: string | null): boolean {
|
|
80
|
+
if (method === 'POST' && subPath === '/git-receive-pack') return true
|
|
81
|
+
if (method === 'GET' && subPath === '/info/refs' && service === 'git-receive-pack') return true
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface AuthResult {
|
|
86
|
+
ok: boolean
|
|
87
|
+
message: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function authenticate(request: Request, db: Database, repoId: string, writeNeeded: boolean): AuthResult {
|
|
91
|
+
const authHeader = request.headers.get('authorization')
|
|
92
|
+
if (!authHeader) {
|
|
93
|
+
return { ok: false, message: 'Authentication required' }
|
|
94
|
+
}
|
|
95
|
+
let token: string | null = null
|
|
96
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
97
|
+
token = authHeader.slice(7).trim()
|
|
98
|
+
} else if (authHeader.startsWith('Basic ')) {
|
|
99
|
+
try {
|
|
100
|
+
const decoded = atob(authHeader.slice(6).trim())
|
|
101
|
+
const colonIdx = decoded.indexOf(':')
|
|
102
|
+
token = colonIdx === -1 ? decoded : decoded.slice(colonIdx + 1)
|
|
103
|
+
} catch {
|
|
104
|
+
return { ok: false, message: 'Invalid Basic auth encoding' }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!token) {
|
|
108
|
+
return { ok: false, message: 'Missing token' }
|
|
109
|
+
}
|
|
110
|
+
const row = db
|
|
111
|
+
.query<TokenRow, [string, string]>(
|
|
112
|
+
'SELECT scope, expires_at, revoked_at FROM artifacts_tokens WHERE repo_id = ? AND plaintext = ?',
|
|
113
|
+
)
|
|
114
|
+
.get(repoId, token)
|
|
115
|
+
if (!row) {
|
|
116
|
+
return { ok: false, message: 'Invalid token' }
|
|
117
|
+
}
|
|
118
|
+
if (row.revoked_at != null) {
|
|
119
|
+
return { ok: false, message: 'Token revoked' }
|
|
120
|
+
}
|
|
121
|
+
if (row.expires_at != null && row.expires_at < Date.now()) {
|
|
122
|
+
return { ok: false, message: 'Token expired' }
|
|
123
|
+
}
|
|
124
|
+
if (writeNeeded && row.scope !== 'write') {
|
|
125
|
+
return { ok: false, message: 'Token lacks write scope' }
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, message: 'ok' }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function proxyToGitHttpBackend(
|
|
131
|
+
request: Request,
|
|
132
|
+
subPath: string,
|
|
133
|
+
repoDir: string,
|
|
134
|
+
gitBinary: string,
|
|
135
|
+
): Promise<Response> {
|
|
136
|
+
const url = new URL(request.url)
|
|
137
|
+
|
|
138
|
+
// CGI env variables required by git http-backend
|
|
139
|
+
const env: Record<string, string> = {
|
|
140
|
+
// Point GIT_PROJECT_ROOT at the repo parent so PATH_INFO can be "/<id>.git/..."
|
|
141
|
+
// But easier: point directly and use PATH_TRANSLATED + GIT_HTTP_EXPORT_ALL.
|
|
142
|
+
// We use PATH_TRANSLATED which tells http-backend exactly which repo dir to serve.
|
|
143
|
+
PATH_TRANSLATED: `${repoDir}${subPath}`,
|
|
144
|
+
GIT_HTTP_EXPORT_ALL: '1',
|
|
145
|
+
REQUEST_METHOD: request.method,
|
|
146
|
+
QUERY_STRING: url.search.startsWith('?') ? url.search.slice(1) : url.search,
|
|
147
|
+
CONTENT_TYPE: request.headers.get('content-type') ?? '',
|
|
148
|
+
REMOTE_USER: 'artifacts',
|
|
149
|
+
REMOTE_ADDR: '127.0.0.1',
|
|
150
|
+
// Let git detect encoding
|
|
151
|
+
HTTP_CONTENT_ENCODING: request.headers.get('content-encoding') ?? '',
|
|
152
|
+
HTTP_USER_AGENT: request.headers.get('user-agent') ?? 'lopata-artifacts',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const proc = Bun.spawn([gitBinary, 'http-backend'], {
|
|
156
|
+
env,
|
|
157
|
+
stdin: 'pipe',
|
|
158
|
+
stdout: 'pipe',
|
|
159
|
+
stderr: 'pipe',
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Pipe request body into the CGI's stdin (if any)
|
|
163
|
+
if (request.body) {
|
|
164
|
+
request.body.pipeTo(
|
|
165
|
+
new WritableStream({
|
|
166
|
+
async write(chunk) {
|
|
167
|
+
proc.stdin.write(chunk)
|
|
168
|
+
},
|
|
169
|
+
close() {
|
|
170
|
+
proc.stdin.end()
|
|
171
|
+
},
|
|
172
|
+
abort() {
|
|
173
|
+
proc.stdin.end()
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
).catch(() => {})
|
|
177
|
+
} else {
|
|
178
|
+
proc.stdin.end()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Read full stdout (git http-backend writes CGI-formatted response: headers + body)
|
|
182
|
+
const stdoutBytes = new Uint8Array(await new Response(proc.stdout).arrayBuffer())
|
|
183
|
+
const exitCode = await proc.exited
|
|
184
|
+
if (exitCode !== 0) {
|
|
185
|
+
const stderr = await new Response(proc.stderr).text()
|
|
186
|
+
return new Response(`git http-backend failed (${exitCode}): ${stderr}`, { status: 500 })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return parseCgiResponse(stdoutBytes)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseCgiResponse(raw: Uint8Array): Response {
|
|
193
|
+
// Find header/body separator: \r\n\r\n or \n\n
|
|
194
|
+
let headerEnd = indexOfDouble(raw, 0x0d, 0x0a) // \r\n\r\n
|
|
195
|
+
let separatorLen = 4
|
|
196
|
+
if (headerEnd === -1) {
|
|
197
|
+
headerEnd = indexOfDouble(raw, 0x0a, 0x0a) // \n\n
|
|
198
|
+
separatorLen = 2
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let headerBytes: Uint8Array
|
|
202
|
+
let body: Uint8Array
|
|
203
|
+
if (headerEnd === -1) {
|
|
204
|
+
headerBytes = new Uint8Array(0)
|
|
205
|
+
body = raw
|
|
206
|
+
} else {
|
|
207
|
+
headerBytes = raw.slice(0, headerEnd)
|
|
208
|
+
body = raw.slice(headerEnd + separatorLen)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const headers = new Headers()
|
|
212
|
+
let status = 200
|
|
213
|
+
let statusText = 'OK'
|
|
214
|
+
const headerText = new TextDecoder().decode(headerBytes)
|
|
215
|
+
for (const line of headerText.split(/\r?\n/)) {
|
|
216
|
+
if (!line) continue
|
|
217
|
+
const colon = line.indexOf(':')
|
|
218
|
+
if (colon === -1) continue
|
|
219
|
+
const key = line.slice(0, colon).trim().toLowerCase()
|
|
220
|
+
const value = line.slice(colon + 1).trim()
|
|
221
|
+
if (key === 'status') {
|
|
222
|
+
const m = value.match(/^(\d+)\s*(.*)$/)
|
|
223
|
+
if (m) {
|
|
224
|
+
status = Number.parseInt(m[1]!, 10)
|
|
225
|
+
statusText = m[2] ?? ''
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
headers.append(key, value)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return new Response(body, { status, statusText, headers })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function indexOfDouble(buf: Uint8Array, a: number, b: number): number {
|
|
236
|
+
// Find position of a,b,a,b (4-byte) or a,a (2-byte) pattern — caller chose pattern length.
|
|
237
|
+
// For \r\n\r\n: a=\r=0x0d, b=\n=0x0a, need 4 bytes
|
|
238
|
+
// For \n\n: a=\n=0x0a, b=\n=0x0a, need 2 bytes; simpler to special-case
|
|
239
|
+
if (a === b) {
|
|
240
|
+
for (let i = 0; i + 1 < buf.length; i++) {
|
|
241
|
+
if (buf[i] === a && buf[i + 1] === b) return i
|
|
242
|
+
}
|
|
243
|
+
return -1
|
|
244
|
+
}
|
|
245
|
+
for (let i = 0; i + 3 < buf.length; i++) {
|
|
246
|
+
if (buf[i] === a && buf[i + 1] === b && buf[i + 2] === a && buf[i + 3] === b) return i
|
|
247
|
+
}
|
|
248
|
+
return -1
|
|
249
|
+
}
|