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/config.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import type { ConfigEnv } from "vite";
|
|
4
|
+
|
|
5
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface FederationConfig {
|
|
8
|
+
/**
|
|
9
|
+
* This application's name — used as the namespace for its exposed modules.
|
|
10
|
+
* Other apps reference exposed components as `<name>/<ExposedName>`.
|
|
11
|
+
*
|
|
12
|
+
* @example "marketing" // exposed modules served at /_alabjs/remotes/marketing/
|
|
13
|
+
*/
|
|
14
|
+
name: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Modules this app exposes to remote hosts.
|
|
18
|
+
*
|
|
19
|
+
* Key: public component name (e.g. `"Button"`).
|
|
20
|
+
* Value: module path relative to the project root (e.g. `"./app/components/Button"`).
|
|
21
|
+
*
|
|
22
|
+
* Each entry is built as a self-contained ESM chunk served at
|
|
23
|
+
* `/_alabjs/remotes/<name>/<key>.js`.
|
|
24
|
+
*/
|
|
25
|
+
exposes?: Record<string, string>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Remote apps this app consumes.
|
|
29
|
+
*
|
|
30
|
+
* Key: remote app name (matches the remote's `federation.name`).
|
|
31
|
+
* Value: base URL where the remote app is hosted.
|
|
32
|
+
*
|
|
33
|
+
* AlabJS injects a `<script type="importmap">` into every page so that
|
|
34
|
+
* `import("RemoteName/ComponentName")` resolves to the remote's pre-built
|
|
35
|
+
* ESM chunk without any bundler runtime.
|
|
36
|
+
*
|
|
37
|
+
* @example { "RemoteApp": "https://remote.example.com" }
|
|
38
|
+
*/
|
|
39
|
+
remotes?: Record<string, string>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extra bare-specifier packages to externalize from exposed modules and
|
|
43
|
+
* provide as shared singletons via the import map.
|
|
44
|
+
*
|
|
45
|
+
* `react`, `react/jsx-runtime`, `react-dom`, and `react-dom/client` are
|
|
46
|
+
* always shared automatically — you do not need to list them.
|
|
47
|
+
*/
|
|
48
|
+
shared?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AlabConfig {
|
|
52
|
+
federation?: FederationConfig;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── defineConfig ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** Define your AlabJS configuration with full TypeScript type inference. */
|
|
58
|
+
export function defineConfig(config: AlabConfig): AlabConfig {
|
|
59
|
+
return config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── loadUserConfig ───────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load `alabjs.config.ts` (or `.js` / `.mjs`) from the given project root.
|
|
66
|
+
* Uses Vite's `loadConfigFromFile` so TypeScript configs are supported with
|
|
67
|
+
* zero extra dependencies. Returns `{}` if no config file is found.
|
|
68
|
+
*/
|
|
69
|
+
export async function loadUserConfig(cwd: string): Promise<AlabConfig> {
|
|
70
|
+
const candidates = [
|
|
71
|
+
"alabjs.config.ts",
|
|
72
|
+
"alabjs.config.js",
|
|
73
|
+
"alabjs.config.mjs",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
for (const name of candidates) {
|
|
77
|
+
const configPath = resolve(cwd, name);
|
|
78
|
+
if (!existsSync(configPath)) continue;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const { loadConfigFromFile } = await import("vite");
|
|
82
|
+
const env: ConfigEnv = { command: "build", mode: "production" };
|
|
83
|
+
const result = await loadConfigFromFile(env, configPath, cwd);
|
|
84
|
+
return (result?.config as AlabConfig | undefined) ?? {};
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.warn(
|
|
87
|
+
`[alabjs] warning: failed to load ${name}: ${String(err)}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
break; // only try the first match
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── buildImportMap ───────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generate the `<script type="importmap">` JSON for a federation config.
|
|
100
|
+
*
|
|
101
|
+
* - **Production** (`dev = false`): React singleton is served from the host
|
|
102
|
+
* app's own `/_alabjs/vendor/*.js` files (built by `alab build`).
|
|
103
|
+
* This guarantees a single React instance across host and all remotes.
|
|
104
|
+
*
|
|
105
|
+
* - **Dev** (`dev = true`): Only remote scope mappings are emitted.
|
|
106
|
+
* React is already provided by Vite's module graph — injecting a duplicate
|
|
107
|
+
* entry would create a second instance and break hooks.
|
|
108
|
+
*
|
|
109
|
+
* Returns `null` when the config has no remotes (no import map needed).
|
|
110
|
+
*/
|
|
111
|
+
export function buildImportMap(
|
|
112
|
+
federation: FederationConfig,
|
|
113
|
+
dev = false,
|
|
114
|
+
): string | null {
|
|
115
|
+
const { remotes = {}, shared = [] } = federation;
|
|
116
|
+
|
|
117
|
+
if (Object.keys(remotes).length === 0 && !dev) return null;
|
|
118
|
+
if (Object.keys(remotes).length === 0) return null;
|
|
119
|
+
|
|
120
|
+
const imports: Record<string, string> = {};
|
|
121
|
+
|
|
122
|
+
// Trailing-slash scope: `RemoteApp/Button` → `https://remote.example.com/_alabjs/remotes/RemoteApp/Button.js`
|
|
123
|
+
for (const [remoteName, baseUrl] of Object.entries(remotes)) {
|
|
124
|
+
imports[`${remoteName}/`] = `${baseUrl.replace(/\/$/, "")}/_alabjs/remotes/${remoteName}/`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!dev) {
|
|
128
|
+
// Production: shared React singleton from locally-built vendor files.
|
|
129
|
+
imports["react"] = "/_alabjs/vendor/react.js";
|
|
130
|
+
imports["react/jsx-runtime"] = "/_alabjs/vendor/react-jsx-runtime.js";
|
|
131
|
+
imports["react-dom"] = "/_alabjs/vendor/react-dom.js";
|
|
132
|
+
imports["react-dom/client"] = "/_alabjs/vendor/react-dom-client.js";
|
|
133
|
+
|
|
134
|
+
// Extra shared packages declared by the user
|
|
135
|
+
for (const pkg of shared) {
|
|
136
|
+
if (!imports[pkg]) {
|
|
137
|
+
imports[pkg] = `/_alabjs/vendor/${pkg.replace(/\//g, "--")}.js`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return JSON.stringify({ imports });
|
|
143
|
+
}
|
package/src/index.ts
CHANGED
package/src/server/app.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
import { createGzip, createBrotliCompress } from "node:zlib";
|
|
5
6
|
import { toNodeListener } from "h3";
|
|
6
7
|
import type { RouteManifest } from "../router/manifest.js";
|
|
7
8
|
import { renderToResponse } from "../ssr/render.js";
|
|
@@ -15,21 +16,51 @@ import { checkRevalidateAuth, applyRevalidate } from "./revalidate.js";
|
|
|
15
16
|
import { applyCdnHeaders, type CdnCache } from "./cdn.js";
|
|
16
17
|
import { getPPRShell, injectBuildIdIntoPPRShell, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
|
|
17
18
|
import { handleVitalsBeacon, handleAnalyticsDashboard } from "../analytics/handler.js";
|
|
19
|
+
import { buildImportMap } from "../config.js";
|
|
20
|
+
import type { FederationConfig } from "../config.js";
|
|
21
|
+
|
|
22
|
+
/** Walk dist/server recursively and collect all *.server.js paths (compiled server functions). */
|
|
23
|
+
function findDistServerFiles(distDir: string): string[] {
|
|
24
|
+
const serverDir = join(distDir, "server");
|
|
25
|
+
const results: string[] = [];
|
|
26
|
+
function walk(dir: string) {
|
|
27
|
+
try {
|
|
28
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const fullPath = join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
walk(fullPath);
|
|
32
|
+
} else if (entry.isFile() && entry.name.endsWith(".server.js")) {
|
|
33
|
+
results.push(fullPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch { /* not readable */ }
|
|
37
|
+
}
|
|
38
|
+
walk(serverDir);
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
18
41
|
|
|
19
42
|
/**
|
|
20
43
|
* Find layout file paths (relative to cwd root) for a given route.file, ordered outermost→innermost.
|
|
21
44
|
* Checks the compiled dist directory for the existence of each layout.
|
|
22
45
|
*/
|
|
46
|
+
/** Convert a TypeScript source path to its compiled .js equivalent. */
|
|
47
|
+
function toJsPath(p: string): string {
|
|
48
|
+
return p.replace(/\.(tsx?)$/, ".js");
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
24
52
|
// routeFile is like "app/users/[id]/page.tsx"
|
|
53
|
+
// Returns source paths (e.g. "app/layout.tsx") so the client bootstrap can look
|
|
54
|
+
// them up in LAYOUT_MODS by their original source key. The import() call in
|
|
55
|
+
// app.ts uses toJsPath() to convert back to the compiled .js path.
|
|
25
56
|
const pageDir = dirname(routeFile);
|
|
26
57
|
const parts = pageDir.split("/");
|
|
27
58
|
const layouts: string[] = [];
|
|
28
59
|
for (let i = 1; i <= parts.length; i++) {
|
|
29
60
|
const dir = parts.slice(0, i).join("/");
|
|
30
|
-
const
|
|
31
|
-
if (existsSync(join(distDir, "server",
|
|
32
|
-
layouts.push(
|
|
61
|
+
const compiledPath = `${dir}/layout.js`;
|
|
62
|
+
if (existsSync(join(distDir, "server", compiledPath))) {
|
|
63
|
+
layouts.push(`${dir}/layout.tsx`);
|
|
33
64
|
}
|
|
34
65
|
}
|
|
35
66
|
return layouts;
|
|
@@ -41,7 +72,7 @@ function findProdLayoutFiles(routeFile: string, distDir: string): string[] {
|
|
|
41
72
|
function findProdErrorFile(routeFile: string, distDir: string): string | null {
|
|
42
73
|
let dir = dirname(routeFile);
|
|
43
74
|
while (dir.length > 0 && dir !== ".") {
|
|
44
|
-
const candidate = `${dir}/error.
|
|
75
|
+
const candidate = `${dir}/error.js`;
|
|
45
76
|
if (existsSync(join(distDir, "server", candidate))) return candidate;
|
|
46
77
|
const parent = dirname(dir);
|
|
47
78
|
if (parent === dir) break;
|
|
@@ -53,8 +84,8 @@ function findProdErrorFile(routeFile: string, distDir: string): string | null {
|
|
|
53
84
|
function findProdLoadingFile(routeFile: string, distDir: string): string | null {
|
|
54
85
|
let dir = dirname(routeFile);
|
|
55
86
|
while (dir.length > 0 && dir !== ".") {
|
|
56
|
-
const
|
|
57
|
-
if (existsSync(join(distDir, "server",
|
|
87
|
+
const compiled = `${dir}/loading.js`;
|
|
88
|
+
if (existsSync(join(distDir, "server", compiled))) return `${dir}/loading.tsx`;
|
|
58
89
|
const parent = dirname(dir);
|
|
59
90
|
if (parent === dir) break;
|
|
60
91
|
dir = parent;
|
|
@@ -86,6 +117,32 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
86
117
|
buildId = readFileSync(resolve(distDir, "BUILD_ID"), "utf8").trim() || undefined;
|
|
87
118
|
} catch { /* no BUILD_ID file — skew protection disabled */ }
|
|
88
119
|
|
|
120
|
+
// Resolve the compiled client entry file path by reading the Vite manifest.
|
|
121
|
+
// /@alabjs/client is a virtual module at build time; at runtime the server
|
|
122
|
+
// must redirect requests for it to the hashed asset file.
|
|
123
|
+
// The manifest key is a relative path ending in "@alabjs/client" (not "/@alabjs/client").
|
|
124
|
+
let clientEntryPath = "";
|
|
125
|
+
try {
|
|
126
|
+
const viteManifest = JSON.parse(
|
|
127
|
+
readFileSync(resolve(distDir, "client/.vite/manifest.json"), "utf8"),
|
|
128
|
+
) as Record<string, { file?: string; isEntry?: boolean; src?: string }>;
|
|
129
|
+
const entry = Object.values(viteManifest).find(
|
|
130
|
+
(e) => e.isEntry && e.src?.endsWith("@alabjs/client"),
|
|
131
|
+
);
|
|
132
|
+
if (entry?.file) clientEntryPath = "/" + entry.file;
|
|
133
|
+
} catch { /* manifest absent — /@alabjs/client will 404 */ }
|
|
134
|
+
|
|
135
|
+
// Load federation config written by `alab build`. Used to:
|
|
136
|
+
// 1. Serve `/_alabjs/federation-manifest.json` (remote discovery)
|
|
137
|
+
// 2. Inject `<script type="importmap">` into every page (host → remote routing)
|
|
138
|
+
let federationConfig: FederationConfig | undefined;
|
|
139
|
+
let importMapJson: string | null = null;
|
|
140
|
+
try {
|
|
141
|
+
const fedJson = readFileSync(resolve(distDir, "federation-config.json"), "utf8");
|
|
142
|
+
federationConfig = JSON.parse(fedJson) as FederationConfig;
|
|
143
|
+
importMapJson = buildImportMap(federationConfig, /* dev= */ false);
|
|
144
|
+
} catch { /* no federation config — federation disabled */ }
|
|
145
|
+
|
|
89
146
|
// Absolute path to the PPR shell cache directory.
|
|
90
147
|
const pprCacheDir = resolve(distDir, "../../", PPR_CACHE_SUBDIR);
|
|
91
148
|
|
|
@@ -98,17 +155,49 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
98
155
|
res.setHeader("referrer-policy", "strict-origin-when-cross-origin");
|
|
99
156
|
res.setHeader("permissions-policy", "camera=(), microphone=(), geolocation=()");
|
|
100
157
|
res.setHeader("x-permitted-cross-domain-policies", "none");
|
|
158
|
+
// NOTE: 'unsafe-inline' is required by React's inline event delegation and
|
|
159
|
+
// Tailwind's runtime style injection. 'unsafe-eval' is required by some
|
|
160
|
+
// React dev-mode internals and dynamic import().
|
|
161
|
+
//
|
|
162
|
+
// ⚠️ Security implication: these directives weaken XSS protection.
|
|
163
|
+
// In production, override this header in your middleware with a nonce-based
|
|
164
|
+
// CSP: `script-src 'self' 'nonce-<random>'` and inject the same nonce into
|
|
165
|
+
// every <script> tag via renderToResponse's headExtra option. The CSRF
|
|
166
|
+
// double-submit pattern relies on XSS prevention — using 'unsafe-inline'
|
|
167
|
+
// without a nonce makes the CSRF token readable by injected scripts.
|
|
168
|
+
res.setHeader(
|
|
169
|
+
"content-security-policy",
|
|
170
|
+
[
|
|
171
|
+
"default-src 'self'",
|
|
172
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
173
|
+
"style-src 'self' 'unsafe-inline'",
|
|
174
|
+
"img-src 'self' data: blob: https:",
|
|
175
|
+
"font-src 'self' data: https:",
|
|
176
|
+
"connect-src 'self'",
|
|
177
|
+
"media-src 'self'",
|
|
178
|
+
"object-src 'none'",
|
|
179
|
+
"base-uri 'self'",
|
|
180
|
+
"form-action 'self'",
|
|
181
|
+
"frame-ancestors 'self'",
|
|
182
|
+
].join("; "),
|
|
183
|
+
);
|
|
101
184
|
// HSTS — only meaningful over HTTPS; set in production only.
|
|
102
185
|
res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains");
|
|
103
186
|
}),
|
|
104
187
|
);
|
|
105
188
|
|
|
106
|
-
// ─── User middleware (middleware.ts compiled to dist/server/middleware.
|
|
107
|
-
const middlewareModulePath = `${distDir}/server/middleware.
|
|
189
|
+
// ─── User middleware (middleware.ts compiled to dist/server/middleware.js) ───
|
|
190
|
+
const middlewareModulePath = `${distDir}/server/middleware.js`;
|
|
108
191
|
if (existsSync(middlewareModulePath)) {
|
|
192
|
+
// Cache the module after first import — avoids redundant dynamic import()
|
|
193
|
+
// overhead on every request (each import() call re-resolves the module graph).
|
|
194
|
+
let _middlewareCache: MiddlewareModule | null = null;
|
|
109
195
|
app.use(
|
|
110
196
|
defineEventHandler(async (event) => {
|
|
111
|
-
|
|
197
|
+
if (!_middlewareCache) {
|
|
198
|
+
_middlewareCache = await import(middlewareModulePath) as MiddlewareModule;
|
|
199
|
+
}
|
|
200
|
+
const mod = _middlewareCache;
|
|
112
201
|
if (typeof mod.middleware !== "function") return;
|
|
113
202
|
const req = event.node.req;
|
|
114
203
|
const res = event.node.res;
|
|
@@ -162,7 +251,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
162
251
|
};
|
|
163
252
|
|
|
164
253
|
app.use(
|
|
165
|
-
defineEventHandler((event) => {
|
|
254
|
+
defineEventHandler(async (event) => {
|
|
166
255
|
const req = event.node.req;
|
|
167
256
|
const res = event.node.res;
|
|
168
257
|
if (req.method !== "GET" && req.method !== "HEAD") return;
|
|
@@ -173,25 +262,89 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
173
262
|
try { relPath = decodeURIComponent(rawPath); } catch { return; }
|
|
174
263
|
if (relPath.includes("..")) return;
|
|
175
264
|
|
|
265
|
+
const acceptEncoding = (req.headers["accept-encoding"] ?? "") as string;
|
|
266
|
+
const useBrotli = acceptEncoding.includes("br");
|
|
267
|
+
const useGzip = !useBrotli && acceptEncoding.includes("gzip");
|
|
268
|
+
|
|
269
|
+
// Virtual client entry — redirect to the hashed asset file resolved at startup.
|
|
270
|
+
// A 302 redirect (not direct serve) is critical: relative imports in the bundle
|
|
271
|
+
// (e.g. "./components-HASH.js") must resolve against the real asset URL
|
|
272
|
+
// (/assets/client-HASH.js), not the virtual path (/@alabjs/client).
|
|
273
|
+
if (relPath === "/@alabjs/client") {
|
|
274
|
+
if (clientEntryPath) {
|
|
275
|
+
res.writeHead(302, { Location: clientEntryPath });
|
|
276
|
+
} else {
|
|
277
|
+
res.statusCode = 404;
|
|
278
|
+
}
|
|
279
|
+
res.end();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
176
283
|
const ext = extname(relPath).toLowerCase();
|
|
177
284
|
const contentType = MIME_TYPES[ext];
|
|
178
285
|
if (!contentType) return; // skip extensionless paths (page routes)
|
|
179
286
|
|
|
287
|
+
/** Stream a file with optional brotli/gzip compression and ETag 304 support.
|
|
288
|
+
*
|
|
289
|
+
* Returns a Promise that resolves when the response is fully sent. The
|
|
290
|
+
* h3 handler awaits this promise so h3 knows the response is complete
|
|
291
|
+
* before considering the next middleware. This avoids h3 passing the
|
|
292
|
+
* request to the router (which would 404) while the async pipe is running.
|
|
293
|
+
*/
|
|
294
|
+
function serveFile(
|
|
295
|
+
filePath: string,
|
|
296
|
+
fileSize: number,
|
|
297
|
+
mtimeMs: number,
|
|
298
|
+
cacheControl: string,
|
|
299
|
+
mime: string,
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
// ETag from file size + mtime — both already known from the caller's stat().
|
|
302
|
+
const etag = `"${fileSize.toString(36)}-${mtimeMs.toString(36)}"`;
|
|
303
|
+
res.setHeader("etag", etag);
|
|
304
|
+
res.setHeader("vary", "Accept-Encoding");
|
|
305
|
+
|
|
306
|
+
if (req.headers["if-none-match"] === etag) {
|
|
307
|
+
res.statusCode = 304;
|
|
308
|
+
res.end();
|
|
309
|
+
return Promise.resolve();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
res.setHeader("content-type", mime);
|
|
313
|
+
res.setHeader("cache-control", cacheControl);
|
|
314
|
+
|
|
315
|
+
if (req.method === "HEAD") { res.end(); return Promise.resolve(); }
|
|
316
|
+
|
|
317
|
+
return new Promise<void>((resolve, reject) => {
|
|
318
|
+
const fileStream = createReadStream(filePath);
|
|
319
|
+
res.on("finish", resolve);
|
|
320
|
+
res.on("error", reject);
|
|
321
|
+
if (useBrotli) {
|
|
322
|
+
res.setHeader("content-encoding", "br");
|
|
323
|
+
fileStream.pipe(createBrotliCompress()).pipe(res);
|
|
324
|
+
} else if (useGzip) {
|
|
325
|
+
res.setHeader("content-encoding", "gzip");
|
|
326
|
+
fileStream.pipe(createGzip()).pipe(res);
|
|
327
|
+
} else {
|
|
328
|
+
res.setHeader("content-length", fileSize);
|
|
329
|
+
fileStream.pipe(res);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
180
334
|
// 1. Built client assets (JS chunks, CSS, source maps)
|
|
181
335
|
const clientCandidate = join(clientDir, relPath);
|
|
182
336
|
if (existsSync(clientCandidate)) {
|
|
183
337
|
const stat = statSync(clientCandidate);
|
|
184
338
|
if (stat.isFile()) {
|
|
185
|
-
res.setHeader("content-type", contentType);
|
|
186
|
-
res.setHeader("content-length", stat.size);
|
|
187
|
-
// Immutable cache for hashed assets; short TTL for others
|
|
188
339
|
const isHashed = /\.[a-f0-9]{8,}\.[a-z]+$/.test(relPath);
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
340
|
+
await serveFile(
|
|
341
|
+
clientCandidate,
|
|
342
|
+
stat.size,
|
|
343
|
+
stat.mtimeMs,
|
|
344
|
+
isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600",
|
|
345
|
+
contentType,
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
195
348
|
}
|
|
196
349
|
}
|
|
197
350
|
|
|
@@ -200,20 +353,34 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
200
353
|
if (existsSync(publicCandidate)) {
|
|
201
354
|
const stat = statSync(publicCandidate);
|
|
202
355
|
if (stat.isFile()) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
res.setHeader("cache-control", "public, max-age=3600");
|
|
206
|
-
if (req.method === "HEAD") { res.end(); return null; }
|
|
207
|
-
createReadStream(publicCandidate).pipe(res);
|
|
208
|
-
return null;
|
|
356
|
+
await serveFile(publicCandidate, stat.size, stat.mtimeMs, "public, max-age=3600", contentType);
|
|
357
|
+
return;
|
|
209
358
|
}
|
|
210
359
|
}
|
|
211
|
-
return undefined;
|
|
212
360
|
}),
|
|
213
361
|
);
|
|
214
362
|
|
|
215
363
|
// ─── Built-in routes ────────────────────────────────────────────────────────
|
|
216
364
|
|
|
365
|
+
// Federation manifest — remote apps can advertise what they expose
|
|
366
|
+
router.get(
|
|
367
|
+
"/_alabjs/federation-manifest.json",
|
|
368
|
+
defineEventHandler((event) => {
|
|
369
|
+
const res = event.node.res;
|
|
370
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
371
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
372
|
+
res.setHeader("cache-control", "no-store");
|
|
373
|
+
|
|
374
|
+
const manifestPath = resolve(distDir, "client/_alabjs/federation-manifest.json");
|
|
375
|
+
if (!existsSync(manifestPath)) {
|
|
376
|
+
res.statusCode = 404;
|
|
377
|
+
return JSON.stringify({ error: "No federation exposes configured." });
|
|
378
|
+
}
|
|
379
|
+
res.statusCode = 200;
|
|
380
|
+
return readFileSync(manifestPath, "utf8");
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
|
|
217
384
|
// Rust-powered image optimisation — resize + JPEG encode via `alab-napi`
|
|
218
385
|
router.get(
|
|
219
386
|
"/_alabjs/image",
|
|
@@ -261,6 +428,87 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
261
428
|
}),
|
|
262
429
|
);
|
|
263
430
|
|
|
431
|
+
// ─── Server function endpoints ──────────────────────────────────────────────
|
|
432
|
+
// GET /_alabjs/data/:fn — used by useServerData (query params as input)
|
|
433
|
+
// POST /_alabjs/fn/:fn — used by useMutation (JSON body as input)
|
|
434
|
+
//
|
|
435
|
+
// Both scan all *.server.js files in dist/server for the named export.
|
|
436
|
+
// Module results are NOT cached — the module cache is Node's own require cache.
|
|
437
|
+
|
|
438
|
+
async function callServerFn(
|
|
439
|
+
fnName: string,
|
|
440
|
+
ctx: { params: Record<string, string>; query: Record<string, string>; headers: Record<string, string | string[] | undefined>; method: string; url: string },
|
|
441
|
+
input: unknown,
|
|
442
|
+
res: import("node:http").ServerResponse,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
const serverFiles = findDistServerFiles(distDir);
|
|
445
|
+
for (const file of serverFiles) {
|
|
446
|
+
const mod = await import(file) as Record<string, unknown>;
|
|
447
|
+
if (typeof mod[fnName] === "function") {
|
|
448
|
+
try {
|
|
449
|
+
const result = await (mod[fnName] as (c: unknown, i: unknown) => Promise<unknown>)(ctx, input);
|
|
450
|
+
res.statusCode = 200;
|
|
451
|
+
res.setHeader("content-type", "application/json");
|
|
452
|
+
res.end(JSON.stringify(result));
|
|
453
|
+
} catch (err) {
|
|
454
|
+
const zodError = (err as Record<string, unknown>)?.["zodError"];
|
|
455
|
+
if (zodError) {
|
|
456
|
+
res.statusCode = 422;
|
|
457
|
+
res.setHeader("content-type", "application/json");
|
|
458
|
+
res.end(JSON.stringify({ zodError }));
|
|
459
|
+
} else {
|
|
460
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
461
|
+
console.error(`[alabjs] server fn "${fnName}" threw:`, err);
|
|
462
|
+
res.statusCode = 500;
|
|
463
|
+
res.setHeader("content-type", "application/json");
|
|
464
|
+
res.end(JSON.stringify({ error: msg }));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
res.statusCode = 404;
|
|
471
|
+
res.setHeader("content-type", "application/json");
|
|
472
|
+
res.end(JSON.stringify({ error: `[alabjs] server function not found: ${fnName}` }));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
router.get(
|
|
476
|
+
"/_alabjs/data/:fn",
|
|
477
|
+
defineEventHandler(async (event) => {
|
|
478
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
479
|
+
const req = event.node.req;
|
|
480
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
481
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
482
|
+
const ctx = {
|
|
483
|
+
params: query,
|
|
484
|
+
query,
|
|
485
|
+
headers: req.headers as Record<string, string>,
|
|
486
|
+
method: "GET",
|
|
487
|
+
url: req.url ?? "/",
|
|
488
|
+
};
|
|
489
|
+
await callServerFn(fnName, ctx, Object.keys(query).length ? query : undefined, event.node.res);
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
router.post(
|
|
494
|
+
"/_alabjs/fn/:fn",
|
|
495
|
+
defineEventHandler(async (event) => {
|
|
496
|
+
const fnName = event.context.params?.["fn"] ?? "";
|
|
497
|
+
const req = event.node.req;
|
|
498
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
499
|
+
const query = Object.fromEntries(url.searchParams.entries());
|
|
500
|
+
const body = await readBody(event);
|
|
501
|
+
const ctx = {
|
|
502
|
+
params: query,
|
|
503
|
+
query,
|
|
504
|
+
headers: req.headers as Record<string, string>,
|
|
505
|
+
method: "POST",
|
|
506
|
+
url: req.url ?? "/",
|
|
507
|
+
};
|
|
508
|
+
await callServerFn(fnName, ctx, body, event.node.res);
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
|
|
264
512
|
// Auto sitemap.xml from route manifest
|
|
265
513
|
router.get(
|
|
266
514
|
"/sitemap.xml",
|
|
@@ -280,7 +528,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
280
528
|
if (route.kind !== "api") continue;
|
|
281
529
|
|
|
282
530
|
const h3ApiPath = route.path.replace(/\[([^\]]+)\]/g, ":$1");
|
|
283
|
-
const apiModulePath = `${distDir}/server/${route.file}`;
|
|
531
|
+
const apiModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
284
532
|
|
|
285
533
|
for (const method of ["get", "post", "put", "patch", "delete", "head"] as const) {
|
|
286
534
|
router[method](
|
|
@@ -369,11 +617,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
369
617
|
}
|
|
370
618
|
|
|
371
619
|
// Dynamically import the compiled page module from the dist directory.
|
|
372
|
-
const pageModulePath = `${distDir}/server/${route.file}`;
|
|
620
|
+
const pageModulePath = `${distDir}/server/${toJsPath(route.file)}`;
|
|
373
621
|
const mod = await import(pageModulePath) as {
|
|
374
622
|
default?: unknown;
|
|
375
623
|
metadata?: PageMetadata;
|
|
376
|
-
generateMetadata?: (params: Record<string, string>) => PageMetadata | Promise<PageMetadata>;
|
|
624
|
+
generateMetadata?: (props: { params: Record<string, string>; searchParams: Record<string, string> }) => PageMetadata | Promise<PageMetadata>;
|
|
377
625
|
ssr?: boolean;
|
|
378
626
|
cdnCache?: CdnCache;
|
|
379
627
|
ppr?: boolean;
|
|
@@ -414,7 +662,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
414
662
|
// Support both static metadata and dynamic generateMetadata (production fix)
|
|
415
663
|
const metadata: PageMetadata =
|
|
416
664
|
typeof mod.generateMetadata === "function"
|
|
417
|
-
? await mod.generateMetadata(params)
|
|
665
|
+
? await mod.generateMetadata({ params, searchParams })
|
|
418
666
|
: (mod.metadata ?? {});
|
|
419
667
|
|
|
420
668
|
const ssrEnabled = mod.ssr === true;
|
|
@@ -422,7 +670,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
422
670
|
// ── Layouts ──────────────────────────────────────────────────────────
|
|
423
671
|
const layoutRelPaths = findProdLayoutFiles(route.file, distDir);
|
|
424
672
|
const layoutMods = await Promise.all(
|
|
425
|
-
layoutRelPaths.map((p) => import(`${distDir}/server/${p}`)),
|
|
673
|
+
layoutRelPaths.map((p) => import(`${distDir}/server/${toJsPath(p)}`)),
|
|
426
674
|
);
|
|
427
675
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
428
676
|
const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown): c is any => typeof c === "function");
|
|
@@ -457,6 +705,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
457
705
|
ssr: ssrEnabled,
|
|
458
706
|
headExtra,
|
|
459
707
|
...(buildId ? { buildId } : {}),
|
|
708
|
+
...(importMapJson ? { importMapJson } : {}),
|
|
460
709
|
});
|
|
461
710
|
} catch (err) {
|
|
462
711
|
// ── error.tsx fallback ────────────────────────────────────────────
|
|
@@ -464,7 +713,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
464
713
|
if (errorRelPath) {
|
|
465
714
|
try {
|
|
466
715
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
467
|
-
const errorMod = await import(`${distDir}/server/${errorRelPath}`) as any;
|
|
716
|
+
const errorMod = await import(`${distDir}/server/${toJsPath(errorRelPath)}`) as any;
|
|
468
717
|
const ErrorPage = errorMod.default;
|
|
469
718
|
if (typeof ErrorPage === "function") {
|
|
470
719
|
renderToResponse(res, {
|
|
@@ -475,6 +724,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
475
724
|
routeFile: errorRelPath,
|
|
476
725
|
ssr: true,
|
|
477
726
|
headExtra,
|
|
727
|
+
...(importMapJson ? { importMapJson } : {}),
|
|
478
728
|
});
|
|
479
729
|
return;
|
|
480
730
|
}
|
|
@@ -495,7 +745,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
495
745
|
app.use(router);
|
|
496
746
|
|
|
497
747
|
// ─── 404 / not-found fallback ────────────────────────────────────────────────
|
|
498
|
-
const notFoundPath = `${distDir}/server/app/not-found.
|
|
748
|
+
const notFoundPath = `${distDir}/server/app/not-found.js`;
|
|
499
749
|
app.use(
|
|
500
750
|
defineEventHandler(async (event) => {
|
|
501
751
|
const res = event.node.res;
|
|
@@ -514,6 +764,7 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
514
764
|
metadata: { title: "404 — Not Found" },
|
|
515
765
|
routeFile: "app/not-found.tsx",
|
|
516
766
|
ssr: true,
|
|
767
|
+
...(importMapJson ? { importMapJson } : {}),
|
|
517
768
|
});
|
|
518
769
|
return;
|
|
519
770
|
}
|
|
@@ -530,8 +781,11 @@ export function createApp(manifest: RouteManifest, distDir: string): AlabApp {
|
|
|
530
781
|
|
|
531
782
|
return {
|
|
532
783
|
listen(port = 3000) {
|
|
784
|
+
// Make the server's base URL available to useServerData during SSR so it
|
|
785
|
+
// can construct an absolute URL for its internal loop-back fetch.
|
|
786
|
+
process.env["ALAB_ORIGIN"] = `http://127.0.0.1:${port}`;
|
|
533
787
|
const server = createServer(toNodeListener(app));
|
|
534
|
-
server.listen(port, () => {
|
|
788
|
+
server.listen(port, "0.0.0.0", () => {
|
|
535
789
|
console.log(` alab ready at http://localhost:${port}`);
|
|
536
790
|
});
|
|
537
791
|
},
|