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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alab in-process cache for server functions.
|
|
3
|
+
*
|
|
4
|
+
* Nothing is cached unless you explicitly opt in — no implicit layers.
|
|
5
|
+
* You own the key, the TTL, and the invalidation.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // app/posts/[id]/page.server.ts
|
|
10
|
+
* export const getPost = defineServerFn(
|
|
11
|
+
* async ({ params }) => db.posts.findById(params.id),
|
|
12
|
+
* { cache: { ttl: 60, tags: ["posts", `post:${params.id}`] } },
|
|
13
|
+
* );
|
|
14
|
+
*
|
|
15
|
+
* // Invalidate from another server function or API route
|
|
16
|
+
* import { invalidateCache } from "alabjs/cache";
|
|
17
|
+
* await invalidateCache({ tags: ["posts"] });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface CacheEntry {
|
|
22
|
+
data: unknown;
|
|
23
|
+
/** Absolute expiry timestamp (ms since epoch). */
|
|
24
|
+
expires: number;
|
|
25
|
+
tags: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sentinel value returned when a cache key has no valid entry. */
|
|
29
|
+
const CACHE_MISS: unique symbol = Symbol("alab:cache_miss");
|
|
30
|
+
|
|
31
|
+
/** Global in-process LRU-style cache. Shared across all server function calls. */
|
|
32
|
+
const _store = new Map<string, CacheEntry>();
|
|
33
|
+
|
|
34
|
+
export { CACHE_MISS };
|
|
35
|
+
|
|
36
|
+
/** Retrieve a cached value. Returns `CACHE_MISS` if absent or expired. */
|
|
37
|
+
export function getCached(key: string): unknown | typeof CACHE_MISS {
|
|
38
|
+
const entry = _store.get(key);
|
|
39
|
+
if (!entry) return CACHE_MISS;
|
|
40
|
+
if (Date.now() > entry.expires) {
|
|
41
|
+
_store.delete(key);
|
|
42
|
+
return CACHE_MISS;
|
|
43
|
+
}
|
|
44
|
+
return entry.data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Store a value in the cache with a TTL (seconds) and optional tags. */
|
|
48
|
+
export function setCache(
|
|
49
|
+
key: string,
|
|
50
|
+
data: unknown,
|
|
51
|
+
opts: { ttl: number; tags?: string[] },
|
|
52
|
+
): void {
|
|
53
|
+
_store.set(key, {
|
|
54
|
+
data,
|
|
55
|
+
expires: Date.now() + opts.ttl * 1_000,
|
|
56
|
+
tags: opts.tags ?? [],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Invalidate all cache entries that carry at least one of the given tags.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { invalidateCache } from "alabjs/cache";
|
|
66
|
+
* await invalidateCache({ tags: ["posts"] });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function invalidateCache(opts: { tags: string[] }): void {
|
|
70
|
+
for (const [key, entry] of _store) {
|
|
71
|
+
if (opts.tags.some((t) => entry.tags.includes(t))) {
|
|
72
|
+
_store.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Invalidate a specific cache entry by its exact key.
|
|
79
|
+
* Prefer `invalidateCache({ tags })` for logical invalidation.
|
|
80
|
+
*/
|
|
81
|
+
export function invalidateCacheKey(key: string): void {
|
|
82
|
+
_store.delete(key);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Page-level HTML cache (ISR) ─────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
interface PageCacheEntry {
|
|
88
|
+
html: string;
|
|
89
|
+
expires: number;
|
|
90
|
+
/** Whether a background revalidation is already in flight. */
|
|
91
|
+
revalidating: boolean;
|
|
92
|
+
/** Tags for on-demand invalidation via `revalidateTag`. */
|
|
93
|
+
tags: string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const _pageStore = new Map<string, PageCacheEntry>();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Retrieve a cached HTML page. Returns `null` if absent or fully expired
|
|
100
|
+
* (more than 2× TTL past — serves stale-while-revalidate window).
|
|
101
|
+
*/
|
|
102
|
+
export function getCachedPage(pathname: string): { html: string; stale: boolean } | null {
|
|
103
|
+
const entry = _pageStore.get(pathname);
|
|
104
|
+
if (!entry) return null;
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
// Still fresh
|
|
107
|
+
if (now <= entry.expires) return { html: entry.html, stale: false };
|
|
108
|
+
// Stale-while-revalidate: serve stale for up to 2× TTL, trigger background regen
|
|
109
|
+
const ttl = entry.expires - (entry.expires - now); // rough TTL from stored data
|
|
110
|
+
if (now <= entry.expires + Math.max(ttl * 2, 60_000)) {
|
|
111
|
+
return { html: entry.html, stale: true };
|
|
112
|
+
}
|
|
113
|
+
_pageStore.delete(pathname);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Store a rendered HTML page with a TTL (seconds). */
|
|
118
|
+
export function setCachedPage(pathname: string, html: string, ttl: number, tags: string[] = []): void {
|
|
119
|
+
_pageStore.set(pathname, { html, expires: Date.now() + ttl * 1_000, revalidating: false, tags });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Mark a page as currently being revalidated to prevent concurrent regen. */
|
|
123
|
+
export function markPageRevalidating(pathname: string): void {
|
|
124
|
+
const entry = _pageStore.get(pathname);
|
|
125
|
+
if (entry) entry.revalidating = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Check if a page is currently being revalidated in the background. */
|
|
129
|
+
export function isPageRevalidating(pathname: string): boolean {
|
|
130
|
+
return _pageStore.get(pathname)?.revalidating ?? false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Purge a specific page's cached HTML, forcing a fresh render on the next request.
|
|
135
|
+
*
|
|
136
|
+
* @example
|
|
137
|
+
* ```ts
|
|
138
|
+
* import { revalidatePath } from "alabjs/cache";
|
|
139
|
+
* await revalidatePath("/posts/1"); // next request regenerates the page
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export function revalidatePath(pathname: string): void {
|
|
143
|
+
_pageStore.delete(pathname);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Purge all cached pages whose pathname starts with the given prefix.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* await revalidatePath("/posts"); // clears /posts, /posts/1, /posts/2, ...
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export function revalidatePathPrefix(prefix: string): void {
|
|
155
|
+
for (const key of _pageStore.keys()) {
|
|
156
|
+
if (key.startsWith(prefix)) _pageStore.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Purge all server-function cache entries AND page HTML cache entries
|
|
162
|
+
* that carry at least one of the given tags.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* import { revalidateTag } from "alabjs/cache";
|
|
167
|
+
* revalidateTag({ tags: ["posts"] }); // clears both data and page caches
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function revalidateTag(opts: { tags: string[] }): void {
|
|
171
|
+
// Server-function data cache
|
|
172
|
+
invalidateCache(opts);
|
|
173
|
+
// Page HTML cache (ISR)
|
|
174
|
+
for (const [path, entry] of _pageStore) {
|
|
175
|
+
if (opts.tags.some((t) => entry.tags.includes(t))) {
|
|
176
|
+
_pageStore.delete(path);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Return a snapshot of all live cache entries (for the dev Cache Inspector). */
|
|
182
|
+
export function inspectCache(): Array<{
|
|
183
|
+
key: string;
|
|
184
|
+
tags: string[];
|
|
185
|
+
expiresIn: number;
|
|
186
|
+
}> {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const result: Array<{ key: string; tags: string[]; expiresIn: number }> = [];
|
|
189
|
+
for (const [key, entry] of _store) {
|
|
190
|
+
const expiresIn = entry.expires - now;
|
|
191
|
+
if (expiresIn > 0) result.push({ key, tags: entry.tags, expiresIn: Math.ceil(expiresIn / 1000) });
|
|
192
|
+
else _store.delete(key);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { createApp, defineEventHandler, toWebHandler } from "h3";
|
|
3
|
+
import { csrfMiddleware, setCsrfCookie, csrfMetaTag, CSRF_COOKIE, CSRF_HEADER } from "./csrf.js";
|
|
4
|
+
|
|
5
|
+
// ─── csrfMetaTag ─────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("csrfMetaTag", () => {
|
|
8
|
+
it("generates a meta tag with the token", () => {
|
|
9
|
+
const html = csrfMetaTag("abc-123");
|
|
10
|
+
expect(html).toBe('<meta name="csrf-token" content="abc-123" />');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("escapes double quotes in the token", () => {
|
|
14
|
+
const html = csrfMetaTag('token"with"quotes');
|
|
15
|
+
expect(html).toContain(""");
|
|
16
|
+
expect(html).not.toContain('content="token"');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("handles empty token", () => {
|
|
20
|
+
const html = csrfMetaTag("");
|
|
21
|
+
expect(html).toBe('<meta name="csrf-token" content="" />');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("handles UUID-style tokens", () => {
|
|
25
|
+
const html = csrfMetaTag("550e8400-e29b-41d4-a716-446655440000");
|
|
26
|
+
expect(html).toContain("550e8400-e29b-41d4-a716-446655440000");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ─── csrfMiddleware ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("csrfMiddleware", () => {
|
|
33
|
+
const savedEnv = process.env["NODE_ENV"];
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
process.env["NODE_ENV"] = savedEnv;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function makeHandler() {
|
|
40
|
+
const app = createApp();
|
|
41
|
+
app.use(csrfMiddleware());
|
|
42
|
+
app.use(defineEventHandler(() => "ok"));
|
|
43
|
+
return toWebHandler(app);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TOKEN = "550e8400-e29b-41d4-a716-446655440000";
|
|
47
|
+
|
|
48
|
+
// Safe methods pass without any token
|
|
49
|
+
|
|
50
|
+
it("allows GET without token in production", async () => {
|
|
51
|
+
process.env["NODE_ENV"] = "production";
|
|
52
|
+
const res = await makeHandler()(new Request("http://localhost/"));
|
|
53
|
+
expect(res.status).toBe(200);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("allows HEAD without token in production", async () => {
|
|
57
|
+
process.env["NODE_ENV"] = "production";
|
|
58
|
+
const res = await makeHandler()(new Request("http://localhost/", { method: "HEAD" }));
|
|
59
|
+
expect(res.status).toBe(200);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("allows OPTIONS without token in production", async () => {
|
|
63
|
+
process.env["NODE_ENV"] = "production";
|
|
64
|
+
const res = await makeHandler()(new Request("http://localhost/", { method: "OPTIONS" }));
|
|
65
|
+
expect(res.status).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Valid POST
|
|
69
|
+
|
|
70
|
+
it("allows POST with matching cookie and header in production", async () => {
|
|
71
|
+
process.env["NODE_ENV"] = "production";
|
|
72
|
+
const res = await makeHandler()(
|
|
73
|
+
new Request("http://localhost/", {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: {
|
|
76
|
+
cookie: `${CSRF_COOKIE}=${TOKEN}`,
|
|
77
|
+
[CSRF_HEADER]: TOKEN,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Missing token cases → 403
|
|
85
|
+
|
|
86
|
+
it("rejects POST with no cookie and no header in production", async () => {
|
|
87
|
+
process.env["NODE_ENV"] = "production";
|
|
88
|
+
const res = await makeHandler()(new Request("http://localhost/", { method: "POST" }));
|
|
89
|
+
expect(res.status).toBe(403);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects POST with cookie but no header in production", async () => {
|
|
93
|
+
process.env["NODE_ENV"] = "production";
|
|
94
|
+
const res = await makeHandler()(
|
|
95
|
+
new Request("http://localhost/", {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { cookie: `${CSRF_COOKIE}=${TOKEN}` },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(res.status).toBe(403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rejects POST with header but no cookie in production", async () => {
|
|
104
|
+
process.env["NODE_ENV"] = "production";
|
|
105
|
+
const res = await makeHandler()(
|
|
106
|
+
new Request("http://localhost/", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { [CSRF_HEADER]: TOKEN },
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
expect(res.status).toBe(403);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects POST with mismatched cookie and header in production", async () => {
|
|
115
|
+
process.env["NODE_ENV"] = "production";
|
|
116
|
+
const res = await makeHandler()(
|
|
117
|
+
new Request("http://localhost/", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
cookie: `${CSRF_COOKIE}=token-a`,
|
|
121
|
+
[CSRF_HEADER]: "token-b",
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
expect(res.status).toBe(403);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Non-production bypasses CSRF check
|
|
129
|
+
|
|
130
|
+
it("allows POST without token in development mode", async () => {
|
|
131
|
+
process.env["NODE_ENV"] = "development";
|
|
132
|
+
const res = await makeHandler()(new Request("http://localhost/", { method: "POST" }));
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("allows POST without token when NODE_ENV is unset", async () => {
|
|
137
|
+
delete process.env["NODE_ENV"];
|
|
138
|
+
const res = await makeHandler()(new Request("http://localhost/", { method: "POST" }));
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ─── setCsrfCookie ────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
describe("setCsrfCookie", () => {
|
|
146
|
+
function makeSetCookieHandler() {
|
|
147
|
+
const app = createApp();
|
|
148
|
+
app.use(
|
|
149
|
+
defineEventHandler((event) => {
|
|
150
|
+
const token = setCsrfCookie(event);
|
|
151
|
+
return { token };
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
return toWebHandler(app);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it("sets a UUID token in the Set-Cookie header", async () => {
|
|
158
|
+
const res = await makeSetCookieHandler()(new Request("http://localhost/"));
|
|
159
|
+
const setCookieHeader = res.headers.get("set-cookie") ?? "";
|
|
160
|
+
expect(setCookieHeader).toMatch(/alab-csrf=[0-9a-f-]{36}/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns the generated token in the response", async () => {
|
|
164
|
+
const res = await makeSetCookieHandler()(new Request("http://localhost/"));
|
|
165
|
+
const body = (await res.json()) as { token: string };
|
|
166
|
+
expect(body.token).toMatch(
|
|
167
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("reuses an existing cookie token instead of generating a new one", async () => {
|
|
172
|
+
const existingToken = "existing-csrf-token-value";
|
|
173
|
+
const res = await makeSetCookieHandler()(
|
|
174
|
+
new Request("http://localhost/", {
|
|
175
|
+
headers: { cookie: `${CSRF_COOKIE}=${existingToken}` },
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
const body = (await res.json()) as { token: string };
|
|
179
|
+
expect(body.token).toBe(existingToken);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not set HttpOnly on the cookie (must be readable by JS)", async () => {
|
|
183
|
+
const res = await makeSetCookieHandler()(new Request("http://localhost/"));
|
|
184
|
+
const setCookieHeader = (res.headers.get("set-cookie") ?? "").toLowerCase();
|
|
185
|
+
expect(setCookieHeader).not.toContain("httponly");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("sets SameSite=Strict on the cookie", async () => {
|
|
189
|
+
const res = await makeSetCookieHandler()(new Request("http://localhost/"));
|
|
190
|
+
const setCookieHeader = (res.headers.get("set-cookie") ?? "").toLowerCase();
|
|
191
|
+
expect(setCookieHeader).toContain("samesite=strict");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("sets path=/ on the cookie", async () => {
|
|
195
|
+
const res = await makeSetCookieHandler()(new Request("http://localhost/"));
|
|
196
|
+
const setCookieHeader = (res.headers.get("set-cookie") ?? "").toLowerCase();
|
|
197
|
+
expect(setCookieHeader).toContain("path=/");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
defineEventHandler,
|
|
4
|
+
getCookie,
|
|
5
|
+
getHeader,
|
|
6
|
+
setCookie,
|
|
7
|
+
createError,
|
|
8
|
+
type H3Event,
|
|
9
|
+
} from "h3";
|
|
10
|
+
|
|
11
|
+
export const CSRF_COOKIE = "alab-csrf";
|
|
12
|
+
export const CSRF_HEADER = "x-csrf-token";
|
|
13
|
+
|
|
14
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* CSRF protection middleware using the Double Submit Cookie pattern.
|
|
18
|
+
*
|
|
19
|
+
* - Safe methods (GET, HEAD, OPTIONS) are always allowed.
|
|
20
|
+
* - Mutating requests must include an `x-csrf-token` header whose value
|
|
21
|
+
* matches the `alab-csrf` cookie set by `setCsrfCookie()`.
|
|
22
|
+
* - The cookie is `SameSite=Strict` (first line of defence). The header
|
|
23
|
+
* check is a second layer that prevents attacks from subdomains.
|
|
24
|
+
* - Disabled in development (NODE_ENV !== "production") for DX.
|
|
25
|
+
*/
|
|
26
|
+
export function csrfMiddleware() {
|
|
27
|
+
return defineEventHandler((event) => {
|
|
28
|
+
// Skip in development — avoid friction during local iteration.
|
|
29
|
+
if (process.env["NODE_ENV"] !== "production") return;
|
|
30
|
+
|
|
31
|
+
const method = event.method.toUpperCase();
|
|
32
|
+
if (SAFE_METHODS.has(method)) return;
|
|
33
|
+
|
|
34
|
+
const cookieToken = getCookie(event, CSRF_COOKIE);
|
|
35
|
+
const headerToken = getHeader(event, CSRF_HEADER);
|
|
36
|
+
|
|
37
|
+
const tokensMatch =
|
|
38
|
+
!!cookieToken &&
|
|
39
|
+
!!headerToken &&
|
|
40
|
+
cookieToken.length === headerToken.length &&
|
|
41
|
+
timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken));
|
|
42
|
+
|
|
43
|
+
if (!tokensMatch) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 403,
|
|
46
|
+
message: "CSRF token mismatch. Include the x-csrf-token header.",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set a CSRF cookie on the response and return the generated token.
|
|
54
|
+
* Call this on every GET page response so the client always has a fresh token.
|
|
55
|
+
*
|
|
56
|
+
* The cookie is intentionally NOT HttpOnly so JavaScript can read and send
|
|
57
|
+
* it as the `x-csrf-token` request header.
|
|
58
|
+
*/
|
|
59
|
+
export function setCsrfCookie(event: H3Event): string {
|
|
60
|
+
const existing = getCookie(event, CSRF_COOKIE);
|
|
61
|
+
if (existing) return existing;
|
|
62
|
+
|
|
63
|
+
const token = crypto.randomUUID();
|
|
64
|
+
setCookie(event, CSRF_COOKIE, token, {
|
|
65
|
+
httpOnly: false,
|
|
66
|
+
sameSite: "strict",
|
|
67
|
+
secure: process.env["NODE_ENV"] === "production",
|
|
68
|
+
path: "/",
|
|
69
|
+
maxAge: 60 * 60 * 24, // 24 hours
|
|
70
|
+
});
|
|
71
|
+
return token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Inject the CSRF token as a `<meta name="csrf-token">` tag into
|
|
76
|
+
* the HTML shell so client code can read it without a separate request.
|
|
77
|
+
*/
|
|
78
|
+
export function csrfMetaTag(token: string): string {
|
|
79
|
+
return `<meta name="csrf-token" content="${token.replace(/"/g, """)}" />`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFile, access } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
4
|
+
import type { AlabNapi } from "../types/napi.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handle `/_alabjs/image` requests using the Rust image optimiser (alab-napi).
|
|
8
|
+
*
|
|
9
|
+
* Node.js reads the source file from `public/` then passes the raw bytes to
|
|
10
|
+
* Rust — same pattern as snapbolt-cli. Rust decodes, resizes, and encodes to
|
|
11
|
+
* WebP (libwebp-sys with `native` feature, or pure-Rust fallback).
|
|
12
|
+
*
|
|
13
|
+
* Query params:
|
|
14
|
+
* src — path relative to the project's `public/` directory (required)
|
|
15
|
+
* w — target width in pixels (required)
|
|
16
|
+
* q — quality 1–100 (default: 80)
|
|
17
|
+
* fmt — "webp" (default) | "jpeg" | "png"
|
|
18
|
+
*
|
|
19
|
+
* Cache-Control is set to 1 year / immutable for optimised responses.
|
|
20
|
+
*/
|
|
21
|
+
export async function handleImageRequest(
|
|
22
|
+
req: IncomingMessage,
|
|
23
|
+
res: ServerResponse,
|
|
24
|
+
publicDir: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
27
|
+
const src = url.searchParams.get("src");
|
|
28
|
+
const wParam = url.searchParams.get("w");
|
|
29
|
+
const quality = Math.max(1, Math.min(100, parseInt(url.searchParams.get("q") ?? "80", 10)));
|
|
30
|
+
const fmt = url.searchParams.get("fmt") ?? "webp";
|
|
31
|
+
|
|
32
|
+
if (!src || !wParam) {
|
|
33
|
+
res.statusCode = 400;
|
|
34
|
+
res.end("[alabjs] Missing src or w parameter");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const width = parseInt(wParam, 10);
|
|
39
|
+
if (!Number.isFinite(width) || width < 1 || width > 4096) {
|
|
40
|
+
res.statusCode = 400;
|
|
41
|
+
res.end("[alabjs] Invalid width — must be 1–4096");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Prevent path traversal
|
|
46
|
+
const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
47
|
+
const filePath = resolve(publicDir, safeSrc);
|
|
48
|
+
if (!filePath.startsWith(publicDir)) {
|
|
49
|
+
res.statusCode = 403;
|
|
50
|
+
res.end("[alabjs] Forbidden");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
await access(filePath);
|
|
56
|
+
} catch {
|
|
57
|
+
res.statusCode = 404;
|
|
58
|
+
res.end("[alabjs] Image not found");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const input = await readFile(filePath);
|
|
63
|
+
|
|
64
|
+
// Load napi binding (built by `cargo build --release -p alab-napi`).
|
|
65
|
+
// Fall back to serving the raw file when the binary isn't available so that
|
|
66
|
+
// images still load during development without the Rust toolchain.
|
|
67
|
+
let napi: AlabNapi | null = null;
|
|
68
|
+
try {
|
|
69
|
+
const mod = await import("@alabjs/compiler") as { default?: AlabNapi } & AlabNapi;
|
|
70
|
+
napi = (mod.default ?? mod) as AlabNapi;
|
|
71
|
+
} catch {
|
|
72
|
+
// napi not built — will serve raw file below.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!napi) {
|
|
76
|
+
const ext = safeSrc.split(".").pop()?.toLowerCase() ?? "";
|
|
77
|
+
const mime =
|
|
78
|
+
ext === "jpg" || ext === "jpeg" ? "image/jpeg"
|
|
79
|
+
: ext === "png" ? "image/png"
|
|
80
|
+
: ext === "gif" ? "image/gif"
|
|
81
|
+
: ext === "webp" ? "image/webp"
|
|
82
|
+
: ext === "avif" ? "image/avif"
|
|
83
|
+
: "application/octet-stream";
|
|
84
|
+
res.statusCode = 200;
|
|
85
|
+
res.setHeader("content-type", mime);
|
|
86
|
+
res.setHeader("cache-control", "no-store");
|
|
87
|
+
res.setHeader("content-length", input.length);
|
|
88
|
+
res.end(input);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Pass raw bytes to Rust — decode + resize + encode on a blocking thread pool.
|
|
94
|
+
const optimised = await napi.optimizeImage(input, quality, width, undefined, fmt);
|
|
95
|
+
|
|
96
|
+
const mime =
|
|
97
|
+
fmt === "jpeg" || fmt === "jpg" ? "image/jpeg"
|
|
98
|
+
: fmt === "png" ? "image/png"
|
|
99
|
+
: "image/webp";
|
|
100
|
+
|
|
101
|
+
res.statusCode = 200;
|
|
102
|
+
res.setHeader("content-type", mime);
|
|
103
|
+
res.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
104
|
+
res.setHeader("content-length", optimised.length);
|
|
105
|
+
res.end(optimised);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
108
|
+
console.error(`[alabjs] image optimisation failed — src=${safeSrc} w=${width} fmt=${fmt}: ${msg}`);
|
|
109
|
+
res.statusCode = 500;
|
|
110
|
+
res.end("[alabjs] Image optimisation failed — check server logs");
|
|
111
|
+
}
|
|
112
|
+
}
|