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:
2026-04-03 09:41:13 +00:00
parent 3709fae5e1
commit ce4c2b7b36
21 changed files with 159 additions and 475 deletions
-11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+1 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+7 -6
View File
@@ -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 () => {
+7 -6
View File
@@ -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')
})
})
+6 -6
View File
@@ -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 () => {
+9 -7
View File
@@ -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)
})
})
+6 -6
View File
@@ -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)
})
})
+8 -7
View File
@@ -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 () => {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+4 -4
View File
@@ -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)
})
+8 -7
View File
@@ -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)
})
})
+4 -4
View File
@@ -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)
})
+4 -4
View File
@@ -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
View File
@@ -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)
}
}