feat: AMD风格 capability 组合功能

- 新增  字段支持依赖声明(KvMetaValue、DeployParams)
- 新增 generateWorkerCodeWithDeps() 函数,支持依赖注入 codegen
- 实现递归依赖解析,自动 bundle 依赖代码到主 capability
- 循环依赖检测,防止无限递归
- execute 函数签名扩展为 (input, deps) => result
- 依赖包装为 async (params?) => result 函数
- 支持 schema 参数解析 for 依赖
- 向后兼容:无依赖 capability 不受影响
- 12个测试用例覆盖:基本依赖、多依赖、链式依赖、循环检测、兼容性、API集成

小橘 🍊 (NEKO Team)
This commit is contained in:
2026-04-03 23:17:38 +00:00
parent d80cc1b9e0
commit 69507fa766
6 changed files with 579 additions and 7 deletions
+1
View File
@@ -15,6 +15,7 @@ export interface DeployParams {
description?: string // 一句话描述
tags?: string[] // 标签
examples?: string[] // 用法示例
requires?: string[] // AMD 风格依赖
}
export interface DeployResult {
+101 -7
View File
@@ -6,6 +6,7 @@ import { KvStore } from '../kv.js'
import { LruScheduler } from '../lru.js'
import { CONFIG } from '../config.js'
import { EmbeddingService, cosineSimilarity, mmrSelect } from '../embedding.js'
import { generateWorkerCode, generateWorkerCodeWithDeps, type InputSchema, type DependencyInfo } from '../codegen.js'
export interface WorkerLoader {
get(id: string, loader: () => any): { getEntrypoint(name?: string): { fetch(request: Request): Promise<Response> } }
@@ -35,16 +36,109 @@ export class WorkerPool implements SigilBackend {
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, this.config.HASH_LENGTH)
}
async deploy(params: DeployParams): Promise<DeployResult> {
const { name, code, schema, type, ttl, bindings, description, tags, examples } = params
/**
* 递归解析依赖,检测循环依赖
*/
private async resolveDependencies(
requires: string[],
visited = new Set<string>(),
path: string[] = []
): Promise<Record<string, DependencyInfo>> {
const deps: Record<string, DependencyInfo> = {}
if (!code) {
throw new Error('deploy: code is required (should be pre-generated by router)')
for (const depName of requires) {
// 检测循环依赖 - 在访问KV之前先检查
if (visited.has(depName)) {
const cycle = [...path, depName].join(' -> ')
throw new Error(`Circular dependency detected: ${cycle}`)
}
// 标记为已访问
const newVisited = new Set(visited)
newVisited.add(depName)
const newPath = [...path, depName]
const depCode = await this.kv.getCode(depName)
const depMeta = await this.kv.getMeta(depName)
if (!depCode || !depMeta) {
throw new Error(`Dependency not found: ${depName}`)
}
// 如果该依赖还有自己的依赖,递归解析
if (depMeta.requires && depMeta.requires.length > 0) {
const nestedDeps = await this.resolveDependencies(depMeta.requires, newVisited, newPath)
Object.assign(deps, nestedDeps)
}
// 对于 schema+execute 模式的依赖,从 code 中提取 execute body
if (depMeta.schema) {
// 从生成的 worker code 中提取用户的 execute body
// 这个比较 hacky,实际应该存储原始的 execute body
// 但为了兼容现有代码,我们从生成的代码中反向提取
deps[depName] = {
code: this.extractExecuteBodyFromWorkerCode(depCode),
schema: depMeta.schema
}
} else {
// 完整代码模式,直接使用
deps[depName] = { code: depCode }
}
}
return deps
}
/**
* 从生成的 worker code 中提取用户的 execute body
* 这是一个简化的实现,实际项目中应该分别存储原始 execute body
*/
private extractExecuteBodyFromWorkerCode(workerCode: string): string {
// 查找 "const __result = await (async (input) => {" 后的内容
const match = workerCode.match(/const __result = await \(async \(input\) => \{\s*([\s\S]*?)\s*\}\)\(input\);/)
if (match && match[1]) {
return match[1].trim()
}
// 如果无法提取,返回空函数
return 'return null;'
}
async deploy(params: DeployParams): Promise<DeployResult> {
const { name, code, schema, execute, type, ttl, bindings, description, tags, examples, requires } = params
let finalCode: string
if (code) {
// 模式 A:完整 Worker 代码
finalCode = code
} else {
// 模式 B:schema + execute
if (!execute) {
throw new Error('deploy: execute is required when using schema mode')
}
const finalSchema = schema || { type: 'object', properties: {} }
if (requires && requires.length > 0) {
// 有依赖,解析并生成带依赖的代码
try {
// 先检查自引用循环
const capabilityName = name || 'temp-name-for-cycle-check'
const initialVisited = new Set([capabilityName])
const deps = await this.resolveDependencies(requires, initialVisited, [capabilityName])
finalCode = generateWorkerCodeWithDeps(finalSchema, execute, deps)
} catch (error: any) {
throw new Error(`Failed to resolve dependencies: ${error.message}`)
}
} else {
// 无依赖,使用原有逻辑
finalCode = generateWorkerCode(finalSchema, execute)
}
}
let capability: string
if (name === null) {
const hash = await this.generateHash(code + Date.now())
const hash = await this.generateHash(finalCode + Date.now())
capability = `t-${hash}`
} else {
capability = name
@@ -72,9 +166,9 @@ export class WorkerPool implements SigilBackend {
const evictedCapability = evictedCapabilities[0]
// Write KV entries - code loaded dynamically at invoke time via LOADER
await this.kv.setCode(capability, code)
await this.kv.setCode(capability, finalCode)
await this.kv.setMeta(capability, {
type, ttl, created_at: now, bindings, description, tags, examples, schema,
type, ttl, created_at: now, bindings, description, tags, examples, schema, requires,
})
await this.kv.setLru(capability, { last_access: now, access_count: 0, deployed: true })
await this.kv.setRoute(capability, { worker_name: capability, subdomain: '' })
+116
View File
@@ -10,6 +10,11 @@ export interface InputSchema {
required?: string[]
}
export interface DependencyInfo {
code: string
schema?: InputSchema
}
/**
* 从 schema + execute body 生成完整 Worker 代码
*/
@@ -77,3 +82,114 @@ ${requiredChecks}
}
};`
}
/**
* 从 schema + execute body + 依赖生成完整 Worker 代码(AMD 风格)
*/
export function generateWorkerCodeWithDeps(
schema: InputSchema,
executeBody: string,
deps: Record<string, DependencyInfo>
): string {
const required = schema.required || []
// 生成参数解析 + 类型转换
const parseLines: string[] = []
for (const [name, prop] of Object.entries(schema.properties || {})) {
if (prop.type === 'number') {
parseLines.push(` if (raw.${name} !== undefined) input.${name} = Number(raw.${name});`)
} else if (prop.type === 'boolean') {
parseLines.push(` if (raw.${name} !== undefined) input.${name} = raw.${name} === 'true' || raw.${name} === true;`)
} else {
parseLines.push(` if (raw.${name} !== undefined) input.${name} = raw.${name};`)
}
// 默认值
if (prop.default !== undefined) {
parseLines.push(` if (input.${name} === undefined) input.${name} = ${JSON.stringify(prop.default)};`)
}
}
// 生成 required 校验
const requiredChecks = required.map(name =>
` if (input.${name} === undefined) return new Response(JSON.stringify({error: "Missing required parameter: ${name}"}), {status: 400, headers: {"Content-Type": "application/json"}});`
).join('\n')
// 生成依赖函数
const depsCode = Object.entries(deps).map(([depName, depInfo]) => {
const depSchema = depInfo.schema
if (!depSchema) {
// 无 schema,直接执行
return ` '${depName}': async (params = {}) => {
const input = params;
${depInfo.code}
}`
}
// 有 schema,需要参数解析
const depParseLines: string[] = []
for (const [name, prop] of Object.entries(depSchema.properties || {})) {
if (prop.type === 'number') {
depParseLines.push(` if (params.${name} !== undefined) input.${name} = Number(params.${name});`)
} else if (prop.type === 'boolean') {
depParseLines.push(` if (params.${name} !== undefined) input.${name} = params.${name} === 'true' || params.${name} === true;`)
} else {
depParseLines.push(` if (params.${name} !== undefined) input.${name} = params.${name};`)
}
// 默认值
if (prop.default !== undefined) {
depParseLines.push(` if (input.${name} === undefined) input.${name} = ${JSON.stringify(prop.default)};`)
}
}
return ` '${depName}': async (params = {}) => {
const input = {};
${depParseLines.join('\n')}
${depInfo.code}
}`
}).join(',\n')
return `export default {
async fetch(request) {
try {
const url = new URL(request.url);
let raw = {};
// Parse input from query params or JSON body
if (request.method === 'POST' || request.method === 'PUT') {
try { raw = await request.json(); } catch(e) { raw = {}; }
}
// Query params override/merge
for (const [k, v] of url.searchParams.entries()) {
raw[k] = v;
}
const input = {};
${parseLines.join('\n')}
// Required field validation
${requiredChecks}
// AMD deps - 每个依赖内联为函数
const deps = {
${depsCode}
};
// Execute user function (with deps)
const __result = await (async (input, deps) => {
${executeBody}
})(input, deps);
// Ensure string output
const output = typeof __result === 'string' ? __result : JSON.stringify(__result);
return new Response(output, {
headers: { "Content-Type": "application/json" }
});
} catch (e) {
return new Response(JSON.stringify({ error: e.message || "Internal error" }), {
status: 500,
headers: { "Content-Type": "application/json" }
});
}
}
};`
}
+1
View File
@@ -15,6 +15,7 @@ export interface KvMetaValue {
tags?: string[]
examples?: string[]
schema?: InputSchema
requires?: string[]
}
export interface KvLruValue {
+2
View File
@@ -74,6 +74,7 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
description?: string
tags?: string[]
examples?: string[]
requires?: string[]
}
// Route validation
@@ -110,6 +111,7 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise<Response>
description: body.description,
tags: body.tags,
examples: body.examples,
requires: body.requires,
})
// Set cooldown after successful deploy
+358
View File
@@ -0,0 +1,358 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { WorkerPool } from '../src/backend/worker-pool.js'
import { AuthModule } from '../src/auth.js'
import { createMockKv, createMockLoader, MockEmbeddingService } from './setup.js'
import { handleRequest } from '../src/router.js'
import { KvStore } from '../src/kv.js'
describe('AMD Capabilities', () => {
let kv: KVNamespace
let kvStore: KvStore
let loader: ReturnType<typeof createMockLoader>
let embeddingService: MockEmbeddingService
let backend: WorkerPool
let auth: AuthModule
let env: any
beforeEach(() => {
kv = createMockKv()
kvStore = new KvStore(kv)
loader = createMockLoader()
embeddingService = new MockEmbeddingService()
backend = new WorkerPool(kv, loader.loader, embeddingService)
auth = new AuthModule(kvStore)
env = {
SIGIL_KV: kv,
backend,
auth,
kv: kvStore,
}
// Set deploy token
auth.setToken('test-token')
})
describe('Basic dependency injection', () => {
it('should deploy capability with single dependency', async () => {
// Deploy dependency first
await backend.deploy({
name: 'dep-a',
execute: 'return "value-a";',
type: 'normal',
description: 'Dependency A',
})
// Deploy capability that requires dep-a
const result = await backend.deploy({
name: 'main-cap',
execute: 'const result = await deps["dep-a"](); return `Main: ${result}`;',
type: 'normal',
description: 'Main capability',
requires: ['dep-a'],
})
expect(result.capability).toBe('main-cap')
expect(result.url).toMatch(/\/run\/main-cap$/)
})
it('should generate correct code structure with dependencies', async () => {
// Deploy dependency
await backend.deploy({
name: 'token-provider',
schema: {
type: 'object',
properties: {
service: { type: 'string', default: 'github' }
},
},
execute: 'return `token-${input.service}`;',
type: 'normal',
})
await backend.deploy({
name: 'api-client',
schema: {
type: 'object',
properties: {
endpoint: { type: 'string' }
},
required: ['endpoint'],
},
execute: `
const token = await deps["token-provider"]({ service: "github" });
return { endpoint: input.endpoint, token };
`,
type: 'normal',
requires: ['token-provider'],
})
const code = await kvStore.getCode('api-client')
expect(code).toContain('const deps = {')
expect(code).toContain("'token-provider':")
expect(code).toContain('async (params = {}) =>')
expect(code).toContain('(input, deps) =>')
})
})
describe('Multiple dependencies', () => {
it('should handle multiple dependencies', async () => {
// Deploy dependencies
await backend.deploy({
name: 'dep-b',
execute: 'return "value-b";',
type: 'normal',
})
await backend.deploy({
name: 'dep-c',
execute: 'return "value-c";',
type: 'normal',
})
// Deploy main capability
const result = await backend.deploy({
name: 'multi-dep',
execute: `
const b = await deps["dep-b"]();
const c = await deps["dep-c"]();
return \`\${b} and \${c}\`;
`,
type: 'normal',
requires: ['dep-b', 'dep-c'],
})
expect(result.capability).toBe('multi-dep')
const code = await kvStore.getCode('multi-dep')
expect(code).toContain("'dep-b':")
expect(code).toContain("'dep-c':")
})
})
describe('Chain dependencies', () => {
it('should handle chained dependencies (A requires B, B requires C)', async () => {
// Deploy C (no deps)
await backend.deploy({
name: 'base-service',
execute: 'return "base-value";',
type: 'normal',
})
// Deploy B (requires C)
await backend.deploy({
name: 'middleware',
execute: `
const base = await deps["base-service"]();
return \`middleware(\${base})\`;
`,
type: 'normal',
requires: ['base-service'],
})
// Deploy A (requires B)
const result = await backend.deploy({
name: 'top-level',
execute: `
const mid = await deps["middleware"]();
return \`top(\${mid})\`;
`,
type: 'normal',
requires: ['middleware'],
})
expect(result.capability).toBe('top-level')
// Should contain both direct and transitive dependencies
const code = await kvStore.getCode('top-level')
expect(code).toContain("'middleware':")
expect(code).toContain("'base-service':")
})
})
describe('Circular dependency detection', () => {
it('should detect simple circular dependency via self-reference', async () => {
// Try to create a capability that depends on itself
await expect(backend.deploy({
name: 'self-ref',
execute: 'const self = await deps["self-ref"](); return `self-${self}`;',
type: 'normal',
requires: ['self-ref'],
})).rejects.toThrow('Circular dependency detected: self-ref -> self-ref')
})
it('should work with real dependencies but detect actual cycle', async () => {
// Create a proper test environment for cycle detection
// Step 1: Create base capabilities
await backend.deploy({ name: 'base-1', execute: 'return "base1";', type: 'normal' })
await backend.deploy({ name: 'base-2', execute: 'return "base2";', type: 'normal' })
// Step 2: Create mid-level that depends on base
await backend.deploy({
name: 'mid-1',
execute: 'const b1 = await deps["base-1"](); return `mid1-${b1}`;',
type: 'normal',
requires: ['base-1']
})
// Step 3: Now let's simulate creating a cycle by having a test resolver
// that tracks dependencies in a simple way
const visited = new Set<string>(['base-1'])
const path = ['base-1']
// Simulate what would happen if base-1 tried to depend on mid-1
if (visited.has('mid-1')) {
const cycle = [...path, 'mid-1'].join(' -> ')
expect(cycle).toContain('base-1 -> mid-1')
}
// The actual way to test would be to create this scenario:
// Let's remove base-1 and try to make it depend on mid-1
await backend.remove('base-1')
// This should now detect circular dependency: base-1 -> mid-1 -> base-1
await expect(backend.deploy({
name: 'base-1',
execute: 'const mid = await deps["mid-1"](); return `new-base1-${mid}`;',
type: 'normal',
requires: ['mid-1']
})).rejects.toThrow('Circular dependency detected')
})
})
describe('Backward compatibility', () => {
it('should not affect capabilities without dependencies', async () => {
const result = await backend.deploy({
name: 'no-deps',
schema: {
type: 'object',
properties: {
message: { type: 'string', default: 'hello' }
},
},
execute: 'return `No deps: ${input.message}`;',
type: 'normal',
})
expect(result.capability).toBe('no-deps')
const code = await kvStore.getCode('no-deps')
expect(code).not.toContain('const deps = {')
expect(code).toContain('(input) => {')
})
it('should handle full code deployment mode', async () => {
const workerCode = `
export default {
async fetch(request) {
return new Response('custom worker code');
}
};
`
const result = await backend.deploy({
name: 'custom-worker',
code: workerCode,
type: 'normal',
})
expect(result.capability).toBe('custom-worker')
const code = await kvStore.getCode('custom-worker')
expect(code).toBe(workerCode)
})
})
describe('Error handling', () => {
it('should fail when dependency not found', async () => {
await expect(backend.deploy({
name: 'missing-dep',
execute: 'return await deps["nonexistent"]();',
type: 'normal',
requires: ['nonexistent'],
})).rejects.toThrow('Dependency not found: nonexistent')
})
it('should handle empty requires array', async () => {
const result = await backend.deploy({
name: 'empty-requires',
execute: 'return "no deps";',
type: 'normal',
requires: [],
})
expect(result.capability).toBe('empty-requires')
const code = await kvStore.getCode('empty-requires')
expect(code).not.toContain('const deps = {')
})
})
describe('API integration', () => {
it('should accept requires field via router', async () => {
// Reset cooldown before test by setting last deploy time to far past
await kvStore.setLastDeployTime(Date.now() - 60000) // 1 minute ago
// Deploy dependency via API
const depRequest = new Request('https://test.com/_api/deploy', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'api-dep',
execute: 'return "api-dependency";',
type: 'normal',
}),
})
const depResponse = await handleRequest(depRequest, env)
expect(depResponse.status).toBe(201)
// Reset cooldown again
await kvStore.setLastDeployTime(Date.now() - 60000) // 1 minute ago
// Deploy main capability with requires
const mainRequest = new Request('https://test.com/_api/deploy', {
method: 'POST',
headers: {
'Authorization': 'Bearer test-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'api-main',
execute: 'const dep = await deps["api-dep"](); return `Main: ${dep}`;',
type: 'normal',
requires: ['api-dep'],
}),
})
const mainResponse = await handleRequest(mainRequest, env)
expect(mainResponse.status).toBe(201)
const result = await mainResponse.json()
expect(result.capability).toBe('api-main')
})
it('should store requires in metadata', async () => {
// Deploy dependency first
await backend.deploy({
name: 'some-dep',
execute: 'return "dependency";',
type: 'normal',
})
// Then deploy main capability
await backend.deploy({
name: 'meta-test',
execute: 'return await deps["some-dep"]();',
type: 'normal',
requires: ['some-dep'],
})
const meta = await kvStore.getMeta('meta-test')
expect(meta?.requires).toEqual(['some-dep'])
})
})
})