alabjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/cloudflare.d.ts +31 -0
- package/dist/adapters/cloudflare.d.ts.map +1 -0
- package/dist/adapters/cloudflare.js +30 -0
- package/dist/adapters/cloudflare.js.map +1 -0
- package/dist/adapters/deno.d.ts +22 -0
- package/dist/adapters/deno.d.ts.map +1 -0
- package/dist/adapters/deno.js +21 -0
- package/dist/adapters/deno.js.map +1 -0
- package/dist/adapters/web.d.ts +47 -0
- package/dist/adapters/web.d.ts.map +1 -0
- package/dist/adapters/web.js +212 -0
- package/dist/adapters/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +61 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/hooks.d.ts +119 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +220 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/hooks.test.d.ts +2 -0
- package/dist/client/hooks.test.d.ts.map +1 -0
- package/dist/client/hooks.test.js +45 -0
- package/dist/client/hooks.test.js.map +1 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +4 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/offline.d.ts +52 -0
- package/dist/client/offline.d.ts.map +1 -0
- package/dist/client/offline.js +90 -0
- package/dist/client/offline.js.map +1 -0
- package/dist/client/provider.d.ts +12 -0
- package/dist/client/provider.d.ts.map +1 -0
- package/dist/client/provider.js +10 -0
- package/dist/client/provider.js.map +1 -0
- package/dist/commands/build.d.ts +18 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +173 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/dev.d.ts +8 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +447 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/info.d.ts +6 -0
- package/dist/commands/info.d.ts.map +1 -0
- package/dist/commands/info.js +92 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/ssg.d.ts +8 -0
- package/dist/commands/ssg.d.ts.map +1 -0
- package/dist/commands/ssg.js +124 -0
- package/dist/commands/ssg.js.map +1 -0
- package/dist/commands/start.d.ts +7 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +26 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/test.d.ts +24 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +87 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +38 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +46 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/Font.d.ts +57 -0
- package/dist/components/Font.d.ts.map +1 -0
- package/dist/components/Font.js +33 -0
- package/dist/components/Font.js.map +1 -0
- package/dist/components/Image.d.ts +74 -0
- package/dist/components/Image.d.ts.map +1 -0
- package/dist/components/Image.js +85 -0
- package/dist/components/Image.js.map +1 -0
- package/dist/components/Link.d.ts +23 -0
- package/dist/components/Link.d.ts.map +1 -0
- package/dist/components/Link.js +48 -0
- package/dist/components/Link.js.map +1 -0
- package/dist/components/Script.d.ts +37 -0
- package/dist/components/Script.d.ts.map +1 -0
- package/dist/components/Script.js +70 -0
- package/dist/components/Script.js.map +1 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/i18n/i18n.test.d.ts +2 -0
- package/dist/i18n/i18n.test.d.ts.map +1 -0
- package/dist/i18n/i18n.test.js +132 -0
- package/dist/i18n/i18n.test.js.map +1 -0
- package/dist/i18n/index.d.ts +135 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +189 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/router/code-router.d.ts +204 -0
- package/dist/router/code-router.d.ts.map +1 -0
- package/dist/router/code-router.js +258 -0
- package/dist/router/code-router.js.map +1 -0
- package/dist/router/code-router.test.d.ts +2 -0
- package/dist/router/code-router.test.d.ts.map +1 -0
- package/dist/router/code-router.test.js +128 -0
- package/dist/router/code-router.test.js.map +1 -0
- package/dist/router/index.d.ts +4 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +2 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/manifest.d.ts +12 -0
- package/dist/router/manifest.d.ts.map +1 -0
- package/dist/router/manifest.js +2 -0
- package/dist/router/manifest.js.map +1 -0
- package/dist/server/app.d.ts +13 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +407 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/cache.d.ts +99 -0
- package/dist/server/cache.d.ts.map +1 -0
- package/dist/server/cache.js +161 -0
- package/dist/server/cache.js.map +1 -0
- package/dist/server/cache.test.d.ts +2 -0
- package/dist/server/cache.test.d.ts.map +1 -0
- package/dist/server/cache.test.js +150 -0
- package/dist/server/cache.test.js.map +1 -0
- package/dist/server/csrf.d.ts +28 -0
- package/dist/server/csrf.d.ts.map +1 -0
- package/dist/server/csrf.js +66 -0
- package/dist/server/csrf.js.map +1 -0
- package/dist/server/csrf.test.d.ts +2 -0
- package/dist/server/csrf.test.d.ts.map +1 -0
- package/dist/server/csrf.test.js +154 -0
- package/dist/server/csrf.test.js.map +1 -0
- package/dist/server/image.d.ts +18 -0
- package/dist/server/image.d.ts.map +1 -0
- package/dist/server/image.js +97 -0
- package/dist/server/image.js.map +1 -0
- package/dist/server/index.d.ts +57 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +58 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/middleware.d.ts +53 -0
- package/dist/server/middleware.d.ts.map +1 -0
- package/dist/server/middleware.js +80 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/middleware.test.d.ts +2 -0
- package/dist/server/middleware.test.d.ts.map +1 -0
- package/dist/server/middleware.test.js +125 -0
- package/dist/server/middleware.test.js.map +1 -0
- package/dist/server/revalidate.d.ts +49 -0
- package/dist/server/revalidate.d.ts.map +1 -0
- package/dist/server/revalidate.js +62 -0
- package/dist/server/revalidate.js.map +1 -0
- package/dist/server/revalidate.test.d.ts +2 -0
- package/dist/server/revalidate.test.d.ts.map +1 -0
- package/dist/server/revalidate.test.js +93 -0
- package/dist/server/revalidate.test.js.map +1 -0
- package/dist/server/server-fn.test.d.ts +2 -0
- package/dist/server/server-fn.test.d.ts.map +1 -0
- package/dist/server/server-fn.test.js +105 -0
- package/dist/server/server-fn.test.js.map +1 -0
- package/dist/server/sitemap.d.ts +9 -0
- package/dist/server/sitemap.d.ts.map +1 -0
- package/dist/server/sitemap.js +26 -0
- package/dist/server/sitemap.js.map +1 -0
- package/dist/server/sitemap.test.d.ts +2 -0
- package/dist/server/sitemap.test.d.ts.map +1 -0
- package/dist/server/sitemap.test.js +61 -0
- package/dist/server/sitemap.test.js.map +1 -0
- package/dist/server/sse.d.ts +59 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +91 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/sse.test.d.ts +2 -0
- package/dist/server/sse.test.d.ts.map +1 -0
- package/dist/server/sse.test.js +68 -0
- package/dist/server/sse.test.js.map +1 -0
- package/dist/signals/index.d.ts +101 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/index.js +149 -0
- package/dist/signals/index.js.map +1 -0
- package/dist/signals/signals.test.d.ts +2 -0
- package/dist/signals/signals.test.d.ts.map +1 -0
- package/dist/signals/signals.test.js +146 -0
- package/dist/signals/signals.test.js.map +1 -0
- package/dist/ssr/html.d.ts +27 -0
- package/dist/ssr/html.d.ts.map +1 -0
- package/dist/ssr/html.js +107 -0
- package/dist/ssr/html.js.map +1 -0
- package/dist/ssr/html.test.d.ts +2 -0
- package/dist/ssr/html.test.d.ts.map +1 -0
- package/dist/ssr/html.test.js +178 -0
- package/dist/ssr/html.test.js.map +1 -0
- package/dist/ssr/render.d.ts +46 -0
- package/dist/ssr/render.d.ts.map +1 -0
- package/dist/ssr/render.js +87 -0
- package/dist/ssr/render.js.map +1 -0
- package/dist/ssr/router-dev.d.ts +60 -0
- package/dist/ssr/router-dev.d.ts.map +1 -0
- package/dist/ssr/router-dev.js +205 -0
- package/dist/ssr/router-dev.js.map +1 -0
- package/dist/ssr/router-dev.test.d.ts +2 -0
- package/dist/ssr/router-dev.test.d.ts.map +1 -0
- package/dist/ssr/router-dev.test.js +189 -0
- package/dist/ssr/router-dev.test.js.map +1 -0
- package/dist/test/index.d.ts +93 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +146 -0
- package/dist/test/index.js.map +1 -0
- package/dist/types/index.d.ts +117 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/napi.d.ts +15 -0
- package/dist/types/napi.d.ts.map +1 -0
- package/dist/types/napi.js +2 -0
- package/dist/types/napi.js.map +1 -0
- package/package.json +107 -0
- package/src/adapters/cloudflare.ts +30 -0
- package/src/adapters/deno.ts +21 -0
- package/src/adapters/web.ts +259 -0
- package/src/cli.ts +68 -0
- package/src/client/hooks.test.ts +54 -0
- package/src/client/hooks.ts +329 -0
- package/src/client/index.ts +5 -0
- package/src/client/offline-sw.ts +191 -0
- package/src/client/offline.ts +114 -0
- package/src/client/provider.tsx +14 -0
- package/src/commands/build.ts +201 -0
- package/src/commands/dev.ts +509 -0
- package/src/commands/info.ts +111 -0
- package/src/commands/ssg.ts +177 -0
- package/src/commands/start.ts +32 -0
- package/src/commands/test.ts +102 -0
- package/src/components/ErrorBoundary.tsx +73 -0
- package/src/components/Font.tsx +100 -0
- package/src/components/Image.tsx +141 -0
- package/src/components/Link.tsx +64 -0
- package/src/components/Script.tsx +97 -0
- package/src/components/index.ts +9 -0
- package/src/i18n/i18n.test.tsx +169 -0
- package/src/i18n/index.tsx +256 -0
- package/src/index.ts +10 -0
- package/src/router/code-router.test.ts +146 -0
- package/src/router/code-router.tsx +459 -0
- package/src/router/index.ts +18 -0
- package/src/router/manifest.ts +13 -0
- package/src/server/app.ts +466 -0
- package/src/server/cache.test.ts +192 -0
- package/src/server/cache.ts +195 -0
- package/src/server/csrf.test.ts +199 -0
- package/src/server/csrf.ts +80 -0
- package/src/server/image.ts +112 -0
- package/src/server/index.ts +144 -0
- package/src/server/middleware.test.ts +151 -0
- package/src/server/middleware.ts +95 -0
- package/src/server/revalidate.test.ts +106 -0
- package/src/server/revalidate.ts +75 -0
- package/src/server/server-fn.test.ts +127 -0
- package/src/server/sitemap.test.ts +68 -0
- package/src/server/sitemap.ts +30 -0
- package/src/server/sse.test.ts +81 -0
- package/src/server/sse.ts +110 -0
- package/src/signals/index.ts +177 -0
- package/src/signals/signals.test.ts +164 -0
- package/src/ssr/html.test.ts +200 -0
- package/src/ssr/html.ts +140 -0
- package/src/ssr/render.ts +144 -0
- package/src/ssr/router-dev.test.ts +230 -0
- package/src/ssr/router-dev.ts +229 -0
- package/src/test/index.ts +206 -0
- package/src/types/compiler.d.ts +25 -0
- package/src/types/index.ts +147 -0
- package/src/types/napi.ts +20 -0
- package/src/types/plugins.d.ts +3 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ServerFn, ServerFnContext } from "../types/index.js";
|
|
2
|
+
import { getCached, setCache, CACHE_MISS } from "./cache.js";
|
|
3
|
+
|
|
4
|
+
// ─── Cache options ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface ServerFnCacheOptions<Input> {
|
|
7
|
+
/** How long to keep the result in seconds. Required when `cache` is set. */
|
|
8
|
+
ttl: number;
|
|
9
|
+
/**
|
|
10
|
+
* Tags for group invalidation via `invalidateCache({ tags })`.
|
|
11
|
+
* Can be a static array or a function that receives the input and returns tags,
|
|
12
|
+
* allowing per-argument granularity like `post:${input.id}`.
|
|
13
|
+
*/
|
|
14
|
+
tags?: string[] | ((input: Input) => string[]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DefineServerFnOptions<Input> {
|
|
18
|
+
/** Opt-in result caching. Nothing is cached unless this is specified. */
|
|
19
|
+
cache?: ServerFnCacheOptions<Input>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Zod schema detection (duck-typed, no hard Zod dependency) ────────────────
|
|
23
|
+
|
|
24
|
+
interface ZodLike<T> {
|
|
25
|
+
safeParse(input: unknown): { success: true; data: T } | { success: false; error: unknown };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isZodSchema(v: unknown): v is ZodLike<unknown> {
|
|
29
|
+
return (
|
|
30
|
+
v !== null &&
|
|
31
|
+
typeof v === "object" &&
|
|
32
|
+
"safeParse" in v &&
|
|
33
|
+
typeof (v as Record<string, unknown>)["safeParse"] === "function"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── defineServerFn overloads ─────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Define a server-only function (no input schema).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* export const getPosts = defineServerFn(async () => db.posts.findAll());
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function defineServerFn<Input = undefined, Output = unknown>(
|
|
48
|
+
handler: (ctx: ServerFnContext, input: Input) => Promise<Output>,
|
|
49
|
+
options?: DefineServerFnOptions<Input>,
|
|
50
|
+
): ServerFn<Input, Output>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Define a server-only function with **Zod input validation**.
|
|
54
|
+
*
|
|
55
|
+
* If validation fails, an HTTP 422 response with the Zod error is returned
|
|
56
|
+
* automatically — the handler is never called with invalid data.
|
|
57
|
+
*
|
|
58
|
+
* The client's `useMutation` / `useServerData` will receive
|
|
59
|
+
* `{ zodError: ZodError }` instead of throwing an untyped error.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { z } from "zod";
|
|
64
|
+
*
|
|
65
|
+
* export const createPost = defineServerFn(
|
|
66
|
+
* z.object({ title: z.string().min(1), body: z.string() }),
|
|
67
|
+
* async ({ params }, input) => db.posts.create(input),
|
|
68
|
+
* { cache: { ttl: 0, tags: ["posts"] } },
|
|
69
|
+
* );
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function defineServerFn<Schema extends ZodLike<unknown>, Output = unknown>(
|
|
73
|
+
schema: Schema,
|
|
74
|
+
handler: (
|
|
75
|
+
ctx: ServerFnContext,
|
|
76
|
+
input: Schema extends ZodLike<infer T> ? T : never,
|
|
77
|
+
) => Promise<Output>,
|
|
78
|
+
options?: DefineServerFnOptions<Schema extends ZodLike<infer T> ? T : never>,
|
|
79
|
+
): ServerFn<Schema extends ZodLike<infer T> ? T : never, Output>;
|
|
80
|
+
|
|
81
|
+
// ─── Implementation ───────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
export function defineServerFn(...args: any[]): ServerFn<any, any> {
|
|
85
|
+
const [schemaOrHandler, handlerOrOptions, maybeOptions] = args as [
|
|
86
|
+
ZodLike<unknown> | ((...a: unknown[]) => Promise<unknown>),
|
|
87
|
+
((...a: unknown[]) => Promise<unknown>) | DefineServerFnOptions<unknown> | undefined,
|
|
88
|
+
DefineServerFnOptions<unknown> | undefined,
|
|
89
|
+
];
|
|
90
|
+
let schema: ZodLike<unknown> | null = null;
|
|
91
|
+
let handler: (...args: unknown[]) => Promise<unknown>;
|
|
92
|
+
let options: DefineServerFnOptions<unknown> | undefined;
|
|
93
|
+
|
|
94
|
+
if (isZodSchema(schemaOrHandler)) {
|
|
95
|
+
schema = schemaOrHandler;
|
|
96
|
+
handler = handlerOrOptions as (...args: unknown[]) => Promise<unknown>;
|
|
97
|
+
options = maybeOptions;
|
|
98
|
+
} else {
|
|
99
|
+
handler = schemaOrHandler as (...args: unknown[]) => Promise<unknown>;
|
|
100
|
+
options = handlerOrOptions as DefineServerFnOptions<unknown> | undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const cacheOpts = options?.cache;
|
|
104
|
+
|
|
105
|
+
const wrapped = async (ctx: ServerFnContext, input: unknown): Promise<unknown> => {
|
|
106
|
+
// ── Zod validation ───────────────────────────────────────────────────────
|
|
107
|
+
let validatedInput = input;
|
|
108
|
+
if (schema) {
|
|
109
|
+
const result = schema.safeParse(input);
|
|
110
|
+
if (!result.success) {
|
|
111
|
+
// Throw a structured validation error that the dev/prod server will
|
|
112
|
+
// serialise as { zodError: ... } with HTTP 422.
|
|
113
|
+
const err = new Error("[alabjs] Validation failed") as Error & { zodError: unknown };
|
|
114
|
+
err.zodError = result.error;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
validatedInput = result.data;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Cache lookup ─────────────────────────────────────────────────────────
|
|
121
|
+
if (cacheOpts) {
|
|
122
|
+
const cacheKey = `${handler.name}:${JSON.stringify(validatedInput)}`;
|
|
123
|
+
const cached = getCached(cacheKey);
|
|
124
|
+
if (cached !== CACHE_MISS) return cached;
|
|
125
|
+
|
|
126
|
+
const data = await handler(ctx, validatedInput);
|
|
127
|
+
|
|
128
|
+
const tags =
|
|
129
|
+
typeof cacheOpts.tags === "function"
|
|
130
|
+
? cacheOpts.tags(validatedInput)
|
|
131
|
+
: (cacheOpts.tags ?? []);
|
|
132
|
+
setCache(cacheKey, data, { ttl: cacheOpts.ttl, tags });
|
|
133
|
+
return data;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return handler(ctx, validatedInput);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
140
|
+
return wrapped as ServerFn<any, any>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { defineSSEHandler } from "./sse.js";
|
|
144
|
+
export type { SSEEvent } from "./sse.js";
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
matcherToRegex,
|
|
4
|
+
matchesMiddleware,
|
|
5
|
+
runMiddleware,
|
|
6
|
+
redirect,
|
|
7
|
+
next,
|
|
8
|
+
type MiddlewareModule,
|
|
9
|
+
} from "./middleware.js";
|
|
10
|
+
|
|
11
|
+
// ─── matcherToRegex ───────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("matcherToRegex", () => {
|
|
14
|
+
it("matches exact static path", () => {
|
|
15
|
+
const re = matcherToRegex("/dashboard");
|
|
16
|
+
expect(re.test("/dashboard")).toBe(true);
|
|
17
|
+
expect(re.test("/dashboard/")).toBe(true);
|
|
18
|
+
expect(re.test("/other")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("handles :param as single segment", () => {
|
|
22
|
+
const re = matcherToRegex("/users/:id");
|
|
23
|
+
expect(re.test("/users/123")).toBe(true);
|
|
24
|
+
expect(re.test("/users/abc")).toBe(true);
|
|
25
|
+
expect(re.test("/users/")).toBe(false);
|
|
26
|
+
expect(re.test("/users/123/extra")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("handles * as single segment wildcard", () => {
|
|
30
|
+
const re = matcherToRegex("/api/*");
|
|
31
|
+
expect(re.test("/api/health")).toBe(true);
|
|
32
|
+
expect(re.test("/api/users")).toBe(true);
|
|
33
|
+
expect(re.test("/api/")).toBe(false);
|
|
34
|
+
expect(re.test("/api/a/b")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles ** as zero-or-more segments", () => {
|
|
38
|
+
const re = matcherToRegex("/docs/**");
|
|
39
|
+
expect(re.test("/docs/")).toBe(true);
|
|
40
|
+
expect(re.test("/docs/intro")).toBe(true);
|
|
41
|
+
expect(re.test("/docs/guides/auth")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles :param* (Next.js :path* style)", () => {
|
|
45
|
+
const re = matcherToRegex("/dashboard/:path*");
|
|
46
|
+
expect(re.test("/dashboard")).toBe(true);
|
|
47
|
+
expect(re.test("/dashboard/")).toBe(true);
|
|
48
|
+
expect(re.test("/dashboard/settings")).toBe(true);
|
|
49
|
+
expect(re.test("/dashboard/users/42")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("escapes special regex characters in static segments", () => {
|
|
53
|
+
const re = matcherToRegex("/search.html");
|
|
54
|
+
expect(re.test("/search.html")).toBe(true);
|
|
55
|
+
expect(re.test("/searchXhtml")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── matchesMiddleware ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("matchesMiddleware", () => {
|
|
62
|
+
it("matches all paths when no matchers provided", () => {
|
|
63
|
+
expect(matchesMiddleware("/anything")).toBe(true);
|
|
64
|
+
expect(matchesMiddleware("/anything", undefined)).toBe(true);
|
|
65
|
+
expect(matchesMiddleware("/anything", [])).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("matches when pathname fits a pattern", () => {
|
|
69
|
+
const matchers = ["/dashboard/:path*", "/api/*"];
|
|
70
|
+
expect(matchesMiddleware("/dashboard/settings", matchers)).toBe(true);
|
|
71
|
+
expect(matchesMiddleware("/api/health", matchers)).toBe(true);
|
|
72
|
+
expect(matchesMiddleware("/login", matchers)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── redirect and next helpers ────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("redirect", () => {
|
|
79
|
+
it("returns a Response with redirect status", () => {
|
|
80
|
+
const res = redirect("https://example.com/login");
|
|
81
|
+
expect(res).toBeInstanceOf(Response);
|
|
82
|
+
expect(res.status).toBe(307);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("supports custom status codes", () => {
|
|
86
|
+
const res = redirect("https://example.com/login", 301);
|
|
87
|
+
expect(res.status).toBe(301);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("next", () => {
|
|
92
|
+
it("returns null", () => {
|
|
93
|
+
expect(next()).toBe(null);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── runMiddleware ────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe("runMiddleware", () => {
|
|
100
|
+
it("returns null if pathname does not match the matcher", async () => {
|
|
101
|
+
const mod: MiddlewareModule = {
|
|
102
|
+
middleware: () => redirect("https://example.com/login"),
|
|
103
|
+
config: { matcher: ["/dashboard/:path*"] },
|
|
104
|
+
};
|
|
105
|
+
const req = new Request("http://localhost/login");
|
|
106
|
+
const result = await runMiddleware(mod, req);
|
|
107
|
+
expect(result).toBe(null);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns Response when middleware redirects", async () => {
|
|
111
|
+
const mod: MiddlewareModule = {
|
|
112
|
+
middleware: () => redirect("https://example.com/login"),
|
|
113
|
+
config: { matcher: ["/dashboard/:path*"] },
|
|
114
|
+
};
|
|
115
|
+
const req = new Request("http://localhost/dashboard/settings");
|
|
116
|
+
const result = await runMiddleware(mod, req);
|
|
117
|
+
expect(result).toBeInstanceOf(Response);
|
|
118
|
+
expect(result!.status).toBe(307);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns null when middleware passes through", async () => {
|
|
122
|
+
const mod: MiddlewareModule = {
|
|
123
|
+
middleware: () => undefined,
|
|
124
|
+
};
|
|
125
|
+
const req = new Request("http://localhost/anything");
|
|
126
|
+
const result = await runMiddleware(mod, req);
|
|
127
|
+
expect(result).toBe(null);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns null when middleware returns null (via next())", async () => {
|
|
131
|
+
const mod: MiddlewareModule = {
|
|
132
|
+
middleware: () => next(),
|
|
133
|
+
};
|
|
134
|
+
const req = new Request("http://localhost/anything");
|
|
135
|
+
const result = await runMiddleware(mod, req);
|
|
136
|
+
expect(result).toBe(null);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("runs on all paths when no config.matcher is set", async () => {
|
|
140
|
+
let called = false;
|
|
141
|
+
const mod: MiddlewareModule = {
|
|
142
|
+
middleware: () => {
|
|
143
|
+
called = true;
|
|
144
|
+
return null;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const req = new Request("http://localhost/random/path");
|
|
148
|
+
await runMiddleware(mod, req);
|
|
149
|
+
expect(called).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware runner — loads and executes the user's `middleware.ts` file.
|
|
3
|
+
*
|
|
4
|
+
* Convention (mirrors Next.js):
|
|
5
|
+
* ```ts
|
|
6
|
+
* // middleware.ts (project root)
|
|
7
|
+
* export async function middleware(req: Request): Promise<Response | void> {
|
|
8
|
+
* if (!isAuthed(req)) return Response.redirect(new URL("/login", req.url));
|
|
9
|
+
* // return nothing (or return NextResponse.next()) to continue
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* // Optional — restrict which paths the middleware runs on.
|
|
13
|
+
* // Patterns support * and ** wildcards (like path-to-regexp lite).
|
|
14
|
+
* export const config = {
|
|
15
|
+
* matcher: ["/dashboard/:path*", "/api/:path*"],
|
|
16
|
+
* };
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ─── Public helpers (importable from "alabjs/middleware") ───────────────────────
|
|
21
|
+
|
|
22
|
+
/** Redirect the request to a new URL (defaults to 307 Temporary Redirect). */
|
|
23
|
+
export function redirect(url: string, status: 301 | 302 | 307 | 308 = 307): Response {
|
|
24
|
+
return Response.redirect(url, status);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Pass the request through to the next handler (no-op).
|
|
29
|
+
* Return the result of `next()` from your middleware to signal "continue".
|
|
30
|
+
*/
|
|
31
|
+
export function next(): null {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Internal types + runner ──────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export interface MiddlewareModule {
|
|
38
|
+
middleware: (req: Request) => Promise<Response | null | void> | Response | null | void;
|
|
39
|
+
config?: { matcher?: string[] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert a matcher pattern like `/dashboard/:path*` or `/api/*` to a RegExp.
|
|
44
|
+
*
|
|
45
|
+
* Supported syntax:
|
|
46
|
+
* - `:param` — one path segment (any chars except `/`)
|
|
47
|
+
* - `*` — one path segment wildcard
|
|
48
|
+
* - `**` — zero or more segments (greedy)
|
|
49
|
+
* - `:param*` — zero or more remaining segments (Next.js `:path*` style)
|
|
50
|
+
*/
|
|
51
|
+
export function matcherToRegex(pattern: string): RegExp {
|
|
52
|
+
// Escape regex special chars except the ones we handle manually
|
|
53
|
+
const escaped = pattern
|
|
54
|
+
.replace(/[.+*^${}()|[\]\\]/g, "\\$&")
|
|
55
|
+
// :param* → zero-or-more segments (greedy, optional slash)
|
|
56
|
+
.replace(/\/:[a-zA-Z_][a-zA-Z0-9_]*\\\*/g, "(?:/.*)?")
|
|
57
|
+
.replace(/:[a-zA-Z_][a-zA-Z0-9_]*\\\*/g, "(?:/.*)?")
|
|
58
|
+
// :param → single segment
|
|
59
|
+
.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, "[^/]+")
|
|
60
|
+
// ** → zero-or-more segments (greedy, optional slash)
|
|
61
|
+
.replace(/\/\*\*\\\*/g, "(?:/.*)?")
|
|
62
|
+
.replace(/\\\*\\\*/g, ".*")
|
|
63
|
+
// * → single segment
|
|
64
|
+
.replace(/\\\*/g, "[^/]+");
|
|
65
|
+
|
|
66
|
+
return new RegExp(`^${escaped}\\/?$`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Test whether a pathname matches any of the given matcher patterns.
|
|
71
|
+
* If no patterns are provided, the middleware runs on every request.
|
|
72
|
+
*/
|
|
73
|
+
export function matchesMiddleware(pathname: string, matchers?: string[]): boolean {
|
|
74
|
+
if (!matchers || matchers.length === 0) return true;
|
|
75
|
+
return matchers.some((pattern) => matcherToRegex(pattern).test(pathname));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run the user middleware against the current request.
|
|
80
|
+
* Returns a `Response` if the middleware handled the request (redirect, early
|
|
81
|
+
* return, etc.) or `null` if it passed through (return nothing / undefined).
|
|
82
|
+
*/
|
|
83
|
+
export async function runMiddleware(
|
|
84
|
+
mod: MiddlewareModule,
|
|
85
|
+
req: Request,
|
|
86
|
+
): Promise<Response | null> {
|
|
87
|
+
const { middleware, config } = mod;
|
|
88
|
+
const pathname = new URL(req.url).pathname;
|
|
89
|
+
|
|
90
|
+
if (!matchesMiddleware(pathname, config?.matcher)) return null;
|
|
91
|
+
|
|
92
|
+
const result = await middleware(req);
|
|
93
|
+
if (result instanceof Response) return result;
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { applyRevalidate, checkRevalidateAuth } from "./revalidate.js";
|
|
3
|
+
import { setCachedPage, getCachedPage, setCache, getCached, CACHE_MISS, revalidatePath } from "./cache.js";
|
|
4
|
+
|
|
5
|
+
// ─── checkRevalidateAuth ──────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("checkRevalidateAuth", () => {
|
|
8
|
+
const savedEnv = process.env["ALAB_REVALIDATE_SECRET"];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
if (savedEnv === undefined) delete process.env["ALAB_REVALIDATE_SECRET"];
|
|
12
|
+
else process.env["ALAB_REVALIDATE_SECRET"] = savedEnv;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns true when no secret is configured", () => {
|
|
16
|
+
delete process.env["ALAB_REVALIDATE_SECRET"];
|
|
17
|
+
expect(checkRevalidateAuth(undefined)).toBe(true);
|
|
18
|
+
expect(checkRevalidateAuth(null)).toBe(true);
|
|
19
|
+
expect(checkRevalidateAuth("Bearer anything")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns true when correct Bearer token is supplied", () => {
|
|
23
|
+
process.env["ALAB_REVALIDATE_SECRET"] = "my-secret";
|
|
24
|
+
expect(checkRevalidateAuth("Bearer my-secret")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns false when wrong token is supplied", () => {
|
|
28
|
+
process.env["ALAB_REVALIDATE_SECRET"] = "my-secret";
|
|
29
|
+
expect(checkRevalidateAuth("Bearer wrong")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns false when Authorization header is missing", () => {
|
|
33
|
+
process.env["ALAB_REVALIDATE_SECRET"] = "my-secret";
|
|
34
|
+
expect(checkRevalidateAuth(undefined)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns false when header is not Bearer scheme", () => {
|
|
38
|
+
process.env["ALAB_REVALIDATE_SECRET"] = "my-secret";
|
|
39
|
+
expect(checkRevalidateAuth("Basic my-secret")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ─── applyRevalidate ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("applyRevalidate", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
revalidatePath("/posts");
|
|
48
|
+
revalidatePath("/posts/1");
|
|
49
|
+
revalidatePath("/about");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns 400 for non-object body", () => {
|
|
53
|
+
const result = applyRevalidate("not an object");
|
|
54
|
+
expect("error" in result).toBe(true);
|
|
55
|
+
if ("error" in result) expect(result.status).toBe(400);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns 400 when no fields are provided", () => {
|
|
59
|
+
const result = applyRevalidate({});
|
|
60
|
+
expect("error" in result).toBe(true);
|
|
61
|
+
if ("error" in result) expect(result.status).toBe(400);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns 400 when tags is an empty array", () => {
|
|
65
|
+
const result = applyRevalidate({ tags: [] });
|
|
66
|
+
expect("error" in result).toBe(true);
|
|
67
|
+
if ("error" in result) expect(result.status).toBe(400);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("purges a single path and returns it in response", () => {
|
|
71
|
+
setCachedPage("/posts/1", "<html>post</html>", 60);
|
|
72
|
+
const result = applyRevalidate({ path: "/posts/1" });
|
|
73
|
+
expect(result).toEqual({ revalidated: true, path: "/posts/1" });
|
|
74
|
+
expect(getCachedPage("/posts/1")).toBe(null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("purges all paths matching a prefix", () => {
|
|
78
|
+
setCachedPage("/posts", "<html>posts</html>", 60);
|
|
79
|
+
setCachedPage("/posts/1", "<html>post 1</html>", 60);
|
|
80
|
+
setCachedPage("/about", "<html>about</html>", 60);
|
|
81
|
+
|
|
82
|
+
const result = applyRevalidate({ prefix: "/posts" });
|
|
83
|
+
expect(result).toEqual({ revalidated: true, prefix: "/posts" });
|
|
84
|
+
expect(getCachedPage("/posts")).toBe(null);
|
|
85
|
+
expect(getCachedPage("/posts/1")).toBe(null);
|
|
86
|
+
expect(getCachedPage("/about")).not.toBe(null);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("purges page HTML and server-fn data cache by tags", () => {
|
|
90
|
+
setCachedPage("/posts", "<html>posts</html>", 60, ["posts"]);
|
|
91
|
+
setCache("getPosts:", "data", { ttl: 60, tags: ["posts"] });
|
|
92
|
+
|
|
93
|
+
const result = applyRevalidate({ tags: ["posts"] });
|
|
94
|
+
expect(result).toEqual({ revalidated: true, tags: ["posts"] });
|
|
95
|
+
expect(getCachedPage("/posts")).toBe(null);
|
|
96
|
+
expect(getCached("getPosts:")).toBe(CACHE_MISS);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("accepts path, prefix, and tags together", () => {
|
|
100
|
+
setCachedPage("/posts/1", "<html>post</html>", 60);
|
|
101
|
+
setCachedPage("/about", "<html>about</html>", 60);
|
|
102
|
+
const result = applyRevalidate({ path: "/posts/1", tags: ["static"] });
|
|
103
|
+
expect(result).toMatchObject({ revalidated: true, path: "/posts/1", tags: ["static"] });
|
|
104
|
+
expect(getCachedPage("/posts/1")).toBe(null);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand ISR revalidation handler.
|
|
3
|
+
*
|
|
4
|
+
* Exposes `POST /_alabjs/revalidate` so CMS webhooks, deploy scripts, or
|
|
5
|
+
* API routes can purge the page HTML and/or server-function data cache
|
|
6
|
+
* without restarting the server.
|
|
7
|
+
*
|
|
8
|
+
* Authentication
|
|
9
|
+
* --------------
|
|
10
|
+
* Set `ALAB_REVALIDATE_SECRET` in your environment. Every request must then
|
|
11
|
+
* include `Authorization: Bearer <secret>`. If the env var is not set the
|
|
12
|
+
* endpoint is open — useful in development, unsafe in production.
|
|
13
|
+
*
|
|
14
|
+
* Request body (JSON) — supply one or more fields:
|
|
15
|
+
* ```json
|
|
16
|
+
* { "path": "/posts/1" } // purge a single page
|
|
17
|
+
* { "prefix": "/posts" } // purge /posts, /posts/1, /posts/2, …
|
|
18
|
+
* { "tags": ["posts", "post:1"] } // purge all entries tagged with any tag
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Response (200):
|
|
22
|
+
* ```json
|
|
23
|
+
* { "revalidated": true, "path": "/posts/1" }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { revalidatePath, revalidatePathPrefix, revalidateTag } from "./cache.js";
|
|
28
|
+
|
|
29
|
+
export interface RevalidateBody {
|
|
30
|
+
/** Purge a single cached page path. */
|
|
31
|
+
path?: string;
|
|
32
|
+
/** Purge all cached pages whose path starts with this prefix. */
|
|
33
|
+
prefix?: string;
|
|
34
|
+
/** Purge all server-function and page cache entries carrying any of these tags. */
|
|
35
|
+
tags?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Returns `true` when the request is authorised to call the revalidate endpoint. */
|
|
39
|
+
export function checkRevalidateAuth(authorizationHeader: string | null | undefined): boolean {
|
|
40
|
+
const secret = process.env["ALAB_REVALIDATE_SECRET"];
|
|
41
|
+
if (!secret) return true; // no secret configured — open endpoint
|
|
42
|
+
const provided = authorizationHeader?.startsWith("Bearer ")
|
|
43
|
+
? authorizationHeader.slice(7)
|
|
44
|
+
: null;
|
|
45
|
+
return provided === secret;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply a revalidation request. Returns a result object on success or an
|
|
50
|
+
* `{ error }` object (with an HTTP status hint) on failure.
|
|
51
|
+
*/
|
|
52
|
+
export function applyRevalidate(
|
|
53
|
+
body: unknown,
|
|
54
|
+
): { revalidated: true; path?: string; prefix?: string; tags?: string[] } | { status: number; error: string } {
|
|
55
|
+
if (typeof body !== "object" || body === null) {
|
|
56
|
+
return { status: 400, error: "Request body must be a JSON object." };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { path, prefix, tags } = body as RevalidateBody;
|
|
60
|
+
|
|
61
|
+
if (!path && !prefix && (!tags || tags.length === 0)) {
|
|
62
|
+
return { status: 400, error: "Provide at least one of: path, prefix, tags." };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (path) revalidatePath(path);
|
|
66
|
+
if (prefix) revalidatePathPrefix(prefix);
|
|
67
|
+
if (tags?.length) revalidateTag({ tags });
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
revalidated: true,
|
|
71
|
+
...(path !== undefined && { path }),
|
|
72
|
+
...(prefix !== undefined && { prefix }),
|
|
73
|
+
...(tags !== undefined && { tags }),
|
|
74
|
+
};
|
|
75
|
+
}
|