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/commands/build.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { build as viteBuild, type PluginOption } from "vite";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
import { spawn, execSync } from "node:child_process";
|
|
4
|
-
import { existsSync, writeFileSync, readFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { loadUserConfig } from "../config.js";
|
|
7
|
+
import type { FederationConfig } from "../config.js";
|
|
5
8
|
import { preRenderPPRShell, findBuildLayoutFiles, PPR_CACHE_SUBDIR } from "../ssr/ppr.js";
|
|
6
9
|
import type { RouteManifest } from "../router/manifest.js";
|
|
7
10
|
|
|
@@ -149,7 +152,10 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
|
|
|
149
152
|
...(visualizerPlugin ? [visualizerPlugin] : []),
|
|
150
153
|
],
|
|
151
154
|
build: {
|
|
152
|
-
|
|
155
|
+
// Output client assets to .alabjs/dist/client/ so the production server's
|
|
156
|
+
// static handler (which serves from distDir/client/) can find them.
|
|
157
|
+
outDir: resolve(cwd, ".alabjs/dist/client"),
|
|
158
|
+
manifest: true,
|
|
153
159
|
ssrManifest: true,
|
|
154
160
|
rolldownOptions: {
|
|
155
161
|
// In SSR mode, we don't use an index.html as the entry point.
|
|
@@ -167,9 +173,19 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
|
|
|
167
173
|
|
|
168
174
|
await Promise.all(tasks);
|
|
169
175
|
|
|
170
|
-
// Write a stable build ID for skew protection (must run after Vite so the
|
|
171
|
-
// route-manifest.json is in place for the content-hash fallback path).
|
|
172
176
|
const distDir = resolve(cwd, ".alabjs/dist");
|
|
177
|
+
|
|
178
|
+
// Scan the app/ directory with the Rust router, normalize paths, and write
|
|
179
|
+
// route-manifest.json. Must run before writeBuildId (hash) and buildPPRShells.
|
|
180
|
+
const manifest = await buildRouteManifest(cwd, distDir);
|
|
181
|
+
|
|
182
|
+
// Compile all app pages, layouts, and server functions to .alabjs/dist/server/.
|
|
183
|
+
// Must run after buildRouteManifest so we have the entry list, and before
|
|
184
|
+
// buildPPRShells which imports the compiled modules.
|
|
185
|
+
await buildSsrBundle(cwd, distDir, manifest);
|
|
186
|
+
|
|
187
|
+
// Write a stable build ID for skew protection (must run after the route
|
|
188
|
+
// manifest is in place for the content-hash fallback path).
|
|
173
189
|
await writeBuildId(distDir, cwd);
|
|
174
190
|
await buildPPRShells(distDir, cwd);
|
|
175
191
|
|
|
@@ -177,6 +193,12 @@ export async function build({ cwd, skipTypecheck = false, mode = "ssr", analyze
|
|
|
177
193
|
// Output: .alabjs/dist/client/_alabjs/offline-sw.js (served at /_alabjs/offline-sw.js)
|
|
178
194
|
await buildOfflineSw(cwd);
|
|
179
195
|
|
|
196
|
+
// Build federation vendor + exposed modules if configured.
|
|
197
|
+
const userConfig = await loadUserConfig(cwd);
|
|
198
|
+
if (userConfig.federation) {
|
|
199
|
+
await buildFederation(cwd, userConfig.federation);
|
|
200
|
+
}
|
|
201
|
+
|
|
180
202
|
console.log("\n alab build complete → .alabjs/dist");
|
|
181
203
|
}
|
|
182
204
|
|
|
@@ -234,57 +256,323 @@ async function buildPPRShells(distDir: string, cwd: string): Promise<void> {
|
|
|
234
256
|
const pprCacheDir = resolve(cwd, PPR_CACHE_SUBDIR);
|
|
235
257
|
let count = 0;
|
|
236
258
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
259
|
+
// Signal to useServerData that it must not make network calls — return
|
|
260
|
+
// empty placeholders instead so components can render their static shell.
|
|
261
|
+
process.env["ALAB_PPR_PRERENDER"] = "1";
|
|
240
262
|
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
263
|
+
try {
|
|
264
|
+
for (const route of pageRoutes) {
|
|
265
|
+
// esbuild compiles .tsx/.ts → .js; use the compiled path.
|
|
266
|
+
const modulePath = resolve(distDir, "server", route.file.replace(/\.(tsx?)$/, ".js"));
|
|
267
|
+
if (!existsSync(modulePath)) continue;
|
|
268
|
+
|
|
269
|
+
// Dynamic import — on Windows absolute paths need a file:// URL.
|
|
270
|
+
const mod = await import(pathToFileURL(modulePath).href) as {
|
|
271
|
+
default?: unknown;
|
|
272
|
+
ppr?: unknown;
|
|
273
|
+
metadata?: Record<string, unknown>;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
if (mod.ppr !== true) continue;
|
|
277
|
+
if (typeof mod.default !== "function") {
|
|
278
|
+
console.warn(` alab ppr: ${route.file} has no default export — skipping.`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
247
281
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
282
|
+
// Load layout modules (outermost → innermost).
|
|
283
|
+
const layoutPaths = findBuildLayoutFiles(route.file, distDir);
|
|
284
|
+
const layoutMods = await Promise.all(
|
|
285
|
+
layoutPaths.map((p) => import(pathToFileURL(resolve(distDir, "server", p)).href)),
|
|
286
|
+
);
|
|
287
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
288
|
+
const layouts = layoutMods.map((m: any) => m.default).filter((c: unknown) => typeof c === "function");
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await preRenderPPRShell({
|
|
292
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
293
|
+
Page: mod.default as any,
|
|
294
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
295
|
+
layouts: layouts as any[],
|
|
296
|
+
shellOpts: {
|
|
297
|
+
metadata: (mod.metadata as never) ?? {},
|
|
298
|
+
paramsJson: "{}",
|
|
299
|
+
searchParamsJson: "{}",
|
|
300
|
+
routeFile: route.file,
|
|
301
|
+
// PPR shells are static snapshots — client mounts via CSR (createRoot)
|
|
302
|
+
// rather than hydration to avoid mismatches with the pre-rendered HTML.
|
|
303
|
+
ssr: false,
|
|
304
|
+
layoutsJson: JSON.stringify(layoutPaths.map(p => p.replace(/\.js$/, ".tsx"))),
|
|
305
|
+
},
|
|
306
|
+
pprCacheDir,
|
|
307
|
+
routePath: route.path,
|
|
308
|
+
});
|
|
309
|
+
count++;
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
312
|
+
console.warn(` alab ppr: failed to pre-render ${route.path}: ${msg}`);
|
|
313
|
+
}
|
|
252
314
|
}
|
|
315
|
+
} finally {
|
|
316
|
+
delete process.env["ALAB_PPR_PRERENDER"];
|
|
317
|
+
}
|
|
253
318
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
319
|
+
if (count > 0) {
|
|
320
|
+
console.log(` alab ppr → ${count} shell${count === 1 ? "" : "s"} written to ${PPR_CACHE_SUBDIR}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Federation build ─────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build all federation artefacts after the main SSR bundle:
|
|
328
|
+
* 1. Vendor ESM chunks for shared React singletons (`/_alabjs/vendor/`)
|
|
329
|
+
* 2. Exposed component modules (`/_alabjs/remotes/<name>/<ExposedName>.js`)
|
|
330
|
+
* 3. `federation-config.json` in the dist root (read by `alab start`)
|
|
331
|
+
* 4. `federation-manifest.json` in the client dir (served at runtime)
|
|
332
|
+
*/
|
|
333
|
+
async function buildFederation(cwd: string, federation: FederationConfig): Promise<void> {
|
|
334
|
+
const { name, exposes = {}, remotes = {} } = federation;
|
|
335
|
+
const distClientAlab = resolve(cwd, ".alabjs/dist/client/_alabjs");
|
|
336
|
+
|
|
337
|
+
const hasExposes = Object.keys(exposes).length > 0;
|
|
338
|
+
const hasRemotes = Object.keys(remotes).length > 0;
|
|
339
|
+
|
|
340
|
+
if (hasRemotes || hasExposes) {
|
|
341
|
+
await buildFederationVendors(cwd, distClientAlab, federation.shared ?? []);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (hasExposes) {
|
|
345
|
+
await buildFederationExposes(cwd, distClientAlab, name, exposes, federation.shared ?? []);
|
|
346
|
+
}
|
|
261
347
|
|
|
348
|
+
// Write federation config for the production server (import map generation).
|
|
349
|
+
writeFileSync(
|
|
350
|
+
resolve(cwd, ".alabjs/dist/federation-config.json"),
|
|
351
|
+
JSON.stringify(federation, null, 2),
|
|
352
|
+
"utf8",
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
console.log(
|
|
356
|
+
` alab federation → ${Object.keys(exposes).length} exposed, ${Object.keys(remotes).length} remote(s)`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Build React + react-dom (and any user `shared` packages) as standalone ESM vendor files. */
|
|
361
|
+
async function buildFederationVendors(
|
|
362
|
+
cwd: string,
|
|
363
|
+
distClientAlab: string,
|
|
364
|
+
extraShared: string[] = [],
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const vendorDir = resolve(distClientAlab, "vendor");
|
|
367
|
+
mkdirSync(vendorDir, { recursive: true });
|
|
368
|
+
|
|
369
|
+
// Always vendor the React singleton packages.
|
|
370
|
+
const coreVendors: Array<{ specifier: string; output: string }> = [
|
|
371
|
+
{ specifier: "react", output: "react.js" },
|
|
372
|
+
{ specifier: "react/jsx-runtime", output: "react-jsx-runtime.js" },
|
|
373
|
+
{ specifier: "react-dom", output: "react-dom.js" },
|
|
374
|
+
{ specifier: "react-dom/client", output: "react-dom-client.js" },
|
|
375
|
+
];
|
|
376
|
+
// User-declared shared packages (e.g. "date-fns", "zustand").
|
|
377
|
+
const extraVendors: Array<{ specifier: string; output: string }> = extraShared.map((pkg) => ({
|
|
378
|
+
specifier: pkg,
|
|
379
|
+
output: `${pkg.replace(/\//g, "--")}.js`,
|
|
380
|
+
}));
|
|
381
|
+
const vendors = [...coreVendors, ...extraVendors];
|
|
382
|
+
|
|
383
|
+
for (const { specifier, output } of vendors) {
|
|
384
|
+
const virtualId = `\0alabjs-vendor:${specifier}`;
|
|
262
385
|
try {
|
|
263
|
-
await
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
386
|
+
await viteBuild({
|
|
387
|
+
root: cwd,
|
|
388
|
+
configFile: false,
|
|
389
|
+
plugins: [{
|
|
390
|
+
name: "alabjs-federation-vendor",
|
|
391
|
+
resolveId: (id: string) => id === virtualId ? id : null,
|
|
392
|
+
load: (id: string) =>
|
|
393
|
+
id === virtualId
|
|
394
|
+
? `export * from "${specifier}"; export { default } from "${specifier}";`
|
|
395
|
+
: null,
|
|
396
|
+
}],
|
|
397
|
+
build: {
|
|
398
|
+
outDir: vendorDir,
|
|
399
|
+
emptyOutDir: false,
|
|
400
|
+
lib: {
|
|
401
|
+
entry: virtualId,
|
|
402
|
+
formats: ["es"],
|
|
403
|
+
fileName: () => output,
|
|
404
|
+
},
|
|
405
|
+
minify: true,
|
|
274
406
|
},
|
|
275
|
-
|
|
276
|
-
routePath: route.path,
|
|
407
|
+
logLevel: "warn",
|
|
277
408
|
});
|
|
278
|
-
count++;
|
|
279
409
|
} catch (err) {
|
|
280
|
-
|
|
281
|
-
console.warn(` alab ppr: failed to pre-render ${route.path}: ${msg}`);
|
|
410
|
+
console.warn(` alab federation: failed to build vendor ${specifier}: ${String(err)}`);
|
|
282
411
|
}
|
|
283
412
|
}
|
|
413
|
+
}
|
|
284
414
|
|
|
285
|
-
|
|
286
|
-
|
|
415
|
+
/** Build each exposed module as an externalized ESM chunk for remote consumption. */
|
|
416
|
+
async function buildFederationExposes(
|
|
417
|
+
cwd: string,
|
|
418
|
+
distClientAlab: string,
|
|
419
|
+
appName: string,
|
|
420
|
+
exposes: Record<string, string>,
|
|
421
|
+
shared: string[],
|
|
422
|
+
): Promise<void> {
|
|
423
|
+
const remotesDir = resolve(distClientAlab, `remotes/${appName}`);
|
|
424
|
+
mkdirSync(remotesDir, { recursive: true });
|
|
425
|
+
|
|
426
|
+
const external = [
|
|
427
|
+
"react",
|
|
428
|
+
"react/jsx-runtime",
|
|
429
|
+
"react-dom",
|
|
430
|
+
"react-dom/client",
|
|
431
|
+
...shared,
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
for (const [exposedName, entryRelPath] of Object.entries(exposes)) {
|
|
435
|
+
const entryAbs = resolve(cwd, entryRelPath.replace(/^\.\//, ""));
|
|
436
|
+
try {
|
|
437
|
+
await viteBuild({
|
|
438
|
+
root: cwd,
|
|
439
|
+
configFile: false,
|
|
440
|
+
build: {
|
|
441
|
+
outDir: remotesDir,
|
|
442
|
+
emptyOutDir: false,
|
|
443
|
+
lib: {
|
|
444
|
+
entry: entryAbs,
|
|
445
|
+
formats: ["es"],
|
|
446
|
+
fileName: () => `${exposedName}.js`,
|
|
447
|
+
},
|
|
448
|
+
minify: true,
|
|
449
|
+
rolldownOptions: { external },
|
|
450
|
+
},
|
|
451
|
+
logLevel: "warn",
|
|
452
|
+
});
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.warn(` alab federation: failed to build exposed "${exposedName}": ${String(err)}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Manifest consumed by host apps discovering what this remote exposes.
|
|
459
|
+
const manifest = {
|
|
460
|
+
name: appName,
|
|
461
|
+
exposes: Object.fromEntries(
|
|
462
|
+
Object.keys(exposes).map((k) => [k, `/_alabjs/remotes/${appName}/${k}.js`]),
|
|
463
|
+
),
|
|
464
|
+
};
|
|
465
|
+
writeFileSync(
|
|
466
|
+
resolve(distClientAlab, "federation-manifest.json"),
|
|
467
|
+
JSON.stringify(manifest, null, 2),
|
|
468
|
+
"utf8",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Scan `app/` with the Rust router napi, normalize absolute file paths to
|
|
474
|
+
* cwd-relative, and write `route-manifest.json` to `distDir`.
|
|
475
|
+
*
|
|
476
|
+
* Returns the in-memory manifest so callers can use it immediately without
|
|
477
|
+
* reading the file back from disk.
|
|
478
|
+
*/
|
|
479
|
+
async function buildRouteManifest(cwd: string, distDir: string): Promise<RouteManifest> {
|
|
480
|
+
const appDir = resolve(cwd, "app");
|
|
481
|
+
let manifest: RouteManifest = { routes: [] };
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
type NapiRoutes = { buildRoutes(appDir: string): string };
|
|
485
|
+
const mod = await import("@alabjs/compiler") as unknown as { default?: NapiRoutes } & NapiRoutes;
|
|
486
|
+
const napi = (mod.default ?? mod) as NapiRoutes;
|
|
487
|
+
const json = napi.buildRoutes(appDir);
|
|
488
|
+
manifest = JSON.parse(json) as RouteManifest;
|
|
489
|
+
|
|
490
|
+
// The Rust scanner stores absolute paths; normalize to cwd-relative so the
|
|
491
|
+
// production server can construct `distDir/server/<file>` paths correctly.
|
|
492
|
+
for (const route of manifest.routes) {
|
|
493
|
+
if (isAbsolute(route.file)) {
|
|
494
|
+
route.file = relative(cwd, route.file);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
} catch {
|
|
498
|
+
console.warn(
|
|
499
|
+
" alab warning: Rust compiler unavailable — route manifest will be empty.\n" +
|
|
500
|
+
" Run `cargo build --release -p alab-napi && bash scripts/copy-napi-binary.sh`.",
|
|
501
|
+
);
|
|
287
502
|
}
|
|
503
|
+
|
|
504
|
+
mkdirSync(distDir, { recursive: true });
|
|
505
|
+
writeFileSync(resolve(distDir, "route-manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
506
|
+
|
|
507
|
+
const pages = manifest.routes.filter((r) => r.kind === "page").length;
|
|
508
|
+
const apis = manifest.routes.filter((r) => r.kind === "api").length;
|
|
509
|
+
console.log(` alab routes → ${pages} page(s), ${apis} api route(s)`);
|
|
510
|
+
|
|
511
|
+
return manifest;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Compile all SSR route files to `distDir/server/` using esbuild's per-file
|
|
516
|
+
* transpilation mode.
|
|
517
|
+
*
|
|
518
|
+
* We use esbuild directly (bundled with Vite) rather than a second viteBuild
|
|
519
|
+
* call because:
|
|
520
|
+
* 1. Preserves directory structure via `outbase` without needing
|
|
521
|
+
* `preserveModules` (which hangs with some Rolldown versions).
|
|
522
|
+
* 2. `packages: "external"` externalizes all npm specifiers while inlining
|
|
523
|
+
* local relative imports — avoids Node ESM extensionless-import failures.
|
|
524
|
+
* 3. Much faster: no second Vite startup overhead.
|
|
525
|
+
*/
|
|
526
|
+
async function buildSsrBundle(cwd: string, distDir: string, manifest: RouteManifest): Promise<void> {
|
|
527
|
+
const entryFiles = manifest.routes.map((r) => resolve(cwd, r.file));
|
|
528
|
+
|
|
529
|
+
// Include top-level middleware.ts if present.
|
|
530
|
+
const middlewarePath = resolve(cwd, "middleware.ts");
|
|
531
|
+
if (existsSync(middlewarePath)) entryFiles.push(middlewarePath);
|
|
532
|
+
|
|
533
|
+
if (entryFiles.length === 0) return;
|
|
534
|
+
|
|
535
|
+
// Build the import.meta.env replacement object for esbuild.
|
|
536
|
+
// Node.js never defines import.meta.env (it's a Vite-only concept), so if
|
|
537
|
+
// we leave it undefined the compiled server modules throw at runtime on any
|
|
538
|
+
// reference to import.meta.env.*. We mirror exactly what Vite inlines for
|
|
539
|
+
// the client build: standard constants + ALAB_PUBLIC_* / VITE_* vars.
|
|
540
|
+
const publicEnv: Record<string, string> = {};
|
|
541
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
542
|
+
if (key.startsWith("ALAB_PUBLIC_") || key.startsWith("VITE_")) {
|
|
543
|
+
publicEnv[key] = val ?? "";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const metaEnv = {
|
|
547
|
+
PROD: true,
|
|
548
|
+
DEV: false,
|
|
549
|
+
SSR: true,
|
|
550
|
+
MODE: "production",
|
|
551
|
+
BASE_URL: "/",
|
|
552
|
+
...publicEnv,
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const { build: esbuild } = await import("esbuild");
|
|
556
|
+
await esbuild({
|
|
557
|
+
entryPoints: entryFiles,
|
|
558
|
+
outbase: cwd, // preserve directory structure: app/page.tsx → server/app/page.js
|
|
559
|
+
outdir: resolve(distDir, "server"),
|
|
560
|
+
format: "esm",
|
|
561
|
+
platform: "node",
|
|
562
|
+
target: "node22",
|
|
563
|
+
bundle: true, // bundle local imports (resolves extensionless paths)
|
|
564
|
+
packages: "external", // keep all npm specifiers (react, alabjs/*…) as-is
|
|
565
|
+
jsx: "automatic",
|
|
566
|
+
jsxImportSource: "react",
|
|
567
|
+
logLevel: "warning",
|
|
568
|
+
define: {
|
|
569
|
+
// Replace the entire import.meta.env expression so property accesses,
|
|
570
|
+
// destructuring, and optional-chaining all resolve correctly at runtime.
|
|
571
|
+
"import.meta.env": JSON.stringify(metaEnv),
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
console.log(" alab SSR bundle → .alabjs/dist/server");
|
|
288
576
|
}
|
|
289
577
|
|
|
290
578
|
/** Compile the offline service worker to a standalone iife bundle. */
|
|
@@ -305,7 +593,10 @@ async function buildOfflineSw(cwd: string): Promise<void> {
|
|
|
305
593
|
fileName: () => "offline-sw.js",
|
|
306
594
|
},
|
|
307
595
|
minify: true,
|
|
308
|
-
|
|
596
|
+
// Note: do NOT set rolldownOptions.output.inlineDynamicImports here.
|
|
597
|
+
// iife format sets codeSplitting:false which already implies
|
|
598
|
+
// inlineDynamicImports:true in Rolldown. Setting it explicitly
|
|
599
|
+
// produces a warning that can cause the build to stall in Rolldown/Vite 8+.
|
|
309
600
|
},
|
|
310
601
|
});
|
|
311
602
|
} catch (err) {
|
package/src/commands/dev.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from "vite";
|
|
2
2
|
import { resolve, join } from "node:path";
|
|
3
|
-
import { readdirSync } from "node:fs";
|
|
3
|
+
import { readdirSync, existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
4
|
+
import { loadUserConfig, buildImportMap } from "../config.js";
|
|
4
5
|
import { Writable } from "node:stream";
|
|
5
6
|
import type { IncomingMessage } from "node:http";
|
|
6
7
|
import {
|
|
@@ -63,6 +64,46 @@ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
|
63
64
|
export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions) {
|
|
64
65
|
console.log(" alab starting dev server...\n");
|
|
65
66
|
|
|
67
|
+
// Load optional alabjs.config.ts for federation and other user config.
|
|
68
|
+
const userConfig = await loadUserConfig(cwd);
|
|
69
|
+
// In dev: only inject remote scope mappings (react is already in Vite's module graph).
|
|
70
|
+
const importMapJson = userConfig.federation
|
|
71
|
+
? buildImportMap(userConfig.federation, /* dev= */ true)
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
// If this app exposes federation modules, build them once at dev startup so
|
|
75
|
+
// the `/_alabjs/remotes/` endpoint can serve them immediately.
|
|
76
|
+
// No hot-reloading of exposed modules in dev — restart the dev server after changes.
|
|
77
|
+
if (userConfig.federation?.exposes && Object.keys(userConfig.federation.exposes).length > 0) {
|
|
78
|
+
const { name, exposes, shared = [] } = userConfig.federation;
|
|
79
|
+
const devRemotesDir = resolve(cwd, `.alabjs/dev-remotes/${name}`);
|
|
80
|
+
try {
|
|
81
|
+
const { mkdirSync } = await import("node:fs");
|
|
82
|
+
const { build: viteBuildDev } = await import("vite");
|
|
83
|
+
mkdirSync(devRemotesDir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
const external = ["react", "react/jsx-runtime", "react-dom", "react-dom/client", ...shared];
|
|
86
|
+
for (const [exposedName, entryRelPath] of Object.entries(exposes)) {
|
|
87
|
+
const entryAbs = resolve(cwd, entryRelPath.replace(/^\.\//, ""));
|
|
88
|
+
await viteBuildDev({
|
|
89
|
+
root: cwd,
|
|
90
|
+
configFile: false,
|
|
91
|
+
build: {
|
|
92
|
+
outDir: devRemotesDir,
|
|
93
|
+
emptyOutDir: false,
|
|
94
|
+
lib: { entry: entryAbs, formats: ["es"], fileName: () => `${exposedName}.js` },
|
|
95
|
+
minify: false,
|
|
96
|
+
rolldownOptions: { external },
|
|
97
|
+
},
|
|
98
|
+
logLevel: "warn",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
console.log(` alab federation dev → ${Object.keys(exposes).length} exposed module(s) built`);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.warn(` alab federation: dev build of exposed modules failed: ${String(err)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
66
107
|
// Per-session build ID for skew protection in dev.
|
|
67
108
|
// A new ID is generated each time the dev server starts so that a browser
|
|
68
109
|
// tab left open across a restart will hard-reload on the next navigation
|
|
@@ -130,6 +171,46 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
|
|
|
130
171
|
}
|
|
131
172
|
}
|
|
132
173
|
|
|
174
|
+
// ── /_alabjs/federation-manifest.json — expose what this app exposes ────────
|
|
175
|
+
if (pathname === "/_alabjs/federation-manifest.json") {
|
|
176
|
+
const fed = userConfig.federation;
|
|
177
|
+
if (!fed?.exposes || Object.keys(fed.exposes).length === 0) {
|
|
178
|
+
res.statusCode = 404;
|
|
179
|
+
res.setHeader("content-type", "application/json");
|
|
180
|
+
res.end(JSON.stringify({ error: "No federation exposes configured." }));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const manifest = {
|
|
184
|
+
name: fed.name,
|
|
185
|
+
exposes: Object.fromEntries(
|
|
186
|
+
Object.keys(fed.exposes).map((k) => [k, `/_alabjs/remotes/${fed.name}/${k}.js`]),
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
res.statusCode = 200;
|
|
190
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
191
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
192
|
+
res.setHeader("cache-control", "no-store");
|
|
193
|
+
res.end(JSON.stringify(manifest, null, 2));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── /_alabjs/remotes/:name/:module.js — serve pre-built exposed modules ─────
|
|
198
|
+
if (pathname.startsWith("/_alabjs/remotes/")) {
|
|
199
|
+
const relPath = pathname.slice("/_alabjs/remotes/".length);
|
|
200
|
+
const filePath = resolve(cwd, ".alabjs/dev-remotes", relPath);
|
|
201
|
+
if (fsExistsSync(filePath)) {
|
|
202
|
+
res.statusCode = 200;
|
|
203
|
+
res.setHeader("content-type", "application/javascript; charset=utf-8");
|
|
204
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
205
|
+
res.setHeader("cache-control", "no-store");
|
|
206
|
+
res.end(fsReadFileSync(filePath));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
res.statusCode = 404;
|
|
210
|
+
res.end("Not found");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
133
214
|
// ── /_alabjs/__devtools — debug dump of routes + server functions ───────────
|
|
134
215
|
if (pathname === "/_alabjs/__devtools") {
|
|
135
216
|
const devRoutes = scanDevRoutes(appDir);
|
|
@@ -387,7 +468,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
|
|
|
387
468
|
onError(e) { fail(e); },
|
|
388
469
|
});
|
|
389
470
|
});
|
|
390
|
-
const shell = htmlShellBefore({ metadata: { title: "404 — Not Found" }, paramsJson: "{}", searchParamsJson: "{}", routeFile: "app/not-found.tsx", ssr: true });
|
|
471
|
+
const shell = htmlShellBefore({ metadata: { title: "404 — Not Found" }, paramsJson: "{}", searchParamsJson: "{}", routeFile: "app/not-found.tsx", ssr: true, ...(importMapJson ? { importMapJson } : {}) });
|
|
391
472
|
const rawHtml = `${shell}${nfContent}${htmlShellAfter({})}`;
|
|
392
473
|
const html = await vite.transformIndexHtml(pathname, rawHtml);
|
|
393
474
|
res.statusCode = 404;
|
|
@@ -535,6 +616,7 @@ export async function dev({ cwd, port = 3000, host = "localhost" }: DevOptions)
|
|
|
535
616
|
loadingFile,
|
|
536
617
|
ssr: ssrEnabled,
|
|
537
618
|
buildId: devBuildId,
|
|
619
|
+
...(importMapJson ? { importMapJson } : {}),
|
|
538
620
|
};
|
|
539
621
|
const renderPageHtml = async (): Promise<string> => {
|
|
540
622
|
let rawHtml: string;
|
package/src/commands/start.ts
CHANGED
|
@@ -8,7 +8,7 @@ interface StartOptions {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export async function start({ cwd, port = 3000 }: StartOptions) {
|
|
11
|
-
const manifestPath = resolve(cwd, ".alabjs/route-manifest.json");
|
|
11
|
+
const manifestPath = resolve(cwd, ".alabjs/dist/route-manifest.json");
|
|
12
12
|
|
|
13
13
|
let manifest: RouteManifest;
|
|
14
14
|
try {
|
package/src/components/Image.tsx
CHANGED
|
@@ -104,38 +104,5 @@ export function Image({
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
* Calls the Rust napi binding to resize the image to 8px wide and encode it
|
|
111
|
-
* as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
|
|
112
|
-
*
|
|
113
|
-
* Run this in a server function — it reads from disk and must not run in the browser.
|
|
114
|
-
*
|
|
115
|
-
* @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
|
|
116
|
-
* @param publicDir - Absolute path to the `public/` directory
|
|
117
|
-
*/
|
|
118
|
-
export async function generateBlurPlaceholder(
|
|
119
|
-
src: string,
|
|
120
|
-
publicDir: string,
|
|
121
|
-
): Promise<string> {
|
|
122
|
-
const { readFile } = await import("node:fs/promises");
|
|
123
|
-
const { resolve } = await import("node:path");
|
|
124
|
-
|
|
125
|
-
const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
126
|
-
const filePath = resolve(publicDir, safeSrc);
|
|
127
|
-
|
|
128
|
-
const input = await readFile(filePath);
|
|
129
|
-
|
|
130
|
-
let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
|
|
131
|
-
try {
|
|
132
|
-
napi = (await import("@alabjs/compiler")) as typeof napi;
|
|
133
|
-
} catch {
|
|
134
|
-
// napi not built — return empty string (image still loads, just no blur effect)
|
|
135
|
-
return "";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
|
|
139
|
-
const b64 = Buffer.from(tiny).toString("base64");
|
|
140
|
-
return `data:image/webp;base64,${b64}`;
|
|
141
|
-
}
|
|
107
|
+
// generateBlurPlaceholder is server-only (uses node:fs/promises).
|
|
108
|
+
// Import it from "alabjs/components/server" in your server functions.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only image utilities.
|
|
3
|
+
*
|
|
4
|
+
* Import from "alabjs/components/server" — do NOT import from "alabjs/components"
|
|
5
|
+
* because this file uses Node.js built-ins (fs, path) and must never be bundled
|
|
6
|
+
* for the browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a Base64 blur-up placeholder for an image in `public/`.
|
|
11
|
+
*
|
|
12
|
+
* Calls the Rust napi binding to resize the image to 8px wide and encode it
|
|
13
|
+
* as a tiny WebP, then Base64-encodes it into a data URL ready for `blurDataURL`.
|
|
14
|
+
*
|
|
15
|
+
* Run this in a server function — it reads from disk and must not run in the browser.
|
|
16
|
+
*
|
|
17
|
+
* @param src - Path relative to `public/` (e.g. `"/hero.jpg"`)
|
|
18
|
+
* @param publicDir - Absolute path to the `public/` directory
|
|
19
|
+
*/
|
|
20
|
+
export async function generateBlurPlaceholder(
|
|
21
|
+
src: string,
|
|
22
|
+
publicDir: string,
|
|
23
|
+
): Promise<string> {
|
|
24
|
+
const { readFile } = await import("node:fs/promises");
|
|
25
|
+
const { resolve } = await import("node:path");
|
|
26
|
+
|
|
27
|
+
const safeSrc = src.replace(/\.\./g, "").replace(/^\/+/, "");
|
|
28
|
+
const filePath = resolve(publicDir, safeSrc);
|
|
29
|
+
|
|
30
|
+
const input = await readFile(filePath);
|
|
31
|
+
|
|
32
|
+
let napi: { optimizeImage: (b: Buffer, q: number | null, w: number | null, h: null, fmt: string) => Promise<Buffer> };
|
|
33
|
+
try {
|
|
34
|
+
napi = (await import("@alabjs/compiler")) as typeof napi;
|
|
35
|
+
} catch {
|
|
36
|
+
// napi not built — return empty string (image still loads, just no blur effect)
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tiny = await napi.optimizeImage(input, 40, 8, null, "webp");
|
|
41
|
+
const b64 = Buffer.from(tiny).toString("base64");
|
|
42
|
+
return `data:image/webp;base64,${b64}`;
|
|
43
|
+
}
|
package/src/components/index.ts
CHANGED