Pular para o conteúdo

Gateway

Este conteúdo não está disponível em sua língua ainda.

Buntime edge middleware: CORS, rate limiting (Token Bucket), micro-frontend shell, request logging, and real-time monitoring via SSE. Base route /gateway, enabled by default.

plugin-gateway is a runtime plugin (see Plugin System) that operates in the onRequest/onResponse pipeline, applying edge policies before each request reaches the workers:

ComponentResponsibilityFile
Rate LimiterToken Bucket per client (IP/user/custom), 429 when exhaustedserver/rate-limit.ts
CORS HandlerOPTIONS preflight + cross-origin response headersserver/cors.ts
Shell RouterRoutes document navigations to a central micro-frontend shellserver/shell-bypass.ts
Request LoggerRing buffer (100 entries) with filters and statisticsserver/request-log.ts
PersistenceMetrics snapshots + shell excludes in gateway_* tables through plugin-tursoserver/persistence.ts
Response CacheIn-memory LRU — disabled by default (config present but has no effect)server/cache.ts

Execution order within onRequest:

  1. Shell routing — if it is a document navigation and the basename is not in excludes, serve the shell.
  2. CORS preflightOPTIONS is answered with 204 + headers.
  3. Rate limit — consumes 1 token; emits 429 with Retry-After if exhausted.
  4. Cache check (disabled).

In onResponse, Access-Control-Allow-* and Access-Control-Expose-Headers headers are added.

The plugin base route is /gateway and the UI (React + TanStack Router) lives in client/.

Configuration blends manifest.yaml, environment variables (override), and a cookie (shell bypass per user only). Environment variables always take precedence over YAML.

VariableTypeDefaultDescription
GATEWAY_SHELL_DIRstring""Absolute path to the shell app (empty = disabled)
GATEWAY_SHELL_EXCLUDESstring"cpanel"Basenames that skip the shell (CSV, not removable via API)
GATEWAY_RATE_LIMIT_REQUESTSnumber100Bucket capacity (1–10000)
GATEWAY_RATE_LIMIT_WINDOWstring"1m"Window: 30s, 1m, 5m, 15m, 1h
GATEWAY_CORS_ORIGINstring"*"Single origin or CSV
GATEWAY_CORS_CREDENTIALSbooleanfalseAllow cookies/credentials cross-origin
FieldTypeDefaultNotes
shellDirstring""Enables micro-frontend shell when set
shellExcludesstring (CSV)"cpanel"Bypass basenames
rateLimit.requestsnumber100Bucket capacity
rateLimit.windowenum"1m"Refill window
rateLimit.keyByenum/function"ip"ip | user | custom function in plugin.ts
rateLimit.excludePathsstring[][]Regex tested against the pathname
cors.originstring | string[]"*""*" forbidden with credentials: true
cors.methodsstring[][GET, HEAD, PUT, PATCH, POST, DELETE]
cors.allowedHeadersstring[]undefinedAdded to simple headers
cors.exposedHeadersstring[]undefinedHeaders exposed to browser JS
cors.credentialsbooleanfalseRequires a specific origin
cors.maxAgenumber86400Preflight cache (s)
cors.preflightbooleantrueAuto-respond to OPTIONS
cache.*objectnullSchema exists, runtime disabled

Declared as optionalDependencies in the manifest:

  • @buntime/plugin-turso — enables persistence of metrics history and dynamic excludes.
  • @buntime/plugin-authn (planned, see the roadmap) — required for keyBy: user (reads X-Identity).

The gateway uses @buntime/plugin-turso as its durable persistence service for metrics history and dynamic shell excludes. Gateway remains usable without Turso, but durable gateway state is only available when Turso is loaded.

The storage architecture is direct Turso-backed storage through @buntime/plugin-turso:

  • @buntime/plugin-gateway owns its gateway_* schema and metrics/excludes repository.
  • The gateway manifest depends on @buntime/plugin-turso for durable SQL access, not on @buntime/plugin-keyval.
  • Do not route gateway storage through plugin-keyval as gateway -> keyval -> turso; KeyVal should be validated by its own tests and smoke flows, not by becoming mandatory gateway infrastructure.
  • Turso Database is the only durable driver for gateway-owned state.
  • local mode is acceptable for tests and single-pod deployments.
  • sync mode is the Kubernetes target because each pod can keep its own local database file and synchronize with a remote sync server.
  • bun:sqlite is not the default durable driver for this state because SQLite WAL still allows only one writer at a time.

See Turso and Storage for the cross-plugin decision.

When cors.preflight: true (default), OPTIONS returns 204 No Content with headers computed from the request. Origins are validated against cors.origin (string, list, or "*"); the handler echoes the received Origin when it is allowed. Access-Control-Allow-Headers echoes whatever came in Access-Control-Request-Headers.

Browsers reject Access-Control-Allow-Origin: * when the request uses credentials: 'include'. Whenever cors.credentials: true, cors.origin must be a specific value (or list).

