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/dist/server/app.js
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 { renderToResponse } from "../ssr/render.js";
|
|
@@ -14,20 +14,88 @@ import { applyCdnHeaders } from "./cdn.js";
|
|
|
14
14
|
import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
|
|
15
15
|
import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
|
|
16
16
|
import { buildImportMap } from "../config.js";
|
|
17
|
+
import { registerLiveComponent } from "../live/registry.js";
|
|
18
|
+
import { renderLiveFragment, hashFragment } from "../live/renderer.js";
|
|
19
|
+
import { subscribeToTag } from "../live/broadcaster.js";
|
|
20
|
+
/** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
|
|
21
|
+
function findDistServerFiles(distDir) {
|
|
22
|
+
const serverDir = join(distDir, "server");
|
|
23
|
+
const results = [];
|
|
24
|
+
function walk(dir) {
|
|
25
|
+
try {
|
|
26
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
const fullPath = join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
walk(fullPath);
|
|
30
|
+
}
|
|
31
|
+
else if (entry.isFile() && entry.name.endsWith(".server.js")) {
|
|
32
|
+
results.push(fullPath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* not readable */ }
|
|
37
|
+
}
|
|
38
|
+
walk(serverDir);
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
/** Walk dist/server recursively and register all *.live.js modules. */
|
|
42
|
+
async function registerAllLiveComponents(distDir) {
|
|
43
|
+
const serverDir = join(distDir, "server");
|
|
44
|
+
const files = [];
|
|
45
|
+
function walk(dir) {
|
|
46
|
+
try {
|
|
47
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
48
|
+
const fullPath = join(dir, entry.name);
|
|
49
|
+
if (entry.isDirectory()) {
|
|
50
|
+
walk(fullPath);
|
|
51
|
+
}
|
|
52
|
+
else if (entry.isFile() && entry.name.endsWith(".live.js")) {
|
|
53
|
+
files.push(fullPath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* not readable */ }
|
|
58
|
+
}
|
|
59
|
+
walk(serverDir);
|
|
60
|
+
for (const filePath of files) {
|
|
61
|
+
try {
|
|
62
|
+
const mod = await import(filePath);
|
|
63
|
+
// liveId is stamped by the Vite plugin (hash of source path).
|
|
64
|
+
// Fall back to a hash of the file path itself.
|
|
65
|
+
const id = mod.liveId ?? filePath.replace(/[^a-z0-9]/gi, "").slice(-16);
|
|
66
|
+
registerLiveComponent({
|
|
67
|
+
id,
|
|
68
|
+
modulePath: filePath,
|
|
69
|
+
...(typeof mod.liveInterval === "number" ? { liveInterval: mod.liveInterval } : {}),
|
|
70
|
+
...(typeof mod.liveTags === "function" ? { liveTags: mod.liveTags } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.warn(`[alabjs] live: failed to register ${filePath}:`, err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
17
78
|
/**
|
|
18
79
|
* Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
|
|
19
80
|
* Checks the compiled dist directory for the existence of each layout.
|
|
20
81
|
*/
|
|
82
|
+
/** Convert a TypeScript source path to its compiled .js equivalent. */
|
|
83
|
+
function toJsPath(p) {
|
|
84
|
+
return p.replace(/\.(tsx?)$/, ".js");
|
|
85
|
+
}
|
|
21
86
|
function findProdLayoutFiles(routeFile, distDir) {
|
|
22
87
|
// routeFile is like "app/users/[id]/page.tsx"
|
|
88
|
+
// Returns source paths (e.g. "app/layout.tsx") so the client bootstrap can look
|
|
89
|
+
// them up in LAYOUT_MODS by their original source key. The import() call in
|
|
90
|
+
// app.ts uses toJsPath() to convert back to the compiled .js path.
|
|
23
91
|
const pageDir = dirname(routeFile);
|
|
24
92
|
const parts = pageDir.split("/");
|
|
25
93
|
const layouts = [];
|
|
26
94
|
for (let i = 1; i <= parts.length; i++) {
|
|
27
95
|
const dir = parts.slice(0, i).join("/");
|
|
28
|
-
const
|
|
29
|
-
if (existsSync(join(distDir, "server",
|
|
30
|
-
layouts.push(
|
|
96
|
+
const compiledPath = `${dir}/layout.js`;
|
|
97
|
+
if (existsSync(join(distDir, "server", compiledPath))) {
|
|
98
|
+
layouts.push(`${dir}/layout.tsx`);
|
|
31
99
|
}
|
|
32
100
|
}
|
|
33
101
|
return layouts;
|
|
@@ -38,7 +106,7 @@ function findProdLayoutFiles(routeFile, distDir) {
|
|
|
38
106
|
function findProdErrorFile(routeFile, distDir) {
|
|
39
107
|
let dir = dirname(routeFile);
|
|
40
108
|
while (dir.length > 0 && dir !== ".") {
|
|
41
|
-
const candidate = `${dir}/error.
|
|
109
|
+
const candidate = `${dir}/error.js`;
|
|
42
110
|
if (existsSync(join(distDir, "server", candidate)))
|
|
43
111
|
return candidate;
|
|
44
112
|
const parent = dirname(dir);
|
|
@@ -51,9 +119,9 @@ function findProdErrorFile(routeFile, distDir) {
|
|
|
51
119
|
function findProdLoadingFile(routeFile, distDir) {
|
|
52
120
|
let dir = dirname(routeFile);
|
|
53
121
|
while (dir.length > 0 && dir !== ".") {
|
|
54
|
-
const
|
|
55
|
-
if (existsSync(join(distDir, "server",
|
|
56
|
-
return
|
|
122
|
+
const compiled = `${dir}/loading.js`;
|
|
123
|
+
if (existsSync(join(distDir, "server", compiled)))
|
|
124
|
+
return `${dir}/loading.tsx`;
|
|
57
125
|
const parent = dirname(dir);
|
|
58
126
|
if (parent === dir)
|
|
59
127
|
break;
|
|
@@ -80,6 +148,18 @@ export function createApp(manifest, distDir) {
|
|
|
80
148
|
buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
|
|
81
149
|
}
|
|
82
150
|
catch { /* no BUILD_ID file — skew protection disabled */ }
|
|
151
|
+
// Resolve the compiled client entry file path by reading the Vite manifest.
|
|
152
|
+
// /@alabjs/client is a virtual module at build time; at runtime the server
|
|
153
|
+
// must redirect requests for it to the hashed asset file.
|
|
154
|
+
// The manifest key is a relative path ending in "@alabjs/client" (not "/@alabjs/client").
|
|
155
|
+
let clientEntryPath = "";
|
|
156
|
+
try {
|
|
157
|
+
const viteManifest = JSON.parse(readFileSync(resolve(distDir, "client/.vite/manifest.json"), "utf8"));
|
|
158
|
+
const entry = Object.values(viteManifest).find((e) => e.isEntry && e.src?.endsWith("@alabjs/client"));
|
|
159
|
+
if (entry?.file)
|
|
160
|
+
clientEntryPath = "/" + entry.file;
|
|
161
|
+
}
|
|
162
|
+
catch { /* manifest absent — /@alabjs/client will 404 */ }
|
|
83
163
|
// Load federation config written by `alab build`. Used to:
|
|
84
164
|
// 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
|
|
85
165
|
// 2. Inject `<script type="importmap">` into every page (host → remote routing)
|
|
@@ -93,6 +173,10 @@ export function createApp(manifest, distDir) {
|
|
|
93
173
|
catch { /* no federation config — federation disabled */ }
|
|
94
174
|
// Absolute path to the PPR shell cache directory.
|
|
95
175
|
const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
|
|
176
|
+
// Register live components at startup (non-blocking — failures are warned, not thrown).
|
|
177
|
+
registerAllLiveComponents(distDir).catch((err) => {
|
|
178
|
+
console.warn("[alabjs] live: component registration failed:", err);
|
|
179
|
+
});
|
|
96
180
|
// ─── Global middleware ───────────────────────────────────────────────────────
|
|
97
181
|
app.use(defineEventHandler((event) => {
|
|
98
182
|
const res = event.node.res;
|
|
@@ -101,6 +185,40 @@ export function createApp(manifest, distDir) {
|
|
|
101
185
|
res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
|
|
102
186
|
res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
|
|
103
187
|
res.setHeader("x-permitted-cross-domain-policies", "none");
|
|
188
|
+
// NOTE: 'unsafe-inline' is required by React's inline event delegation and
|
|
189
|
+
// Tailwind's runtime style injection. 'unsafe-eval' is required by some
|
|
190
|
+
// React dev-mode internals and dynamic import().
|
|
191
|
+
//
|
|
192
|
+
// ⚠️ Security implication: these directives weaken XSS protection.
|
|
193
|
+
// In production, override this header in your middleware with a nonce-based
|
|
194
|
+
// CSP: `script-src 'self' 'nonce-<random>'` and inject the same nonce into
|
|
195
|
+
// every <script> tag via renderToResponse's headExtra option. The CSRF
|
|
196
|
+
// double-submit pattern relies on XSS prevention — using 'unsafe-inline'
|
|
197
|
+
// without a nonce makes the CSRF token readable by injected scripts.
|
|
198
|
+
//
|
|
199
|
+
// NOTE: 'upgrade-insecure-requests' is intentionally omitted from the
|
|
200
|
+
// default CSP. That directive tells browsers to silently rewrite http://
|
|
201
|
+
// sub-resource URLs to https://, which breaks any app served over plain
|
|
202
|
+
// HTTP (local dev, internal tooling, HTTP-only staging servers) because
|
|
203
|
+
// the browser will refuse to load scripts and stylesheets redirected from
|
|
204
|
+
// the virtual /@alabjs/client path.
|
|
205
|
+
//
|
|
206
|
+
// Add it in your own middleware when you are certain every environment
|
|
207
|
+
// runs behind HTTPS:
|
|
208
|
+
//
|
|
209
|
+
// // middleware.ts
|
|
210
|
+
// export async function middleware(req: Request) {
|
|
211
|
+
// const isHttps = req.headers.get("x-forwarded-proto") === "https"
|
|
212
|
+
// || new URL(req.url).protocol === "https:";
|
|
213
|
+
// if (isHttps) {
|
|
214
|
+
// // Append the directive to whatever CSP the framework already set.
|
|
215
|
+
// const existing = res.headers.get("content-security-policy") ?? "";
|
|
216
|
+
// res.headers.set(
|
|
217
|
+
// "content-security-policy",
|
|
218
|
+
// existing + "; upgrade-insecure-requests",
|
|
219
|
+
// );
|
|
220
|
+
// }
|
|
221
|
+
// }
|
|
104
222
|
res.setHeader("content-security-policy", [
|
|
105
223
|
"default-src 'self'",
|
|
106
224
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
@@ -113,16 +231,21 @@ export function createApp(manifest, distDir) {
|
|
|
113
231
|
"base-uri 'self'",
|
|
114
232
|
"form-action 'self'",
|
|
115
233
|
"frame-ancestors 'self'",
|
|
116
|
-
"upgrade-insecure-requests",
|
|
117
234
|
].join("; "));
|
|
118
235
|
// HSTS — only meaningful over HTTPS; set in production only.
|
|
119
236
|
res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
|
|
120
237
|
}));
|
|
121
|
-
// ─── User middleware (middleware.ts compiled to dist/server/middleware.
|
|
122
|
-
const middlewareModulePath = `${distDir}/server/middleware.
|
|
238
|
+
// ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
|
|
239
|
+
const middlewareModulePath = `${distDir}/server/middleware.js`;
|
|
123
240
|
if (existsSync(middlewareModulePath)) {
|
|
241
|
+
// Cache the module after first import — avoids redundant dynamic import()
|
|
242
|
+
// overhead on every request (each import() call re-resolves the module graph).
|
|
243
|
+
let _middlewareCache = null;
|
|
124
244
|
app.use(defineEventHandler(async (event) => {
|
|
125
|
-
|
|
245
|
+
if (!_middlewareCache) {
|
|
246
|
+
_middlewareCache = await import(middlewareModulePath);
|
|
247
|
+
}
|
|
248
|
+
const mod = _middlewareCache;
|
|
126
249
|
if (typeof mod.middleware !== "function")
|
|
127
250
|
return;
|
|
128
251
|
const req = event.node.req;
|
|
@@ -169,7 +292,7 @@ export function createApp(manifest, distDir) {
|
|
|
169
292
|
".xml": "application/xml; charset=utf-8",
|
|
170
293
|
".map": "application/json; charset=utf-8",
|
|
171
294
|
};
|
|
172
|
-
app.use(defineEventHandler((event) => {
|
|
295
|
+
app.use(defineEventHandler(async (event) => {
|
|
173
296
|
const req = event.node.req;
|
|
174
297
|
const res = event.node.res;
|
|
175
298
|
if (req.method !== "GET" && req.method !== "HEAD")
|
|
@@ -185,14 +308,34 @@ export function createApp(manifest, distDir) {
|
|
|
185
308
|
}
|
|
186
309
|
if (relPath.includes(".."))
|
|
187
310
|
return;
|
|
311
|
+
const acceptEncoding = (req.headers["accept-encoding"] ?? "");
|
|
312
|
+
const useBrotli = acceptEncoding.includes("br");
|
|
313
|
+
const useGzip = !useBrotli && acceptEncoding.includes("gzip");
|
|
314
|
+
// Virtual client entry — redirect to the hashed asset file resolved at startup.
|
|
315
|
+
// A 302 redirect (not direct serve) is critical: relative imports in the bundle
|
|
316
|
+
// (e.g. "./components-HASH.js") must resolve against the real asset URL
|
|
317
|
+
// (/assets/client-HASH.js), not the virtual path (/@alabjs/client).
|
|
318
|
+
if (relPath === "/@alabjs/client") {
|
|
319
|
+
if (clientEntryPath) {
|
|
320
|
+
res.writeHead(302, { Location: clientEntryPath });
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
res.statusCode = 404;
|
|
324
|
+
}
|
|
325
|
+
res.end();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
188
328
|
const ext = extname(relPath).toLowerCase();
|
|
189
329
|
const contentType = MIME_TYPES[ext];
|
|
190
330
|
if (!contentType)
|
|
191
331
|
return; // skip extensionless paths (page routes)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
332
|
+
/** Stream a file with optional brotli/gzip compression and ETag 304 support.
|
|
333
|
+
*
|
|
334
|
+
* Returns a Promise that resolves when the response is fully sent. The
|
|
335
|
+
* h3 handler awaits this promise so h3 knows the response is complete
|
|
336
|
+
* before considering the next middleware. This avoids h3 passing the
|
|
337
|
+
* request to the router (which would 404) while the async pipe is running.
|
|
338
|
+
*/
|
|
196
339
|
function serveFile(filePath, fileSize, mtimeMs, cacheControl, mime) {
|
|
197
340
|
// ETag from file size + mtime — both already known from the caller's stat().
|
|
198
341
|
const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
|
|
@@ -201,28 +344,31 @@ export function createApp(manifest, distDir) {
|
|
|
201
344
|
if (req.headers["if-none-match"] === etag) {
|
|
202
345
|
res.statusCode = 304;
|
|
203
346
|
res.end();
|
|
204
|
-
return
|
|
347
|
+
return Promise.resolve();
|
|
205
348
|
}
|
|
206
349
|
res.setHeader("content-type", mime);
|
|
207
350
|
res.setHeader("cache-control", cacheControl);
|
|
208
351
|
if (req.method === "HEAD") {
|
|
209
352
|
res.end();
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
const stream = createReadStream(filePath);
|
|
213
|
-
if (useBrotli) {
|
|
214
|
-
res.setHeader("content-encoding", "br");
|
|
215
|
-
stream.pipe(createBrotliCompress()).pipe(res);
|
|
216
|
-
}
|
|
217
|
-
else if (useGzip) {
|
|
218
|
-
res.setHeader("content-encoding", "gzip");
|
|
219
|
-
stream.pipe(createGzip()).pipe(res);
|
|
220
|
-
}
|
|
221
|
-
else {
|
|
222
|
-
res.setHeader("content-length", fileSize);
|
|
223
|
-
stream.pipe(res);
|
|
353
|
+
return Promise.resolve();
|
|
224
354
|
}
|
|
225
|
-
return
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const fileStream = createReadStream(filePath);
|
|
357
|
+
res.on("finish", resolve);
|
|
358
|
+
res.on("error", reject);
|
|
359
|
+
if (useBrotli) {
|
|
360
|
+
res.setHeader("content-encoding", "br");
|
|
361
|
+
fileStream.pipe(createBrotliCompress()).pipe(res);
|
|
362
|
+
}
|
|
363
|
+
else if (useGzip) {
|
|
364
|
+
res.setHeader("content-encoding", "gzip");
|
|
365
|
+
fileStream.pipe(createGzip()).pipe(res);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
res.setHeader("content-length", fileSize);
|
|
369
|
+
fileStream.pipe(res);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
226
372
|
}
|
|
227
373
|
// 1. Built client assets (JS chunks, CSS, source maps)
|
|
228
374
|
const clientCandidate = join(clientDir, relPath);
|
|
@@ -230,7 +376,8 @@ export function createApp(manifest, distDir) {
|
|
|
230
376
|
const stat = statSync(clientCandidate);
|
|
231
377
|
if (stat.isFile()) {
|
|
232
378
|
const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
|
|
233
|
-
|
|
379
|
+
await serveFile(clientCandidate, stat.size, stat.mtimeMs, isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600", contentType);
|
|
380
|
+
return;
|
|
234
381
|
}
|
|
235
382
|
}
|
|
236
383
|
// 2. Public directory (favicons, fonts, open-graph images, etc.)
|
|
@@ -238,10 +385,10 @@ export function createApp(manifest, distDir) {
|
|
|
238
385
|
if (existsSync(publicCandidate)) {
|
|
239
386
|
const stat = statSync(publicCandidate);
|
|
240
387
|
if (stat.isFile()) {
|
|
241
|
-
|
|
388
|
+
await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
|
|
389
|
+
return;
|
|
242
390
|
}
|
|
243
391
|
}
|
|
244
|
-
return undefined;
|
|
245
392
|
}));
|
|
246
393
|
// ─── Built-in routes ────────────────────────────────────────────────────────
|
|
247
394
|
// Federation manifest — remote apps can advertise what they expose
|
|
@@ -287,6 +434,152 @@ export function createApp(manifest, distDir) {
|
|
|
287
434
|
handleAnalyticsDashboard(event.node.req, event.node.res);
|
|
288
435
|
return null;
|
|
289
436
|
}));
|
|
437
|
+
// ─── Live component SSE endpoint ────────────────────────────────────────────
|
|
438
|
+
// GET /_alabjs/live/:id?props=<base64-json>
|
|
439
|
+
//
|
|
440
|
+
// Opens a persistent SSE stream for a live component. On connect:
|
|
441
|
+
// 1. Renders the component immediately and sends the first fragment.
|
|
442
|
+
// 2. Sets up an interval (if liveInterval is set) to re-render and push.
|
|
443
|
+
// 3. Subscribes to tag broadcasts (if liveTags is set).
|
|
444
|
+
// 4. On client disconnect: clears interval + unsubscribes tags.
|
|
445
|
+
router.get("/_alabjs/live/:id", defineEventHandler(async (event) => {
|
|
446
|
+
const req = event.node.req;
|
|
447
|
+
const res = event.node.res;
|
|
448
|
+
const id = (event.context.params?.["id"] ?? "");
|
|
449
|
+
const { getLiveComponent } = await import("../live/registry.js");
|
|
450
|
+
const entry = getLiveComponent(id);
|
|
451
|
+
if (!entry) {
|
|
452
|
+
res.statusCode = 404;
|
|
453
|
+
res.setHeader("content-type", "application/json");
|
|
454
|
+
res.end(JSON.stringify({ error: `[alabjs] live component not found: ${id}` }));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Parse props from base64-encoded JSON query param.
|
|
458
|
+
let props = {};
|
|
459
|
+
try {
|
|
460
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
461
|
+
const raw = url.searchParams.get("props");
|
|
462
|
+
if (raw)
|
|
463
|
+
props = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
464
|
+
}
|
|
465
|
+
catch { /* malformed props — use empty object */ }
|
|
466
|
+
// SSE headers.
|
|
467
|
+
res.statusCode = 200;
|
|
468
|
+
res.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
469
|
+
res.setHeader("cache-control", "no-cache, no-transform");
|
|
470
|
+
res.setHeader("connection", "keep-alive");
|
|
471
|
+
res.setHeader("x-accel-buffering", "no"); // disable nginx buffering
|
|
472
|
+
let lastHash = "";
|
|
473
|
+
let closed = false;
|
|
474
|
+
async function pushFragment() {
|
|
475
|
+
if (closed)
|
|
476
|
+
return;
|
|
477
|
+
try {
|
|
478
|
+
const html = await renderLiveFragment(entry.modulePath, props);
|
|
479
|
+
const hash = hashFragment(html);
|
|
480
|
+
if (hash === lastHash)
|
|
481
|
+
return; // no-op — output unchanged
|
|
482
|
+
lastHash = hash;
|
|
483
|
+
res.write(`data: ${html}\n\n`);
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
console.error(`[alabjs] live render error (${id}):`, err);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Send initial fragment immediately.
|
|
490
|
+
await pushFragment();
|
|
491
|
+
// Interval-based polling.
|
|
492
|
+
let intervalHandle = null;
|
|
493
|
+
if (entry.liveInterval && entry.liveInterval > 0) {
|
|
494
|
+
intervalHandle = setInterval(() => { void pushFragment(); }, entry.liveInterval);
|
|
495
|
+
}
|
|
496
|
+
// Tag-based subscriptions.
|
|
497
|
+
const unsubFns = [];
|
|
498
|
+
if (entry.liveTags) {
|
|
499
|
+
const tags = entry.liveTags(props);
|
|
500
|
+
for (const tag of tags) {
|
|
501
|
+
unsubFns.push(subscribeToTag(tag, () => { void pushFragment(); }));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Cleanup on disconnect.
|
|
505
|
+
req.on("close", () => {
|
|
506
|
+
closed = true;
|
|
507
|
+
if (intervalHandle)
|
|
508
|
+
clearInterval(intervalHandle);
|
|
509
|
+
for (const unsub of unsubFns)
|
|
510
|
+
unsub();
|
|
511
|
+
});
|
|
512
|
+
// Return null so h3 does not try to end the response — SSE keeps it open.
|
|
513
|
+
return null;
|
|
514
|
+
}));
|
|
515
|
+
// ─── Server function endpoints ──────────────────────────────────────────────
|
|
516
|
+
// GET /_alabjs/data/:fn — used by useServerData (query params as input)
|
|
517
|
+
// POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
|
|
518
|
+
//
|
|
519
|
+
// Both scan all *.server.js files in dist/server for the named export.
|
|
520
|
+
// Module results are NOT cached — the module cache is Node's own require cache.
|
|
521
|
+
async function callServerFn(fnName, ctx, input, res) {
|
|
522
|
+
const serverFiles = findDistServerFiles(distDir);
|
|
523
|
+
for (const file of serverFiles) {
|
|
524
|
+
const mod = await import(file);
|
|
525
|
+
if (typeof mod[fnName] === "function") {
|
|
526
|
+
try {
|
|
527
|
+
const result = await mod[fnName](ctx, input);
|
|
528
|
+
res.statusCode = 200;
|
|
529
|
+
res.setHeader("content-type", "application/json");
|
|
530
|
+
res.end(JSON.stringify(result));
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
const zodError = err?.["zodError"];
|
|
534
|
+
if (zodError) {
|
|
535
|
+
res.statusCode = 422;
|
|
536
|
+
res.setHeader("content-type", "application/json");
|
|
537
|
+
res.end(JSON.stringify({ zodError }));
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
541
|
+
console.error(`[alabjs] server fn "${fnName}" threw:`, err);
|
|
542
|
+
res.statusCode = 500;
|
|
543
|
+
res.setHeader("content-type", "application/json");
|
|
544
|
+
res.end(JSON.stringify({ error: msg }));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
res.statusCode = 404;
|
|
551
|
+
res.setHeader("content-type", "application/json");
|
|
552
|
+
res.end(JSON.stringify({ error: `[alabjs] server function not found: ${fnName}` }));
|
|
553
|
+
}
|
|
554
|
+
router.get("/_alabjs/data/:fn", defineEventHandler(async (event) => {
|
|
555
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
556
|
+
const req = event.node.req;
|
|
557
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
558
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
559
|
+
const ctx = {
|
|
560
|
+
params: query,
|
|
561
|
+
query,
|
|
562
|
+
headers: req.headers,
|
|
563
|
+
method: "GET",
|
|
564
|
+
url: req.url ?? "/",
|
|
565
|
+
};
|
|
566
|
+
await callServerFn(fnName, ctx, Object.keys(query).length ? query : undefined, event.node.res);
|
|
567
|
+
}));
|
|
568
|
+
router.post("/_alabjs/fn/:fn", defineEventHandler(async (event) => {
|
|
569
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
570
|
+
const req = event.node.req;
|
|
571
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
572
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
573
|
+
const body = await readBody(event);
|
|
574
|
+
const ctx = {
|
|
575
|
+
params: query,
|
|
576
|
+
query,
|
|
577
|
+
headers: req.headers,
|
|
578
|
+
method: "POST",
|
|
579
|
+
url: req.url ?? "/",
|
|
580
|
+
};
|
|
581
|
+
await callServerFn(fnName, ctx, body, event.node.res);
|
|
582
|
+
}));
|
|
290
583
|
// Auto sitemap.xml from route manifest
|
|
291
584
|
router.get("/sitemap.xml", defineEventHandler((event) => {
|
|
292
585
|
const baseUrl = process.env["PUBLIC_URL"] ??
|
|
@@ -301,7 +594,7 @@ export function createApp(manifest, distDir) {
|
|
|
301
594
|
if (route.kind !== "api")
|
|
302
595
|
continue;
|
|
303
596
|
const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
|
|
304
|
-
const apiModulePath = `${distDir}/server/${route.file}`;
|
|
597
|
+
const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
305
598
|
for (const method of ["get", "post", "put", "patch", "delete", "head"]) {
|
|
306
599
|
router[method](h3ApiPath, defineEventHandler(async (event) => {
|
|
307
600
|
const apiMod = await import(apiModulePath);
|
|
@@ -377,7 +670,7 @@ export function createApp(manifest, distDir) {
|
|
|
377
670
|
searchParams[k] = Array.isArray(v) ? v[0] ?? "" : v;
|
|
378
671
|
}
|
|
379
672
|
// Dynamically import the compiled page module from the dist directory.
|
|
380
|
-
const pageModulePath = `${distDir}/server/${route.file}`;
|
|
673
|
+
const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
381
674
|
const mod = await import(pageModulePath);
|
|
382
675
|
const Page = mod.default;
|
|
383
676
|
if (typeof Page !== "function") {
|
|
@@ -412,12 +705,12 @@ export function createApp(manifest, distDir) {
|
|
|
412
705
|
}
|
|
413
706
|
// Support both static metadata and dynamic generateMetadata (production fix)
|
|
414
707
|
const metadata = typeof mod.generateMetadata === "function"
|
|
415
|
-
? await mod.generateMetadata(params)
|
|
708
|
+
? await mod.generateMetadata({ params, searchParams })
|
|
416
709
|
: (mod.metadata ?? {});
|
|
417
710
|
const ssrEnabled = mod.ssr === true;
|
|
418
711
|
// ── Layouts ──────────────────────────────────────────────────────────
|
|
419
712
|
const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
|
|
420
|
-
const layoutMods = await Promise.all(layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)));
|
|
713
|
+
const layoutMods = await Promise.all(layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)));
|
|
421
714
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
422
715
|
const layouts = layoutMods.map((m) => m.default).filter((c) => typeof c === "function");
|
|
423
716
|
const layoutsJson = JSON.stringify(layoutRelPaths);
|
|
@@ -459,7 +752,7 @@ export function createApp(manifest, distDir) {
|
|
|
459
752
|
if (errorRelPath) {
|
|
460
753
|
try {
|
|
461
754
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
462
|
-
const errorMod = await import(`${distDir}/server/${errorRelPath}`);
|
|
755
|
+
const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`);
|
|
463
756
|
const ErrorPage = errorMod.default;
|
|
464
757
|
if (typeof ErrorPage === "function") {
|
|
465
758
|
renderToResponse(res, {
|
|
@@ -489,7 +782,7 @@ export function createApp(manifest, distDir) {
|
|
|
489
782
|
}
|
|
490
783
|
app.use(router);
|
|
491
784
|
// ─── 404 / not-found fallback ────────────────────────────────────────────────
|
|
492
|
-
const notFoundPath = `${distDir}/server/app/not-found.
|
|
785
|
+
const notFoundPath = `${distDir}/server/app/not-found.js`;
|
|
493
786
|
app.use(defineEventHandler(async (event) => {
|
|
494
787
|
const res = event.node.res;
|
|
495
788
|
res.statusCode = 404;
|
|
@@ -521,8 +814,11 @@ export function createApp(manifest, distDir) {
|
|
|
521
814
|
}));
|
|
522
815
|
return {
|
|
523
816
|
listen(port = 3000) {
|
|
817
|
+
// Make the server's base URL available to useServerData during SSR so it
|
|
818
|
+
// can construct an absolute URL for its internal loop-back fetch.
|
|
819
|
+
process.env["ALAB_ORIGIN"] = `http://127.0.0.1:${port}`;
|
|
524
820
|
const server = createServer(toNodeListener(app));
|
|
525
|
-
server.listen(port, () => {
|
|
821
|
+
server.listen(port, "0.0.0.0", () => {
|
|
526
822
|
console.log(` alab ready at http://localhost:${port}`);
|
|
527
823
|
});
|
|
528
824
|
},
|