alabjs 0.2.6 → 0.4.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/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 +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client/hooks.d.ts +9 -1
- package/dist/client/hooks.d.ts.map +1 -1
- package/dist/client/hooks.js +26 -2
- package/dist/client/hooks.js.map +1 -1
- package/dist/commands/build.d.ts.map +1 -1
- package/dist/commands/build.js +279 -65
- package/dist/commands/build.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +225 -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/Link.d.ts +16 -0
- package/dist/components/Link.d.ts.map +1 -1
- package/dist/components/Link.js +10 -0
- package/dist/components/Link.js.map +1 -1
- package/dist/components/index.d.ts +3 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +2 -2
- package/dist/components/index.js.map +1 -1
- package/dist/live/broadcaster.d.ts +64 -0
- package/dist/live/broadcaster.d.ts.map +1 -0
- package/dist/live/broadcaster.js +78 -0
- package/dist/live/broadcaster.js.map +1 -0
- package/dist/live/registry.d.ts +34 -0
- package/dist/live/registry.d.ts.map +1 -0
- package/dist/live/registry.js +33 -0
- package/dist/live/registry.js.map +1 -0
- package/dist/live/renderer.d.ts +22 -0
- package/dist/live/renderer.d.ts.map +1 -0
- package/dist/live/renderer.js +45 -0
- package/dist/live/renderer.js.map +1 -0
- package/dist/router/manifest.d.ts +1 -1
- package/dist/router/manifest.d.ts.map +1 -1
- package/dist/server/app.d.ts.map +1 -1
- package/dist/server/app.js +339 -43
- package/dist/server/app.js.map +1 -1
- package/dist/server/cache.d.ts.map +1 -1
- package/dist/server/cache.js +23 -1
- 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/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/revalidate.d.ts.map +1 -1
- package/dist/server/revalidate.js +3 -0
- package/dist/server/revalidate.js.map +1 -1
- package/dist/ssr/html.d.ts.map +1 -1
- package/dist/ssr/html.js +15 -0
- 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/package.json +8 -3
- package/src/analytics/handler.ts +15 -10
- package/src/cli.ts +3 -1
- package/src/client/hooks.ts +30 -2
- package/src/commands/build.ts +316 -69
- package/src/commands/dev.ts +246 -3
- package/src/commands/start.ts +1 -1
- package/src/components/Image.tsx +2 -35
- package/src/components/ImageServer.ts +43 -0
- package/src/components/Link.tsx +20 -0
- package/src/components/index.ts +3 -3
- package/src/live/broadcaster.ts +83 -0
- package/src/live/registry.ts +56 -0
- package/src/live/renderer.ts +54 -0
- package/src/router/manifest.ts +1 -1
- package/src/server/app.ts +369 -44
- package/src/server/cache.ts +23 -1
- package/src/server/csrf.ts +5 -0
- package/src/server/index.ts +1 -0
- package/src/server/revalidate.ts +3 -0
- package/src/ssr/html.ts +15 -0
- package/src/ssr/ppr.ts +2 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/server/app.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createApp as createH3App, createRouter, defineEventHandler, getQuery, readBody } from "h3";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { resolve, dirname, join, extname } from "node:path";
|
|
4
|
-
import { existsSync, createReadStream, statSync, readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, createReadStream, statSync, readFileSync, readdirSync } from "node:fs";
|
|
5
5
|
import { createGzip, createBrotliCompress } from "node:zlib";
|
|
6
6
|
import { toNodeListener } from "h3";
|
|
7
7
|
import type { RouteManifest } from "../router/manifest.js";
|
|
@@ -18,21 +18,93 @@ import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr
|
|
|
18
18
|
import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
|
|
19
19
|
import { buildImportMap } from "../config.js";
|
|
20
20
|
import type { FederationConfig } from "../config.js";
|
|
21
|
+
import { registerLiveComponent } from "../live/registry.js";
|
|
22
|
+
import { renderLiveFragment, hashFragment } from "../live/renderer.js";
|
|
23
|
+
import { subscribeToTag } from "../live/broadcaster.js";
|
|
24
|
+
|
|
25
|
+
/** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
|
|
26
|
+
function findDistServerFiles(distDir: string): string[] {
|
|
27
|
+
const serverDir = join(distDir, "server");
|
|
28
|
+
const results: string[] = [];
|
|
29
|
+
function walk(dir: string) {
|
|
30
|
+
try {
|
|
31
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
const fullPath = join(dir, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
walk(fullPath);
|
|
35
|
+
} else if (entry.isFile() && entry.name.endsWith(".server.js")) {
|
|
36
|
+
results.push(fullPath);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch { /* not readable */ }
|
|
40
|
+
}
|
|
41
|
+
walk(serverDir);
|
|
42
|
+
return results;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Walk dist/server recursively and register all *.live.js modules. */
|
|
46
|
+
async function registerAllLiveComponents(distDir: string): Promise<void> {
|
|
47
|
+
const serverDir = join(distDir, "server");
|
|
48
|
+
const files: string[] = [];
|
|
49
|
+
|
|
50
|
+
function walk(dir: string) {
|
|
51
|
+
try {
|
|
52
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
53
|
+
const fullPath = join(dir, entry.name);
|
|
54
|
+
if (entry.isDirectory()) {
|
|
55
|
+
walk(fullPath);
|
|
56
|
+
} else if (entry.isFile() && entry.name.endsWith(".live.js")) {
|
|
57
|
+
files.push(fullPath);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* not readable */ }
|
|
61
|
+
}
|
|
62
|
+
walk(serverDir);
|
|
63
|
+
|
|
64
|
+
for (const filePath of files) {
|
|
65
|
+
try {
|
|
66
|
+
const mod = await import(filePath) as {
|
|
67
|
+
liveId?: string;
|
|
68
|
+
liveInterval?: number;
|
|
69
|
+
liveTags?: (props: unknown) => string[];
|
|
70
|
+
};
|
|
71
|
+
// liveId is stamped by the Vite plugin (hash of source path).
|
|
72
|
+
// Fall back to a hash of the file path itself.
|
|
73
|
+
const id = mod.liveId ?? filePath.replace(/[^a-z0-9]/gi, "").slice(-16);
|
|
74
|
+
registerLiveComponent({
|
|
75
|
+
id,
|
|
76
|
+
modulePath: filePath,
|
|
77
|
+
...(typeof mod.liveInterval === "number" ? { liveInterval: mod.liveInterval } : {}),
|
|
78
|
+
...(typeof mod.liveTags === "function" ? { liveTags: mod.liveTags } : {}),
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn(`[alabjs] live: failed to register ${filePath}:`, err);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
21
85
|
|
|
22
86
|
/**
|
|
23
87
|
* Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
|
|
24
88
|
* Checks the compiled dist directory for the existence of each layout.
|
|
25
89
|
*/
|
|
90
|
+
/** Convert a TypeScript source path to its compiled .js equivalent. */
|
|
91
|
+
function toJsPath(p: string): string {
|
|
92
|
+
return p.replace(/\.(tsx?)$/, ".js");
|
|
93
|
+
}
|
|
94
|
+
|
|
26
95
|
function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
27
96
|
// routeFile is like "app/users/[id]/page.tsx"
|
|
97
|
+
// Returns source paths (e.g. "app/layout.tsx") so the client bootstrap can look
|
|
98
|
+
// them up in LAYOUT_MODS by their original source key. The import() call in
|
|
99
|
+
// app.ts uses toJsPath() to convert back to the compiled .js path.
|
|
28
100
|
const pageDir = dirname(routeFile);
|
|
29
101
|
const parts = pageDir.split("/");
|
|
30
102
|
const layouts: string[] = [];
|
|
31
103
|
for (let i = 1; i <= parts.length; i++) {
|
|
32
104
|
const dir = parts.slice(0, i).join("/");
|
|
33
|
-
const
|
|
34
|
-
if (existsSync(join(distDir, "server",
|
|
35
|
-
layouts.push(
|
|
105
|
+
const compiledPath = `${dir}/layout.js`;
|
|
106
|
+
if (existsSync(join(distDir, "server", compiledPath))) {
|
|
107
|
+
layouts.push(`${dir}/layout.tsx`);
|
|
36
108
|
}
|
|
37
109
|
}
|
|
38
110
|
return layouts;
|
|
@@ -44,7 +116,7 @@ function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
|
44
116
|
function findProdErrorFile(routeFile: string, distDir: string): string | null {
|
|
45
117
|
let dir = dirname(routeFile);
|
|
46
118
|
while (dir.length > 0 && dir !== ".") {
|
|
47
|
-
const candidate = `${dir}/error.
|
|
119
|
+
const candidate = `${dir}/error.js`;
|
|
48
120
|
if (existsSync(join(distDir, "server", candidate))) return candidate;
|
|
49
121
|
const parent = dirname(dir);
|
|
50
122
|
if (parent === dir) break;
|
|
@@ -56,8 +128,8 @@ function findProdErrorFile(routeFile: string, distDir: string): string | null {
|
|
|
56
128
|
function findProdLoadingFile(routeFile: string, distDir: string): string | null {
|
|
57
129
|
let dir = dirname(routeFile);
|
|
58
130
|
while (dir.length > 0 && dir !== ".") {
|
|
59
|
-
const
|
|
60
|
-
if (existsSync(join(distDir, "server",
|
|
131
|
+
const compiled = `${dir}/loading.js`;
|
|
132
|
+
if (existsSync(join(distDir, "server", compiled))) return `${dir}/loading.tsx`;
|
|
61
133
|
const parent = dirname(dir);
|
|
62
134
|
if (parent === dir) break;
|
|
63
135
|
dir = parent;
|
|
@@ -89,6 +161,21 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
89
161
|
buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
|
|
90
162
|
} catch { /* no BUILD_ID file — skew protection disabled */ }
|
|
91
163
|
|
|
164
|
+
// Resolve the compiled client entry file path by reading the Vite manifest.
|
|
165
|
+
// /@alabjs/client is a virtual module at build time; at runtime the server
|
|
166
|
+
// must redirect requests for it to the hashed asset file.
|
|
167
|
+
// The manifest key is a relative path ending in "@alabjs/client" (not "/@alabjs/client").
|
|
168
|
+
let clientEntryPath = "";
|
|
169
|
+
try {
|
|
170
|
+
const viteManifest = JSON.parse(
|
|
171
|
+
readFileSync(resolve(distDir, "client/.vite/manifest.json"), "utf8"),
|
|
172
|
+
) as Record<string, { file?: string; isEntry?: boolean; src?: string }>;
|
|
173
|
+
const entry = Object.values(viteManifest).find(
|
|
174
|
+
(e) => e.isEntry && e.src?.endsWith("@alabjs/client"),
|
|
175
|
+
);
|
|
176
|
+
if (entry?.file) clientEntryPath = "/" + entry.file;
|
|
177
|
+
} catch { /* manifest absent — /@alabjs/client will 404 */ }
|
|
178
|
+
|
|
92
179
|
// Load federation config written by `alab build`. Used to:
|
|
93
180
|
// 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
|
|
94
181
|
// 2. Inject `<script type="importmap">` into every page (host → remote routing)
|
|
@@ -103,6 +190,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
103
190
|
// Absolute path to the PPR shell cache directory.
|
|
104
191
|
const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
|
|
105
192
|
|
|
193
|
+
// Register live components at startup (non-blocking — failures are warned, not thrown).
|
|
194
|
+
registerAllLiveComponents(distDir).catch((err) => {
|
|
195
|
+
console.warn("[alabjs] live: component registration failed:", err);
|
|
196
|
+
});
|
|
197
|
+
|
|
106
198
|
// ─── Global middleware ───────────────────────────────────────────────────────
|
|
107
199
|
app.use(
|
|
108
200
|
defineEventHandler((event) => {
|
|
@@ -112,6 +204,40 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
112
204
|
res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
|
|
113
205
|
res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
|
|
114
206
|
res.setHeader("x-permitted-cross-domain-policies", "none");
|
|
207
|
+
// NOTE: 'unsafe-inline' is required by React's inline event delegation and
|
|
208
|
+
// Tailwind's runtime style injection. 'unsafe-eval' is required by some
|
|
209
|
+
// React dev-mode internals and dynamic import().
|
|
210
|
+
//
|
|
211
|
+
// ⚠️ Security implication: these directives weaken XSS protection.
|
|
212
|
+
// In production, override this header in your middleware with a nonce-based
|
|
213
|
+
// CSP: `script-src 'self' 'nonce-<random>'` and inject the same nonce into
|
|
214
|
+
// every <script> tag via renderToResponse's headExtra option. The CSRF
|
|
215
|
+
// double-submit pattern relies on XSS prevention — using 'unsafe-inline'
|
|
216
|
+
// without a nonce makes the CSRF token readable by injected scripts.
|
|
217
|
+
//
|
|
218
|
+
// NOTE: 'upgrade-insecure-requests' is intentionally omitted from the
|
|
219
|
+
// default CSP. That directive tells browsers to silently rewrite http://
|
|
220
|
+
// sub-resource URLs to https://, which breaks any app served over plain
|
|
221
|
+
// HTTP (local dev, internal tooling, HTTP-only staging servers) because
|
|
222
|
+
// the browser will refuse to load scripts and stylesheets redirected from
|
|
223
|
+
// the virtual /@alabjs/client path.
|
|
224
|
+
//
|
|
225
|
+
// Add it in your own middleware when you are certain every environment
|
|
226
|
+
// runs behind HTTPS:
|
|
227
|
+
//
|
|
228
|
+
// // middleware.ts
|
|
229
|
+
// export async function middleware(req: Request) {
|
|
230
|
+
// const isHttps = req.headers.get("x-forwarded-proto") === "https"
|
|
231
|
+
// || new URL(req.url).protocol === "https:";
|
|
232
|
+
// if (isHttps) {
|
|
233
|
+
// // Append the directive to whatever CSP the framework already set.
|
|
234
|
+
// const existing = res.headers.get("content-security-policy") ?? "";
|
|
235
|
+
// res.headers.set(
|
|
236
|
+
// "content-security-policy",
|
|
237
|
+
// existing + "; upgrade-insecure-requests",
|
|
238
|
+
// );
|
|
239
|
+
// }
|
|
240
|
+
// }
|
|
115
241
|
res.setHeader(
|
|
116
242
|
"content-security-policy",
|
|
117
243
|
[
|
|
@@ -126,7 +252,6 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
126
252
|
"base-uri 'self'",
|
|
127
253
|
"form-action 'self'",
|
|
128
254
|
"frame-ancestors 'self'",
|
|
129
|
-
"upgrade-insecure-requests",
|
|
130
255
|
].join("; "),
|
|
131
256
|
);
|
|
132
257
|
// HSTS — only meaningful over HTTPS; set in production only.
|
|
@@ -134,12 +259,18 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
134
259
|
}),
|
|
135
260
|
);
|
|
136
261
|
|
|
137
|
-
// ─── User middleware (middleware.ts compiled to dist/server/middleware.
|
|
138
|
-
const middlewareModulePath = `${distDir}/server/middleware.
|
|
262
|
+
// ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
|
|
263
|
+
const middlewareModulePath = `${distDir}/server/middleware.js`;
|
|
139
264
|
if (existsSync(middlewareModulePath)) {
|
|
265
|
+
// Cache the module after first import — avoids redundant dynamic import()
|
|
266
|
+
// overhead on every request (each import() call re-resolves the module graph).
|
|
267
|
+
let _middlewareCache: MiddlewareModule | null = null;
|
|
140
268
|
app.use(
|
|
141
269
|
defineEventHandler(async (event) => {
|
|
142
|
-
|
|
270
|
+
if (!_middlewareCache) {
|
|
271
|
+
_middlewareCache = await import(middlewareModulePath) as MiddlewareModule;
|
|
272
|
+
}
|
|
273
|
+
const mod = _middlewareCache;
|
|
143
274
|
if (typeof mod.middleware !== "function") return;
|
|
144
275
|
const req = event.node.req;
|
|
145
276
|
const res = event.node.res;
|
|
@@ -193,7 +324,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
193
324
|
};
|
|
194
325
|
|
|
195
326
|
app.use(
|
|
196
|
-
defineEventHandler((event) => {
|
|
327
|
+
defineEventHandler(async (event) => {
|
|
197
328
|
const req = event.node.req;
|
|
198
329
|
const res = event.node.res;
|
|
199
330
|
if (req.method !== "GET" && req.method !== "HEAD") return;
|
|
@@ -204,22 +335,42 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
204
335
|
try { relPath = decodeURIComponent(rawPath); } catch { return; }
|
|
205
336
|
if (relPath.includes("..")) return;
|
|
206
337
|
|
|
207
|
-
const ext = extname(relPath).toLowerCase();
|
|
208
|
-
const contentType = MIME_TYPES[ext];
|
|
209
|
-
if (!contentType) return; // skip extensionless paths (page routes)
|
|
210
|
-
|
|
211
338
|
const acceptEncoding = (req.headers["accept-encoding"] ?? "") as string;
|
|
212
339
|
const useBrotli = acceptEncoding.includes("br");
|
|
213
340
|
const useGzip = !useBrotli && acceptEncoding.includes("gzip");
|
|
214
341
|
|
|
215
|
-
|
|
342
|
+
// Virtual client entry — redirect to the hashed asset file resolved at startup.
|
|
343
|
+
// A 302 redirect (not direct serve) is critical: relative imports in the bundle
|
|
344
|
+
// (e.g. "./components-HASH.js") must resolve against the real asset URL
|
|
345
|
+
// (/assets/client-HASH.js), not the virtual path (/@alabjs/client).
|
|
346
|
+
if (relPath === "/@alabjs/client") {
|
|
347
|
+
if (clientEntryPath) {
|
|
348
|
+
res.writeHead(302, { Location: clientEntryPath });
|
|
349
|
+
} else {
|
|
350
|
+
res.statusCode = 404;
|
|
351
|
+
}
|
|
352
|
+
res.end();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const ext = extname(relPath).toLowerCase();
|
|
357
|
+
const contentType = MIME_TYPES[ext];
|
|
358
|
+
if (!contentType) return; // skip extensionless paths (page routes)
|
|
359
|
+
|
|
360
|
+
/** Stream a file with optional brotli/gzip compression and ETag 304 support.
|
|
361
|
+
*
|
|
362
|
+
* Returns a Promise that resolves when the response is fully sent. The
|
|
363
|
+
* h3 handler awaits this promise so h3 knows the response is complete
|
|
364
|
+
* before considering the next middleware. This avoids h3 passing the
|
|
365
|
+
* request to the router (which would 404) while the async pipe is running.
|
|
366
|
+
*/
|
|
216
367
|
function serveFile(
|
|
217
368
|
filePath: string,
|
|
218
369
|
fileSize: number,
|
|
219
370
|
mtimeMs: number,
|
|
220
371
|
cacheControl: string,
|
|
221
372
|
mime: string,
|
|
222
|
-
):
|
|
373
|
+
): Promise<void> {
|
|
223
374
|
// ETag from file size + mtime — both already known from the caller's stat().
|
|
224
375
|
const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
|
|
225
376
|
res.setHeader("etag", etag);
|
|
@@ -228,26 +379,29 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
228
379
|
if (req.headers["if-none-match"] === etag) {
|
|
229
380
|
res.statusCode = 304;
|
|
230
381
|
res.end();
|
|
231
|
-
return
|
|
382
|
+
return Promise.resolve();
|
|
232
383
|
}
|
|
233
384
|
|
|
234
385
|
res.setHeader("content-type", mime);
|
|
235
386
|
res.setHeader("cache-control", cacheControl);
|
|
236
387
|
|
|
237
|
-
if (req.method === "HEAD") { res.end(); return
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
res.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
388
|
+
if (req.method === "HEAD") { res.end(); return Promise.resolve(); }
|
|
389
|
+
|
|
390
|
+
return new Promise<void>((resolve, reject) => {
|
|
391
|
+
const fileStream = createReadStream(filePath);
|
|
392
|
+
res.on("finish", resolve);
|
|
393
|
+
res.on("error", reject);
|
|
394
|
+
if (useBrotli) {
|
|
395
|
+
res.setHeader("content-encoding", "br");
|
|
396
|
+
fileStream.pipe(createBrotliCompress()).pipe(res);
|
|
397
|
+
} else if (useGzip) {
|
|
398
|
+
res.setHeader("content-encoding", "gzip");
|
|
399
|
+
fileStream.pipe(createGzip()).pipe(res);
|
|
400
|
+
} else {
|
|
401
|
+
res.setHeader("content-length", fileSize);
|
|
402
|
+
fileStream.pipe(res);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
251
405
|
}
|
|
252
406
|
|
|
253
407
|
// 1. Built client assets (JS chunks, CSS, source maps)
|
|
@@ -256,13 +410,14 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
256
410
|
const stat = statSync(clientCandidate);
|
|
257
411
|
if (stat.isFile()) {
|
|
258
412
|
const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
|
|
259
|
-
|
|
413
|
+
await serveFile(
|
|
260
414
|
clientCandidate,
|
|
261
415
|
stat.size,
|
|
262
416
|
stat.mtimeMs,
|
|
263
417
|
isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
264
418
|
contentType,
|
|
265
419
|
);
|
|
420
|
+
return;
|
|
266
421
|
}
|
|
267
422
|
}
|
|
268
423
|
|
|
@@ -271,10 +426,10 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
271
426
|
if (existsSync(publicCandidate)) {
|
|
272
427
|
const stat = statSync(publicCandidate);
|
|
273
428
|
if (stat.isFile()) {
|
|
274
|
-
|
|
429
|
+
await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
|
|
430
|
+
return;
|
|
275
431
|
}
|
|
276
432
|
}
|
|
277
|
-
return undefined;
|
|
278
433
|
}),
|
|
279
434
|
);
|
|
280
435
|
|
|
@@ -346,6 +501,173 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
346
501
|
}),
|
|
347
502
|
);
|
|
348
503
|
|
|
504
|
+
// ─── Live component SSE endpoint ────────────────────────────────────────────
|
|
505
|
+
// GET /_alabjs/live/:id?props=<base64-json>
|
|
506
|
+
//
|
|
507
|
+
// Opens a persistent SSE stream for a live component. On connect:
|
|
508
|
+
// 1. Renders the component immediately and sends the first fragment.
|
|
509
|
+
// 2. Sets up an interval (if liveInterval is set) to re-render and push.
|
|
510
|
+
// 3. Subscribes to tag broadcasts (if liveTags is set).
|
|
511
|
+
// 4. On client disconnect: clears interval + unsubscribes tags.
|
|
512
|
+
router.get(
|
|
513
|
+
"/_alabjs/live/:id",
|
|
514
|
+
defineEventHandler(async (event) => {
|
|
515
|
+
const req = event.node.req;
|
|
516
|
+
const res = event.node.res;
|
|
517
|
+
|
|
518
|
+
const id = (event.context.params?.["id"] ?? "") as string;
|
|
519
|
+
const { getLiveComponent } = await import("../live/registry.js");
|
|
520
|
+
const entry = getLiveComponent(id);
|
|
521
|
+
|
|
522
|
+
if (!entry) {
|
|
523
|
+
res.statusCode = 404;
|
|
524
|
+
res.setHeader("content-type", "application/json");
|
|
525
|
+
res.end(JSON.stringify({ error: `[alabjs] live component not found: ${id}` }));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Parse props from base64-encoded JSON query param.
|
|
530
|
+
let props: unknown = {};
|
|
531
|
+
try {
|
|
532
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
533
|
+
const raw = url.searchParams.get("props");
|
|
534
|
+
if (raw) props = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
535
|
+
} catch { /* malformed props — use empty object */ }
|
|
536
|
+
|
|
537
|
+
// SSE headers.
|
|
538
|
+
res.statusCode = 200;
|
|
539
|
+
res.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
540
|
+
res.setHeader("cache-control", "no-cache, no-transform");
|
|
541
|
+
res.setHeader("connection", "keep-alive");
|
|
542
|
+
res.setHeader("x-accel-buffering", "no"); // disable nginx buffering
|
|
543
|
+
|
|
544
|
+
let lastHash = "";
|
|
545
|
+
let closed = false;
|
|
546
|
+
|
|
547
|
+
async function pushFragment(): Promise<void> {
|
|
548
|
+
if (closed) return;
|
|
549
|
+
try {
|
|
550
|
+
const html = await renderLiveFragment(entry!.modulePath, props);
|
|
551
|
+
const hash = hashFragment(html);
|
|
552
|
+
if (hash === lastHash) return; // no-op — output unchanged
|
|
553
|
+
lastHash = hash;
|
|
554
|
+
res.write(`data: ${html}\n\n`);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
console.error(`[alabjs] live render error (${id}):`, err);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Send initial fragment immediately.
|
|
561
|
+
await pushFragment();
|
|
562
|
+
|
|
563
|
+
// Interval-based polling.
|
|
564
|
+
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
565
|
+
if (entry.liveInterval && entry.liveInterval > 0) {
|
|
566
|
+
intervalHandle = setInterval(() => { void pushFragment(); }, entry.liveInterval);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Tag-based subscriptions.
|
|
570
|
+
const unsubFns: Array<() => void> = [];
|
|
571
|
+
if (entry.liveTags) {
|
|
572
|
+
const tags = entry.liveTags(props);
|
|
573
|
+
for (const tag of tags) {
|
|
574
|
+
unsubFns.push(subscribeToTag(tag, () => { void pushFragment(); }));
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Cleanup on disconnect.
|
|
579
|
+
req.on("close", () => {
|
|
580
|
+
closed = true;
|
|
581
|
+
if (intervalHandle) clearInterval(intervalHandle);
|
|
582
|
+
for (const unsub of unsubFns) unsub();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Return null so h3 does not try to end the response — SSE keeps it open.
|
|
586
|
+
return null;
|
|
587
|
+
}),
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
// ─── Server function endpoints ──────────────────────────────────────────────
|
|
591
|
+
// GET /_alabjs/data/:fn — used by useServerData (query params as input)
|
|
592
|
+
// POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
|
|
593
|
+
//
|
|
594
|
+
// Both scan all *.server.js files in dist/server for the named export.
|
|
595
|
+
// Module results are NOT cached — the module cache is Node's own require cache.
|
|
596
|
+
|
|
597
|
+
async function callServerFn(
|
|
598
|
+
fnName: string,
|
|
599
|
+
ctx: { params: Record<string, string>; query: Record<string, string>; headers: Record<string, string | string[] | undefined>; method: string; url: string },
|
|
600
|
+
input: unknown,
|
|
601
|
+
res: import("node:http").ServerResponse,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
const serverFiles = findDistServerFiles(distDir);
|
|
604
|
+
for (const file of serverFiles) {
|
|
605
|
+
const mod = await import(file) as Record<string, unknown>;
|
|
606
|
+
if (typeof mod[fnName] === "function") {
|
|
607
|
+
try {
|
|
608
|
+
const result = await (mod[fnName] as (c: unknown, i: unknown) => Promise<unknown>)(ctx, input);
|
|
609
|
+
res.statusCode = 200;
|
|
610
|
+
res.setHeader("content-type", "application/json");
|
|
611
|
+
res.end(JSON.stringify(result));
|
|
612
|
+
} catch (err) {
|
|
613
|
+
const zodError = (err as Record<string, unknown>)?.["zodError"];
|
|
614
|
+
if (zodError) {
|
|
615
|
+
res.statusCode = 422;
|
|
616
|
+
res.setHeader("content-type", "application/json");
|
|
617
|
+
res.end(JSON.stringify({ zodError }));
|
|
618
|
+
} else {
|
|
619
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
620
|
+
console.error(`[alabjs] server fn "${fnName}" threw:`, err);
|
|
621
|
+
res.statusCode = 500;
|
|
622
|
+
res.setHeader("content-type", "application/json");
|
|
623
|
+
res.end(JSON.stringify({ error: msg }));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
res.statusCode = 404;
|
|
630
|
+
res.setHeader("content-type", "application/json");
|
|
631
|
+
res.end(JSON.stringify({ error: `[alabjs] server function not found: ${fnName}` }));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
router.get(
|
|
635
|
+
"/_alabjs/data/:fn",
|
|
636
|
+
defineEventHandler(async (event) => {
|
|
637
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
638
|
+
const req = event.node.req;
|
|
639
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
640
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
641
|
+
const ctx = {
|
|
642
|
+
params: query,
|
|
643
|
+
query,
|
|
644
|
+
headers: req.headers as Record<string, string>,
|
|
645
|
+
method: "GET",
|
|
646
|
+
url: req.url ?? "/",
|
|
647
|
+
};
|
|
648
|
+
await callServerFn(fnName, ctx, Object.keys(query).length ? query : undefined, event.node.res);
|
|
649
|
+
}),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
router.post(
|
|
653
|
+
"/_alabjs/fn/:fn",
|
|
654
|
+
defineEventHandler(async (event) => {
|
|
655
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
656
|
+
const req = event.node.req;
|
|
657
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
658
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
659
|
+
const body = await readBody(event);
|
|
660
|
+
const ctx = {
|
|
661
|
+
params: query,
|
|
662
|
+
query,
|
|
663
|
+
headers: req.headers as Record<string, string>,
|
|
664
|
+
method: "POST",
|
|
665
|
+
url: req.url ?? "/",
|
|
666
|
+
};
|
|
667
|
+
await callServerFn(fnName, ctx, body, event.node.res);
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
|
|
349
671
|
// Auto sitemap.xml from route manifest
|
|
350
672
|
router.get(
|
|
351
673
|
"/sitemap.xml",
|
|
@@ -365,7 +687,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
365
687
|
if (route.kind !== "api") continue;
|
|
366
688
|
|
|
367
689
|
const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
|
|
368
|
-
const apiModulePath = `${distDir}/server/${route.file}`;
|
|
690
|
+
const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
369
691
|
|
|
370
692
|
for (const method of ["get", "post", "put", "patch", "delete", "head"] as const) {
|
|
371
693
|
router[method](
|
|
@@ -454,11 +776,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
454
776
|
}
|
|
455
777
|
|
|
456
778
|
// Dynamically import the compiled page module from the dist directory.
|
|
457
|
-
const pageModulePath = `${distDir}/server/${route.file}`;
|
|
779
|
+
const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
458
780
|
const mod = await import(pageModulePath) as {
|
|
459
781
|
default?: unknown;
|
|
460
782
|
metadata?: PageMetadata;
|
|
461
|
-
generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
|
|
783
|
+
generateMetadata?: (props: { params: Record<string, string>; searchParams: Record<string, string> }) => PageMetadata | Promise<PageMetadata>;
|
|
462
784
|
ssr?: boolean;
|
|
463
785
|
cdnCache?: CdnCache;
|
|
464
786
|
ppr?: boolean;
|
|
@@ -499,7 +821,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
499
821
|
// Support both static metadata and dynamic generateMetadata (production fix)
|
|
500
822
|
const metadata: PageMetadata =
|
|
501
823
|
typeof mod.generateMetadata === "function"
|
|
502
|
-
? await mod.generateMetadata(params)
|
|
824
|
+
? await mod.generateMetadata({ params, searchParams })
|
|
503
825
|
: (mod.metadata ?? {});
|
|
504
826
|
|
|
505
827
|
const ssrEnabled = mod.ssr === true;
|
|
@@ -507,7 +829,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
507
829
|
// ── Layouts ──────────────────────────────────────────────────────────
|
|
508
830
|
const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
|
|
509
831
|
const layoutMods = await Promise.all(
|
|
510
|
-
layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)),
|
|
832
|
+
layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)),
|
|
511
833
|
);
|
|
512
834
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
513
835
|
const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown): c is any => typeof c === "function");
|
|
@@ -550,7 +872,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
550
872
|
if (errorRelPath) {
|
|
551
873
|
try {
|
|
552
874
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
553
|
-
const errorMod = await import(`${distDir}/server/${errorRelPath}`) as any;
|
|
875
|
+
const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`) as any;
|
|
554
876
|
const ErrorPage = errorMod.default;
|
|
555
877
|
if (typeof ErrorPage === "function") {
|
|
556
878
|
renderToResponse(res, {
|
|
@@ -582,7 +904,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
582
904
|
app.use(router);
|
|
583
905
|
|
|
584
906
|
// ─── 404 / not-found fallback ────────────────────────────────────────────────
|
|
585
|
-
const notFoundPath = `${distDir}/server/app/not-found.
|
|
907
|
+
const notFoundPath = `${distDir}/server/app/not-found.js`;
|
|
586
908
|
app.use(
|
|
587
909
|
defineEventHandler(async (event) => {
|
|
588
910
|
const res = event.node.res;
|
|
@@ -618,8 +940,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
618
940
|
|
|
619
941
|
return {
|
|
620
942
|
listen(port = 3000) {
|
|
943
|
+
// Make the server's base URL available to useServerData during SSR so it
|
|
944
|
+
// can construct an absolute URL for its internal loop-back fetch.
|
|
945
|
+
process.env["ALAB_ORIGIN"] = `http://127.0.0.1:${port}`;
|
|
621
946
|
const server = createServer(toNodeListener(app));
|
|
622
|
-
server.listen(port, () => {
|
|
947
|
+
server.listen(port, "0.0.0.0", () => {
|
|
623
948
|
console.log(` alab ready at http://localhost:${port}`);
|
|
624
949
|
});
|
|
625
950
|
},
|
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,
|
|
@@ -95,6 +113,7 @@ interface PageCacheEntry {
|
|
|
95
113
|
tags: string[];
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
const _PAGE_STORE_MAX = 1024;
|
|
98
117
|
const _pageStore = new Map<string, PageCacheEntry>();
|
|
99
118
|
|
|
100
119
|
/**
|
|
@@ -118,6 +137,9 @@ export function getCachedPage(pathname: string): { html: string; stale: boolean
|
|
|
118
137
|
|
|
119
138
|
/** Store a rendered HTML page with a TTL (seconds). */
|
|
120
139
|
export function setCachedPage(pathname: string, html: string, ttl: number, tags: string[] = []): void {
|
|
140
|
+
if (_pageStore.size >= _PAGE_STORE_MAX && !_pageStore.has(pathname)) {
|
|
141
|
+
_pageStore.delete(_pageStore.keys().next().value!);
|
|
142
|
+
}
|
|
121
143
|
_pageStore.set(pathname, { html, expires: Date.now() + ttl * 1_000, ttl, revalidating: false, tags });
|
|
122
144
|
}
|
|
123
145
|
|
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/index.ts
CHANGED