Middleware & Cache
# Middleware
Create src/middleware.ts to run logic before every request (a single
global file — there is no middleware directory or per-route middleware):
import type { Middleware } from "@elurjs/kit";
const middleware: Middleware = (request, context) => {
if (!request.headers.get("Cookie")?.includes("session=")) {
return Response.redirect(new URL("/login", request.url), 307);
}
// Return nothing to continue to the route handler
};
export default middleware;
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};Since v2.5 middleware runs in the unified Web handler — dev, preview,
and start share the same pipeline: it executes after redirects/rewrites
and the internal endpoints, before routing. A returned Response
short-circuits through the standard finalize step (security headers,
X-Request-ID, Server-Timing still apply); next({ headers, locals })
merges into the downstream request and exposes locals to API routes.
Middleware errors return a sanitized 500 instead of crashing the
request. The generated Node/Bun adapter servers do not run the middleware
file yet.
# Middleware type
type Middleware = (
request: Request,
context: MiddlewareContext,
) => Response | void | Promise<Response | void>;# MiddlewareContext
| Field | Type | Description |
|---|---|---|
next(options?) |
(options?: { headers?, params?, locals? }) => void |
Continue to next handler with optional headers/params/locals |
params |
Record<string, string | string[]> | undefined |
Matched route params (if path matches a page route) |
locals |
Record<string, unknown> | undefined |
Per-request data published via next({ locals }) — reaches API route handlers as ctx.locals (does not reach page.data.ts loaders; the built-in action endpoint currently passes an empty object) |
# MiddlewareConfig
| Field | Type | Description |
|---|---|---|
matcher |
string[] |
Path patterns — * wildcard, :param segments, :param* catch-alls (a catch-all also matches the bare base path, so /dashboard/:path* matches /dashboard itself). No matcher = runs on every request |
# LoadedMiddleware
interface LoadedMiddleware {
handler: Middleware;
config: MiddlewareConfig;
}# MiddlewareResult
Tagged union returned by runMiddleware:
type MiddlewareResult =
| { kind: "response"; response: Response }
| {
kind: "continue";
headers?: Record<string, string>;
params?: Record<string, string | string[]>;
locals?: Record<string, unknown>;
};# Matching patterns
export const config = {
matcher: [
"/dashboard/:path*", // all dashboard routes
"/admin/*", // all admin routes
"/api/:method", // specific param
],
};# streamBoundary(options) — streaming content
Renders fallback content while a promise resolves, then swaps in the real content during streaming SSR:
import { streamBoundary } from "@elurjs/kit";
import { html } from "@elurjs/core";
html`
<h1>Blog Post</h1>
${streamBoundary({
fallback: html`<p>Loading comments…</p>`,
promise: fetchComments(postId),
children: (comments) => html`
<ul>${comments.map(c => html`<li>${c.text}</li>`)}</ul>
`,
})}
`# StreamBoundaryOptions<T>
| Field | Type | Description |
|---|---|---|
fallback |
ElurTemplate |
Content shown while promise resolves |
promise |
Promise<T> |
Promise that resolves to data |
children |
(value: T) => ElurTemplate |
Renders resolved value |
Streaming is experimental. Some adapters may buffer the response instead of streaming.
# HTML cache
Legacy cache functions. getCachedHtml, setCachedHtml, and clearCache
are exported from both @elurjs/kit and @elurjs/kit/cache for backward
compatibility; isStale lives only on the /cache subpath:
import { getCachedHtml, setCachedHtml, clearCache, isStale } from "@elurjs/kit/cache";
// Check cache before rendering — returns the entry only while fresh
const cached = await getCachedHtml(cacheDir, "/blog/hello-world");
if (cached) return cached.html;
// Staleness probe (cacheDir + pathname, async)
const stale = await isStale(cacheDir, "/blog/hello-world");
// Cache after rendering (60s revalidate)
await setCachedHtml(cacheDir, "/blog/hello-world", html, 60);
// Clear all cache
await clearCache(cacheDir);# CacheEntry
interface CacheEntry {
html: string;
generatedAt: number;
revalidate: number;
tags?: string[]; // adapter CacheEntry only (tag-based invalidation)
version?: string; // adapter CacheEntry only
}getCachedHtml returns the entry only while it is fresh (returns
undefined once stale — use isStale() to distinguish miss from stale),
and the legacy entry shape has no tags/version fields.
# Cache adapters
Everything below is exported from the dedicated @elurjs/kit/cache
subpath only. Wire an adapter globally via
defineConfig({ cache: { adapter } }), or manually with
connectCacheAdapter.
# Filesystem (default)
import { createFsCacheAdapter } from "@elurjs/kit/cache";
const adapter = createFsCacheAdapter({
cacheDir: "./.elur/cache",
maxEntries: 1000, // default: 1000
maxAgeMs: 86_400_000, // default: 24h
});When cache.adapter is omitted, the handler creates a filesystem adapter
rooted at cache.dir and shares it per directory for the process.
# Redis
import { createRedisCacheAdapter } from "@elurjs/kit/cache";
const adapter = createRedisCacheAdapter({
client: redisClient, // ioredis, node-redis, or Upstash
prefix: "elur-kit:", // default prefix
});# Cloudflare KV
import { createCloudflareKVCacheAdapter } from "@elurjs/kit/cache";
const adapter = createCloudflareKVCacheAdapter({
namespace: KV_NAMESPACE, // Cloudflare KV binding
});# CacheAdapter interface
interface CacheAdapter {
get(key: string): Promise<CacheEntry | null>;
set(key: string, value: CacheEntry, options: CacheWriteOptions): Promise<void>;
delete(key: string): Promise<void>;
invalidateTags(tags: readonly string[]): Promise<void>;
}# CacheWriteOptions
| Field | Type | Description |
|---|---|---|
revalidate |
number |
Revalidation seconds |
tags |
string[]? |
Tags for tag-based invalidation |
version |
string? |
Version string |
# getWithSWR(adapter, key, revalidate)
Stale-while-revalidate read: returns the cached entry immediately and revalidates in the background when it is stale. On a cache miss the revalidation runs synchronously:
import { getWithSWR, cacheKey } from "@elurjs/kit/cache";
const { entry, stale } = await getWithSWR(
adapter,
cacheKey("/blog/hello-world"),
async () => {
const html = await renderPage(options);
return { html, generatedAt: Date.now(), revalidate: 60, tags: ["posts"] };
},
);# cacheKey(...parts)
SHA-256 page cache keys — the same scheme used by path-based invalidation:
const key = cacheKey("/blog/hello-world"); // sha256 hex# Cache policy helpers
import {
normalizeCachePolicy,
shouldCachePublic,
DEFAULT_CACHE_POLICY,
} from "@elurjs/kit/cache";
const policy = normalizeCachePolicy(route.cache); // fills defaults
shouldCachePublic(policy, request); // false when Cookie/Authorization presentshouldCachePublic only inspects the request — it does not check
response headers (Set-Cookie, private, no-store). A route that sets
cookies in its response can still be publicly cached; mark those routes
mode: "private" or "dynamic" explicitly.
# Cache policy
Per-page cache policy via the loader — export cache (not cachePolicy):
// src/app/blog/[slug]/page.data.ts
export const load = async ({ params }) => {
const post = await db.posts.findBySlug(params.slug);
return { post };
};
export const cache = {
mode: "public", // "public" | "private" | "dynamic"
revalidate: 60, // seconds (0 = always revalidate)
tags: ["posts"], // for tag-based invalidation
};A plain export const revalidate = 60 on the same data module also works —
it sets revalidate without a full policy object. If both are present,
cache.revalidate wins when it is greater than 0.
Default policy is dynamic (no caching). Requests with Cookie or
Authorization headers are never cached publicly.
| Mode | Description |
|---|---|
public |
ISR/public cache, shared (shouldCachePublic still skips requests with Cookie/Authorization) |
private |
Never publicly cached — reserved for a future per-user adapter (today it renders on demand) |
dynamic |
Never cache, always render on demand |
# Invalidation
# Tag-based
import { defaultInvalidator } from "@elurjs/kit/cache";
await defaultInvalidator.invalidateTags(["posts"]);# Path-based
await defaultInvalidator.invalidatePaths(["/blog", "/blog/hello-world"]);
// paths are hashed with cacheKey() before hitting the adapter# From actions
export const deletePost = defineAction(
{
invalidateTags: ["posts"],
invalidatePaths: ["/blog"],
},
async (input) => {
await db.posts.delete(input.id);
return { ok: true };
}
);Actions declared via defineAction dispatch their invalidateTags /
invalidatePaths to the connected cache adapter automatically on success —
actions that return fail(...) invalidate nothing.
# CacheInvalidator
A pub/sub hub for invalidation events — actions emit, adapters listen:
import { CacheInvalidator } from "@elurjs/kit/cache";
const invalidator = new CacheInvalidator();
const unsubscribe = invalidator.on(async (event) => {
// event: { tags?: string[]; paths?: string[]; source?: string }
});
await invalidator.invalidateTags(["posts"], "admin-panel");
await invalidator.emit({ tags: ["posts"], source: "webhook" });
unsubscribe(); // remove one listener
invalidator.clear(); // remove all listenersdefaultInvalidator is the global instance the runtime registers the cache
adapter on.
# InvalidationEvent
interface InvalidationEvent {
tags?: readonly string[];
paths?: readonly string[];
source?: string;
}# connectCacheAdapter(adapter, invalidator?)
Connects a cache adapter to the invalidation system so tag/path events from actions reach the cache. Returns an unsubscribe function:
import { connectCacheAdapter, defaultInvalidator, createRedisCacheAdapter } from "@elurjs/kit/cache";
const adapter = createRedisCacheAdapter({ client: redisClient });
const unsubscribe = connectCacheAdapter(adapter, defaultInvalidator);
// later: unsubscribe() to disconnect# Low-level middleware utilities
# loadMiddleware(root?)
Loads src/middleware.ts and returns the loaded middleware with its config:
import { loadMiddleware } from "@elurjs/kit";
const loaded = await loadMiddleware(".");
// loaded.handler, loaded.config — root is the project root;
// candidates are `<root>/src/middleware.ts` then `<root>/middleware.ts`
// Returns null if no middleware file exists# runMiddleware(middleware, request, params?)
import { runMiddleware } from "@elurjs/kit";
const result = await runMiddleware(loaded, request);
if (result.kind === "response") {
// middleware returned a redirect/error response
return result.response;
}
// result.kind === "continue"
// result.headers, result.params, result.locals# matchesMiddleware(pathname, config)
import { matchesMiddleware } from "@elurjs/kit";
matchesMiddleware("/dashboard/users", { matcher: ["/dashboard/:path*"] });
// → true