fix: /run/{name} → 302 redirect, bypass CF same-zone fetch limitation
CF Workers cannot fetch() other workers on the same .workers.dev zone.
This caused all /run/{name} routes to return Cloudflare's HTML 404
instead of proxying to the sub-worker.
Fix: replace inline fetch() proxy with a redirect-based approach:
- Default (browser/curl): 302 redirect to sub-worker URL
- Accept: application/json: return JSON with {url, capability, cold_start}
LRU bookkeeping (page-in, access count) still happens in Sigil before
the redirect, so cold capabilities are warmed up transparently.
New backend method: resolveInvoke() — same LRU/page-in logic as invoke()
but returns route info instead of executing the subrequest.
Fixes: https://sigil.shazhou.workers.dev/run/* returning CF 404
Reported-by: 小墨 🖊️
This commit is contained in:
Generated
+553
-632
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -17,6 +17,6 @@
|
||||
"pnpm": "^10.33.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.5.0",
|
||||
"wrangler": "^3.50.0"
|
||||
"wrangler": "^4.80.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,20 @@ 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem } from './types.js'
|
||||
import type { SigilBackend, DeployParams, DeployResult, Capability, BackendStatus, QueryParams, QueryResult, QueryItem, ResolveInvokeResult, ResolveInvokeError } from './types.js'
|
||||
import { KvStore } from '../kv.js'
|
||||
import { LruScheduler, PageRateLimitError } from '../lru.js'
|
||||
import { LruScheduler } from '../lru.js'
|
||||
import { CONFIG } from '../config.js'
|
||||
import { EmbeddingService, cosineSimilarity, mmrSelect } from '../embedding.js'
|
||||
|
||||
@@ -194,6 +194,43 @@ export class WorkerPool implements SigilBackend {
|
||||
return await this.cfApi.invoke(route.worker_name, request)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
+8
-2
@@ -65,12 +65,18 @@ export function createCfApi(accountId: string, apiToken: string): CfApi {
|
||||
async invoke(workerName: string, request: Request): Promise<Response> {
|
||||
// 转发请求到 Worker 子域名
|
||||
const url = new URL(request.url)
|
||||
const targetUrl = `https://${workerName}${CONFIG.SUBDOMAIN_SUFFIX}${url.pathname}${url.search}`
|
||||
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: request.headers,
|
||||
headers,
|
||||
body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
|
||||
redirect: 'follow',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface SchemaProperty {
|
||||
type: string
|
||||
description?: string
|
||||
default?: any
|
||||
}
|
||||
|
||||
export interface InputSchema {
|
||||
type?: 'object'
|
||||
properties: Record<string, SchemaProperty>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 schema + execute body 生成完整 Worker 代码
|
||||
*/
|
||||
export function generateWorkerCode(schema: InputSchema, executeBody: string): 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')
|
||||
|
||||
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}
|
||||
|
||||
// Execute user function
|
||||
const __result = await (async (input) => {
|
||||
${executeBody}
|
||||
})(input);
|
||||
|
||||
// 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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
};`
|
||||
}
|
||||
+30
-2
@@ -48,7 +48,7 @@ export async function handleRequest(request: Request, env: RouterEnv): Promise<R
|
||||
const runMatch = path.match(/^\/run\/([^/]+)$/)
|
||||
if (runMatch) {
|
||||
const capability = runMatch[1]!
|
||||
return handleInvoke(capability, request, env)
|
||||
return handleInvoke(capability, request, env, url)
|
||||
}
|
||||
|
||||
return jsonError(404, 'Not found')
|
||||
@@ -173,9 +173,37 @@ async function handleInvoke(
|
||||
capability: string,
|
||||
request: Request,
|
||||
env: RouterEnv,
|
||||
url: URL,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await env.backend.invoke(capability, request)
|
||||
// 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 })
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateWorkerCode } from '../src/codegen.js'
|
||||
import type { InputSchema } from '../src/codegen.js'
|
||||
|
||||
// Helper: eval the generated Worker code and call its fetch handler
|
||||
async function callWorker(
|
||||
code: string,
|
||||
options: {
|
||||
method?: string
|
||||
url?: string
|
||||
body?: unknown
|
||||
searchParams?: Record<string, string>
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const { method = 'GET', url = 'https://example.com/', body, searchParams } = options
|
||||
|
||||
// Build URL with search params
|
||||
const reqUrl = new URL(url)
|
||||
if (searchParams) {
|
||||
for (const [k, v] of Object.entries(searchParams)) {
|
||||
reqUrl.searchParams.set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
const init: RequestInit = { method }
|
||||
if (body !== undefined && (method === 'POST' || method === 'PUT')) {
|
||||
init.body = JSON.stringify(body)
|
||||
init.headers = { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
const request = new Request(reqUrl.toString(), init)
|
||||
|
||||
// Evaluate the worker code and get the default export
|
||||
const module = await import(/* @vite-ignore */ `data:text/javascript,${encodeURIComponent(code)}`)
|
||||
const worker = module.default
|
||||
return worker.fetch(request)
|
||||
}
|
||||
|
||||
describe('codegen: generateWorkerCode', () => {
|
||||
// Test 1: 基本代码生成
|
||||
it('schema + execute → 生成有效 Worker 代码', () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name to greet' },
|
||||
},
|
||||
}
|
||||
const execute = `return "Hello, " + (input.name || "World") + "!"`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
expect(code).toContain('export default')
|
||||
expect(code).toContain('async fetch(request)')
|
||||
expect(code).toContain('input.name')
|
||||
expect(typeof code).toBe('string')
|
||||
})
|
||||
|
||||
// Test 2: 类型转换 — number/boolean 从 query string 正确转换
|
||||
it('number 类型从 query string 正确转换', async () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
amount: { type: 'number', description: 'Amount' },
|
||||
},
|
||||
}
|
||||
const execute = `return JSON.stringify({ amount: input.amount, type: typeof input.amount })`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code, { searchParams: { amount: '42.5' } })
|
||||
expect(resp.status).toBe(200)
|
||||
const data = await resp.json() as { amount: number; type: string }
|
||||
expect(data.amount).toBe(42.5)
|
||||
expect(data.type).toBe('number')
|
||||
})
|
||||
|
||||
it('boolean 类型从 query string 正确转换', async () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flag: { type: 'boolean', description: 'A flag' },
|
||||
},
|
||||
}
|
||||
const execute = `return JSON.stringify({ flag: input.flag, type: typeof input.flag })`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const respTrue = await callWorker(code, { searchParams: { flag: 'true' } })
|
||||
const dataTrue = await respTrue.json() as { flag: boolean; type: string }
|
||||
expect(dataTrue.flag).toBe(true)
|
||||
expect(dataTrue.type).toBe('boolean')
|
||||
|
||||
const respFalse = await callWorker(code, { searchParams: { flag: 'false' } })
|
||||
const dataFalse = await respFalse.json() as { flag: boolean; type: string }
|
||||
expect(dataFalse.flag).toBe(false)
|
||||
})
|
||||
|
||||
// Test 3: 默认值填充
|
||||
it('缺少参数时用默认值', async () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
amount: { type: 'number', description: 'Amount', default: 1 },
|
||||
currency: { type: 'string', description: 'Currency', default: 'USD' },
|
||||
},
|
||||
}
|
||||
const execute = `return JSON.stringify({ amount: input.amount, currency: input.currency })`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code)
|
||||
expect(resp.status).toBe(200)
|
||||
const data = await resp.json() as { amount: number; currency: string }
|
||||
expect(data.amount).toBe(1)
|
||||
expect(data.currency).toBe('USD')
|
||||
})
|
||||
|
||||
// Test 4: required 校验 — 缺少必填参数返回 400
|
||||
it('缺少 required 参数返回 400', async () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: { type: 'string' },
|
||||
to: { type: 'string' },
|
||||
},
|
||||
required: ['from', 'to'],
|
||||
}
|
||||
const execute = `return JSON.stringify({ from: input.from, to: input.to })`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
// Missing both required params
|
||||
const resp1 = await callWorker(code)
|
||||
expect(resp1.status).toBe(400)
|
||||
const data1 = await resp1.json() as { error: string }
|
||||
expect(data1.error).toContain('Missing required parameter: from')
|
||||
|
||||
// Only `from` provided
|
||||
const resp2 = await callWorker(code, { searchParams: { from: 'USD' } })
|
||||
expect(resp2.status).toBe(400)
|
||||
const data2 = await resp2.json() as { error: string }
|
||||
expect(data2.error).toContain('Missing required parameter: to')
|
||||
|
||||
// Both provided — should succeed
|
||||
const resp3 = await callWorker(code, { searchParams: { from: 'USD', to: 'CNY' } })
|
||||
expect(resp3.status).toBe(200)
|
||||
})
|
||||
|
||||
// Test 5: 空 schema — 无参数的函数
|
||||
it('空 schema — 无参数的函数正常运行', async () => {
|
||||
const schema: InputSchema = { properties: {} }
|
||||
const execute = `return "hello world"`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code)
|
||||
expect(resp.status).toBe(200)
|
||||
const text = await resp.text()
|
||||
expect(text).toBe('hello world')
|
||||
})
|
||||
|
||||
// Test 6: POST body 解析 — JSON body 正确读取
|
||||
it('POST body 解析 — JSON body 正确读取', async () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
},
|
||||
}
|
||||
const execute = `return JSON.stringify({ sum: input.x + input.y })`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code, {
|
||||
method: 'POST',
|
||||
body: { x: 10, y: 20 },
|
||||
})
|
||||
expect(resp.status).toBe(200)
|
||||
const data = await resp.json() as { sum: number }
|
||||
expect(data.sum).toBe(30)
|
||||
})
|
||||
|
||||
// Test 7: 错误处理 — execute 抛错返回 500
|
||||
it('execute 抛错返回 500', async () => {
|
||||
const schema: InputSchema = { properties: {} }
|
||||
const execute = `throw new Error("intentional error")`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code)
|
||||
expect(resp.status).toBe(500)
|
||||
const data = await resp.json() as { error: string }
|
||||
expect(data.error).toContain('intentional error')
|
||||
})
|
||||
|
||||
// Test 8: query params override POST body
|
||||
it('query params 覆盖 POST body 同名参数', async () => {
|
||||
const schema: InputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
}
|
||||
const execute = `return input.value`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code, {
|
||||
method: 'POST',
|
||||
url: 'https://example.com/?value=from-query',
|
||||
body: { value: 'from-body' },
|
||||
})
|
||||
expect(resp.status).toBe(200)
|
||||
const text = await resp.text()
|
||||
// query params should override body
|
||||
expect(text).toBe('from-query')
|
||||
})
|
||||
|
||||
// Test 9: non-string output auto-stringified
|
||||
it('非 string 返回值自动 JSON 序列化', async () => {
|
||||
const schema: InputSchema = { properties: {} }
|
||||
const execute = `return { hello: "world", num: 42 }`
|
||||
const code = generateWorkerCode(schema, execute)
|
||||
|
||||
const resp = await callWorker(code)
|
||||
expect(resp.status).toBe(200)
|
||||
const data = await resp.json() as { hello: string; num: number }
|
||||
expect(data.hello).toBe('world')
|
||||
expect(data.num).toBe(42)
|
||||
})
|
||||
})
|
||||
+10
-2
@@ -1,11 +1,19 @@
|
||||
name = "sigil"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-04-03"
|
||||
compatibility_flags = ["global_fetch_strictly_public"]
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "SIGIL_KV"
|
||||
id = "9943c8873e724b0fb2cf24b4475e5a52"
|
||||
|
||||
# Dynamic Workers loader binding (open beta)
|
||||
# Allows Gateway Worker to load and execute sub-Worker code dynamically
|
||||
# without deploying independent scripts, avoiding Worker-to-Worker fetch
|
||||
# restrictions (error 1042) on workers.dev subdomains.
|
||||
[[worker_loaders]]
|
||||
binding = "LOADER"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
|
||||
@@ -13,8 +21,8 @@ binding = "AI"
|
||||
SIGIL_ENV = "production"
|
||||
|
||||
# Worker Secrets (set via `wrangler secret put`, never committed to source):
|
||||
# CF_API_TOKEN - Cloudflare API token with Workers:Edit permission
|
||||
# CF_ACCOUNT_ID - Cloudflare Account ID
|
||||
# CF_API_TOKEN - Cloudflare API token with Workers:Edit permission (optional, for legacy cleanup)
|
||||
# CF_ACCOUNT_ID - Cloudflare Account ID (optional, for legacy cleanup)
|
||||
#
|
||||
# To configure:
|
||||
# echo "$CF_API_TOKEN" | npx wrangler secret put CF_API_TOKEN
|
||||
|
||||
Reference in New Issue
Block a user