alabjs 0.2.5 → 0.3.0-alpha.1
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/analytics/handler.d.ts +5 -1
- package/dist/analytics/handler.d.ts.map +1 -1
- package/dist/analytics/handler.js +14 -10
- package/dist/analytics/handler.js.map +1 -1
- package/dist/cli.js +7 -2
- package/dist/cli.js.map +1 -1
- package/dist/client/federation.d.ts +41 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +48 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/hooks.d.ts +9 -1
- package/dist/client/hooks.d.ts.map +1 -1
- package/dist/client/hooks.js +37 -4
- package/dist/client/hooks.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/offline-sw.js +142 -0
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +279 -40
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +78 -2
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/start.js +1 -1
- package/dist/commands/start.js.map +1 -1
- package/dist/components/Image.d.ts +0 -12
- package/dist/components/Image.d.ts.map +1 -1
- package/dist/components/Image.js +2 -29
- package/dist/components/Image.js.map +1 -1
- package/dist/components/ImageServer.d.ts +20 -0
- package/dist/components/ImageServer.d.ts.map +1 -0
- package/dist/components/ImageServer.js +37 -0
- package/dist/components/ImageServer.js.map +1 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/config.d.ts +66 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +77 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +251 -41
- package/dist/server/app.js.map +1 -1
- package/dist/server/cache.d.ts.map +1 -1
- package/dist/server/cache.js +26 -4
- package/dist/server/cache.js.map +1 -1
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/csrf.js +5 -0
- package/dist/server/csrf.js.map +1 -1
- package/dist/server/revalidate.d.ts.map +1 -1
- package/dist/server/revalidate.js +10 -3
- package/dist/server/revalidate.js.map +1 -1
- package/dist/ssr/html.d.ts +7 -0
- package/dist/ssr/html.d.ts.map +1 -1
- package/dist/ssr/html.js +24 -4
- package/dist/ssr/html.js.map +1 -1
- package/dist/ssr/ppr.d.ts.map +1 -1
- package/dist/ssr/ppr.js +2 -1
- package/dist/ssr/ppr.js.map +1 -1
- package/dist/ssr/render.d.ts +5 -0
- package/dist/ssr/render.d.ts.map +1 -1
- package/dist/ssr/render.js +2 -1
- package/dist/ssr/render.js.map +1 -1
- package/package.json +9 -4
- package/src/analytics/handler.ts +15 -10
- package/src/cli.ts +9 -2
- package/src/client/federation.ts +55 -0
- package/src/client/hooks.ts +42 -4
- package/src/client/index.ts +1 -0
- package/src/client/offline-sw.ts +7 -2
- package/src/commands/build.ts +335 -44
- package/src/commands/dev.ts +84 -2
- package/src/commands/start.ts +1 -1
- package/src/components/Image.tsx +2 -35
- package/src/components/ImageServer.ts +43 -0
- package/src/components/index.ts +1 -1
- package/src/config.ts +143 -0
- package/src/index.ts +2 -0
- package/src/server/app.ts +289 -35
- package/src/server/cache.ts +28 -4
- package/src/server/csrf.ts +5 -0
- package/src/server/revalidate.ts +14 -2
- package/src/ssr/html.ts +31 -3
- package/src/ssr/ppr.ts +2 -1
- package/src/ssr/render.ts +7 -0
- package/tsconfig.sw.json +18 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/server/cache.ts
CHANGED
|
@@ -28,7 +28,18 @@ interface CacheEntry {
|
|
|
28
28
|
/** Sentinel value returned when a cache key has no valid entry. */
|
|
29
29
|
const CACHE_MISS: unique symbol = Symbol("alab:cache_miss");
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Global in-process LRU cache. Shared across all server function calls.
|
|
33
|
+
*
|
|
34
|
+
* Max size is capped to prevent unbounded memory growth in long-running servers.
|
|
35
|
+
* When the cap is reached, the oldest entry (by insertion order) is evicted.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ Multi-tenant note: this cache is process-wide and shared across all
|
|
38
|
+
* requests. In multi-tenant deployments, cache keys MUST include a tenant
|
|
39
|
+
* identifier (e.g. `tenant:${tenantId}:posts`) to prevent data leakage
|
|
40
|
+
* between tenants.
|
|
41
|
+
*/
|
|
42
|
+
const _STORE_MAX = 2048;
|
|
32
43
|
const _store = new Map<string, CacheEntry>();
|
|
33
44
|
|
|
34
45
|
export { CACHE_MISS };
|
|
@@ -41,6 +52,9 @@ export function getCached(key: string): unknown | typeof CACHE_MISS {
|
|
|
41
52
|
_store.delete(key);
|
|
42
53
|
return CACHE_MISS;
|
|
43
54
|
}
|
|
55
|
+
// Move to end (LRU: mark as recently used)
|
|
56
|
+
_store.delete(key);
|
|
57
|
+
_store.set(key, entry);
|
|
44
58
|
return entry.data;
|
|
45
59
|
}
|
|
46
60
|
|
|
@@ -50,6 +64,10 @@ export function setCache(
|
|
|
50
64
|
data: unknown,
|
|
51
65
|
opts: { ttl: number; tags?: string[] },
|
|
52
66
|
): void {
|
|
67
|
+
// Evict oldest entry when at capacity
|
|
68
|
+
if (_store.size >= _STORE_MAX && !_store.has(key)) {
|
|
69
|
+
_store.delete(_store.keys().next().value!);
|
|
70
|
+
}
|
|
53
71
|
_store.set(key, {
|
|
54
72
|
data,
|
|
55
73
|
expires: Date.now() + opts.ttl * 1_000,
|
|
@@ -87,12 +105,15 @@ export function invalidateCacheKey(key: string): void {
|
|
|
87
105
|
interface PageCacheEntry {
|
|
88
106
|
html: string;
|
|
89
107
|
expires: number;
|
|
108
|
+
/** Original TTL in seconds — used to compute the stale-while-revalidate window. */
|
|
109
|
+
ttl: number;
|
|
90
110
|
/** Whether a background revalidation is already in flight. */
|
|
91
111
|
revalidating: boolean;
|
|
92
112
|
/** Tags for on-demand invalidation via `revalidateTag`. */
|
|
93
113
|
tags: string[];
|
|
94
114
|
}
|
|
95
115
|
|
|
116
|
+
const _PAGE_STORE_MAX = 1024;
|
|
96
117
|
const _pageStore = new Map<string, PageCacheEntry>();
|
|
97
118
|
|
|
98
119
|
/**
|
|
@@ -106,8 +127,8 @@ export function getCachedPage(pathname: string): { html: string; stale: boolean
|
|
|
106
127
|
// Still fresh
|
|
107
128
|
if (now <= entry.expires) return { html: entry.html, stale: false };
|
|
108
129
|
// Stale-while-revalidate: serve stale for up to 2× TTL, trigger background regen
|
|
109
|
-
const
|
|
110
|
-
if (now <= entry.expires +
|
|
130
|
+
const swrWindow = Math.max(entry.ttl * 2 * 1_000, 60_000);
|
|
131
|
+
if (now <= entry.expires + swrWindow) {
|
|
111
132
|
return { html: entry.html, stale: true };
|
|
112
133
|
}
|
|
113
134
|
_pageStore.delete(pathname);
|
|
@@ -116,7 +137,10 @@ export function getCachedPage(pathname: string): { html: string; stale: boolean
|
|
|
116
137
|
|
|
117
138
|
/** Store a rendered HTML page with a TTL (seconds). */
|
|
118
139
|
export function setCachedPage(pathname: string, html: string, ttl: number, tags: string[] = []): void {
|
|
119
|
-
_pageStore.
|
|
140
|
+
if (_pageStore.size >= _PAGE_STORE_MAX && !_pageStore.has(pathname)) {
|
|
141
|
+
_pageStore.delete(_pageStore.keys().next().value!);
|
|
142
|
+
}
|
|
143
|
+
_pageStore.set(pathname, { html, expires: Date.now() + ttl * 1_000, ttl, revalidating: false, tags });
|
|
120
144
|
}
|
|
121
145
|
|
|
122
146
|
/** Mark a page as currently being revalidated to prevent concurrent regen. */
|
package/src/server/csrf.ts
CHANGED
|
@@ -31,6 +31,11 @@ export function csrfMiddleware() {
|
|
|
31
31
|
const method = event.method.toUpperCase();
|
|
32
32
|
if (SAFE_METHODS.has(method)) return;
|
|
33
33
|
|
|
34
|
+
// Internal endpoints that use their own auth (Bearer token, no cookie session)
|
|
35
|
+
// don't need CSRF protection.
|
|
36
|
+
const path = (event.node.req.url ?? "").split("?")[0] ?? "";
|
|
37
|
+
if (path === "/_alabjs/revalidate" || path === "/_alabjs/vitals") return;
|
|
38
|
+
|
|
34
39
|
const cookieToken = getCookie(event, CSRF_COOKIE);
|
|
35
40
|
const headerToken = getHeader(event, CSRF_HEADER);
|
|
36
41
|
|
package/src/server/revalidate.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* ```
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { timingSafeEqual } from "node:crypto";
|
|
27
28
|
import { revalidatePath, revalidatePathPrefix, revalidateTag } from "./cache.js";
|
|
28
29
|
import { purgeCdnByTags } from "./cdn.js";
|
|
29
30
|
|
|
@@ -39,11 +40,22 @@ export interface RevalidateBody {
|
|
|
39
40
|
/** Returns `true` when the request is authorised to call the revalidate endpoint. */
|
|
40
41
|
export function checkRevalidateAuth(authorizationHeader: string | null | undefined): boolean {
|
|
41
42
|
const secret = process.env["ALAB_REVALIDATE_SECRET"];
|
|
42
|
-
if (!secret)
|
|
43
|
+
if (!secret) {
|
|
44
|
+
console.warn(
|
|
45
|
+
"[alabjs] WARNING: ALAB_REVALIDATE_SECRET is not set. " +
|
|
46
|
+
"The /_alabjs/revalidate endpoint accepts unauthenticated requests. " +
|
|
47
|
+
"Set this variable in production.",
|
|
48
|
+
);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
43
51
|
const provided = authorizationHeader?.startsWith("Bearer ")
|
|
44
52
|
? authorizationHeader.slice(7)
|
|
45
53
|
: null;
|
|
46
|
-
return
|
|
54
|
+
return (
|
|
55
|
+
provided !== null &&
|
|
56
|
+
provided.length === secret.length &&
|
|
57
|
+
timingSafeEqual(Buffer.from(provided), Buffer.from(secret))
|
|
58
|
+
);
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/**
|
package/src/ssr/html.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface HtmlShellOptions {
|
|
|
18
18
|
headExtra?: string | undefined;
|
|
19
19
|
/** Nonce for CSP inline scripts (optional). */
|
|
20
20
|
nonce?: string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Serialised `<script type="importmap">` JSON.
|
|
23
|
+
* Injected before any module scripts so the browser can resolve federated
|
|
24
|
+
* remote specifiers (e.g. `"RemoteApp/Button"`) via native ESM import maps.
|
|
25
|
+
* Generated by `buildImportMap()` from the project's `alabjs.config.ts`.
|
|
26
|
+
*/
|
|
27
|
+
importMapJson?: string | undefined;
|
|
21
28
|
/**
|
|
22
29
|
* Build ID for skew protection.
|
|
23
30
|
* Injected as `<meta name="alabjs-build-id">` so the client SPA router can
|
|
@@ -39,6 +46,7 @@ export function htmlShellBefore(opts: HtmlShellOptions): string {
|
|
|
39
46
|
loadingFile,
|
|
40
47
|
headExtra = "",
|
|
41
48
|
buildId,
|
|
49
|
+
importMapJson,
|
|
42
50
|
} = opts;
|
|
43
51
|
|
|
44
52
|
const titleTag = metadata.title
|
|
@@ -85,6 +93,7 @@ export function htmlShellBefore(opts: HtmlShellOptions): string {
|
|
|
85
93
|
${layoutsJson ? `<meta name="alabjs-layouts" content="${escAttr(layoutsJson)}" />` : ""}
|
|
86
94
|
${loadingFile ? `<meta name="alabjs-loading" content="${escAttr(loadingFile)}" />` : ""}
|
|
87
95
|
${buildId ? `<meta name="alabjs-build-id" content="${escAttr(buildId)}" />` : ""}
|
|
96
|
+
${importMapJson ? `<script type="importmap">${importMapJson}</script>` : ""}
|
|
88
97
|
<link rel="stylesheet" href="/app/globals.css" />
|
|
89
98
|
${headExtra}
|
|
90
99
|
</head>
|
|
@@ -95,7 +104,22 @@ export function htmlShellBefore(opts: HtmlShellOptions): string {
|
|
|
95
104
|
/** Build the closing HTML fragment — everything after the SSR content. */
|
|
96
105
|
export function htmlShellAfter(opts: { nonce?: string | undefined }): string {
|
|
97
106
|
const nonceAttr = opts.nonce ? ` nonce="${escAttr(opts.nonce)}"` : "";
|
|
107
|
+
// The Rust compiler (oxc_transformer::enable_all) always emits $RefreshReg$ /
|
|
108
|
+
// $RefreshSig$ calls into TSX files, even in production builds. These are
|
|
109
|
+
// React Fast Refresh globals that only exist when the dev preamble is injected.
|
|
110
|
+
// In production there is no preamble, so the calls throw ReferenceError which
|
|
111
|
+
// silently aborts module loading and prevents React from mounting.
|
|
112
|
+
//
|
|
113
|
+
// A classic (non-module) <script> executes synchronously during HTML parsing,
|
|
114
|
+
// before any <script type="module"> is evaluated (modules are always deferred).
|
|
115
|
+
// Defining no-op shims here guarantees they exist before any page chunk runs.
|
|
116
|
+
const refreshShim = `<script${nonceAttr}>` +
|
|
117
|
+
`if(typeof $RefreshReg$==="undefined"){` +
|
|
118
|
+
`window.$RefreshReg$=function(){};` +
|
|
119
|
+
`window.$RefreshSig$=function(){return function(x){return x};}` +
|
|
120
|
+
`}</script>`;
|
|
98
121
|
return `</div>
|
|
122
|
+
${refreshShim}
|
|
99
123
|
<script type="module" src="/@alabjs/client"${nonceAttr}></script>
|
|
100
124
|
</body>
|
|
101
125
|
</html>`;
|
|
@@ -136,14 +160,18 @@ export function injectIntoFullDocument(
|
|
|
136
160
|
const metaTags = buildAlabMetaTags(opts);
|
|
137
161
|
const nonceAttr = opts.nonce ? ` nonce="${escAttr(opts.nonce)}"` : "";
|
|
138
162
|
const clientScript = `<script type="module" src="/@alabjs/client"${nonceAttr}></script>`;
|
|
163
|
+
const importMapTag = opts.importMapJson
|
|
164
|
+
? `<script type="importmap">${opts.importMapJson}</script>\n `
|
|
165
|
+
: "";
|
|
139
166
|
|
|
140
167
|
let result = ssrHtml;
|
|
141
168
|
|
|
142
|
-
// Inject meta tags before </head> (case-insensitive).
|
|
169
|
+
// Inject import map + meta tags before </head> (case-insensitive).
|
|
170
|
+
// Import map must precede any <script type="module"> elements.
|
|
143
171
|
if (/<\/head>/i.test(result)) {
|
|
144
|
-
result = result.replace(/<\/head>/i, ` ${metaTags}\n </head>`);
|
|
172
|
+
result = result.replace(/<\/head>/i, ` ${importMapTag}${metaTags}\n </head>`);
|
|
145
173
|
} else {
|
|
146
|
-
result = metaTags
|
|
174
|
+
result = `${importMapTag}${metaTags}\n` + result;
|
|
147
175
|
}
|
|
148
176
|
|
|
149
177
|
// Inject client bootstrap script before </body>.
|
package/src/ssr/ppr.ts
CHANGED
|
@@ -158,7 +158,8 @@ export function findBuildLayoutFiles(routeFile: string, distDir: string): string
|
|
|
158
158
|
const layouts: string[] = [];
|
|
159
159
|
for (let i = 1; i <= parts.length; i++) {
|
|
160
160
|
const dir = parts.slice(0, i).join("/");
|
|
161
|
-
|
|
161
|
+
// esbuild compiles layout.tsx → layout.js in the dist/server tree.
|
|
162
|
+
const candidate = `${dir}/layout.js`;
|
|
162
163
|
if (existsSync(join(distDir, "server", candidate))) {
|
|
163
164
|
layouts.push(candidate);
|
|
164
165
|
}
|
package/src/ssr/render.ts
CHANGED
|
@@ -31,6 +31,11 @@ export interface RenderOptions {
|
|
|
31
31
|
nonce?: string;
|
|
32
32
|
/** Build ID for skew protection (see html.ts). */
|
|
33
33
|
buildId?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Serialised import map JSON (`{ imports: { ... } }`).
|
|
36
|
+
* Injected as `<script type="importmap">` when federation remotes are configured.
|
|
37
|
+
*/
|
|
38
|
+
importMapJson?: string;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -53,6 +58,7 @@ export function renderToResponse(res: ServerResponse, opts: RenderOptions): void
|
|
|
53
58
|
headExtra,
|
|
54
59
|
nonce,
|
|
55
60
|
buildId,
|
|
61
|
+
importMapJson,
|
|
56
62
|
} = opts;
|
|
57
63
|
|
|
58
64
|
const shellOpts: HtmlShellOptions = {
|
|
@@ -66,6 +72,7 @@ export function renderToResponse(res: ServerResponse, opts: RenderOptions): void
|
|
|
66
72
|
headExtra,
|
|
67
73
|
nonce,
|
|
68
74
|
buildId,
|
|
75
|
+
importMapJson,
|
|
69
76
|
};
|
|
70
77
|
|
|
71
78
|
const before = htmlShellBefore(shellOpts);
|
package/tsconfig.sw.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"isolatedModules": true,
|
|
9
|
+
"verbatimModuleSyntax": true,
|
|
10
|
+
"declaration": false,
|
|
11
|
+
"declarationMap": false,
|
|
12
|
+
"sourceMap": false,
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"outDir": "dist",
|
|
15
|
+
"skipLibCheck": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/client/offline-sw.ts"]
|
|
18
|
+
}
|