Status: Engineering Design and Execution Roadmap
Date: March 7, 2026
Scope: Filesystem-based routing, multi-function deployment flavor, global domain routing manifest, and a RESTful function contract based on
export defaultRelated documents:
ROADMAP.md,ROADMAP_OPENNEXT.md,docs/reference/function-manifest.md,docs/function-contract-design.md,docs/reference/cli.md,CURRENT_ARCHITECTURE_ANALYSIS.md,schemas/function-manifest.v2.schema.json
- Objective
- Current Platform Baseline
- Problem Statement
- Vision & Success Metrics
- Proposed Architecture
- Function Flavor Support
- Detailed Implementation Roadmap
- File-by-File Implementation Details
- Real-World Examples
- Phase-by-Phase Breakdown
- Migration Strategy
- Performance Impact Analysis
- Security & Edge Cases
- Testing Strategy
- Open Questions & Design Decisions
- Related ROADMAP.md Tasks
This document defines the target architecture to evolve Thunder from the current model:
- Ingress routing by
/{function_name}/* - One deployed function per bundle
- Single manifest entrypoint
- User code centered on
Deno.serve()interception
To a model that supports:
- Filesystem-based routing inspired by Next.js, Remix, SvelteKit, and Astro
- Two deploy flavors:
single: One self-contained file that handles all requestsrouted-app: One deployment containing multiple routed modules, suitable for REST APIs or frontend apps
- Preserved deployment mount prefix so the runtime keeps serving requests under
/{function_id}/... - RESTful authoring contract based on
export default - Runtime method dispatch with
405 Method Not AllowedandAllowheaders - Route metadata available at build time, deploy time, and runtime
The goal is to give the platform a framework-friendly substrate that is minimal, predictable, and compatible with edge-runtime constraints.
The current ingress path resolution is implemented in crates/server/src/ingress_router.rs.
Current behavior:
- The first path segment is interpreted as the function name
- The remaining suffix is forwarded to the isolate unchanged
- No filesystem discovery exists
- No route manifest exists
- No path parameter extraction exists
- HTTP method is not used for routing before reaching user code
Current request flow:
Request: GET /my-function/api/users/123
↓
ingress_router extracts "my-function"
↓
registry resolves one deployed function
↓
path forwarded as "/api/users/123"
↓
user handler parses URL and method manually
↓
Response
This gives maximum flexibility but pushes all routing complexity into each user function.
Today the runtime canonical addressing model is path-prefix based:
http://localhost:9000/function_id_xpto/api/xpto
That first segment is not an implementation detail to be removed. It is the deployment identifier used by the runtime to decide which function bundle or routed app should receive the request.
This constraint matters operationally because the intended edge topology is:
https://function_id_xpto.my-edge-runtime.com/api/xpto
-> reverse proxy rewrite
http://localhost:9000/function_id_xpto/api/xpto
Therefore, any routing redesign must preserve these invariants:
- The runtime continues to accept
/{function_id}/...as the canonical ingress format - Filesystem routing happens only inside the namespace of one deployed function/app
- Reverse proxies may map hostnames to path prefixes, but the runtime contract remains path-prefix based
- The deploy identifier remains visible and stable for local debugging, internal traffic, and non-proxied environments
The current admin API is centered on POST /_internal/functions with:
x-function-nameheader- Optional
x-function-manifest-b64header (base64-encoded JSON) - Request body containing one ESZIP bundle
Current manifest v1 (from schemas/function-manifest.v1.schema.json):
{
"manifestVersion": 1,
"name": "hello",
"entrypoint": "./index.ts",
"env": {
"allow": ["DATABASE_URL"],
"secretRefs": ["API_KEY"]
},
"network": {
"mode": "allowlist",
"allow": ["api.example.com:443"]
},
"resources": {
"maxHeapMiB": 256,
"cpuTimeMs": 5000,
"wallClockTimeoutMs": 30000
}
}Limitations of v1:
entrypointis singular (one file per deployment)- No
routessection - No route-level metadata
- No deploy flavor field
- No way to detect route conflicts at build time
Runtime behavior is centered on Deno.serve() interception.
Current pattern (only supported):
import { serve } from "https://deno.land/std@x.y.z/http/server.ts";
serve(async (req) => {
const url = new URL(req.url);
// Users implement all routing manually
if (url.pathname === "/api/users") {
return new Response(JSON.stringify([...users...]));
}
return new Response("Not found", { status: 404 });
});Forward-looking design (from docs/function-contract-design.md) proposes:
- Default function export
- Default object with HTTP methods
- Named method exports
| Issue | Current Impact |
|---|---|
| Manual Routing | Every function manually implements URL pattern matching, string parsing, and HTTP method dispatch |
| No IDE Support | Routes are opaque to editors; no autocomplete or type checking |
| Parameter Parsing | Manual extraction using regex/string parsing is error-prone and verbose |
| Framework Incompatibility | Doesn't match Next.js, Remix, SvelteKit patterns that developers expect |
| Code Organization | One function = one directory = monolithic bundle; doesn't scale for APIs with 10+ endpoints |
| No Type Safety | Parameters are strings; no validation or type checking |
| Issue | Current Impact |
|---|---|
| No Per-Route Metrics | Can't identify which endpoints are slow or failing |
| No Route Metadata | Runtime unaware of available routes; can't validate at deploy time |
| Route Collisions | Not detected; silent behavior changes on updates |
| Limited Introspection | No way to query available routes or metrics |
| Monolithic Deployments | Can't split API into multiple files without multiple deployments |
| Issue | Current Impact |
|---|---|
| String-Based Routing | Manual URL parsing has performance overhead |
| Handler Interception | Deno.serve() replacement couples user code to runtime |
| Manifest Gaps | Route information lost at deploy time; can't optimize |
| No HTTP Method Dispatch | All methods go to same handler in user code |
Implement a cohesive routing and function contract system that:
- Aligns with modern frameworks - Developers use familiar filesystem routing patterns
- Reduces boilerplate - Framework provides routing, method dispatch, and parameter extraction
- Improves observability - Routes are explicit at build time; metrics are per-route
- Scales gracefully - From 1 route to 1000+ routes without complexity explosion
- Maintains compatibility - Existing Deno.serve() functions continue to work
| Metric | Target |
|---|---|
| Developer Friction | 50% less code for REST APIs |
| Framework Alignment | Filesystem structure matches Next.js/Remix |
| Performance | Route matching adds < 1ms per request |
| Adoption | 80% of new functions use new patterns within 6 months |
| Backward Compatibility | 100% of existing functions work without changes |
| Cold-Start Impact | < 20ms additional latency |
Phase 0: Foundation (Weeks 1-2) → Manifest v2, Route struct, Examples
Phase 1: Core Routing (Weeks 3-5) → Directory scanning, Pattern matching
Phase 2: Export Patterns (Weeks 6-8) → Default, Object, Named exports
Phase 3: Parameters (Weeks 9-10) → Dynamic segments, Type passing
Phase 4: HTTP Methods (Weeks 11-12) → Per-method handlers, 405 support
Phase 5: Documentation & Polish (Weeks 13-16) → Migration guide, Examples, Tooling
Total: 16 weeks to full production readiness
The routing redesign must preserve the existing two-stage runtime router and add an optional global pre-stage, not replace the deployment prefix model.
For multi-tenant ingress by domain, the runtime also needs an optional global stage that resolves host + path to one deployed function namespace before the existing prefix logic.
Stage 0: Global edge routing resolution (optional)
- Read
Hostand request path - Match against a global routing manifest (
routing-manifest.v1) - Resolve target
function_idwhile keeping function isolation boundaries
Stage 1: Deployment resolution
- Read the first path segment
- Resolve the deployed function/app by
function_id - Preserve current semantics of
/{function_id}/...
Stage 2: Intra-app route resolution
- Strip only the leading
/{function_id}for internal matching - Match the remaining suffix against the manifest route table of that deployment
- Dispatch to the correct route module or asset entry
Canonical internal example:
Incoming runtime URL: /function_id_xpto/api/users/123
Deployment scope: function_id_xpto
Route suffix seen by routed app: /api/users/123
Matched route module: api/users/[id].ts
Extracted params: { id: "123" }
Canonical reverse proxy example:
External URL: https://function_id_xpto.my-edge-runtime.com/api/users/123
Proxy rewrite: http://localhost:9000/function_id_xpto/api/users/123
Canonical domain-routing example (without explicit prefix in external URL):
External URL: https://api.customer-a.com/users/123
Stage 0 match: host=api.customer-a.com, path=/users/123 -> function_id_xpto
Internal URL: /function_id_xpto/users/123
Stage 2 match: /users/:id
This preserves compatibility with:
- Current local development and direct runtime access
- Subdomain-based multi-tenant ingress via reverse proxy
- Existing tooling that already assumes
function_nameorfunction_idin the first path segment
The important architectural point is: filesystem routing augments the current addressing model; it does not remove the deployment mount prefix.
To support domain-based routing while preserving physical separation between functions, introduce a second manifest layer at the edge/runtime boundary.
Global manifest scope:
- Maps
host + pathto one deployedfunction_id - Contains no code entrypoints and no per-route JS handler metadata
- Is validated independently from function manifests
Example shape (conceptual):
{
"manifestVersion": 1,
"routes": [
{
"host": "api.customer-a.com",
"path": "/users/:id",
"targetFunction": "customer-a-api"
},
{
"host": "*.apps.example.com",
"path": "/*",
"targetFunction": "frontend-gateway"
}
]
}Deterministic precedence (global layer):
- Exact host + exact static path
- Exact host + dynamic path
- Wildcard host + exact static path
- Wildcard host + dynamic path
- Catch-all host/path fallback
Compatibility rule:
- If no global route matches, fallback remains the canonical
/{function_id}/...resolution path.
Location: schemas/function-manifest.v2.schema.json
{
"manifestVersion": 2,
"name": "api-v2",
"flavor": "routed-app",
"routes": [
{
"path": "/",
"pattern": "/",
"entrypoint": "index.ts",
"methods": ["GET"]
},
{
"path": "/api/users",
"pattern": "/api/users",
"entrypoint": "api/users.ts",
"methods": ["GET", "POST"]
},
{
"path": "/api/users/:id",
"pattern": "/api/users/[id]",
"entrypoint": "api/users/[id].ts",
"methods": ["GET", "PUT", "DELETE"],
"parameters": [
{
"name": "id",
"segment": "id",
"optional": false,
"catchAll": false
}
]
},
{
"path": "/admin/*",
"pattern": "/admin/[...rest]",
"entrypoint": "admin/[...rest].ts",
"methods": ["GET"],
"parameters": [
{
"name": "rest",
"segment": "rest",
"optional": false,
"catchAll": true
}
]
}
],
"env": {
"allow": ["DATABASE_URL"],
"secretRefs": ["API_KEY"]
},
"network": {
"mode": "allowlist",
"allow": ["api.example.com:443"]
},
"resources": {
"maxHeapMiB": 256,
"cpuTimeMs": 5000,
"wallClockTimeoutMs": 30000
}
}Key additions over v1:
manifestVersion: 2(required for routing)flavor: "routed-app" | "single"(determines how routes are treated)routes: Route[](filesystem-discovered routes with patterns and methods)- Route metadata for build-time validation and deploy-time introspection
Important non-change:
- The manifest does not replace the runtime deployment prefix
namecontinues to identify the deployed function/app mounted under/{name}/...- Route entries describe paths inside that mount, for example
/api/usersmeans/{function_id}/api/usersat runtime
v1 Compatibility:
- v1 manifests with single
entrypointautomatically upgrade to v2 with one route at/
The runtime detects and supports four mutually-exclusive patterns:
Single function handling all methods:
export default async function(
req: Request,
params?: Record<string, string>
): Promise<Response> {
return new Response("Hello");
}Object with method-specific handlers:
export default {
async GET(req: Request, params?: Record<string, string>) {
return new Response("GET response");
},
async POST(req: Request, params?: Record<string, string>) {
return new Response("Created", { status: 201 });
},
async ALL(req: Request, params?: Record<string, string>) {
// Optional fallback for unsupported methods
return new Response("Method not allowed", { status: 405 });
}
};Named exports for each method:
export async function GET(
req: Request,
params?: Record<string, string>
): Promise<Response> {
return new Response("GET response");
}
export async function POST(
req: Request,
params?: Record<string, string>
): Promise<Response> {
return new Response("Created", { status: 201 });
}Existing pattern continues to work unchanged:
import { serve } from "https://deno.land/std@x.y.z/http/server.ts";
serve(async (req) => {
return new Response("Hello");
});Current (Name-Based):
GET /api-v1/users/123
├─ Extract "api-v1" from first segment
├─ Lookup function "api-v1" in registry
├─ Rewrite path to "/users/123"
├─ Forward to isolate
└─ Handler implements routing manually
New (Pattern-Based, preserving deployment prefix):
GET /function_id_xpto/api/users/123
├─ Extract and resolve deployment prefix: function_id_xpto
├─ Load route manifest from that deployment
├─ Match path against patterns: /, /api/health, /api/users, /api/users/:id, ...
├─ Best match: /api/users/:id (specificity: highest)
├─ Extract parameter: { id: "123" }
├─ Validate method: GET in [GET, PUT, DELETE]? Yes ✓
├─ Load handler for /api/users/[id].ts
├─ Detect export pattern → Named exports
├─ Call: await GET(req, { id: "123" })
└─ Return response
The CLI bundle command now generates routes:
$ thunder bundle functions/ -o api.eszip --manifest-out manifest.json
1. Scan functions/ directory recursively
2. For each .ts/.js file:
- Parse filename to route pattern: [id].ts → :id
- Load module to detect export pattern
- Detect supported HTTP methods
- Generate route entry
3. Validate for route conflicts
4. Write manifest.json with all routes
5. Bundle into ESZIP with manifest embeddedExample directory structure to routes:
functions/
index.ts → route: /, entrypoint: index.ts
api/
users.ts → route: /api/users, entrypoint: api/users.ts
users/[id].ts → route: /api/users/:id, entrypoint: api/users/[id].ts
users/[id]/posts.ts → route: /api/users/:id/posts
admin/
[...rest].ts → route: /admin/*, entrypoint: admin/[...rest].ts
blog/
[[lang]].ts → route: /blog/:lang (optional), entrypoint: blog/[[lang]].ts
At runtime those routes are mounted under the deploy identifier:
Deployment name: function_id_xpto
Discovered route: /api/users/[id]
Runtime URL: /function_id_xpto/api/users/123
Proxy-facing URL: https://function_id_xpto.my-edge-runtime.com/api/users/123
To avoid ambiguity and match developer expectations from modern frameworks, routed apps need deterministic precedence rules.
Recommended precedence inside one deployment namespace:
- Static asset exact match
- Static route exact match
- Dynamic segment routes like
/users/[id] - Optional segment routes like
/blog/[[lang]] - Catch-all routes like
/admin/[...rest] - Explicit not-found fallback
This matters for frontend and backend coexistence in the same routed app.
Example:
public/logo.svg -> asset route /logo.svg
routes/index.ts -> module route /
routes/blog/[slug].ts -> module route /blog/:slug
routes/[...spa].ts -> SPA fallback only if explicitly defined
Operational rules:
- Asset routes should short-circuit before isolate execution when the manifest marks them as static
- Frontend apps should be allowed to ship
public/or equivalent asset trees in the same deployment artifact - SPA fallback should never happen implicitly; it must be an explicit catch-all route or explicit framework flavor behavior
- Route collision detection must consider both module routes and asset routes
The routed deployment should still be one logical deployed function/app, but the artifact needs richer metadata than v1.
Conceptually, a routed-app deployment contains:
- One deploy identifier (
function_id_xpto) - One ESZIP bundle with all route modules
- One manifest v2 route table
- Optional static asset table for frontend files
- Optional route-level metadata such as allowed methods and cache hints
This keeps operational simplicity:
- One admin deploy call
- One mounted namespace at
/{function_id} - One isolate pool policy per deployed app
- One place to inspect routes and assets administratively
Thunder supports two deployment modes using the same routing engine:
For simple, single-endpoint functions:
functions/
index.ts
Manifest (auto-generated):
{
"manifestVersion": 2,
"name": "my-function",
"flavor": "single",
"routes": [
{
"path": "/",
"entrypoint": "index.ts",
"methods": ["GET", "POST"]
}
]
}Function code (any pattern works):
// Option 1: Default function
export default async function(req: Request) {
return new Response("Hello");
}
// Option 2: Method object
export default {
async GET() { return new Response("GET"); },
async POST() { return new Response("POST"); }
}
// Option 3: Named exports
export async function GET() { return new Response("GET"); }
export async function POST() { return new Response("POST"); }
// Option 4: Legacy Deno.serve()
import { serve } from "https://deno.land/std@x.y.z/http/server.ts";
serve(async (req) => new Response("Hello"));For REST APIs with multiple endpoints:
functions/
index.ts
api/
health.ts
users.ts
users/[id].ts
users/[id]/posts.ts
posts/[slug].ts
admin/
[...rest].ts
blog/
[[lang]].ts
Manifest (auto-generated):
{
"manifestVersion": 2,
"name": "api-v2",
"flavor": "routed-app",
"routes": [
{ "path": "/", "entrypoint": "index.ts", "methods": ["GET"] },
{ "path": "/api/health", "entrypoint": "api/health.ts", "methods": ["GET"] },
{ "path": "/api/users", "entrypoint": "api/users.ts", "methods": ["GET", "POST"] },
{
"path": "/api/users/:id",
"pattern": "/api/users/[id]",
"entrypoint": "api/users/[id].ts",
"methods": ["GET", "PUT", "DELETE"]
}
]
}File: crates/cli/src/lib/route_discovery.rs (new)
Purpose: Scan filesystem and discover routes
Key functions:
discover_routes(functions_dir: &Path) -> Result<Vec<RouteInfo>>parse_route_from_filesystem_path(path: &Path) -> Result<RouteInfo>validate_route_conflicts(routes: &[RouteInfo]) -> Result<()>
File: crates/cli/src/lib/export_detection.rs (new)
Purpose: Detect export patterns and supported HTTP methods
Key functions:
detect_export_pattern(worker: &mut Worker, module_path: &str) -> Result<ExportPattern>detect_supported_methods(worker: &mut Worker, pattern: ExportPattern) -> Result<Vec<String>>
File: crates/cli/src/commands/bundle.rs
Changes:
- Add
--manifest-outflag to write generated manifest - Implement
discover_routes_and_methods()function - Call route discovery before bundling
- Validate routes and generate manifest
- Embed manifest in ESZIP
- Handle v1→v2 manifest migration
File: crates/server/src/ingress_router.rs
Changes:
- Add
PatternMatcherstruct with regex compilation - Implement
match_route(path: &str, routes: &[Route]) -> Option<(Route, Params)> - Implement
validate_method(route: &Route, method: &str) -> Result<()> - Modify
route_to_function()to use pattern matching - Return 405 Method Not Allowed with Allow header for unsupported methods
File: crates/runtime-core/src/isolate.rs
Changes:
- Detect export pattern at module load time
- Implement
ExportedHandlerstruct for managing different patterns - Add method dispatch logic
- Support all four patterns (default function, method object, named exports, Deno.serve)
File: crates/runtime-core/src/handlers.rs (new)
Purpose: Handler dispatch and execution
Structs:
ExportedHandler- Represents loaded module exportsHandlerContext- Request context with parameters
Functions:
dispatch_to_handler()- Route to correct handler based on methodcall_default_function()- Call default exportcall_method_object()- Call method from objectcall_named_export()- Call named function
File: schemas/function-manifest.v2.schema.json (new)
Purpose: JSON Schema for v2 manifests
Key fields:
manifestVersion: 2flavor: "single" | "routed-app"routes: Route[]array with path patterns and methods- Route parameters with optional/catchAll flags
File: crates/runtime-core/src/manifest.rs
Changes:
- Add
Routestruct - Add
manifest_versiondetection - Parse both v1 and v2 formats
- Auto-upgrade v1 to v2 format
- Validate route conflicts
File: crates/server/src/admin_router.rs
New endpoints:
GET /_internal/routes
→ List all routes from manifest
GET /_internal/routes/:path
→ Get details for specific route
PUT /_internal/routes/:path/config
→ Update per-route configuration (future)
GET /_internal/metrics/routes
→ Get per-route metrics
File: schemas/routing-manifest.v1.schema.json (new)
Purpose: Define global host/path routing table to map requests to deployed function IDs.
File: crates/server/src/ingress_router.rs
Changes:
- Add optional Stage 0 resolver for
host + pathlookup - Apply deterministic precedence and ambiguity detection
- Rewrite internal request target to
/{function_id}/...after global match - Keep current prefix-based flow as fallback when no global match exists
File: crates/server/src/admin_router.rs
New endpoints (proposed):
GET /_internal/routing
→ List global domain/path routing table
PUT /_internal/routing
→ Replace routing table atomically
POST /_internal/routing/validate
→ Validate conflicts and precedence before apply
This section provides concrete implementation details for each modified/new file.
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct RouteInfo {
pub file_path: PathBuf,
pub url_pattern: String, // e.g., "/api/users/:id"
pub filesystem_pattern: String, // e.g., "/api/users/[id]"
pub dynamic_segments: Vec<RouteParameter>,
}
#[derive(Debug, Clone)]
pub struct RouteParameter {
pub name: String,
pub segment: String,
pub optional: bool,
pub catch_all: bool,
}
/// Main function to discover all routes in a directory
pub fn discover_routes(functions_dir: &Path) -> Result<Vec<RouteInfo>> {
let mut routes = Vec::new();
for entry in WalkDir::new(functions_dir)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
// Skip non-TS/JS files
if !is_route_file(path) {
continue;
}
// Skip special/hidden files
if is_special_file(path) {
continue;
}
let relative = path.strip_prefix(functions_dir)?;
let route_info = parse_route_from_filesystem_path(relative)?;
routes.push(route_info);
}
// Validate before returning
validate_route_conflicts(&routes)?;
Ok(routes)
}
fn is_route_file(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| matches!(e, "ts" | "tsx" | "js" | "jsx"))
.unwrap_or(false)
}
fn is_special_file(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| {
n == "_middleware.ts"
|| n == "_layout.ts"
|| n.starts_with('.')
})
.unwrap_or(false)
}
/// Convert filesystem path to route information
/// Examples:
/// index.ts → /
/// api/users.ts → /api/users
/// api/[id].ts → /api/:id
/// api/users/[id]/posts.ts → /api/users/:id/posts
/// admin/[...rest].ts → /admin/*
/// blog/[[lang]].ts → /blog/:lang (optional)
fn parse_route_from_filesystem_path(path: &Path) -> Result<RouteInfo> {
let path_str = path.to_str().ok_or("Invalid path")?;
let parts: Vec<&str> = path_str
.split('/')
.filter(|p| !p.is_empty())
.collect();
let mut url_parts = Vec::new();
let mut parameters = Vec::new();
for (i, part) in parts.iter().enumerate() {
let is_last = i == parts.len() - 1;
// Remove extension from last part
let clean_part = if is_last {
part.split('.').next().unwrap_or("")
} else {
part
};
if clean_part.is_empty() {
continue;
}
// Parse dynamic segments: [id], [[id]], [...rest]
if clean_part.starts_with('[') && clean_part.ends_with(']') {
let inner = &clean_part[1..clean_part.len()-1];
if inner.starts_with("...") {
// Catch-all: [...rest]
let param_name = &inner[3..];
url_parts.push("*".to_string());
parameters.push(RouteParameter {
name: param_name.to_string(),
segment: param_name.to_string(),
optional: false,
catch_all: true,
});
} else if inner.starts_with('[') && inner.ends_with(']') {
// Optional: [[id]]
let param_name = &inner[1..inner.len()-1];
url_parts.push(format!(":{}", param_name));
parameters.push(RouteParameter {
name: param_name.to_string(),
segment: param_name.to_string(),
optional: true,
catch_all: false,
});
} else {
// Required: [id]
url_parts.push(format!(":{}", inner));
parameters.push(RouteParameter {
name: inner.to_string(),
segment: inner.to_string(),
optional: false,
catch_all: false,
});
}
} else {
// Static segment
url_parts.push(clean_part.to_string());
}
}
let url_pattern = if url_parts.is_empty() {
"/".to_string()
} else {
format!("/{}", url_parts.join("/"))
};
Ok(RouteInfo {
file_path: path.to_path_buf(),
url_pattern,
filesystem_pattern: format!("/{}", parts.join("/")),
dynamic_segments: parameters,
})
}
fn validate_route_conflicts(routes: &[RouteInfo]) -> Result<()> {
// Check for exact duplicate patterns
let mut patterns = std::collections::HashSet::new();
for route in routes {
if !patterns.insert(route.url_pattern.clone()) {
return Err(format!("Duplicate route: {}", route.url_pattern).into());
}
}
Ok(())
}#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ExportPattern {
DefaultFunction,
MethodObject,
NamedExports,
DenoServe,
Unknown,
}
/// Detect which export pattern a module uses by loading and inspecting it
pub async fn detect_export_pattern(
worker: &mut Worker,
module_path: &str,
) -> Result<ExportPattern> {
// Load module to populate exports
worker.load_main_module(module_path).await?;
// Check for each pattern
let pattern_script = r#"
const module = globalThis.__esModule || {};
const hasDefault = 'default' in module;
const defaultValue = module.default;
let pattern = 'unknown';
if (typeof defaultValue === 'function') {
pattern = 'default-function';
} else if (typeof defaultValue === 'object' && defaultValue !== null) {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
if (methods.some(m => typeof defaultValue[m] === 'function')) {
pattern = 'method-object';
}
}
// Check for named exports
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
if (methods.some(m => typeof module[m] === 'function')) {
pattern = 'named-exports';
}
globalThis.__detected_pattern = pattern;
"#;
worker.execute_script("detect", pattern_script)?;
let pattern_str = worker.execute_script(
"get_pattern",
"globalThis.__detected_pattern"
)?.to_string();
match pattern_str.as_str() {
"default-function" => Ok(ExportPattern::DefaultFunction),
"method-object" => Ok(ExportPattern::MethodObject),
"named-exports" => Ok(ExportPattern::NamedExports),
"deno-serve" => Ok(ExportPattern::DenoServe),
_ => Ok(ExportPattern::Unknown),
}
}
/// Detect which HTTP methods are supported by a module
pub async fn detect_supported_methods(
worker: &mut Worker,
pattern: ExportPattern,
) -> Result<Vec<String>> {
let all_methods = vec![
"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"
];
match pattern {
ExportPattern::DefaultFunction | ExportPattern::DenoServe => {
// Default function/Deno.serve handles all methods
Ok(all_methods.iter().map(|s| s.to_string()).collect())
}
ExportPattern::MethodObject => {
let mut supported = Vec::new();
for method in &all_methods {
let check = format!(
"typeof globalThis.__esModule?.default?.{} === 'function'",
method
);
let exists = worker.execute_script("check", &check)?;
if exists.as_bool()? {
supported.push(method.to_string());
}
}
// Check for ALL fallback
let has_all = worker.execute_script(
"check_all",
"typeof globalThis.__esModule?.default?.ALL === 'function'"
)?;
if has_all.as_bool()? && supported.is_empty() {
// ALL fallback covers all methods
supported = all_methods.iter().map(|s| s.to_string()).collect();
}
Ok(supported)
}
ExportPattern::NamedExports => {
let mut supported = Vec::new();
for method in &all_methods {
let check = format!(
"typeof globalThis.__esModule?.{} === 'function'",
method
);
let exists = worker.execute_script("check", &check)?;
if exists.as_bool()? {
supported.push(method.to_string());
}
}
Ok(supported)
}
ExportPattern::Unknown => Ok(all_methods.iter().map(|s| s.to_string()).collect()),
}
}Before (Current):
// functions/index.ts
import { serve } from "https://deno.land/std@x.y.z/http/server.ts";
serve(async (req) => {
return new Response("Hello, World!");
});After (Pattern 1: Default Function):
// functions/index.ts
export default async function(req: Request): Promise<Response> {
return new Response("Hello, World!");
}Benefits:
- No import needed
- Clearer intent (exported function vs. global side effect)
- Matches serverless patterns (AWS Lambda, Vercel, etc.)
Directory structure:
functions/
api/
users.ts # /api/users
users/[id].ts # /api/users/:id
Implementation:
// functions/api/users.ts
const users = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
export default {
async GET(req: Request, params?: Record<string, string>) {
return new Response(JSON.stringify(users), {
headers: { "content-type": "application/json" }
});
},
async POST(req: Request) {
const body = await req.json() as { name: string };
const newUser = { id: users.length + 1, ...body };
users.push(newUser);
return new Response(JSON.stringify(newUser), {
status: 201,
headers: { "content-type": "application/json" }
});
}
};// functions/api/users/[id].ts
const users = [/* ... */];
export async function GET(
req: Request,
params: Record<string, string>
): Promise<Response> {
const { id } = params;
const user = users.find(u => u.id === parseInt(id));
if (user) {
return new Response(JSON.stringify(user), {
headers: { "content-type": "application/json" }
});
}
return new Response("Not found", { status: 404 });
}
export async function PUT(
req: Request,
params: Record<string, string>
): Promise<Response> {
const { id } = params;
const user = users.find(u => u.id === parseInt(id));
if (user) {
const body = await req.json() as { name: string };
user.name = body.name;
return new Response(JSON.stringify(user), {
headers: { "content-type": "application/json" }
});
}
return new Response("Not found", { status: 404 });
}
export async function DELETE(
req: Request,
params: Record<string, string>
): Promise<Response> {
const { id } = params;
const index = users.findIndex(u => u.id === parseInt(id));
if (index !== -1) {
users.splice(index, 1);
return new Response("", { status: 204 });
}
return new Response("Not found", { status: 404 });
}Benefits:
- Clear file structure mirrors API structure
- Type-safe parameter extraction
- Built-in method dispatch
- 60% less code than manual routing
functions/
blog/
[[lang]].ts # /blog or /blog/:lang
[slug].ts # /blog/:slug
// functions/blog/[[lang]].ts
// Matches: /blog, /blog/en, /blog/fr, etc.
export async function GET(
req: Request,
params: Record<string, string>
): Promise<Response> {
const lang = params.lang || 'en';
const posts = [
{ id: 1, title: 'First Post', lang: 'en' },
{ id: 2, title: 'Premier Article', lang: 'fr' }
];
const filtered = posts.filter(p => p.lang === lang);
return new Response(JSON.stringify(filtered), {
headers: { "content-type": "application/json" }
});
}Key feature: Optional parameter lang allows both /blog and /blog/en
functions/
admin/
[...rest].ts # /admin/*
// functions/admin/[...rest].ts
export async function GET(
req: Request,
params: Record<string, string>
): Promise<Response> {
const { rest } = params; // e.g., "users/settings"
const url = new URL(req.url);
// Authorization check
const token = url.searchParams.get('token');
if (!token || token !== 'secret') {
return new Response('Unauthorized', { status: 401 });
}
// Serve content based on path
const sections: Record<string, object> = {
'users': { page: 'Users', count: 100 },
'settings': { page: 'Settings', count: 0 },
'logs': { page: 'Logs', count: 1000 }
};
const section = rest?.split('/')[0] || 'home';
const content = sections[section] || { error: 'Not found' };
return new Response(JSON.stringify(content), {
headers: { "content-type": "application/json" }
});
}Using higher-order functions for protected routes:
// shared/auth.ts
export type Handler = (
req: Request,
params: Record<string, string>
) => Promise<Response>;
export function withAuth(handler: Handler): Handler {
return async (req: Request, params: Record<string, string>) => {
const auth = req.headers.get('authorization');
if (!auth || !validateToken(auth)) {
return new Response('Unauthorized', { status: 401 });
}
// Store user info for handler
(req as any).user = extractUser(auth);
return handler(req, params);
};
}
function validateToken(token: string): boolean {
return token.startsWith('Bearer ') && token.length > 10;
}
function extractUser(token: string) {
return { id: '123', name: 'Alice' };
}// functions/api/protected.ts
import { withAuth, Handler } from '../shared/auth.ts';
const getHandler: Handler = async (req, params) => {
const user = (req as any).user;
return new Response(`Hello, ${user.name}!`);
};
export default {
GET: withAuth(getHandler),
POST: withAuth(async (req, params) => {
const user = (req as any).user;
const body = await req.json();
return new Response(JSON.stringify({ user, data: body }), {
status: 201
});
})
};Objectives:
- Design route struct and manifest v2 schema
- Create example projects
- Write comprehensive tests for route matching
Files:
schemas/function-manifest.v2.schema.json(new)crates/cli/src/lib/route_discovery.rs(new)crates/server/src/route_matcher.rs(new)- Example projects (new)
Deliverables:
- v2 manifest schema passing JSON Schema validation
- Route matching algorithm with 100% test coverage
- 5 example projects demonstrating all patterns
- Design decision documentation
No breaking changes - foundation only, no runtime integration yet.
Objectives:
- Implement filesystem route discovery
- Integrate pattern matching into ingress router
- Add CLI support for directory-based bundling
Files Modified:
crates/cli/src/commands/bundle.rs(add route discovery)crates/server/src/ingress_router.rs(add pattern matching)crates/functions/src/registry.rs(store routes)crates/runtime-core/src/manifest.rs(v2 schema)
Key Features:
- Discover all route files in directory
- Parse filesystem paths to URL patterns
- Detect route conflicts at build time
- Generate manifest with route metadata
- Match incoming requests to routes
- Extract path parameters
Testing:
- Unit: Route discovery, pattern parsing, matching
- Integration: Full deploy-to-invoke flow
- E2E: Multi-route deployments
Acceptance Criteria:
- Directory scanner finds all files correctly
- Route patterns generated from filesystem
- Route conflicts detected and reported
- Manifest v2 generated correctly
- Ingress router matches patterns
- Parameters extracted correctly
Backward Compatibility:
- v1 manifests still work (single entrypoint)
- Single-entrypoint deployments still work
- No breaking changes to existing APIs
Objectives:
- Implement export pattern detection
- Support default function export
- Support method object export
- Maintain Deno.serve() compatibility
Files Modified:
crates/cli/src/lib/export_detection.rs(new)crates/runtime-core/src/isolate.rs(pattern detection)crates/runtime-core/src/handlers.rs(new)crates/cli/src/commands/bundle.rs(call detector)
Key Features:
- Detect export pattern at bundle time
- Support all 4 patterns
- Store pattern in manifest
- Route to appropriate handler at runtime
- Maintain backward compatibility
Testing:
- Unit: Pattern detection for each type
- Integration: Each pattern invoked correctly
- Compat: Deno.serve() still works
Objectives:
- Extract dynamic parameters from paths
- Pass parameters to handlers
- Support optional and catch-all parameters
Files Modified:
crates/server/src/ingress_router.rs(parameter extraction)crates/runtime-core/src/handlers.rs(parameter passing)
Key Features:
- Extract captured groups from regex
- Map to parameter names
- Handle optional parameters
- Handle catch-all parameters
- Pass as Record<string, string>
Objectives:
- Generate method metadata in manifest
- Validate HTTP method
- Return 405 Method Not Allowed correctly
- Add per-method metrics
Files Modified:
crates/cli/src/lib/export_detection.rs(method detection)crates/server/src/ingress_router.rs(method validation)crates/functions/src/metrics.rs(per-method metrics)
Key Features:
- Method list in manifest
- Validate method against route
- Return 405 with Allow header
- Per-method metrics
- ALL fallback support
Objectives:
- Complete migration guide
- Create comprehensive examples
- Performance optimization
- Edge case handling
Deliverables:
- Migration guide from Deno.serve() to new patterns
- Filesystem routing guide
- 5+ example projects
- Performance benchmarks
- Security guidelines
- Complete API documentation
v1 Manifests: Single entrypoint auto-upgrades to v2:
// v1
{ "manifestVersion": 1, "entrypoint": "index.ts" }
// Becomes v2
{
"manifestVersion": 2,
"flavor": "single",
"routes": [{ "path": "/", "entrypoint": "index.ts" }]
}Deno.serve() Functions:
Continue to work unchanged. Runtime detects serve() call and routes all requests to that handler.
Addressing compatibility:
Existing callers that invoke functions using http://localhost:9000/{function_id}/... must continue to work unchanged. The migration only changes how the suffix after /{function_id} is interpreted inside the deployed app.
| Time | Action | User Impact |
|---|---|---|
| v2.0 | New patterns available | No breaking changes |
| v2.1 (+3 months) | CLI deprecation warning | Optional migration |
| v2.2 (+6 months) | Runtime deprecation warning | Optional migration |
| v3.0 (+12 months) | Deno.serve() optional | Recommended upgrade |
Proposed CLI tooling:
# Analyze and suggest migration
thunder migrate-suggest functions/index.ts
→ Shows recommended pattern
→ Shows example code
# Auto-migrate function
thunder migrate functions/index.ts --pattern method-object
→ Updates function
→ Keeps backup
# Migrate all functions
thunder migrate-all functions/ --pattern method-object
→ Updates all functions
→ Keeps backups
→ Creates manifestBaseline: ~200ms (from ROADMAP.md)
Route Matching Overhead:
- Route discovery at startup: 5-10ms for 100 routes
- Route manifest loading: 1-2ms
- Regex compilation: 0.5-1ms per route (cached)
- Total: ~20-30ms overhead
Optimizations:
- Lazy compile regex (on first match)
- Cache compiled patterns
- Pre-index routes by specificity
Expected Impact: +15-20ms (acceptable)
Route Matching:
- Pattern matching: O(n) where n = routes (linear search)
- Best match: O(1) amortized (compiled once)
- Parameter extraction: O(k) where k = parameters
- Total: < 1ms per request
Benchmarks:
10 routes: < 0.1ms
100 routes: < 0.5ms
1000 routes: < 5ms
Per-route overhead:
- Route struct: ~200 bytes
- Compiled regex: 1-5 KB
- Parameter names: 50-200 bytes
- Total: ~1-5 KB per route
Examples:
- 100 routes: 200-500 KB
- 1000 routes: 2-5 MB
- 10000 routes: 20-50 MB (rare)
All parameters are strings from URL path. Sanitize before use:
export async function GET(
req: Request,
params: Record<string, string>
): Promise<Response> {
const { id } = params;
// ✓ GOOD: Parameterized query
const user = await db.query(
"SELECT * FROM users WHERE id = $1",
[id]
);
// ✗ BAD: String interpolation
// const sql = `SELECT * FROM users WHERE id = ${id}`;
}Existing allowlist model continues to work:
{
"network": {
"mode": "allowlist",
"allow": ["api.example.com:443"]
}
}Routing has no impact on SSRF protection.
Build error on ambiguous routes:
functions/
users/[id].ts
users/[slug].ts
Both generate /users/:id pattern → Build error
405 Method Not Allowed response format:
HTTP/1.1 405 Method Not Allowed
Allow: GET, POST
Content-Length: 18
Content-Type: text/plain
Method Not Allowed
404 for no matching route.
Route Discovery:
#[test]
fn test_discovers_all_files() { }
#[test]
fn test_skips_special_files() { }
#[test]
fn test_parses_static_segments() { }
#[test]
fn test_parses_dynamic_segments() { }
#[test]
fn test_parses_optional_segments() { }
#[test]
fn test_parses_catchall_segments() { }
#[test]
fn test_detects_conflicts() { }Route Matching:
#[test]
fn test_exact_match() { }
#[test]
fn test_dynamic_match_single() { }
#[test]
fn test_optional_present() { }
#[test]
fn test_optional_absent() { }
#[test]
fn test_catchall() { }
#[test]
fn test_specificity_ordering() { }
#[test]
fn test_url_decoding() { }#[tokio::test]
async fn test_e2e_single_route() { }
#[tokio::test]
async fn test_e2e_multiple_routes() { }
#[tokio::test]
async fn test_e2e_dynamic_parameters() { }
#[tokio::test]
async fn test_e2e_method_dispatch() { }
#[tokio::test]
async fn test_e2e_deno_serve_compatibility() { }
#[tokio::test]
async fn test_e2e_preserves_function_prefix_mount() { }
#[tokio::test]
async fn test_e2e_reverse_proxy_path_rewrite_compatibility() { }#[bench]
fn bench_route_matching_10() { /* target: < 0.1ms */ }
#[bench]
fn bench_route_matching_100() { /* target: < 0.5ms */ }
#[bench]
fn bench_route_matching_1000() { /* target: < 5ms */ }
#[bench]
fn bench_parameter_extraction() { /* target: < 0.05ms */ }Question: Separate bundles per route or monolithic?
Decision: Monolithic (Phase 1)
- Simpler implementation
- Uses existing ESZIP infrastructure
- Per-file bundling as future optimization
Question: Typed parameters or always strings?
Decision: Phase 1 = strings, Phase 2+ = optional explicit typing
// Phase 1
params: Record<string, string>
// Phase 2+
params: { id: string; slug: string }Question: When to implement middleware?
Decision: Phase 2+ via higher-order functions (not built-in)
Question: Auto-parse JSON/form data?
Decision: User handles in Phase 1, helpers in Phase 2+
Question: How to version APIs?
Decision: Filesystem-based
functions/
api/v1/users.ts
api/v2/users.ts
Question: What does [...rest] capture?
Decision: Without leading slash, without query string
Request: /admin/users/settings?page=1
Route: /admin/[...rest]
params.rest = "users/settings"
Add these items to main ROADMAP.md with links to specific sections:
-
Filesystem-based routing implementation (#7)
- Route discovery system
- Pattern matching algorithm
- Build-time route validation
-
RESTful function contract (#5.2)
- Export pattern detection
- HTTP method dispatch
- Multi-pattern support
-
Runtime router refactoring (#7.2)
- Pattern matching instead of name-only
- Parameter extraction
- 405 Method Not Allowed
-
Manifest schema evolution (v1 → v2) (#5.1)
- Route definitions
- Method configuration
- Parameter metadata
-
Global domain routing manifest
- Host/path to function mapping with deterministic precedence
- Conflict validation across functions
- Fallback compatibility to
/{function_id}canonical ingress
-
CLI bundle enhancements (#7.1)
- Directory scanning
- Route discovery
- Manifest auto-generation
-
Admin API route introspection (#7.5)
- GET /_internal/routes
- GET /_internal/metrics/routes
- PUT /_internal/routes/:path/config
| Term | Definition |
|---|---|
| Isolate | V8 runtime instance with own heap and globals |
| ESZIP | Deno's serialized module format |
| Manifest | JSON config defining function routes, permissions, limits |
| Ingress Router | Public HTTP router accepting requests |
| Route Pattern | URL pattern like /api/users/:id |
| Filesystem Pattern | File structure like /api/users/[id].ts |
| Export Pattern | How function exports handler |
| Specificity | How exactly a pattern matches |
# Bundle with route discovery
thunder bundle functions/ -o api.eszip --manifest-out manifest.json
# Deploy
thunder deploy api.eszip --name api-v1 --manifest manifest.json
# Admin API
curl http://localhost:9000/_internal/routes
curl http://localhost:9000/_internal/metrics/routesThis comprehensive roadmap provides a clear, phased approach to implementing filesystem-based routing and RESTful function contracts in Thunder. The implementation:
- Maintains 100% backward compatibility with existing Deno.serve()
- Improves developer experience with familiar modern patterns
- Enables route-level observability for better monitoring
- Scales from 1 to 1000+ routes gracefully
- Delivers incremental value through five independent phases
Implementation spans 16 weeks with five independently testable and deployable phases. Each phase delivers production-quality functionality with comprehensive testing and documentation.