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:
2026-04-03 09:15:58 +00:00
parent fd210c0edd
commit 3709fae5e1
9 changed files with 954 additions and 641 deletions
+553 -632
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -17,6 +17,6 @@
"pnpm": "^10.33.0",
"typescript": "^5.4.5",
"vitest": "^1.5.0",
"wrangler": "^3.50.0"
"wrangler": "^4.80.0"
}
}
+11
View File
@@ -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>
+39 -2
View File
@@ -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
View File
@@ -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',
})
},
}
+79
View File
@@ -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
View File
@@ -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 })
+223
View File
@@ -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
View File
@@ -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