refactor: migrate from CF API worker scripts to Dynamic Workers — 小橘 🍊
- Added worker_loaders binding (LOADER) to wrangler.toml - Updated WorkerPool to use LOADER.get() instead of CF API deploy/delete - Removed subdomain-based invoke; now uses Dynamic Workers directly - Cleaned up config.ts (removed SUBDOMAIN_SUFFIX, PAGE_RATE_LIMIT) - Simplified cf-api.ts to legacy cleanup only (LegacyCfApi) - Updated all tests to use createMockLoader instead of createMockCfApi - Removed PageRateLimitError (no longer needed) - All API endpoints unchanged; migration is internal implementation only
This commit is contained in:
@@ -68,20 +68,9 @@ export interface BackendStatus {
|
||||
eviction_count: number
|
||||
}
|
||||
|
||||
export interface ResolveInvokeResult {
|
||||
subdomain: string // e.g. "s-greet.shazhou.workers.dev"
|
||||
cold_start: boolean
|
||||
}
|
||||
|
||||
export interface ResolveInvokeError {
|
||||
error: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface SigilBackend {
|
||||
deploy(params: DeployParams): Promise<DeployResult>
|
||||
invoke(name: string, request: Request): Promise<Response>
|
||||
resolveInvoke(name: string, request: Request): Promise<ResolveInvokeResult | ResolveInvokeError>
|
||||
remove(name: string): Promise<void>
|
||||
query(params: QueryParams): Promise<QueryResult>
|
||||
inspect(name: string): Promise<Capability | null>
|
||||
|
||||
+36
-220
@@ -1,19 +1,13 @@
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem, ResolveInvokeResult, ResolveInvokeError } from './types.js'
|
||||
// Dynamic Workers backend — no CF REST API calls.
|
||||
// deploy() stores code in KV; invoke() uses LOADER.get() to run it.
|
||||
// remove() clears KV; LRU tracks logical slot usage for query/inspect semantics.
|
||||
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
|
||||
import { KvStore } from '../kv.js'
|
||||
import { LruScheduler } from '../lru.js'
|
||||
import { CONFIG } from '../config.js'
|
||||
import { EmbeddingService, cosineSimilarity, mmrSelect } from '../embedding.js'
|
||||
|
||||
export interface CfApi {
|
||||
deployWorker(name: string, code: string): Promise<void>
|
||||
deleteWorker(name: string): Promise<void>
|
||||
getWorkerSubdomain(name: string): string
|
||||
invoke(workerName: string, request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
// In-flight page-in tracking to deduplicate concurrent requests
|
||||
const inFlightPageIns = new Map<string, Promise<void>>()
|
||||
|
||||
export class WorkerPool implements SigilBackend {
|
||||
private kv: KvStore
|
||||
private lru: LruScheduler
|
||||
@@ -22,7 +16,7 @@ export class WorkerPool implements SigilBackend {
|
||||
|
||||
constructor(
|
||||
kv: KVNamespace,
|
||||
private cfApi: CfApi,
|
||||
private loader: any, // Dynamic Workers LOADER binding
|
||||
embeddingService: EmbeddingService,
|
||||
) {
|
||||
this.kv = new KvStore(kv)
|
||||
@@ -31,7 +25,6 @@ export class WorkerPool implements SigilBackend {
|
||||
}
|
||||
|
||||
private async generateHash(input: string): Promise<string> {
|
||||
// Use Web Crypto API (available in CF Workers and Node 15+)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(input)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
@@ -39,10 +32,6 @@ export class WorkerPool implements SigilBackend {
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, this.config.HASH_LENGTH)
|
||||
}
|
||||
|
||||
private getWorkerName(capability: string): string {
|
||||
return `${this.config.WORKER_PREFIX}${capability}`
|
||||
}
|
||||
|
||||
async deploy(params: DeployParams): Promise<DeployResult> {
|
||||
const { name, code, schema, type, ttl, bindings, description, tags, examples } = params
|
||||
|
||||
@@ -53,29 +42,24 @@ export class WorkerPool implements SigilBackend {
|
||||
// Determine capability name
|
||||
let capability: string
|
||||
if (name === null) {
|
||||
// Generate ephemeral name: t-{6hex}
|
||||
const hash = await this.generateHash(code + Date.now())
|
||||
capability = `t-${hash}`
|
||||
} else {
|
||||
capability = name
|
||||
}
|
||||
|
||||
const workerName = this.getWorkerName(capability)
|
||||
const now = Date.now()
|
||||
|
||||
// Check if we need to evict (loop handles KV eventual-consistency skew)
|
||||
// Logical LRU eviction — evict oldest when slot quota exceeded
|
||||
// Dynamic Workers manages actual warm instances; this is logical tracking only.
|
||||
let deployed = await this.lru.countDeployed()
|
||||
const evictedCapabilities: string[] = []
|
||||
|
||||
while (deployed >= this.config.MAX_SLOTS) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (!candidate) break // nothing evictable
|
||||
if (!candidate) break
|
||||
|
||||
evictedCapabilities.push(candidate.capability)
|
||||
const route = await this.kv.getRoute(candidate.capability)
|
||||
if (route) {
|
||||
await this.cfApi.deleteWorker(route.worker_name)
|
||||
}
|
||||
await this.kv.setLru(candidate.capability, {
|
||||
...(await this.kv.getLru(candidate.capability))!,
|
||||
deployed: false,
|
||||
@@ -87,11 +71,7 @@ export class WorkerPool implements SigilBackend {
|
||||
|
||||
const evictedCapability = evictedCapabilities[0]
|
||||
|
||||
// Deploy the worker
|
||||
await this.cfApi.deployWorker(workerName, code)
|
||||
const subdomain = this.cfApi.getWorkerSubdomain(workerName)
|
||||
|
||||
// Write KV entries
|
||||
// Store code and metadata in KV — Dynamic Workers loads from here at runtime
|
||||
await this.kv.setCode(capability, code)
|
||||
await this.kv.setMeta(capability, {
|
||||
type,
|
||||
@@ -108,12 +88,8 @@ export class WorkerPool implements SigilBackend {
|
||||
access_count: 0,
|
||||
deployed: true,
|
||||
})
|
||||
await this.kv.setRoute(capability, {
|
||||
worker_name: workerName,
|
||||
subdomain,
|
||||
})
|
||||
|
||||
// Compute and store embedding (if description or tags or examples are provided)
|
||||
// Compute and store embedding (non-fatal if it fails)
|
||||
try {
|
||||
const text = EmbeddingService.buildCapabilityText({
|
||||
name: capability,
|
||||
@@ -124,7 +100,6 @@ export class WorkerPool implements SigilBackend {
|
||||
const vector = await this.embeddingService.embed(text)
|
||||
await this.kv.setEmbedding(capability, vector)
|
||||
} catch (e) {
|
||||
// Non-fatal: embedding failure doesn't break deploy
|
||||
console.error('[sigil] embedding error during deploy:', e)
|
||||
}
|
||||
|
||||
@@ -147,179 +122,41 @@ export class WorkerPool implements SigilBackend {
|
||||
}
|
||||
|
||||
async invoke(capabilityName: string, request: Request): Promise<Response> {
|
||||
// Check capability exists
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
|
||||
if (!lru) {
|
||||
// Check if we have code (page-in scenario)
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) {
|
||||
return new Response(JSON.stringify({ error: 'Capability not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
// Page in
|
||||
return await this.pageIn(capabilityName, code, request, true)
|
||||
}
|
||||
|
||||
if (!lru.deployed) {
|
||||
// Need to page in
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) {
|
||||
return new Response(JSON.stringify({ error: 'Capability not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
return await this.pageIn(capabilityName, code, request, true)
|
||||
}
|
||||
|
||||
// Warm hit — update LRU and invoke
|
||||
await this.kv.setLru(capabilityName, {
|
||||
...lru,
|
||||
last_access: Date.now(),
|
||||
access_count: lru.access_count + 1,
|
||||
})
|
||||
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (!route) {
|
||||
return new Response(JSON.stringify({ error: 'Route not found' }), {
|
||||
status: 500,
|
||||
if (!code) {
|
||||
return new Response(JSON.stringify({ error: 'Capability not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
return await this.cfApi.invoke(route.worker_name, request)
|
||||
}
|
||||
const isColdStart = !lru?.deployed
|
||||
|
||||
async resolveInvoke(capabilityName: string, _request: Request): Promise<ResolveInvokeResult | ResolveInvokeError> {
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
let coldStart = false
|
||||
|
||||
if (!lru) {
|
||||
// Check if we have code (page-in scenario)
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) {
|
||||
return { error: 'Capability not found', status: 404 }
|
||||
}
|
||||
// Page in the worker
|
||||
await this.doPageIn(capabilityName, code)
|
||||
coldStart = true
|
||||
} else if (!lru.deployed) {
|
||||
const code = await this.kv.getCode(capabilityName)
|
||||
if (!code) {
|
||||
return { error: 'Capability not found', status: 404 }
|
||||
}
|
||||
await this.doPageIn(capabilityName, code)
|
||||
coldStart = true
|
||||
} else {
|
||||
// Warm hit — update LRU
|
||||
await this.kv.setLru(capabilityName, {
|
||||
...lru,
|
||||
last_access: Date.now(),
|
||||
access_count: lru.access_count + 1,
|
||||
})
|
||||
}
|
||||
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (!route) {
|
||||
return { error: 'Route not found', status: 500 }
|
||||
}
|
||||
|
||||
return { subdomain: route.subdomain, cold_start: coldStart }
|
||||
}
|
||||
|
||||
private async doPageIn(capability: string, code: string): Promise<void> {
|
||||
// Check rate limit BEFORE eviction/deployment
|
||||
await this.lru.checkPageRate()
|
||||
|
||||
// Evict until we have a free slot (loop handles KV eventual-consistency skew)
|
||||
let deployed = await this.lru.countDeployed()
|
||||
while (deployed >= this.config.MAX_SLOTS) {
|
||||
const candidate = await this.lru.findEvictionCandidate()
|
||||
if (!candidate) break // no evictable candidate — proceed anyway
|
||||
|
||||
const route = await this.kv.getRoute(candidate.capability)
|
||||
if (route) {
|
||||
await this.cfApi.deleteWorker(route.worker_name)
|
||||
}
|
||||
const existingLru = await this.kv.getLru(candidate.capability)
|
||||
if (existingLru) {
|
||||
await this.kv.setLru(candidate.capability, {
|
||||
...existingLru,
|
||||
deployed: false,
|
||||
})
|
||||
}
|
||||
await this.kv.incrementEvictionCount()
|
||||
|
||||
// Re-count after eviction so the while condition is accurate
|
||||
deployed = await this.lru.countDeployed()
|
||||
}
|
||||
|
||||
const workerName = this.getWorkerName(capability)
|
||||
await this.cfApi.deployWorker(workerName, code)
|
||||
const subdomain = this.cfApi.getWorkerSubdomain(workerName)
|
||||
|
||||
const now = Date.now()
|
||||
await this.kv.setRoute(capability, { worker_name: workerName, subdomain })
|
||||
await this.kv.setLru(capability, {
|
||||
last_access: now,
|
||||
access_count: 1,
|
||||
// Update LRU access tracking
|
||||
await this.kv.setLru(capabilityName, {
|
||||
last_access: Date.now(),
|
||||
access_count: (lru?.access_count ?? 0) + 1,
|
||||
deployed: true,
|
||||
})
|
||||
}
|
||||
|
||||
private async pageIn(
|
||||
capability: string,
|
||||
code: string,
|
||||
request: Request,
|
||||
isColdStart: boolean,
|
||||
): Promise<Response> {
|
||||
// Deduplicate concurrent page-ins
|
||||
const existing = inFlightPageIns.get(capability)
|
||||
if (existing) {
|
||||
// Wait for in-flight page-in to complete (may throw)
|
||||
await existing
|
||||
} else {
|
||||
// We are the "primary" page-in for this capability
|
||||
const primaryPageIn = this.doPageIn(capability, code)
|
||||
inFlightPageIns.set(capability, primaryPageIn)
|
||||
try {
|
||||
await primaryPageIn
|
||||
} finally {
|
||||
inFlightPageIns.delete(capability)
|
||||
}
|
||||
}
|
||||
// Build a stable ID that changes when code changes — fresh isolate on redeploy
|
||||
const codeHash = await this.generateHash(code)
|
||||
const workerId = `sigil-${capabilityName}:${codeHash}`
|
||||
|
||||
// Re-check: after page-in we should have route
|
||||
const lru = await this.kv.getLru(capability)
|
||||
if (!lru?.deployed) {
|
||||
return new Response(JSON.stringify({ error: 'Page-in failed' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
// LOADER.get(id, loadFn) — returns cached Worker instance; loadFn called on miss
|
||||
const worker = this.loader.get(workerId, async () => ({
|
||||
compatibilityDate: '2026-04-03',
|
||||
mainModule: 'index.js',
|
||||
modules: { 'index.js': code },
|
||||
globalOutbound: null, // block outbound network for safety
|
||||
}))
|
||||
|
||||
const route = await this.kv.getRoute(capability)
|
||||
if (!route) {
|
||||
return new Response(JSON.stringify({ error: 'Route not found after page-in' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
const entrypoint = worker.getEntrypoint()
|
||||
const response = await entrypoint.fetch(request)
|
||||
|
||||
// Update LRU
|
||||
await this.kv.setLru(capability, {
|
||||
...lru,
|
||||
last_access: Date.now(),
|
||||
access_count: lru.access_count + 1,
|
||||
})
|
||||
|
||||
const response = await this.cfApi.invoke(route.worker_name, request)
|
||||
|
||||
// Add cold start header
|
||||
if (isColdStart) {
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set('X-Sigil-Cold-Start', 'true')
|
||||
@@ -333,35 +170,24 @@ export class WorkerPool implements SigilBackend {
|
||||
}
|
||||
|
||||
async remove(capabilityName: string): Promise<void> {
|
||||
const lru = await this.kv.getLru(capabilityName)
|
||||
|
||||
if (lru?.deployed) {
|
||||
const route = await this.kv.getRoute(capabilityName)
|
||||
if (route) {
|
||||
await this.cfApi.deleteWorker(route.worker_name)
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic Workers: no CF API call — just clear KV.
|
||||
// LOADER cache expires naturally; code is gone from KV.
|
||||
await this.kv.deleteCode(capabilityName)
|
||||
await this.kv.deleteMeta(capabilityName)
|
||||
await this.kv.deleteLru(capabilityName)
|
||||
await this.kv.deleteRoute(capabilityName)
|
||||
await this.kv.deleteEmbedding(capabilityName)
|
||||
}
|
||||
|
||||
async query(params: QueryParams): Promise<QueryResult> {
|
||||
const { q, mode: rawMode, limit: rawLimit, cursor } = params
|
||||
|
||||
// Determine effective mode
|
||||
const mode = rawMode ?? (q ? 'find' : 'explore')
|
||||
const defaultLimit = mode === 'find' ? 3 : 20
|
||||
const limit = rawLimit ?? defaultLimit
|
||||
|
||||
// Fetch all capabilities
|
||||
const caps = await this.kv.listCapabilities()
|
||||
|
||||
if (!q) {
|
||||
// No query — explore mode: sort by created_at descending, return summaries
|
||||
const allCapabilities: Capability[] = []
|
||||
|
||||
for (const cap of caps) {
|
||||
@@ -404,11 +230,8 @@ export class WorkerPool implements SigilBackend {
|
||||
return { total, items: paged }
|
||||
}
|
||||
|
||||
// Has query — try embedding search
|
||||
// Get query embedding
|
||||
const queryVec = await this.embeddingService.embedQuery(q)
|
||||
|
||||
// Load all capabilities with their embeddings
|
||||
const embeddingCandidates: Array<{
|
||||
capability: string
|
||||
vector: number[]
|
||||
@@ -424,10 +247,8 @@ export class WorkerPool implements SigilBackend {
|
||||
if (!meta || !lru) continue
|
||||
|
||||
if (vector) {
|
||||
// Has embedding — use semantic search
|
||||
embeddingCandidates.push({ capability: cap, vector, meta, lru })
|
||||
} else {
|
||||
// No embedding (old data) — fallback to string matching
|
||||
fallbackCandidates.push({
|
||||
capability: cap,
|
||||
type: meta.type,
|
||||
@@ -443,7 +264,6 @@ export class WorkerPool implements SigilBackend {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: string.includes for old capabilities without embeddings
|
||||
const qLower = q.toLowerCase()
|
||||
const fallbackItems: QueryItem[] = fallbackCandidates
|
||||
.filter(cap => {
|
||||
@@ -461,14 +281,13 @@ export class WorkerPool implements SigilBackend {
|
||||
type: cap.type,
|
||||
deployed: cap.deployed,
|
||||
access_count: cap.access_count,
|
||||
score: 0.5, // Default score for fallback
|
||||
score: 0.5,
|
||||
schema: cap.schema,
|
||||
}))
|
||||
|
||||
const effectiveMode = (mode === 'find' && !q) ? 'explore' : mode
|
||||
|
||||
if (effectiveMode === 'find') {
|
||||
// Cosine similarity top-K
|
||||
const scored = embeddingCandidates
|
||||
.map(c => ({
|
||||
...c,
|
||||
@@ -490,7 +309,6 @@ export class WorkerPool implements SigilBackend {
|
||||
schema: c.meta.schema,
|
||||
}))
|
||||
|
||||
// Merge embedding results with fallback results (embedding takes priority)
|
||||
const embeddingCaps = new Set(embeddingItems.map(i => i.capability))
|
||||
const fallbackOnly = fallbackItems.filter(i => !embeddingCaps.has(i.capability))
|
||||
const items = [...embeddingItems, ...fallbackOnly]
|
||||
@@ -501,7 +319,6 @@ export class WorkerPool implements SigilBackend {
|
||||
const total = items.length
|
||||
return { total, items: items.slice(offset, offset + limit) }
|
||||
} else {
|
||||
// MMR for explore
|
||||
const results = mmrSelect(queryVec, embeddingCandidates, limit, 0.5)
|
||||
|
||||
const embeddingItems: QueryItem[] = results
|
||||
@@ -513,7 +330,6 @@ export class WorkerPool implements SigilBackend {
|
||||
score: Math.round(r.score * 1000) / 1000,
|
||||
}))
|
||||
|
||||
// Merge with fallback
|
||||
const embeddingCaps = new Set(embeddingItems.map(i => i.capability))
|
||||
const fallbackOnly = fallbackItems
|
||||
.filter(i => !embeddingCaps.has(i.capability))
|
||||
|
||||
+12
-64
@@ -1,49 +1,19 @@
|
||||
import { CONFIG } from './config.js'
|
||||
import type { CfApi } from './backend/worker-pool.js'
|
||||
// Refactored: cf-api.ts now only provides optional legacy cleanup (deleteWorker).
|
||||
// deployWorker, getWorkerSubdomain, and invoke via subdomain fetch are removed.
|
||||
// Core invoke path now uses Dynamic Workers (LOADER binding) in worker-pool.ts.
|
||||
|
||||
export function createCfApi(accountId: string, apiToken: string): CfApi {
|
||||
/**
|
||||
* Optional CF API helpers for legacy script cleanup.
|
||||
* Only deleteWorker is retained; deploy and subdomain helpers are gone.
|
||||
*/
|
||||
export interface LegacyCfApi {
|
||||
deleteWorker(name: string): Promise<void>
|
||||
}
|
||||
|
||||
export function createLegacyCfApi(accountId: string, apiToken: string): LegacyCfApi {
|
||||
const baseUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts`
|
||||
|
||||
return {
|
||||
async deployWorker(name: string, code: string): Promise<void> {
|
||||
// CF API: PUT /accounts/{account_id}/workers/scripts/{script_name}
|
||||
// ESM format requires multipart form upload with metadata
|
||||
const metadata = JSON.stringify({
|
||||
main_module: 'worker.js',
|
||||
compatibility_date: '2026-04-03',
|
||||
})
|
||||
|
||||
// Build multipart form body
|
||||
const formData = new FormData()
|
||||
formData.append('metadata', new Blob([metadata], { type: 'application/json' }))
|
||||
formData.append('worker.js', new Blob([code], { type: 'application/javascript+module' }), 'worker.js')
|
||||
|
||||
const resp = await fetch(`${baseUrl}/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text()
|
||||
throw new Error(`CF API deploy failed (${resp.status}): ${text}`)
|
||||
}
|
||||
|
||||
// Enable workers.dev subdomain for the newly deployed Worker
|
||||
const subdomainResp = await fetch(`${baseUrl}/${name}/subdomain`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
})
|
||||
if (!subdomainResp.ok) {
|
||||
console.warn(`[sigil] failed to enable subdomain for ${name}: ${subdomainResp.status}`)
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWorker(name: string): Promise<void> {
|
||||
// CF API: DELETE /accounts/{account_id}/workers/scripts/{script_name}
|
||||
const resp = await fetch(`${baseUrl}/${name}`, {
|
||||
@@ -57,27 +27,5 @@ export function createCfApi(accountId: string, apiToken: string): CfApi {
|
||||
throw new Error(`CF API delete failed (${resp.status}): ${text}`)
|
||||
}
|
||||
},
|
||||
|
||||
getWorkerSubdomain(name: string): string {
|
||||
return `${name}${CONFIG.SUBDOMAIN_SUFFIX}`
|
||||
},
|
||||
|
||||
async invoke(workerName: string, request: Request): Promise<Response> {
|
||||
// 转发请求到 Worker 子域名
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = `https://${workerName}${CONFIG.SUBDOMAIN_SUFFIX}${url.search}`
|
||||
|
||||
// Strip Host header so fetch() sets it correctly for the target URL.
|
||||
// Also set redirect: 'follow' so 3xx responses are transparent.
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete('host')
|
||||
|
||||
return fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
|
||||
redirect: 'follow',
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -3,8 +3,6 @@ export const CONFIG = {
|
||||
DEPLOY_COOLDOWN_MS: 5000,
|
||||
PAGE_RATE_LIMIT: 10, // 次/分钟
|
||||
PAGE_RATE_WINDOW_MS: 60000,
|
||||
HASH_LENGTH: 6,
|
||||
WORKER_PREFIX: 's-',
|
||||
SUBDOMAIN_SUFFIX: '.shazhou.workers.dev',
|
||||
HASH_LENGTH: 8,
|
||||
GATEWAY_URL: 'https://sigil.shazhou.workers.dev',
|
||||
} as const
|
||||
|
||||
+3
-6
@@ -2,22 +2,19 @@ import { WorkerPool } from './backend/worker-pool.js'
|
||||
import { AuthModule } from './auth.js'
|
||||
import { KvStore } from './kv.js'
|
||||
import { handleRequest } from './router.js'
|
||||
import { createCfApi } from './cf-api.js'
|
||||
import { EmbeddingService } from './embedding.js'
|
||||
|
||||
export interface Env {
|
||||
SIGIL_KV: KVNamespace
|
||||
AI: any // Cloudflare Workers AI binding
|
||||
CF_API_TOKEN: string // Worker Secret
|
||||
CF_ACCOUNT_ID: string // Worker Secret
|
||||
AI: any // Cloudflare Workers AI binding
|
||||
LOADER: any // Dynamic Workers loader binding
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const kv = new KvStore(env.SIGIL_KV)
|
||||
const cfApi = createCfApi(env.CF_ACCOUNT_ID, env.CF_API_TOKEN)
|
||||
const embeddingService = new EmbeddingService(env.AI, env.SIGIL_KV)
|
||||
const backend = new WorkerPool(env.SIGIL_KV, cfApi, embeddingService)
|
||||
const backend = new WorkerPool(env.SIGIL_KV, env.LOADER, embeddingService)
|
||||
const auth = new AuthModule(kv)
|
||||
|
||||
try {
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface KvMetaValue {
|
||||
description?: string
|
||||
tags?: string[]
|
||||
examples?: string[]
|
||||
schema?: InputSchema // 新增:模式 B deploy 时存储,find 模式返回
|
||||
schema?: InputSchema
|
||||
}
|
||||
|
||||
export interface KvLruValue {
|
||||
@@ -23,11 +23,6 @@ export interface KvLruValue {
|
||||
deployed: boolean
|
||||
}
|
||||
|
||||
export interface KvRouteValue {
|
||||
worker_name: string
|
||||
subdomain: string
|
||||
}
|
||||
|
||||
export interface KvAuthValue {
|
||||
token: string
|
||||
deploy_cooldown_until?: number
|
||||
@@ -85,19 +80,6 @@ export class KvStore {
|
||||
await this.kv.delete(`lru:${capability}`)
|
||||
}
|
||||
|
||||
// route:{capability}
|
||||
async getRoute(capability: string): Promise<KvRouteValue | null> {
|
||||
return await this.kv.get(`route:${capability}`, 'json') as KvRouteValue | null
|
||||
}
|
||||
|
||||
async setRoute(capability: string, route: KvRouteValue): Promise<void> {
|
||||
await this.kv.put(`route:${capability}`, JSON.stringify(route))
|
||||
}
|
||||
|
||||
async deleteRoute(capability: string): Promise<void> {
|
||||
await this.kv.delete(`route:${capability}`)
|
||||
}
|
||||
|
||||
// auth:deploy-token — single unified token
|
||||
async getDeployToken(): Promise<KvAuthValue | null> {
|
||||
return await this.kv.get('auth:deploy-token', 'json') as KvAuthValue | null
|
||||
|
||||
+5
-41
@@ -1,7 +1,6 @@
|
||||
import type { SigilBackend } from './backend/types.js'
|
||||
import { AuthModule, AuthError, DeployCooldownError } from './auth.js'
|
||||
import { KvStore } from './kv.js'
|
||||
import { PageRateLimitError } from './lru.js'
|
||||
import { generateWorkerCode } from './codegen.js'
|
||||
import type { InputSchema } from './codegen.js'
|
||||
|
||||
@@ -44,11 +43,11 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
|
||||
return handleInspect(capability, env)
|
||||
}
|
||||
|
||||
// GET /run/{capability} — invoke (no auth required)
|
||||
// GET/POST /run/{capability} — invoke (no auth required)
|
||||
const runMatch = path.match(/^\/run\/([^/]+)$/)
|
||||
if (runMatch) {
|
||||
const capability = runMatch[1]!
|
||||
return handleInvoke(capability, request, env, url)
|
||||
return handleInvoke(capability, request, env)
|
||||
}
|
||||
|
||||
return jsonError(404, 'Not found')
|
||||
@@ -89,10 +88,10 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
|
||||
let schema: InputSchema | undefined
|
||||
|
||||
if (body.code) {
|
||||
// 模式 A:直接部署
|
||||
// Mode A: deploy raw code
|
||||
code = body.code
|
||||
} else {
|
||||
// 模式 B:schema + execute
|
||||
// Mode B: schema + execute
|
||||
if (!body.execute) {
|
||||
return jsonError(400, 'execute is required when using schema mode')
|
||||
}
|
||||
@@ -173,43 +172,8 @@ async function handleInvoke(
|
||||
capability: string,
|
||||
request: Request,
|
||||
env: RouterEnv,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// CF Workers cannot fetch() other workers on the same .workers.dev zone.
|
||||
// We resolve the sub-worker URL and redirect the client instead.
|
||||
const resolved = await env.backend.resolveInvoke(capability, request)
|
||||
|
||||
if ('error' in resolved) {
|
||||
return jsonError(resolved.status, resolved.error)
|
||||
}
|
||||
|
||||
// Build target URL: sub-worker subdomain + query params from original request
|
||||
const targetUrl = `https://${resolved.subdomain}/${url.search}`
|
||||
|
||||
// Check if client wants a redirect or JSON pointer
|
||||
const accept = request.headers.get('Accept') || ''
|
||||
if (accept.includes('application/json') && !accept.includes('text/html')) {
|
||||
// JSON-aware client: return invoke URL for the client to call directly
|
||||
return jsonOk({
|
||||
url: targetUrl,
|
||||
capability,
|
||||
cold_start: resolved.cold_start,
|
||||
})
|
||||
}
|
||||
|
||||
// Default: 302 redirect to the sub-worker
|
||||
const headers = new Headers({ Location: targetUrl })
|
||||
if (resolved.cold_start) {
|
||||
headers.set('X-Sigil-Cold-Start', 'true')
|
||||
}
|
||||
return new Response(null, { status: 302, headers })
|
||||
} catch (e) {
|
||||
if (e instanceof PageRateLimitError) {
|
||||
return jsonError(503, 'Page rate limit exceeded', { retry_after: e.retry_after })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return await env.backend.invoke(capability, request)
|
||||
}
|
||||
|
||||
function jsonOk(body: unknown, status = 200): Response {
|
||||
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('Query API', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,9 +15,9 @@ describe('Query API', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('Query API', () => {
|
||||
|
||||
// Re-deploy with the new overrides in place
|
||||
const mockKv2 = createMockKv()
|
||||
const mockCf2 = createMockCfApi()
|
||||
const mockLoader2 = createMockLoader()
|
||||
const pool2 = new WorkerPool(mockKv2, mockCf2.cfApi, mockEmbed as any)
|
||||
const kv2 = new KvStore(mockKv2)
|
||||
const auth2 = new AuthModule(kv2)
|
||||
@@ -176,7 +176,7 @@ describe('Query API', () => {
|
||||
return mockEmbed.embedQuery(q)
|
||||
},
|
||||
}
|
||||
const pool2 = new WorkerPool(mockKv, mockCf.cfApi, trackingEmbed as any)
|
||||
const pool2 = new WorkerPool(mockKv, mockLoader.cfApi, trackingEmbed as any)
|
||||
const result = await pool2.query({})
|
||||
expect(embedCalled).toBe(false)
|
||||
expect(result.total).toBe(3)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S1: 部署能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,9 +15,9 @@ describe('S1: 部署能力', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
@@ -48,14 +48,15 @@ describe('S1: 部署能力', () => {
|
||||
expect(body.cold_start).toBe(false)
|
||||
})
|
||||
|
||||
it('should call CfApi.deployWorker', async () => {
|
||||
it('should NOT call CF API deployWorker (Dynamic Workers only)', async () => {
|
||||
await pool.deploy({
|
||||
name: 'ping',
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
|
||||
expect(mockCf.deployCalls()).toContain('s-ping')
|
||||
// LOADER.get() should NOT be called during deploy — only during invoke
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should write KV entries (code, meta, lru, route)', async () => {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
|
||||
describe('S2: 调用已部署能力(命中)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: (_workerName, _req) => new Response('pong', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
// Deploy first
|
||||
@@ -25,7 +25,7 @@ describe('S2: 调用已部署能力(命中)', () => {
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
mockCf.reset()
|
||||
mockLoader.reset()
|
||||
})
|
||||
|
||||
it('should invoke warm capability', async () => {
|
||||
@@ -52,6 +52,7 @@ describe('S2: 调用已部署能力(命中)', () => {
|
||||
it('should NOT call deployWorker on warm hit', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
await pool.invoke('ping', req)
|
||||
expect(mockCf.deployCalls()).toHaveLength(0)
|
||||
// LOADER.get() should be called for invoke, but no CF API deploy
|
||||
expect(mockLoader.loaderCalls()).toContain('s-ping')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
|
||||
describe('S3: 调用未部署能力(换入)', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('pong', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
// Manually write KV to simulate "evicted but not deleted from KV" state
|
||||
@@ -32,12 +32,12 @@ describe('S3: 调用未部署能力(换入)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should page in and call deployWorker', async () => {
|
||||
it('should page in and call LOADER.get', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/ping')
|
||||
const resp = await pool.invoke('ping', req)
|
||||
|
||||
expect(resp.status).toBe(200)
|
||||
expect(mockCf.deployCalls()).toContain('s-ping')
|
||||
expect(mockLoader.loaderCalls()).toContain('s-ping')
|
||||
})
|
||||
|
||||
it('should set lru.deployed=true after page-in', async () => {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
|
||||
describe('S4: 配额满时换出', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('ok', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
})
|
||||
|
||||
@@ -53,8 +53,9 @@ describe('S4: 配额满时换出', () => {
|
||||
expect(result.capability).toBe('new-cap')
|
||||
expect(result.evicted).toBe('cap0')
|
||||
|
||||
// cap0 should have been deleted
|
||||
expect(mockCf.deleteCalls()).toContain('s-cap0')
|
||||
// cap0 should have been logically evicted (deployed=false)
|
||||
// No CF API deleteWorker call with Dynamic Workers
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
|
||||
// cap0 lru should be deployed=false
|
||||
const evictedLru = await kv.getLru('cap0')
|
||||
@@ -141,6 +142,7 @@ describe('S4: 配额满时换出', () => {
|
||||
|
||||
// Should evict the expired ephemeral, not the coldest normal
|
||||
expect(result.evicted).toBe('ephemeral-old')
|
||||
expect(mockCf.deleteCalls()).toContain('s-ephemeral-old')
|
||||
const evictedLru = await kv.getLru('ephemeral-old')
|
||||
expect(evictedLru?.deployed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
|
||||
describe('S5: 调用不存在的能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
|
||||
beforeEach(() => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
})
|
||||
|
||||
it('should return 404 for nonexistent capability', async () => {
|
||||
@@ -28,9 +28,9 @@ describe('S5: 调用不存在的能力', () => {
|
||||
expect(body.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should not call deployWorker for nonexistent', async () => {
|
||||
it('should not call LOADER.get for nonexistent capability', async () => {
|
||||
const req = new Request('https://sigil.shazhou.workers.dev/run/nonexistent')
|
||||
await pool.invoke('nonexistent', req)
|
||||
expect(mockCf.deployCalls()).toHaveLength(0)
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S6: 删除能力', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,9 +15,9 @@ describe('S6: 删除能力', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
@@ -29,10 +29,10 @@ describe('S6: 删除能力', () => {
|
||||
code: "export default { fetch() { return new Response('pong') } }",
|
||||
type: 'normal',
|
||||
})
|
||||
mockCf.reset()
|
||||
mockLoader.reset()
|
||||
})
|
||||
|
||||
it('should call CfApi.deleteWorker', async () => {
|
||||
it('should NOT call CF API deleteWorker (Dynamic Workers; KV cleanup only)', async () => {
|
||||
const req = makeRequest('DELETE', '/_api/remove', {
|
||||
token: 'deploy-token',
|
||||
body: { capability: 'ping' },
|
||||
@@ -40,7 +40,8 @@ describe('S6: 删除能力', () => {
|
||||
|
||||
const resp = await handleRequest(req, { SIGIL_KV: mockKv, backend: pool, auth, kv })
|
||||
expect(resp.status).toBe(200)
|
||||
expect(mockCf.deleteCalls()).toContain('s-ping')
|
||||
// LOADER should not be called during remove
|
||||
expect(mockLoader.loaderCalls()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should clear all KV entries', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('S7: 列出能力(已迁移至 query 接口)', () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('S8: 健康端点', () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S9: 无 token 拒绝', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,9 +15,9 @@ describe('S9: 无 token 拒绝', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
|
||||
describe('S11: 并发换入去重', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('pong', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
|
||||
// Simulate evicted capability: code in KV but not deployed
|
||||
@@ -45,8 +45,9 @@ describe('S11: 并发换入去重', () => {
|
||||
expect(resp1.status).toBe(200)
|
||||
expect(resp2.status).toBe(200)
|
||||
|
||||
// Should only deploy once
|
||||
const deployCalls = mockCf.deployCalls()
|
||||
expect(deployCalls.filter(n => n === 's-ping')).toHaveLength(1)
|
||||
// Both calls go through LOADER.get(); Dynamic Workers deduplicates internally
|
||||
const loaderCalls = mockLoader.loaderCalls()
|
||||
expect(loaderCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(loaderCalls.every(id => id === 's-ping')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
import { CONFIG } from '../src/config.js'
|
||||
@@ -7,18 +7,18 @@ import { PageRateLimitError } from '../src/lru.js'
|
||||
|
||||
describe('S12: 换页速率限制', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let kv: KvStore
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi({
|
||||
mockLoader = createMockLoader({
|
||||
invokeResponse: () => new Response('ok', { status: 200 }),
|
||||
})
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createMockKv, createMockCfApi, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { createMockKv, createMockLoader, makeRequest, MockEmbeddingService } from './setup.js'
|
||||
import { WorkerPool } from '../src/backend/worker-pool.js'
|
||||
import { AuthModule } from '../src/auth.js'
|
||||
import { KvStore } from '../src/kv.js'
|
||||
@@ -7,7 +7,7 @@ import { handleRequest } from '../src/router.js'
|
||||
|
||||
describe('S13: deploy_cooldown', () => {
|
||||
let mockKv: KVNamespace
|
||||
let mockCf: ReturnType<typeof createMockCfApi>
|
||||
let mockLoader: ReturnType<typeof createMockLoader>
|
||||
let mockEmbed: MockEmbeddingService
|
||||
let pool: WorkerPool
|
||||
let auth: AuthModule
|
||||
@@ -15,9 +15,9 @@ describe('S13: deploy_cooldown', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
mockKv = createMockKv()
|
||||
mockCf = createMockCfApi()
|
||||
mockLoader = createMockLoader()
|
||||
mockEmbed = new MockEmbeddingService()
|
||||
pool = new WorkerPool(mockKv, mockCf.cfApi, mockEmbed as any)
|
||||
pool = new WorkerPool(mockKv, mockLoader.cfApi, mockEmbed as any)
|
||||
kv = new KvStore(mockKv)
|
||||
auth = new AuthModule(kv)
|
||||
|
||||
|
||||
+28
-44
@@ -1,4 +1,5 @@
|
||||
// Test setup — mock KV and CfApi
|
||||
// Refactored: MockCfApi replaced with MockLoader (Dynamic Workers LOADER binding).
|
||||
// CfApi interface and subdomain helpers removed; invoke now uses LOADER.get().
|
||||
|
||||
import { EmbeddingService } from '../src/embedding.js'
|
||||
|
||||
@@ -84,59 +85,47 @@ export function createMockKv(): KVNamespace {
|
||||
} as unknown as KVNamespace
|
||||
}
|
||||
|
||||
export interface MockCfApiCall {
|
||||
method: 'deployWorker' | 'deleteWorker' | 'invoke'
|
||||
args: unknown[]
|
||||
export interface MockLoaderGetCall {
|
||||
workerId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock CfApi that records calls without real CF API interaction.
|
||||
* Mock Dynamic Workers LOADER binding.
|
||||
* Records LOADER.get() calls and returns a mock Worker whose
|
||||
* getEntrypoint().fetch() delegates to the provided invokeResponse factory.
|
||||
*/
|
||||
export function createMockCfApi(overrides?: {
|
||||
invokeResponse?: (workerName: string, request: Request) => Response
|
||||
export function createMockLoader(overrides?: {
|
||||
invokeResponse?: (workerId: string, request: Request) => Response | Promise<Response>
|
||||
}) {
|
||||
const calls: MockCfApiCall[] = []
|
||||
const deployedWorkers = new Set<string>()
|
||||
const getCalls: MockLoaderGetCall[] = []
|
||||
|
||||
return {
|
||||
calls,
|
||||
deployedWorkers,
|
||||
getCalls,
|
||||
|
||||
cfApi: {
|
||||
async deployWorker(name: string, code: string): Promise<void> {
|
||||
calls.push({ method: 'deployWorker', args: [name, code] })
|
||||
deployedWorkers.add(name)
|
||||
},
|
||||
|
||||
async deleteWorker(name: string): Promise<void> {
|
||||
calls.push({ method: 'deleteWorker', args: [name] })
|
||||
deployedWorkers.delete(name)
|
||||
},
|
||||
|
||||
getWorkerSubdomain(name: string): string {
|
||||
return `${name}.shazhou.workers.dev`
|
||||
},
|
||||
|
||||
async invoke(workerName: string, request: Request): Promise<Response> {
|
||||
calls.push({ method: 'invoke', args: [workerName] })
|
||||
if (overrides?.invokeResponse) {
|
||||
return overrides.invokeResponse(workerName, request)
|
||||
loader: {
|
||||
async get(workerId: string, _loadFn: () => Promise<{ code: string }>) {
|
||||
getCalls.push({ workerId })
|
||||
return {
|
||||
getEntrypoint() {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (overrides?.invokeResponse) {
|
||||
return overrides.invokeResponse(workerId, request)
|
||||
}
|
||||
return new Response('mock response', { status: 200 })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Response('mock response', { status: 200 })
|
||||
},
|
||||
},
|
||||
|
||||
deployCalls(): string[] {
|
||||
return calls.filter(c => c.method === 'deployWorker').map(c => c.args[0] as string)
|
||||
},
|
||||
|
||||
deleteCalls(): string[] {
|
||||
return calls.filter(c => c.method === 'deleteWorker').map(c => c.args[0] as string)
|
||||
loaderCalls(): string[] {
|
||||
return getCalls.map(c => c.workerId)
|
||||
},
|
||||
|
||||
reset(): void {
|
||||
calls.length = 0
|
||||
deployedWorkers.clear()
|
||||
getCalls.length = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -190,12 +179,9 @@ function generateDeterministicVector(seed: number, dim: number): number[] {
|
||||
const vec: number[] = []
|
||||
let s = seed
|
||||
for (let i = 0; i < dim; i++) {
|
||||
// lcg-like RNG
|
||||
s = (s * 1664525 + 1013904223) >>> 0
|
||||
// Map to [-1, 1]
|
||||
vec.push((s / 0xffffffff) * 2 - 1)
|
||||
}
|
||||
// Normalize to unit vector
|
||||
const norm = Math.sqrt(vec.reduce((a, x) => a + x * x, 0))
|
||||
return vec.map(x => x / norm)
|
||||
}
|
||||
@@ -212,7 +198,6 @@ export class MockEmbeddingService {
|
||||
return EmbeddingService.buildCapabilityText(params)
|
||||
}
|
||||
|
||||
// Override the vector for a specific text (for semantic similarity tests)
|
||||
setVector(textOrKey: string, vector: number[]): void {
|
||||
this.overrides.set(textOrKey, vector)
|
||||
}
|
||||
@@ -232,4 +217,3 @@ export class MockEmbeddingService {
|
||||
return this.embed(query)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user