From 69507fa766b331a06ef306da0fdcd2c6fb974d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 3 Apr 2026 23:17:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20AMD=E9=A3=8E=E6=A0=BC=20capability=20?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 字段支持依赖声明(KvMetaValue、DeployParams) - 新增 generateWorkerCodeWithDeps() 函数,支持依赖注入 codegen - 实现递归依赖解析,自动 bundle 依赖代码到主 capability - 循环依赖检测,防止无限递归 - execute 函数签名扩展为 (input, deps) => result - 依赖包装为 async (params?) => result 函数 - 支持 schema 参数解析 for 依赖 - 向后兼容:无依赖 capability 不受影响 - 12个测试用例覆盖:基本依赖、多依赖、链式依赖、循环检测、兼容性、API集成 小橘 🍊 (NEKO Team) --- src/backend/types.ts | 1 + src/backend/worker-pool.ts | 108 ++++++++++- src/codegen.ts | 116 ++++++++++++ src/kv.ts | 1 + src/router.ts | 2 + test/amd.test.ts | 358 +++++++++++++++++++++++++++++++++++++ 6 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 test/amd.test.ts diff --git a/src/backend/types.ts b/src/backend/types.ts index 085fa22..a2f7a3b 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -15,6 +15,7 @@ export interface DeployParams { description?: string // 一句话描述 tags?: string[] // 标签 examples?: string[] // 用法示例 + requires?: string[] // AMD 风格依赖 } export interface DeployResult { diff --git a/src/backend/worker-pool.ts b/src/backend/worker-pool.ts index 50546a1..2bf0da4 100644 --- a/src/backend/worker-pool.ts +++ b/src/backend/worker-pool.ts @@ -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 } } @@ -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 { - const { name, code, schema, type, ttl, bindings, description, tags, examples } = params + /** + * 递归解析依赖,检测循环依赖 + */ + private async resolveDependencies( + requires: string[], + visited = new Set(), + path: string[] = [] + ): Promise> { + const deps: Record = {} - 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 { + 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: '' }) diff --git a/src/codegen.ts b/src/codegen.ts index ad429a3..c076a02 100644 --- a/src/codegen.ts +++ b/src/codegen.ts @@ -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 { + 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" } + }); + } + } +};` +} diff --git a/src/kv.ts b/src/kv.ts index 539b08b..7bab160 100644 --- a/src/kv.ts +++ b/src/kv.ts @@ -15,6 +15,7 @@ export interface KvMetaValue { tags?: string[] examples?: string[] schema?: InputSchema + requires?: string[] } export interface KvLruValue { diff --git a/src/router.ts b/src/router.ts index fc88282..a27345d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -74,6 +74,7 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise description?: string tags?: string[] examples?: string[] + requires?: string[] } // Route validation @@ -110,6 +111,7 @@ async function handleDeploy(request: Request, env: RouterEnv): Promise description: body.description, tags: body.tags, examples: body.examples, + requires: body.requires, }) // Set cooldown after successful deploy diff --git a/test/amd.test.ts b/test/amd.test.ts new file mode 100644 index 0000000..415d3da --- /dev/null +++ b/test/amd.test.ts @@ -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 + 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(['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']) + }) + }) +}) \ No newline at end of file