# Wrong — browser blocks
cors: { origin: "*", credentials: true }
# Correct
cors: { origin: "https://app.example.com", credentials: true }

Always allowed without allowedHeaders: Accept, Accept-Language, Content-Language, Content-Type (with simple values). Always exposed without exposedHeaders: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma.

Each client has a bucket with capacity requests. The refill rate is requests / windowSeconds tokens/second, computed lazily on each call (no per-bucket timer). A request consumes 1 token; if tokens < 1, returns 429.

refillRate = capacity / windowSeconds
# Example: 100 / 60 = 1.67 tokens/s

Cleanup removes inactive buckets every 60s. Memory is ~100 B/bucket (~1 MB for 10k active clients). The check itself is O(1).

ValueGenerated keyHeader readPrerequisite
"ip" (default)ip:<addr>X-Forwarded-For (1st) → X-Real-IP"unknown"
"user"user:<sub>X-Identity (JSON with sub)plugin-authn (planned)
(req) => stringfunction returnanyconfigured in code

Custom functions can only be configured via plugin.ts, not via manifest:

export default gatewayPlugin({
rateLimit: {
requests: 5000,
window: "1h",
keyBy: (req) => `tenant:${req.headers.get("X-Tenant-Id") ?? "anon"}`,
},
});
HeaderWhenValue
X-RateLimit-Limitalwaysbucket capacity
X-RateLimit-Remainingalwaysremaining tokens
X-RateLimit-Reset429 onlyms timestamp when bucket is fully refilled
Retry-After429 onlyseconds until next token

List of regexes tested against the full pathname. Useful for health checks and internal routes:

rateLimit:
excludePaths:
- "/health"
- "/_/api/health"
- "/api/public/.*"

When shellDir is configured, every document navigation (Sec-Fetch-Dest: document) is served by the central app shell. The shell renders layout (header, sidebar) and loads the specific app inside an <iframe>. Apps in shellExcludes skip the shell and render directly.

The onRequest shell branch fires when:

!isApiRoute && !shouldBypass && (isDocument || (isRootPath && !isFrameEmbedding))
// ^ single path segment: !url.pathname.slice(1).includes("/")

So the shell worker is invoked not only for document navigations but also for single-segment asset paths:

