Skip to content

Commit abbd025

Browse files
authored
Merge pull request #64 from InsForge/feat/functions-in-process-dispatch
INS-75 - feat(functions): in-process dispatch to avoid 508 Loop Detected
2 parents 24435e3 + 55f7a37 commit abbd025

8 files changed

Lines changed: 1860 additions & 105 deletions

File tree

docs/superpowers/plans/2026-04-15-functions-in-process-dispatch.md

Lines changed: 1043 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Functions In-Process Dispatch — Design
2+
3+
## Problem
4+
5+
InsForge functions run on Deno Subhosting under a single-project, multi-function model: every function in a project is bundled into one Deno deployment, and an auto-generated `main.ts` does path-based routing (`/{slug}`). The deployment is reachable at `https://{appKey}.functions.insforge.app`.
6+
7+
When function B (running inside that deployment) uses the SDK to invoke function A, the SDK currently calls `https://{appKey}.functions.insforge.app/A`. Deno Subhosting detects this as a recursive request to the same deployment and returns:
8+
9+
```text
10+
508 Loop Detected — Recursive requests to the same deployment cannot be processed.
11+
```
12+
13+
This makes function-to-function composition impossible from inside a function.
14+
15+
## Solution
16+
17+
When the SDK is running **inside the same bundled deployment** as the target function, dispatch in-process — call the router's handler directly with a constructed `Request`, skipping the network entirely. When the SDK is running anywhere else (browser, external server, a different deployment), behavior is unchanged: HTTP to the public functions URL.
18+
19+
The trigger is the presence of `globalThis.__insforge_dispatch__`, which the auto-generated router publishes at module load. No new SDK config flag is required; existing `isServerMode` keeps its current CSRF/localStorage semantics and is not involved.
20+
21+
## Architecture
22+
23+
```text
24+
┌─────────────────────────────────────────────────────────┐
25+
│ Deno deployment ({appKey}.functions.insforge.app) │
26+
│ │
27+
│ main.ts (auto-generated) │
28+
│ const dispatch = async (req) => { ...router... } │
29+
│ globalThis.__insforge_dispatch__ = dispatch │
30+
│ Deno.serve(dispatch) │
31+
│ │
32+
│ ┌──────────────┐ ┌──────────────┐ │
33+
│ │ function A │ │ function B │ │
34+
│ │ handler │ │ handler │ │
35+
│ └──────────────┘ └──────┬───────┘ │
36+
│ ▲ │ │
37+
│ │ ▼ │
38+
│ │ sdk.functions.invoke(A) │
39+
│ │ │ │
40+
│ │ ▼ │
41+
│ │ ┌───────────────────────────┐ │
42+
│ └─── dispatch ◄───┤ globalThis.__insforge_... │ │
43+
│ └───────────────────────────┘ │
44+
└─────────────────────────────────────────────────────────┘
45+
46+
External caller (browser / other server):
47+
fetch → https://{appKey}.functions.insforge.app/A → Deno.serve(dispatch) → A
48+
```
49+
50+
Function B's invocation never leaves the process. The router's full logic still runs (health check, slug lookup, subpath rewrite, request log, error wrapping), so the call is semantically equivalent to an HTTP hit.
51+
52+
## Component 1: Router Generator (Backend)
53+
54+
**Location:** the `generateRouter(functions)` function in the InsForge backend (separate repo from this SDK; the function body is reproduced in the original brainstorming context).
55+
56+
**Change:** extract the existing inline `Deno.serve` callback into a named `dispatch` const, publish it on `globalThis`, then pass it to `Deno.serve`. Apply to both empty and non-empty router branches so behavior is consistent regardless of how many functions a deployment has.
57+
58+
Non-empty branch (skeleton):
59+
60+
```ts
61+
// Auto-generated router
62+
${imports}
63+
64+
const routes: Record<string, (req: Request) => Promise<Response>> = {
65+
${routes}
66+
};
67+
68+
const dispatch = async (req: Request): Promise<Response> => {
69+
// ...identical to today's Deno.serve callback body:
70+
// health check, slug parse, subpath rewrite, handler call,
71+
// duration log, error wrapping
72+
};
73+
74+
(globalThis as any).__insforge_dispatch__ = dispatch;
75+
76+
Deno.serve(dispatch);
77+
```
78+
79+
Empty branch: same shape, with the existing "no functions" 404 logic inside `dispatch`.
80+
81+
**Logic inside `dispatch` is unchanged.** Only the wrapping shape moves.
82+
83+
## Component 2: SDK — In-Process Dispatch (`src/modules/functions.ts`)
84+
85+
At the top of `Functions.invoke`, probe for the global. If present, dispatch in-process; otherwise, fall through to the existing subhosting → proxy fallback chain.
86+
87+
```ts
88+
async invoke<T>(slug: string, options: FunctionInvokeOptions = {}) {
89+
const { method = 'POST', body, headers = {} } = options;
90+
91+
const dispatch = globalThis.__insforge_dispatch__;
92+
if (typeof dispatch === 'function') {
93+
try {
94+
const req = this.buildInProcessRequest(slug, method, body, headers);
95+
const res = await dispatch(req);
96+
const data = await parseResponse<T>(res);
97+
return { data, error: null };
98+
} catch (error) {
99+
if (error instanceof Error && error.name === 'AbortError') throw error;
100+
return {
101+
data: null,
102+
error: error instanceof InsForgeError
103+
? error
104+
: new InsForgeError(
105+
error instanceof Error ? error.message : 'Function invocation failed',
106+
500,
107+
'FUNCTION_ERROR',
108+
),
109+
};
110+
}
111+
}
112+
113+
// existing subhosting → proxy fallback unchanged
114+
...
115+
}
116+
```
117+
118+
### Request construction
119+
120+
`buildInProcessRequest(slug, method, body, headers)` produces a `Request`:
121+
122+
- **URL:** `new URL('/' + slug, 'http://insforge.local').toString()`. The router only reads `pathname`; the placeholder host/scheme are intentionally non-routable to make the in-process intent explicit. Slugs containing `/` (e.g. `'foo/bar'`) become pathname `/foo/bar` and exercise the router's existing subpath rewrite.
123+
- **Headers:** start with `this.http.getHeaders()` so `Authorization` (user token or anon key) and any default headers match the HTTP path; merge caller-provided `headers` on top (caller wins on conflict).
124+
- **Body:** mirror `HttpClient.handleRequest` serialization:
125+
- `undefined` → no body.
126+
- `FormData` instance → pass through unchanged; do not set `Content-Type`.
127+
- Anything else (object, array, string, etc.) → `JSON.stringify(body)` and set `Content-Type: application/json;charset=UTF-8` (skipped only for `GET`).
128+
129+
To avoid duplicating serialization logic, factor a shared `serializeBody(method, body, headers)` helper used by both `HttpClient.handleRequest` and `buildInProcessRequest`. It returns `{ body, contentType }` or similar.
130+
131+
### Response parsing
132+
133+
Extract the response-parsing block of `HttpClient.handleRequest` (currently `src/lib/http-client.ts:276–337`) into a free function `parseResponse<T>(response: Response): Promise<T>`. The helper handles:
134+
135+
- `204``undefined`
136+
- JSON `Content-Type``await response.json()`
137+
- Other → `await response.text()`
138+
- Non-2xx → throw `InsForgeError`, preferring `InsForgeError.fromApiError(data)` when the body has the `{ error, ... }` shape, otherwise a generic `InsForgeError(statusText, status, 'REQUEST_FAILED')`.
139+
- Body parse failures → `InsForgeError('Failed to parse response body…', status, response.ok ? 'PARSE_ERROR' : 'REQUEST_FAILED')`.
140+
141+
`HttpClient.handleRequest` is refactored to call `parseResponse` instead of doing it inline. Behavior is unchanged; this is a pure extraction so both call sites share one implementation.
142+
143+
### What is intentionally NOT applied to in-process dispatch
144+
145+
- **Retry / exponential backoff.** Network failures don't exist in-process; retrying a function handler would silently double-execute side effects.
146+
- **SDK timeout.** No fetch involved; if a handler hangs, that's a business bug and should surface, not be swallowed by SDK's 30s default.
147+
- **Subhosting → proxy fallback.** If `routes[slug]` doesn't exist, the router returns 404 and the SDK surfaces it. There's no proxy that would have it; falling back would just add latency and could re-trigger HTTP-loop scenarios in odd misconfigurations.
148+
149+
### Token refresh
150+
151+
`HttpClient.request` wraps `handleRequest` with 401-triggered token refresh. In-process dispatch does **not** participate in refresh: a function handler running inside the deployment uses whatever credentials the original incoming request carried, and silent token rotation across an internal call would mutate caller-visible auth state in surprising ways. If a handler returns 401, the caller sees 401 and decides what to do.
152+
153+
## Component 3: Type Declaration
154+
155+
New file `src/types/globals.d.ts`:
156+
157+
```ts
158+
export {};
159+
160+
declare global {
161+
// eslint-disable-next-line no-var
162+
var __insforge_dispatch__:
163+
| ((req: Request) => Promise<Response>)
164+
| undefined;
165+
}
166+
```
167+
168+
This lets `globalThis.__insforge_dispatch__` be referenced directly without `as any` casts. The `export {}` keeps the file a module so `declare global` works.
169+
170+
Confirm `tsconfig.json` includes the file (typically picked up by default `include: ["src"]`); add explicitly only if needed.
171+
172+
## Error Handling Summary
173+
174+
| Scenario | Outcome |
175+
|---|---|
176+
| `dispatch` returns 2xx with JSON | `{ data, error: null }` |
177+
| `dispatch` returns 2xx with non-JSON | `{ data: <text>, error: null }` |
178+
| `dispatch` returns 204 | `{ data: undefined, error: null }` |
179+
| `dispatch` returns non-2xx with `{ error, message }` body | `{ data: null, error: InsForgeError(status, code) }` (preserves all fields) |
180+
| `dispatch` returns non-2xx with non-error body | `{ data: null, error: InsForgeError(status, 'REQUEST_FAILED') }` |
181+
| `dispatch` returns 404 (slug not in routes) | Returned as error (no HTTP fallback) |
182+
| `dispatch` throws synchronously or rejects | `{ data: null, error: InsForgeError(500, 'FUNCTION_ERROR') }` |
183+
| `AbortError` propagates from caller-cancellation | re-throw, matches HTTP path |
184+
185+
## Backward Compatibility
186+
187+
| SDK | Router | Behavior |
188+
|---|---|---|
189+
| Old | Old | HTTP, loop bug present (status quo) |
190+
| New | Old | HTTP (global absent → fallthrough), loop bug present until router updated |
191+
| Old | New | HTTP (old SDK doesn't read global), loop bug present until SDK updated |
192+
| New | New | In-process dispatch, no loop |
193+
194+
Both sides degrade safely. Mixed deployments work; no synchronized rollout required.
195+
196+
## Testing
197+
198+
New file `src/modules/__tests__/functions.test.ts`. Each test sets/clears `globalThis.__insforge_dispatch__` in setup/teardown. Mock `dispatch` with `jest.fn()` returning crafted `Response` objects.
199+
200+
| # | Setup | Assertion |
201+
|---|---|---|
202+
| 1 | No global, mock `http.request` returns data | Returns `{ data, error: null }`; HTTP path used (existing behavior preserved) |
203+
| 2 | No global, mock `http.request` throws 404, then returns data on second call | Subhosting → proxy fallback (existing behavior preserved) |
204+
| 3 | global present, dispatch returns `Response('{"x":1}', { headers: { 'content-type': 'application/json' } })` | Returns `{ data: { x: 1 }, error: null }`; underlying `fetch` mock **never called** |
205+
| 4 | global present, dispatch returns 500 with `{"error":"E","message":"M"}` JSON | Returns `{ data: null, error }` with `error.statusCode === 500`, `error.error === 'E'` |
206+
| 5 | global present, dispatch throws `new Error('boom')` | Returns `{ data: null, error: InsForgeError('boom', 500, 'FUNCTION_ERROR') }` |
207+
| 6 | global present, body is `{ a: 1 }` | dispatch's received Request has `content-type: application/json`; `await req.json()` deep-equals `{ a: 1 }` |
208+
| 7 | global present, options `headers: { Authorization: 'Bearer xyz' }` | dispatch's received Request has `Authorization: Bearer xyz` |
209+
| 8 | global present, `slug = 'foo/bar'` | dispatch's received Request `new URL(req.url).pathname === '/foo/bar'` |
210+
| 9 | global present, `method: 'GET'`, no body | dispatch's received Request has method `GET`, no `content-type` set by SDK |
211+
| 10 | global present, dispatch returns 204 | Returns `{ data: undefined, error: null }` |
212+
213+
Existing HTTP-path tests (if any) must continue to pass unchanged.
214+
215+
## Out of Scope
216+
217+
- Cross-deployment function calls (different `appKey`s). Those go HTTP and don't trigger Deno's loop detection — current behavior is correct.
218+
- Streaming responses. The current `invoke` API returns parsed `data`; streaming is a separate feature not changed here.
219+
- Telemetry/metrics on in-process calls. Router's existing `console.log` line still fires (since the full router runs); no new instrumentation added.
220+
- Changes to `isServerMode` semantics.
221+
222+
## Open Questions
223+
224+
None at design time. Implementation may surface minor decisions (e.g., exact placement of `parseResponse` — own file vs. exported from `http-client.ts`); resolve those during plan writing.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@insforge/sdk",
3-
"version": "1.2.4",
3+
"version": "1.2.5",
44
"description": "Official JavaScript/TypeScript client for InsForge Backend-as-a-Service platform",
55
"main": "./dist/index.js",
66
"module": "./dist/index.mjs",

0 commit comments

Comments
 (0)