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.
Files changed (94) hide show
  1. package/dist/analytics/handler.d.ts +5 -1
  2. package/dist/analytics/handler.d.ts.map +1 -1
  3. package/dist/analytics/handler.js +14 -10
  4. package/dist/analytics/handler.js.map +1 -1
  5. package/dist/cli.js +7 -2
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client/federation.d.ts +41 -0
  8. package/dist/client/federation.d.ts.map +1 -0
  9. package/dist/client/federation.js +48 -0
  10. package/dist/client/federation.js.map +1 -0
  11. package/dist/client/hooks.d.ts +9 -1
  12. package/dist/client/hooks.d.ts.map +1 -1
  13. package/dist/client/hooks.js +37 -4
  14. package/dist/client/hooks.js.map +1 -1
  15. package/dist/client/index.d.ts +1 -0
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +1 -0
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/offline-sw.js +142 -0
  20. package/dist/commands/build.d.ts.map +1 -1
  21. package/dist/commands/build.js +279 -40
  22. package/dist/commands/build.js.map +1 -1
  23. package/dist/commands/dev.d.ts.map +1 -1
  24. package/dist/commands/dev.js +78 -2
  25. package/dist/commands/dev.js.map +1 -1
  26. package/dist/commands/start.js +1 -1
  27. package/dist/commands/start.js.map +1 -1
  28. package/dist/components/Image.d.ts +0 -12
  29. package/dist/components/Image.d.ts.map +1 -1
  30. package/dist/components/Image.js +2 -29
  31. package/dist/components/Image.js.map +1 -1
  32. package/dist/components/ImageServer.d.ts +20 -0
  33. package/dist/components/ImageServer.d.ts.map +1 -0
  34. package/dist/components/ImageServer.js +37 -0
  35. package/dist/components/ImageServer.js.map +1 -0
  36. package/dist/components/index.d.ts +1 -1
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/index.js +1 -1
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/config.d.ts +66 -0
  41. package/dist/config.d.ts.map +1 -0
  42. package/dist/config.js +77 -0
  43. package/dist/config.js.map +1 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/server/app.d.ts.map +1 -1
  49. package/dist/server/app.js +251 -41
  50. package/dist/server/app.js.map +1 -1
  51. package/dist/server/cache.d.ts.map +1 -1
  52. package/dist/server/cache.js +26 -4
  53. package/dist/server/cache.js.map +1 -1
  54. package/dist/server/csrf.d.ts.map +1 -1
  55. package/dist/server/csrf.js +5 -0
  56. package/dist/server/csrf.js.map +1 -1
  57. package/dist/server/revalidate.d.ts.map +1 -1
  58. package/dist/server/revalidate.js +10 -3
  59. package/dist/server/revalidate.js.map +1 -1
  60. package/dist/ssr/html.d.ts +7 -0
  61. package/dist/ssr/html.d.ts.map +1 -1
  62. package/dist/ssr/html.js +24 -4
  63. package/dist/ssr/html.js.map +1 -1
  64. package/dist/ssr/ppr.d.ts.map +1 -1
  65. package/dist/ssr/ppr.js +2 -1
  66. package/dist/ssr/ppr.js.map +1 -1
  67. package/dist/ssr/render.d.ts +5 -0
  68. package/dist/ssr/render.d.ts.map +1 -1
  69. package/dist/ssr/render.js +2 -1
  70. package/dist/ssr/render.js.map +1 -1
  71. package/package.json +9 -4
  72. package/src/analytics/handler.ts +15 -10
  73. package/src/cli.ts +9 -2
  74. package/src/client/federation.ts +55 -0
  75. package/src/client/hooks.ts +42 -4
  76. package/src/client/index.ts +1 -0
  77. package/src/client/offline-sw.ts +7 -2
  78. package/src/commands/build.ts +335 -44
  79. package/src/commands/dev.ts +84 -2
  80. package/src/commands/start.ts +1 -1
  81. package/src/components/Image.tsx +2 -35
  82. package/src/components/ImageServer.ts +43 -0
  83. package/src/components/index.ts +1 -1
  84. package/src/config.ts +143 -0
  85. package/src/index.ts +2 -0
  86. package/src/server/app.ts +289 -35
  87. package/src/server/cache.ts +28 -4
  88. package/src/server/csrf.ts +5 -0
  89. package/src/server/revalidate.ts +14 -2
  90. package/src/ssr/html.ts +31 -3
  91. package/src/ssr/ppr.ts +2 -1
  92. package/src/ssr/render.ts +7 -0
  93. package/tsconfig.sw.json +18 -0
  94. package/tsconfig.tsbuildinfo +1 -1
@@ -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
- /** Global in-process LRU-style cache. Shared across all server function calls. */
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 ttl = entry.expires - (entry.expires - now); // rough TTL from stored data
110
- if (now <= entry.expires + Math.max(ttl * 2, 60_000)) {
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.set(pathname, { html, expires: Date.now() + ttl * 1_000, revalidating: false, tags });
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. */
@@ -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
 
@@ -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) return true; // no secret configured — open endpoint
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 provided === secret;
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 + "\n" + result;
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
- const candidate = `${dir}/layout.tsx`;
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);
@@ -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
+ }