RequestSec-Fetch-DestPath shapeRouted to shell worker?
Document navigationdocumentanyYes (unless excluded)
Single-segment assetnot document/chunk-abc.jsYes — shell worker serves the file
Multi-segment assetnot document/assets/x.jsNo — falls through to worker/proxy resolution
Frame embediframe, embed, objectsingle-segmentNo — bypass directly to the worker
API route/_/api/*, etc.No — always bypass

API routes (/_/api/*, /gateway/api/*, etc.) always bypass.

SourcePriorityRestart?Use caseRemovable via API?
GATEWAY_SHELL_EXCLUDES (env)baseyesdefault excludes at deployno
Turso (gateway_shell_excludes)additivenodynamic excludes via APIyes
Cookie GATEWAY_SHELL_EXCLUDESper-usernoindividual bypass in browsern/a (set/unset cookie)

The final list is the union (no duplicates) of all three sources. Excludes are loaded from Turso into memory during plugin initialization and updated immediately after API mutations.

Only ^[a-zA-Z0-9_-]+$. Invalid basenames (with dot, space, slash) are rejected by the API and ignored during merge.

The shell always serves from /, but is mounted at any pathname. The gateway adds x-base: / to the request, and the shell worker injects <base href="/"> into the HTML so that relative assets resolve correctly.

GET /deployments/list (Sec-Fetch-Dest: document)
→ gateway: basename "deployments" not in excludes
→ serve shell HTML
→ shell JS reads pathname → <iframe src="/deployments">
→ GET /deployments (Sec-Fetch-Dest: iframe)
→ gateway: automatic bypass → deployments app worker

Shell-to-frame communication uses @zomme/frame over MessageChannel. See also Micro-frontend.

/data/apps/example-spa/manifest.yaml
name: "@buntime/example-spa"
base: "/"
visibility: public
entrypoint: dist/index.html
publicRoutes:
- "/"
- "/assets/**"

React implementation details (Layout/iframe/navigation) are out of scope for this page — they are generic micro-frontend patterns.

All routes mounted at /gateway/api/*. No auth by default (protect via publicRoutes).

MethodPathDescription
GET/sseServer-Sent Events, snapshot every 1s (metrics, config, recent logs)
GET/statsFull snapshot: rateLimit, cors, cache, shell, logs
GET/configRead-only resolved configuration (manifest + env)
MethodPathDescription400 error if RL disabled
GET/rate-limit/metricsAggregated totals + bucket configyes
GET/rate-limit/buckets?limit&sortByList active buckets (sortBy: tokens | lastActivity)yes
DELETE/rate-limit/buckets/:keyReset a single bucket (key URL-encoded)yes
POST/rate-limit/clearReset all buckets, returns {cleared: N}yes
MethodPathDescription
GET/logsFilters: limit (default 50), ip, rateLimited (bool), statusRange (4 → 4xx)
DELETE/logsClears the ring buffer
GET/logs/statstotal, rateLimited, byStatus, avgDuration (ms)

Each entry: { id, timestamp, ip, method, path, status, duration, rateLimited }.

MethodPathDescription
GET/metrics/history?limit1s snapshots, up to 3600 entries (1h). Default limit=60
DELETE/metrics/historyClears all history
MethodPathDescription400 errors
GET/shell/excludesCombined list [{basename, source: env|turso, addedAt?}]shell not configured
POST/shell/excludesBody {basename} → writes gateway_shell_excludes through plugin-tursoinvalid basename / already in env / shell not configured
DELETE/shell/excludes/:basenameRemoves only dynamic excludes”Cannot remove environment-based exclude”
MethodPathDescription
POST/cache/invalidateBody {key} or {pattern} or {}. Always returns 400 today
const es = new EventSource("/gateway/api/sse");
es.onmessage = (e) => {
const { rateLimit, recentLogs } = JSON.parse(e.data);
console.log("active buckets:", rateLimit?.metrics.activeBuckets);
};

The gateway persists durable state through @buntime/plugin-turso, owning its own gateway_* tables:

ResourceStorageFormatVolume
Metrics historygateway_metrics_historysnapshots {timestamp, totalRequests, blockedRequests, allowedRequests, activeBuckets}up to 3600 (1h) with cleanup
Dynamic excludesgateway_shell_excludesone row per dynamic basename

Without Turso, the gateway works normally — it only loses history after restart and excludes are limited to env+cookie. Persistent gateway state does not depend on KeyVal: gateway owns its schema directly on top of the Turso service.

PluginTypeRole
@buntime/plugin-tursooptional dependencyDurable SQL provider for gateway_* tables
@buntime/plugin-authn (planned)optionalRequired for rateLimit.keyBy: user (reads X-Identity.sub)

The pipeline and lifecycle (onInit, onRequest, onResponse, onShutdown) follow the general contract described in the Plugin System.

Scenariocors.origincors.credentialsrateLimitShell
Local dev"*"false1000/1m per IPdisabled
Public API"*"false1000/1m per IP, exclude /api/public/.*disabled
SPA + API (prod)"https://app.example.com"true60/1m per userexample-spa, cpanel excluded
Multi-tenantlist of hoststrue5000/1h per user (or custom function per tenant)shell + legacy excludes
  1. Build the shell app (any framework) with entrypoint: dist/index.html and base: "/" in the manifest.
  2. Set shellDir: /data/apps/<name> and shellExcludes in plugin-gateway.
  3. In the shell, derive the basename from window.location.pathname and render <iframe src="/${basename}">.
  4. Ensure shell assets use absolute paths (the gateway injects <base href="/">).
  5. Apps inside iframes can use @zomme/frame to emit navigation events to the shell.

Per-user override during development:

document.cookie = "GATEWAY_SHELL_EXCLUDES=deployments; path=/";
location.reload();
SymptomLikely causeFix
Browser: No 'Access-Control-Allow-Origin' header is presentOrigin not allowedAdd the origin to cors.origin
Browser: wildcard '*' ... credentials mode is 'include'credentials: true with origin: "*"Replace * with a specific origin
Browser: Request header field X is not allowedCustom header outside allowedHeadersAdd X to cors.allowedHeaders
Unexpected 429 on healthcheck/health not in excludePathsAdd pattern to rateLimit.excludePaths
Shell does not loadGATEWAY_SHELL_DIR points to invalid path or missing dist/Check ls $GATEWAY_SHELL_DIR/dist/index.html
Shell assets 404 (e.g. /deployments/assets/main.js)HTML uses relative paths without <base>Ensure build uses absolute paths; gateway injects <base href="/"> automatically
App in iframe does not loadCORS/CSP blockingcors.origin: "*" on the app + frame-ancestors 'self' if CSP is present
Shell bypass via shellExcludes does not workInvalid basename (characters outside [a-zA-Z0-9_-])Use only alphanumeric, -, _
DELETE /shell/excludes/:basename returns 400Attempting to remove an env-sourced excludeRemove via GATEWAY_SHELL_EXCLUDES (requires restart)
keyBy: user does not rate-limit per userplugin-authn (planned) missing, X-Identity not reaching gatewaySee the roadmap
/metrics/history emptyplugin-turso missing or not configuredEnable plugin-turso with durable storage

Setting RUNTIME_LOG_LEVEL=debug:

[gateway] Rate limiting: 100 requests per 1m
[gateway] Rate limited: ip:192.168.1.1
[gateway] CORS enabled: origin="*"
[gateway] Micro-frontend shell: /data/apps/example-spa
[gateway] Shell bypass basenames: cpanel, admin
[gateway] Shell serving: /deployments (dest: document)
[gateway] Shell bypassed: /cpanel