@jay-framework/production-server 0.17.3 → 0.17.4
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/index.d.ts +100 -5
- package/dist/index.js +987 -251
- package/package.json +12 -12
package/dist/index.js
CHANGED
|
@@ -12,15 +12,16 @@ import path from "node:path";
|
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { parseJayFile, JAY_IMPORT_RESOLVER, injectHeadfullFSTemplates, discoverHeadlessInstances, assignCoordinatesToJayHtml, generateServerElementFile, parseContract, slowRenderTransform, resolveHeadlessInstances } from "@jay-framework/compiler-jay-html";
|
|
15
|
-
import { DevSlowlyChangingPhase, slowRenderInstances, scanPlugins, runLoadParams, renderFastChangingData, mergeHeadTags, serializeHeadTags, getClientInitData, actionRegistry, setClientInitData } from "@jay-framework/stack-server-runtime";
|
|
15
|
+
import { DevSlowlyChangingPhase, slowRenderInstances, scanPlugins, runLoadParams, renderFastChangingData, mergeHeadTags, serializeHeadTags, getClientInitData, actionRegistry, setClientInitData, parseCookies } from "@jay-framework/stack-server-runtime";
|
|
16
16
|
import { checkValidationErrors } from "@jay-framework/compiler-shared";
|
|
17
17
|
import { jayRuntime } from "@jay-framework/vite-plugin";
|
|
18
18
|
import crypto from "node:crypto";
|
|
19
19
|
import fs$1 from "node:fs";
|
|
20
20
|
import http from "node:http";
|
|
21
|
+
import { Readable } from "node:stream";
|
|
21
22
|
import { deepMergeViewStates } from "@jay-framework/view-state-merge";
|
|
22
23
|
import { asyncSwapScript } from "@jay-framework/ssr-runtime";
|
|
23
|
-
import { isJayAction, isJayStreamAction } from "@jay-framework/fullstack-component";
|
|
24
|
+
import { isJayAction, isJayStreamAction, isJayWebhook } from "@jay-framework/fullstack-component";
|
|
24
25
|
async function discoverServerEntries(projectRoot, pagesRoot) {
|
|
25
26
|
const logger = getLogger();
|
|
26
27
|
const routes = await scanRoutes(pagesRoot, {
|
|
@@ -57,7 +58,7 @@ async function discoverServerEntries(projectRoot, pagesRoot) {
|
|
|
57
58
|
const dirPath = path.join(scanDir, dir.name);
|
|
58
59
|
const files = await fs.readdir(dirPath);
|
|
59
60
|
for (const file of files) {
|
|
60
|
-
if (file.endsWith(".ts") && !file.endsWith(".d.ts") && file !== "
|
|
61
|
+
if (file.endsWith(".ts") && !file.endsWith(".d.ts") && file !== "page.ts") {
|
|
61
62
|
const entryName = `${subDir}/${dir.name}/${file.replace(/\.ts$/, "")}`;
|
|
62
63
|
pages[entryName] = path.join(dirPath, file);
|
|
63
64
|
}
|
|
@@ -226,6 +227,7 @@ async function loadProductionPageParts(route, pageModule, jayHtmlContent, projec
|
|
|
226
227
|
const jayHtml = checkValidationErrors(jayHtmlWithValidations);
|
|
227
228
|
const headlessInstanceComponents = [];
|
|
228
229
|
const keyedPartModules = [];
|
|
230
|
+
const headlessModuleInfos = [];
|
|
229
231
|
const headlessImports = jayHtml.headlessImports ?? [];
|
|
230
232
|
getLogger().info(
|
|
231
233
|
`[Build] headlessImports for ${fileName}: ${headlessImports.length}, keys: ${Object.keys(jayHtml).join(",")}`
|
|
@@ -261,21 +263,26 @@ async function loadProductionPageParts(route, pageModule, jayHtmlContent, projec
|
|
|
261
263
|
const headlessCompDef = headlessModule[name];
|
|
262
264
|
if (headlessImport.key) {
|
|
263
265
|
const clientModulePath = isLocalModule ? path.resolve(dirName, module) : `${module}/client`;
|
|
266
|
+
const ci = headlessImport.contract ? { contractName: headlessImport.contract.name, metadata: headlessImport.metadata } : void 0;
|
|
264
267
|
parts.push({
|
|
265
268
|
key: headlessImport.key,
|
|
266
269
|
compDefinition: headlessCompDef,
|
|
267
270
|
clientImport: "",
|
|
268
271
|
clientPart: "",
|
|
269
|
-
contractInfo:
|
|
270
|
-
contractName: headlessImport.contract.name,
|
|
271
|
-
metadata: headlessImport.metadata
|
|
272
|
-
} : void 0
|
|
272
|
+
contractInfo: ci
|
|
273
273
|
});
|
|
274
274
|
keyedPartModules.push({
|
|
275
275
|
key: headlessImport.key,
|
|
276
276
|
modulePath: clientModulePath,
|
|
277
277
|
exportName: name
|
|
278
278
|
});
|
|
279
|
+
headlessModuleInfos.push({
|
|
280
|
+
modulePath,
|
|
281
|
+
exportName: name,
|
|
282
|
+
isLocal: isLocalModule,
|
|
283
|
+
key: headlessImport.key,
|
|
284
|
+
contractInfo: ci
|
|
285
|
+
});
|
|
279
286
|
}
|
|
280
287
|
if (!headlessImport.key && headlessImport.contract) {
|
|
281
288
|
headlessInstanceComponents.push({
|
|
@@ -283,6 +290,13 @@ async function loadProductionPageParts(route, pageModule, jayHtmlContent, projec
|
|
|
283
290
|
compDefinition: headlessCompDef,
|
|
284
291
|
contract: headlessImport.contract
|
|
285
292
|
});
|
|
293
|
+
headlessModuleInfos.push({
|
|
294
|
+
modulePath,
|
|
295
|
+
exportName: name,
|
|
296
|
+
isLocal: isLocalModule,
|
|
297
|
+
contractName: headlessImport.contractName,
|
|
298
|
+
propNames: headlessImport.contract.props?.map((p) => p.name) ?? []
|
|
299
|
+
});
|
|
286
300
|
}
|
|
287
301
|
}
|
|
288
302
|
const headlessContracts = (jayHtml.headlessImports ?? []).filter((hi) => hi.contract && hi.key).map((hi) => ({
|
|
@@ -315,10 +329,101 @@ async function loadProductionPageParts(route, pageModule, jayHtmlContent, projec
|
|
|
315
329
|
discoveredInstances,
|
|
316
330
|
forEachInstances,
|
|
317
331
|
keyedPartModules,
|
|
332
|
+
headlessModuleInfos,
|
|
318
333
|
serverTrackByMap: jayHtml.serverTrackByMap,
|
|
319
334
|
clientTrackByMap: jayHtml.clientTrackByMap
|
|
320
335
|
};
|
|
321
336
|
}
|
|
337
|
+
function buildPagePartsConfig(pageParts, pageServerModule, pageExportName, buildDir, pageIsPlugin = false) {
|
|
338
|
+
const parts = [];
|
|
339
|
+
if (pageServerModule) {
|
|
340
|
+
parts.push({
|
|
341
|
+
modulePath: pageServerModule,
|
|
342
|
+
exportName: pageExportName,
|
|
343
|
+
source: pageIsPlugin ? "npm" : "local"
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
for (const info of pageParts.headlessModuleInfos) {
|
|
347
|
+
if (info.key) {
|
|
348
|
+
parts.push({
|
|
349
|
+
modulePath: info.isLocal ? path.relative(buildDir, info.modulePath) : info.modulePath,
|
|
350
|
+
exportName: info.exportName,
|
|
351
|
+
source: info.isLocal ? "local" : "npm",
|
|
352
|
+
key: info.key,
|
|
353
|
+
contractInfo: info.contractInfo
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const instanceComponents = [];
|
|
358
|
+
for (const info of pageParts.headlessModuleInfos) {
|
|
359
|
+
if (info.contractName) {
|
|
360
|
+
instanceComponents.push({
|
|
361
|
+
modulePath: info.isLocal ? path.relative(buildDir, info.modulePath) : info.modulePath,
|
|
362
|
+
exportName: info.exportName,
|
|
363
|
+
source: info.isLocal ? "local" : "npm",
|
|
364
|
+
contractName: info.contractName,
|
|
365
|
+
propNames: info.propNames ?? []
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
parts,
|
|
371
|
+
instanceComponents,
|
|
372
|
+
forEachInstances: pageParts.forEachInstances.map((fi) => ({
|
|
373
|
+
contractName: fi.contractName,
|
|
374
|
+
forEachPath: fi.forEachPath,
|
|
375
|
+
trackBy: fi.trackBy,
|
|
376
|
+
propBindings: fi.propBindings,
|
|
377
|
+
coordinateSuffix: fi.coordinateSuffix
|
|
378
|
+
}))
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async function loadPagePartsFromConfig(configPath, buildDir) {
|
|
382
|
+
const config = JSON.parse(await fs.readFile(configPath, "utf-8"));
|
|
383
|
+
async function importModule(entry) {
|
|
384
|
+
if (!entry.modulePath) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Empty modulePath in page-parts.json for "${entry.exportName}" (source: ${entry.source}). Rebuild required.`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (entry.source === "local") {
|
|
390
|
+
return import(path.join(buildDir, entry.modulePath));
|
|
391
|
+
}
|
|
392
|
+
return import(entry.modulePath);
|
|
393
|
+
}
|
|
394
|
+
const parts = [];
|
|
395
|
+
for (const entry of config.parts) {
|
|
396
|
+
const mod = await importModule(entry);
|
|
397
|
+
parts.push({
|
|
398
|
+
compDefinition: mod[entry.exportName] ?? mod.default,
|
|
399
|
+
key: entry.key,
|
|
400
|
+
clientImport: "",
|
|
401
|
+
clientPart: "",
|
|
402
|
+
contractInfo: entry.contractInfo
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
const headlessInstanceComponents = [];
|
|
406
|
+
for (const entry of config.instanceComponents) {
|
|
407
|
+
const mod = await importModule(entry);
|
|
408
|
+
const serveTimeContract = {
|
|
409
|
+
props: entry.propNames.map((name) => ({ name }))
|
|
410
|
+
};
|
|
411
|
+
headlessInstanceComponents.push({
|
|
412
|
+
contractName: entry.contractName,
|
|
413
|
+
compDefinition: mod[entry.exportName] ?? mod.default,
|
|
414
|
+
contract: serveTimeContract
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
parts,
|
|
419
|
+
headlessContracts: [],
|
|
420
|
+
headlessInstanceComponents,
|
|
421
|
+
discoveredInstances: [],
|
|
422
|
+
forEachInstances: config.forEachInstances,
|
|
423
|
+
keyedPartModules: [],
|
|
424
|
+
headlessModuleInfos: []
|
|
425
|
+
};
|
|
426
|
+
}
|
|
322
427
|
async function compileServerElement(jayHtmlContent, jayHtmlFilename, jayHtmlDir, outputPath, projectRoot, tsConfigFilePath, sourceDir) {
|
|
323
428
|
const jayFile = await parseJayFile(
|
|
324
429
|
jayHtmlContent,
|
|
@@ -482,7 +587,7 @@ function resolvePackageNameForRoute(compPath) {
|
|
|
482
587
|
}
|
|
483
588
|
return void 0;
|
|
484
589
|
}
|
|
485
|
-
function hashParams(params) {
|
|
590
|
+
function hashParams(params, suffix) {
|
|
486
591
|
const sorted = Object.keys(params).sort().reduce(
|
|
487
592
|
(acc, key) => {
|
|
488
593
|
acc[key] = params[key];
|
|
@@ -491,20 +596,23 @@ function hashParams(params) {
|
|
|
491
596
|
{}
|
|
492
597
|
);
|
|
493
598
|
const json = JSON.stringify(sorted);
|
|
494
|
-
if (json === "{}")
|
|
599
|
+
if (json === "{}" && !suffix)
|
|
495
600
|
return "";
|
|
496
|
-
|
|
601
|
+
const input = suffix ? json + ":" + suffix : json;
|
|
602
|
+
return "_" + crypto.createHash("md5").update(input).digest("hex").substring(0, 8);
|
|
497
603
|
}
|
|
498
604
|
async function buildInstance(route, params, pageModule, ctx) {
|
|
499
605
|
const logger = getLogger();
|
|
500
606
|
const routeDir = route.rawRoute.replace(/^\//, "") || "index";
|
|
501
|
-
const paramHash = hashParams(params);
|
|
607
|
+
const paramHash = hashParams(params, ctx.rebuildSuffix);
|
|
502
608
|
const instanceId = `page${paramHash}`;
|
|
503
|
-
const
|
|
504
|
-
|
|
609
|
+
const backendInstanceDir = path.join(ctx.backendDir, "pre-rendered", routeDir);
|
|
610
|
+
const frontendInstanceDir = path.join(ctx.frontendDir, "pages", routeDir);
|
|
611
|
+
await fs.mkdir(backendInstanceDir, { recursive: true });
|
|
612
|
+
await fs.mkdir(frontendInstanceDir, { recursive: true });
|
|
505
613
|
const jayHtmlContent = await fs.readFile(route.jayHtmlPath, "utf-8");
|
|
506
614
|
const sourceDir = path.dirname(route.jayHtmlPath);
|
|
507
|
-
const serverBuildDir = path.join(ctx.
|
|
615
|
+
const serverBuildDir = path.join(ctx.backendDir, "server");
|
|
508
616
|
const pageParts = await loadProductionPageParts(
|
|
509
617
|
route,
|
|
510
618
|
pageModule,
|
|
@@ -513,6 +621,38 @@ async function buildInstance(route, params, pageModule, ctx) {
|
|
|
513
621
|
ctx.tsConfigFilePath,
|
|
514
622
|
serverBuildDir
|
|
515
623
|
);
|
|
624
|
+
const contracts = [
|
|
625
|
+
.../* @__PURE__ */ new Set([
|
|
626
|
+
...pageParts.headlessInstanceComponents.map((c) => c.contractName),
|
|
627
|
+
...pageParts.parts.filter((p) => p.contractInfo?.contractName).map((p) => p.contractInfo.contractName)
|
|
628
|
+
])
|
|
629
|
+
];
|
|
630
|
+
const pagePartsConfigPath = path.join(backendInstanceDir, "page-parts.json");
|
|
631
|
+
try {
|
|
632
|
+
await fs.access(pagePartsConfigPath);
|
|
633
|
+
} catch {
|
|
634
|
+
const exportName = route.componentExport || "page";
|
|
635
|
+
let pageServerModule = "";
|
|
636
|
+
let pageIsPlugin = false;
|
|
637
|
+
if (route.compPath) {
|
|
638
|
+
if (route.componentExport) {
|
|
639
|
+
pageServerModule = route.compPath;
|
|
640
|
+
pageIsPlugin = true;
|
|
641
|
+
} else {
|
|
642
|
+
const relativePath = path.relative(ctx.projectRoot, route.compPath);
|
|
643
|
+
pageServerModule = relativePath.replace(/^src\//, "server/").replace(/\.ts$/, ".js").replace(/\[/g, "_").replace(/\]/g, "_");
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const config = buildPagePartsConfig(
|
|
647
|
+
pageParts,
|
|
648
|
+
pageServerModule,
|
|
649
|
+
exportName,
|
|
650
|
+
ctx.backendDir,
|
|
651
|
+
pageIsPlugin
|
|
652
|
+
);
|
|
653
|
+
await fs.writeFile(pagePartsConfigPath, JSON.stringify(config, null, 2));
|
|
654
|
+
logger.info(`[Build] Page parts config: ${routeDir}/page-parts.json`);
|
|
655
|
+
}
|
|
516
656
|
const slowPhase = new DevSlowlyChangingPhase();
|
|
517
657
|
const slowResult = await slowPhase.runSlowlyForPage(
|
|
518
658
|
params,
|
|
@@ -619,39 +759,62 @@ async function buildInstance(route, params, pageModule, ctx) {
|
|
|
619
759
|
}
|
|
620
760
|
}
|
|
621
761
|
}
|
|
622
|
-
|
|
762
|
+
preRenderedJayHtml = preRenderedJayHtml.replace(
|
|
763
|
+
/(<script\s+type="application\/jay-headfull"[^>]*\s)(src="([^"]*)")/g,
|
|
764
|
+
(_match, prefix, _srcAttr, srcVal) => {
|
|
765
|
+
if (path.isAbsolute(srcVal))
|
|
766
|
+
return prefix + `src="${srcVal}"`;
|
|
767
|
+
const abs = path.resolve(sourceDir, srcVal);
|
|
768
|
+
let rel = path.relative(backendInstanceDir, abs);
|
|
769
|
+
if (!rel.startsWith("."))
|
|
770
|
+
rel = "./" + rel;
|
|
771
|
+
return prefix + `src="${rel}"`;
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
preRenderedJayHtml = preRenderedJayHtml.replace(
|
|
775
|
+
/(<script\s+type="application\/jay-headfull"[^>]*\s)(contract="([^"]*)")/g,
|
|
776
|
+
(_match, prefix, _contractAttr, contractVal) => {
|
|
777
|
+
if (path.isAbsolute(contractVal))
|
|
778
|
+
return prefix + `contract="${contractVal}"`;
|
|
779
|
+
const abs = path.resolve(sourceDir, contractVal);
|
|
780
|
+
let rel = path.relative(backendInstanceDir, abs);
|
|
781
|
+
if (!rel.startsWith("."))
|
|
782
|
+
rel = "./" + rel;
|
|
783
|
+
return prefix + `contract="${rel}"`;
|
|
784
|
+
}
|
|
785
|
+
);
|
|
786
|
+
const preRenderedPath = path.join(backendInstanceDir, `${instanceId}.jay-html`);
|
|
623
787
|
await fs.writeFile(preRenderedPath, preRenderedJayHtml, "utf-8");
|
|
624
|
-
const cacheMetadataPath = path.join(
|
|
788
|
+
const cacheMetadataPath = path.join(backendInstanceDir, `${instanceId}.cache.json`);
|
|
625
789
|
await fs.writeFile(
|
|
626
790
|
cacheMetadataPath,
|
|
627
791
|
JSON.stringify({
|
|
628
792
|
slowViewState,
|
|
629
|
-
carryForward
|
|
630
|
-
sourcePath: route.jayHtmlPath
|
|
793
|
+
carryForward
|
|
631
794
|
}),
|
|
632
795
|
"utf-8"
|
|
633
796
|
);
|
|
634
797
|
logger.info(`[Build] Pre-rendered: ${routeDir}/${instanceId}`);
|
|
635
|
-
const serverElementPath = path.join(
|
|
798
|
+
const serverElementPath = path.join(backendInstanceDir, `${instanceId}.server-element.js`);
|
|
636
799
|
const serverElementResult = await compileServerElement(
|
|
637
800
|
preRenderedJayHtml,
|
|
638
801
|
`${instanceId}.jay-html`,
|
|
639
|
-
|
|
802
|
+
backendInstanceDir,
|
|
640
803
|
serverElementPath,
|
|
641
804
|
ctx.projectRoot,
|
|
642
805
|
ctx.tsConfigFilePath,
|
|
643
806
|
sourceDir
|
|
644
807
|
);
|
|
645
|
-
const hydrateEntryPath = path.join(
|
|
646
|
-
const relativeJayHtmlPath = path.relative(
|
|
808
|
+
const hydrateEntryPath = path.join(backendInstanceDir, `${instanceId}.hydrate-entry.ts`);
|
|
809
|
+
const relativeJayHtmlPath = path.relative(backendInstanceDir, preRenderedPath);
|
|
647
810
|
let pageModulePath;
|
|
648
811
|
let pageExportName;
|
|
649
812
|
if (route.componentExport) {
|
|
650
813
|
const pkgName = resolvePackageNameForRoute(route.compPath);
|
|
651
|
-
pageModulePath = pkgName ? `${pkgName}/client` : "./" + path.relative(
|
|
814
|
+
pageModulePath = pkgName ? `${pkgName}/client` : "./" + path.relative(backendInstanceDir, route.compPath);
|
|
652
815
|
pageExportName = route.componentExport;
|
|
653
816
|
} else if (route.compPath) {
|
|
654
|
-
pageModulePath = "./" + path.relative(
|
|
817
|
+
pageModulePath = "./" + path.relative(backendInstanceDir, route.compPath);
|
|
655
818
|
pageExportName = "page";
|
|
656
819
|
} else {
|
|
657
820
|
pageModulePath = "";
|
|
@@ -675,7 +838,7 @@ async function buildInstance(route, params, pageModule, ctx) {
|
|
|
675
838
|
const clientResult = await buildInstanceClient(
|
|
676
839
|
hydrateEntryPath,
|
|
677
840
|
instanceId,
|
|
678
|
-
|
|
841
|
+
frontendInstanceDir,
|
|
679
842
|
ctx.projectRoot,
|
|
680
843
|
ctx.jayOptions,
|
|
681
844
|
ctx.minify ?? true,
|
|
@@ -684,14 +847,27 @@ async function buildInstance(route, params, pageModule, ctx) {
|
|
|
684
847
|
);
|
|
685
848
|
await fs.rm(hydrateEntryPath, { force: true });
|
|
686
849
|
const cssFile = clientResult.cssFile || serverElementResult.cssFile;
|
|
850
|
+
if (serverElementResult.cssFile && !clientResult.cssFile) {
|
|
851
|
+
const srcCss = path.join(backendInstanceDir, serverElementResult.cssFile);
|
|
852
|
+
const dstCss = path.join(frontendInstanceDir, serverElementResult.cssFile);
|
|
853
|
+
try {
|
|
854
|
+
await fs.rename(srcCss, dstCss);
|
|
855
|
+
} catch {
|
|
856
|
+
await fs.copyFile(srcCss, dstCss);
|
|
857
|
+
await fs.rm(srcCss, { force: true });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
687
860
|
const instanceEntry = {
|
|
688
861
|
params,
|
|
689
|
-
preRenderedPath: path.relative(ctx.
|
|
690
|
-
serverElementPath: path.relative(ctx.
|
|
691
|
-
clientBundlePath: path.relative(
|
|
692
|
-
|
|
862
|
+
preRenderedPath: path.relative(ctx.backendDir, preRenderedPath),
|
|
863
|
+
serverElementPath: path.relative(ctx.backendDir, serverElementPath),
|
|
864
|
+
clientBundlePath: path.relative(
|
|
865
|
+
ctx.frontendDir,
|
|
866
|
+
path.join(frontendInstanceDir, clientResult.jsFile)
|
|
867
|
+
),
|
|
868
|
+
clientCssPath: cssFile ? path.relative(ctx.frontendDir, path.join(frontendInstanceDir, cssFile)) : void 0
|
|
693
869
|
};
|
|
694
|
-
return { status: "success", instanceEntry, slowViewState, carryForward };
|
|
870
|
+
return { status: "success", instanceEntry, slowViewState, carryForward, contracts };
|
|
695
871
|
}
|
|
696
872
|
function convertSegments(segments) {
|
|
697
873
|
return segments.map((s) => {
|
|
@@ -713,7 +889,6 @@ function buildRouteEntry(route, serverModulePath) {
|
|
|
713
889
|
pattern: route.rawRoute,
|
|
714
890
|
segments: convertSegments(route.segments),
|
|
715
891
|
serverModule: serverModulePath,
|
|
716
|
-
jayHtmlPath: route.jayHtmlPath,
|
|
717
892
|
componentExport: route.componentExport,
|
|
718
893
|
instances: []
|
|
719
894
|
};
|
|
@@ -1006,11 +1181,14 @@ async function discoverPluginClientPackages(projectRoot) {
|
|
|
1006
1181
|
async function buildVersion(options) {
|
|
1007
1182
|
const logger = getLogger();
|
|
1008
1183
|
const buildDir = path.join(options.buildRoot, `v${options.version}`);
|
|
1184
|
+
const backendDir = path.join(buildDir, "backend");
|
|
1185
|
+
const frontendDir = path.join(buildDir, "frontend");
|
|
1009
1186
|
logger.important(`[Build] Starting production build v${options.version}`);
|
|
1010
1187
|
logger.important(`[Build] Project: ${options.projectRoot}`);
|
|
1011
|
-
await fs.mkdir(
|
|
1188
|
+
await fs.mkdir(backendDir, { recursive: true });
|
|
1189
|
+
await fs.mkdir(frontendDir, { recursive: true });
|
|
1012
1190
|
const { entries, routes } = await discoverServerEntries(options.projectRoot, options.pagesRoot);
|
|
1013
|
-
const serverOutputDir = path.join(
|
|
1191
|
+
const serverOutputDir = path.join(backendDir, "server");
|
|
1014
1192
|
await buildServerCode(
|
|
1015
1193
|
entries,
|
|
1016
1194
|
{ tsConfigFilePath: options.tsConfigFilePath },
|
|
@@ -1021,7 +1199,7 @@ async function buildVersion(options) {
|
|
|
1021
1199
|
if (pluginClientPackages.length > 0) {
|
|
1022
1200
|
logger.important(`[Build] Plugin client packages: ${pluginClientPackages.join(", ")}`);
|
|
1023
1201
|
}
|
|
1024
|
-
const sharedOutputDir = path.join(
|
|
1202
|
+
const sharedOutputDir = path.join(frontendDir, "shared");
|
|
1025
1203
|
const { manifest: sharedManifest } = await buildSharedChunks(
|
|
1026
1204
|
sharedOutputDir,
|
|
1027
1205
|
options.projectRoot,
|
|
@@ -1031,7 +1209,7 @@ async function buildVersion(options) {
|
|
|
1031
1209
|
const { actions, plugins } = await discoverActions(
|
|
1032
1210
|
entries.actions,
|
|
1033
1211
|
serverOutputDir,
|
|
1034
|
-
|
|
1212
|
+
backendDir,
|
|
1035
1213
|
options.projectRoot
|
|
1036
1214
|
);
|
|
1037
1215
|
const { discoverPluginsWithInit, sortPluginsByDependencies } = await import("@jay-framework/stack-server-runtime");
|
|
@@ -1106,6 +1284,8 @@ async function buildVersion(options) {
|
|
|
1106
1284
|
projectRoot: options.projectRoot,
|
|
1107
1285
|
pagesRoot: options.pagesRoot,
|
|
1108
1286
|
buildDir,
|
|
1287
|
+
backendDir,
|
|
1288
|
+
frontendDir,
|
|
1109
1289
|
jayOptions: { tsConfigFilePath: options.tsConfigFilePath },
|
|
1110
1290
|
tsConfigFilePath: options.tsConfigFilePath,
|
|
1111
1291
|
minify: options.minify ?? true,
|
|
@@ -1141,7 +1321,7 @@ async function buildVersion(options) {
|
|
|
1141
1321
|
return {};
|
|
1142
1322
|
if (entry.isPlugin)
|
|
1143
1323
|
return import(entry.serverModule);
|
|
1144
|
-
return import(path.join(
|
|
1324
|
+
return import(path.join(backendDir, entry.serverModule));
|
|
1145
1325
|
}
|
|
1146
1326
|
const routeInfos = routeEntries.map((re) => {
|
|
1147
1327
|
const optionalNames = re.route.segments.filter((s) => typeof s !== "string" && s.type === JayRouteParamType.optional).map((s) => s.name);
|
|
@@ -1172,7 +1352,7 @@ async function buildVersion(options) {
|
|
|
1172
1352
|
await fs.readFile(route.jayHtmlPath, "utf-8"),
|
|
1173
1353
|
options.projectRoot,
|
|
1174
1354
|
options.tsConfigFilePath,
|
|
1175
|
-
|
|
1355
|
+
serverOutputDir
|
|
1176
1356
|
);
|
|
1177
1357
|
const partsWithLoadParams = pageParts.parts.filter((p) => p.compDefinition?.loadParams);
|
|
1178
1358
|
if (partsWithLoadParams.length === 0)
|
|
@@ -1228,6 +1408,9 @@ async function buildVersion(options) {
|
|
|
1228
1408
|
const result = await buildInstance(route, params, pageModule, instanceCtx);
|
|
1229
1409
|
if (result.status === "success") {
|
|
1230
1410
|
entry.instances.push(result.instanceEntry);
|
|
1411
|
+
if (result.contracts.length > 0 && !entry.contracts) {
|
|
1412
|
+
entry.contracts = result.contracts;
|
|
1413
|
+
}
|
|
1231
1414
|
logInstance(route.rawRoute || "/", params);
|
|
1232
1415
|
} else {
|
|
1233
1416
|
logger.warn(
|
|
@@ -1248,13 +1431,12 @@ async function buildVersion(options) {
|
|
|
1248
1431
|
buildTimestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1249
1432
|
sourceHash: "",
|
|
1250
1433
|
projectRoot: options.projectRoot,
|
|
1251
|
-
publicBasePath: options.publicBasePath,
|
|
1252
1434
|
sharedManifest,
|
|
1253
1435
|
routes: routeEntries.map((r) => r.entry),
|
|
1254
1436
|
actions,
|
|
1255
1437
|
plugins
|
|
1256
1438
|
};
|
|
1257
|
-
await writeRouteManifest(manifest,
|
|
1439
|
+
await writeRouteManifest(manifest, backendDir);
|
|
1258
1440
|
const metadata = {
|
|
1259
1441
|
version: options.version,
|
|
1260
1442
|
sourceHash: "",
|
|
@@ -1263,9 +1445,16 @@ async function buildVersion(options) {
|
|
|
1263
1445
|
instanceCount
|
|
1264
1446
|
};
|
|
1265
1447
|
await fs.writeFile(
|
|
1266
|
-
path.join(
|
|
1448
|
+
path.join(backendDir, "build-metadata.json"),
|
|
1267
1449
|
JSON.stringify(metadata, null, 2)
|
|
1268
1450
|
);
|
|
1451
|
+
const publicFolder = path.join(options.projectRoot, "public");
|
|
1452
|
+
try {
|
|
1453
|
+
await fs.access(publicFolder);
|
|
1454
|
+
await fs.cp(publicFolder, path.join(frontendDir, "public"), { recursive: true });
|
|
1455
|
+
logger.info("[Build] Copied public/ to frontend/public/");
|
|
1456
|
+
} catch {
|
|
1457
|
+
}
|
|
1269
1458
|
logger.important(`[Build] Done! ${instanceCount} instances built in ${buildDir}`);
|
|
1270
1459
|
return manifest;
|
|
1271
1460
|
}
|
|
@@ -1274,17 +1463,24 @@ const CACHE_TAG_END = "<\/script>";
|
|
|
1274
1463
|
class FilesystemArtifactStore {
|
|
1275
1464
|
constructor(basePath) {
|
|
1276
1465
|
__publicField(this, "manifestCache");
|
|
1466
|
+
__publicField(this, "metadataMtime");
|
|
1277
1467
|
__publicField(this, "moduleCache", /* @__PURE__ */ new Map());
|
|
1278
1468
|
this.basePath = basePath;
|
|
1279
1469
|
}
|
|
1280
1470
|
async readManifest() {
|
|
1471
|
+
const metadataPath = path.join(this.basePath, "build-metadata.json");
|
|
1281
1472
|
const manifestPath = path.join(this.basePath, "route-manifest.json");
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1473
|
+
try {
|
|
1474
|
+
const metaStat = await fs.stat(metadataPath);
|
|
1475
|
+
if (this.manifestCache && this.metadataMtime === metaStat.mtimeMs) {
|
|
1476
|
+
return this.manifestCache.manifest;
|
|
1477
|
+
}
|
|
1478
|
+
this.metadataMtime = metaStat.mtimeMs;
|
|
1479
|
+
} catch {
|
|
1285
1480
|
}
|
|
1286
1481
|
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
|
|
1287
|
-
|
|
1482
|
+
const manifestStat = await fs.stat(manifestPath);
|
|
1483
|
+
this.manifestCache = { manifest, mtime: manifestStat.mtimeMs };
|
|
1288
1484
|
return manifest;
|
|
1289
1485
|
}
|
|
1290
1486
|
async readPreRenderedHtml(relativePath) {
|
|
@@ -1315,6 +1511,9 @@ class FilesystemArtifactStore {
|
|
|
1315
1511
|
getAssetPath(relativePath) {
|
|
1316
1512
|
return path.join(this.basePath, relativePath);
|
|
1317
1513
|
}
|
|
1514
|
+
getBuildDir() {
|
|
1515
|
+
return this.basePath;
|
|
1516
|
+
}
|
|
1318
1517
|
async loadModule(relativePath) {
|
|
1319
1518
|
const fullPath = path.join(this.basePath, relativePath);
|
|
1320
1519
|
const stat = await fs.stat(fullPath);
|
|
@@ -1421,56 +1620,23 @@ function buildImportMap(sharedManifest, publicBasePath, sharedDir = "shared") {
|
|
|
1421
1620
|
return imports;
|
|
1422
1621
|
}
|
|
1423
1622
|
const pagePartsCache = /* @__PURE__ */ new Map();
|
|
1424
|
-
async function getPageParts(route,
|
|
1623
|
+
async function getPageParts(route, artifacts, preRenderedPath) {
|
|
1425
1624
|
const cacheKey = route.pattern;
|
|
1426
1625
|
const cached = pagePartsCache.get(cacheKey);
|
|
1427
1626
|
if (cached)
|
|
1428
1627
|
return cached;
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
compDefinition: pageModule.page ?? pageModule.default,
|
|
1434
|
-
clientImport: "",
|
|
1435
|
-
clientPart: ""
|
|
1436
|
-
}
|
|
1437
|
-
],
|
|
1438
|
-
headlessContracts: [],
|
|
1439
|
-
headlessInstanceComponents: [],
|
|
1440
|
-
discoveredInstances: [],
|
|
1441
|
-
forEachInstances: [],
|
|
1442
|
-
keyedPartModules: []
|
|
1443
|
-
};
|
|
1444
|
-
}
|
|
1445
|
-
const jayHtmlContent = await artifacts.readRawFile(preRenderedPath);
|
|
1446
|
-
const serverBuildDir = artifacts.getAssetPath("server");
|
|
1447
|
-
const parts = await loadProductionPageParts(
|
|
1448
|
-
{ jayHtmlPath: route.jayHtmlPath, componentExport: route.componentExport },
|
|
1449
|
-
pageModule,
|
|
1450
|
-
jayHtmlContent,
|
|
1451
|
-
manifest.projectRoot,
|
|
1452
|
-
void 0,
|
|
1453
|
-
serverBuildDir
|
|
1454
|
-
);
|
|
1628
|
+
const routeDir = path.dirname(preRenderedPath);
|
|
1629
|
+
const configPath = artifacts.getAssetPath(path.join(routeDir, "page-parts.json"));
|
|
1630
|
+
const buildDir = artifacts.getBuildDir();
|
|
1631
|
+
const parts = await loadPagePartsFromConfig(configPath, buildDir);
|
|
1455
1632
|
pagePartsCache.set(cacheKey, parts);
|
|
1456
1633
|
return parts;
|
|
1457
1634
|
}
|
|
1458
|
-
async function
|
|
1635
|
+
async function fetchPageRequest(match, manifest, requestUrl, artifacts, staticBaseUrl, cookies = {}) {
|
|
1459
1636
|
const { route, instance } = match;
|
|
1460
1637
|
const preRendered = await artifacts.readPreRenderedHtml(instance.preRenderedPath);
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
pageModule = route.isPlugin ? await import(route.serverModule) : await artifacts.loadPageModule(route.serverModule);
|
|
1464
|
-
}
|
|
1465
|
-
const pageParts = await getPageParts(
|
|
1466
|
-
route,
|
|
1467
|
-
pageModule,
|
|
1468
|
-
artifacts,
|
|
1469
|
-
instance.preRenderedPath,
|
|
1470
|
-
manifest
|
|
1471
|
-
);
|
|
1472
|
-
const url = new URL(`http://localhost${match.pathname}`);
|
|
1473
|
-
const query = Object.fromEntries(url.searchParams.entries());
|
|
1638
|
+
const pageParts = await getPageParts(route, artifacts, instance.preRenderedPath);
|
|
1639
|
+
const query = Object.fromEntries(requestUrl.searchParams.entries());
|
|
1474
1640
|
const fastResult = await renderFastChangingData(
|
|
1475
1641
|
match.params,
|
|
1476
1642
|
{ params: match.params, query },
|
|
@@ -1480,17 +1646,19 @@ async function handlePageRequest(res, match, manifest, artifacts) {
|
|
|
1480
1646
|
pageParts.forEachInstances,
|
|
1481
1647
|
pageParts.headlessInstanceComponents,
|
|
1482
1648
|
preRendered.slowViewState,
|
|
1483
|
-
query
|
|
1649
|
+
query,
|
|
1650
|
+
cookies
|
|
1484
1651
|
);
|
|
1485
1652
|
if (fastResult.kind === "Redirect3xx") {
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1653
|
+
return new Response(null, {
|
|
1654
|
+
status: fastResult.status,
|
|
1655
|
+
headers: { Location: fastResult.location }
|
|
1656
|
+
});
|
|
1489
1657
|
}
|
|
1490
1658
|
if (fastResult.kind === "ServerError5xx" || fastResult.kind === "ClientError4xx") {
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1659
|
+
return new Response(fastResult.message || "Error", {
|
|
1660
|
+
status: fastResult.status
|
|
1661
|
+
});
|
|
1494
1662
|
}
|
|
1495
1663
|
const fastViewState = fastResult.rendered || {};
|
|
1496
1664
|
const fastCarryForward = fastResult.carryForward || {};
|
|
@@ -1503,7 +1671,6 @@ async function handlePageRequest(res, match, manifest, artifacts) {
|
|
|
1503
1671
|
headTagSources.push(fastHeadTags);
|
|
1504
1672
|
const headTags = headTagSources.length > 0 ? mergeHeadTags(headTagSources) : [];
|
|
1505
1673
|
const headTagsHtml = headTags.length > 0 ? serializeHeadTags(headTags) + "\n" : "";
|
|
1506
|
-
headTags.some((t) => t.tag.toLowerCase() === "title");
|
|
1507
1674
|
const fullViewState = deepMergeViewStates(
|
|
1508
1675
|
preRendered.slowViewState,
|
|
1509
1676
|
fastViewState,
|
|
@@ -1511,15 +1678,15 @@ async function handlePageRequest(res, match, manifest, artifacts) {
|
|
|
1511
1678
|
);
|
|
1512
1679
|
const serverElement = await artifacts.loadServerElement(instance.serverElementPath);
|
|
1513
1680
|
const asyncPromises = [];
|
|
1514
|
-
const importMap = buildImportMap(manifest.sharedManifest,
|
|
1515
|
-
const modulePreloads = Object.values(importMap).map((
|
|
1516
|
-
const cssLink = instance.clientCssPath ? ` <link rel="stylesheet" href="${
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1681
|
+
const importMap = buildImportMap(manifest.sharedManifest, staticBaseUrl);
|
|
1682
|
+
const modulePreloads = Object.values(importMap).map((url) => ` <link rel="modulepreload" href="${url}" />`).join("\n");
|
|
1683
|
+
const cssLink = instance.clientCssPath ? ` <link rel="stylesheet" href="${staticBaseUrl}${instance.clientCssPath}" />` : "";
|
|
1684
|
+
const encoder = new TextEncoder();
|
|
1685
|
+
const stream = new ReadableStream({
|
|
1686
|
+
async start(controller) {
|
|
1687
|
+
const write = (s) => controller.enqueue(encoder.encode(s));
|
|
1688
|
+
const headParts = [headTagsHtml, modulePreloads, cssLink].filter(Boolean).join("\n");
|
|
1689
|
+
write(`<!doctype html>
|
|
1523
1690
|
<html lang="en">
|
|
1524
1691
|
<head>
|
|
1525
1692
|
<meta charset="UTF-8" />
|
|
@@ -1529,82 +1696,47 @@ ${headParts}
|
|
|
1529
1696
|
</head>
|
|
1530
1697
|
<body>
|
|
1531
1698
|
<div id="target">`);
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1699
|
+
serverElement.renderToStream(fullViewState, {
|
|
1700
|
+
write: (chunk) => write(chunk),
|
|
1701
|
+
onAsync: (promise, id, templates) => {
|
|
1702
|
+
asyncPromises.push(
|
|
1703
|
+
promise.then(
|
|
1704
|
+
(val) => asyncSwapScript(id, templates.resolved(val)),
|
|
1705
|
+
(err) => asyncSwapScript(id, templates.rejected(err))
|
|
1706
|
+
)
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
});
|
|
1710
|
+
write("</div>");
|
|
1711
|
+
const asyncScripts = (await Promise.all(asyncPromises)).filter((s) => s).join("");
|
|
1712
|
+
if (asyncScripts)
|
|
1713
|
+
write(asyncScripts);
|
|
1714
|
+
const clientInitData = getClientInitData();
|
|
1715
|
+
const clientBundleUrl = `${staticBaseUrl}${instance.clientBundlePath}`;
|
|
1716
|
+
write(`
|
|
1550
1717
|
<script type="module">
|
|
1551
1718
|
import { init } from '${clientBundleUrl}';
|
|
1552
1719
|
await init(${JSON.stringify(fastViewState)}, ${JSON.stringify(fastCarryForward)}, ${JSON.stringify(clientInitData)});
|
|
1553
1720
|
<\/script>
|
|
1554
1721
|
</body>
|
|
1555
1722
|
</html>`);
|
|
1556
|
-
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
".svg": "image/svg+xml",
|
|
1564
|
-
".png": "image/png",
|
|
1565
|
-
".jpg": "image/jpeg",
|
|
1566
|
-
".woff2": "font/woff2",
|
|
1567
|
-
".woff": "font/woff"
|
|
1568
|
-
};
|
|
1569
|
-
async function handleStaticRequest(req, res, basePath, urlPrefix) {
|
|
1570
|
-
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1571
|
-
if (!url.pathname.startsWith(urlPrefix))
|
|
1572
|
-
return false;
|
|
1573
|
-
const relativePath = url.pathname.slice(urlPrefix.length);
|
|
1574
|
-
const filePath = path.join(basePath, relativePath);
|
|
1575
|
-
const normalizedBase = path.resolve(basePath);
|
|
1576
|
-
const normalizedFile = path.resolve(filePath);
|
|
1577
|
-
if (!normalizedFile.startsWith(normalizedBase)) {
|
|
1578
|
-
res.writeHead(403);
|
|
1579
|
-
res.end("Forbidden");
|
|
1580
|
-
return true;
|
|
1581
|
-
}
|
|
1582
|
-
try {
|
|
1583
|
-
const content = await fs.readFile(filePath);
|
|
1584
|
-
const ext = path.extname(filePath);
|
|
1585
|
-
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
1586
|
-
const isHashed = /[-][a-zA-Z0-9_-]{6,}\./.test(path.basename(filePath));
|
|
1587
|
-
const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600";
|
|
1588
|
-
res.writeHead(200, {
|
|
1589
|
-
"Content-Type": contentType,
|
|
1590
|
-
"Content-Length": content.length,
|
|
1591
|
-
"Cache-Control": cacheControl
|
|
1592
|
-
});
|
|
1593
|
-
res.end(content);
|
|
1594
|
-
return true;
|
|
1595
|
-
} catch {
|
|
1596
|
-
return false;
|
|
1597
|
-
}
|
|
1723
|
+
controller.close();
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
const responseHeaders = fastResult.responseHeaders || {};
|
|
1727
|
+
return new Response(stream, {
|
|
1728
|
+
headers: { "Content-Type": "text/html; charset=utf-8", ...responseHeaders }
|
|
1729
|
+
});
|
|
1598
1730
|
}
|
|
1599
1731
|
const ACTION_PREFIX = "/_jay/actions/";
|
|
1600
1732
|
function isActionRequest(pathname) {
|
|
1601
1733
|
return pathname.startsWith(ACTION_PREFIX);
|
|
1602
1734
|
}
|
|
1603
|
-
async function
|
|
1604
|
-
const url = new URL(
|
|
1735
|
+
async function fetchActionRequest(request, registry = actionRegistry) {
|
|
1736
|
+
const url = new URL(request.url);
|
|
1605
1737
|
const actionName = url.pathname.slice(ACTION_PREFIX.length);
|
|
1606
1738
|
if (!actionName) {
|
|
1607
|
-
jsonResponse(
|
|
1739
|
+
return jsonResponse(400, {
|
|
1608
1740
|
success: false,
|
|
1609
1741
|
error: {
|
|
1610
1742
|
code: "MISSING_ACTION_NAME",
|
|
@@ -1612,11 +1744,10 @@ async function handleActionRequest(req, res, registry = actionRegistry) {
|
|
|
1612
1744
|
isActionError: false
|
|
1613
1745
|
}
|
|
1614
1746
|
});
|
|
1615
|
-
return;
|
|
1616
1747
|
}
|
|
1617
1748
|
const action = registry.get(actionName);
|
|
1618
1749
|
if (!action) {
|
|
1619
|
-
jsonResponse(
|
|
1750
|
+
return jsonResponse(404, {
|
|
1620
1751
|
success: false,
|
|
1621
1752
|
error: {
|
|
1622
1753
|
code: "ACTION_NOT_FOUND",
|
|
@@ -1624,11 +1755,10 @@ async function handleActionRequest(req, res, registry = actionRegistry) {
|
|
|
1624
1755
|
isActionError: false
|
|
1625
1756
|
}
|
|
1626
1757
|
});
|
|
1627
|
-
return;
|
|
1628
1758
|
}
|
|
1629
|
-
const requestMethod =
|
|
1759
|
+
const requestMethod = request.method.toUpperCase();
|
|
1630
1760
|
if (requestMethod !== action.method) {
|
|
1631
|
-
jsonResponse(
|
|
1761
|
+
return jsonResponse(405, {
|
|
1632
1762
|
success: false,
|
|
1633
1763
|
error: {
|
|
1634
1764
|
code: "METHOD_NOT_ALLOWED",
|
|
@@ -1636,7 +1766,6 @@ async function handleActionRequest(req, res, registry = actionRegistry) {
|
|
|
1636
1766
|
isActionError: false
|
|
1637
1767
|
}
|
|
1638
1768
|
});
|
|
1639
|
-
return;
|
|
1640
1769
|
}
|
|
1641
1770
|
let input;
|
|
1642
1771
|
try {
|
|
@@ -1649,10 +1778,11 @@ async function handleActionRequest(req, res, registry = actionRegistry) {
|
|
|
1649
1778
|
delete input._input;
|
|
1650
1779
|
}
|
|
1651
1780
|
} else {
|
|
1652
|
-
|
|
1781
|
+
const text = await request.text();
|
|
1782
|
+
input = text ? JSON.parse(text) : {};
|
|
1653
1783
|
}
|
|
1654
1784
|
} catch {
|
|
1655
|
-
jsonResponse(
|
|
1785
|
+
return jsonResponse(400, {
|
|
1656
1786
|
success: false,
|
|
1657
1787
|
error: {
|
|
1658
1788
|
code: "INVALID_INPUT",
|
|
@@ -1660,39 +1790,50 @@ async function handleActionRequest(req, res, registry = actionRegistry) {
|
|
|
1660
1790
|
isActionError: false
|
|
1661
1791
|
}
|
|
1662
1792
|
});
|
|
1663
|
-
return;
|
|
1664
1793
|
}
|
|
1665
1794
|
if (registry.isStreaming(actionName)) {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1795
|
+
const encoder = new TextEncoder();
|
|
1796
|
+
const stream = new ReadableStream({
|
|
1797
|
+
async start(controller) {
|
|
1798
|
+
try {
|
|
1799
|
+
const generator = registry.executeStream(actionName, input);
|
|
1800
|
+
for await (const chunk of generator) {
|
|
1801
|
+
controller.enqueue(encoder.encode(JSON.stringify({ chunk }) + "\n"));
|
|
1802
|
+
}
|
|
1803
|
+
controller.enqueue(encoder.encode(JSON.stringify({ done: true }) + "\n"));
|
|
1804
|
+
} catch (err) {
|
|
1805
|
+
controller.enqueue(
|
|
1806
|
+
encoder.encode(JSON.stringify({ error: err.message }) + "\n")
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
controller.close();
|
|
1674
1810
|
}
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
}
|
|
1679
|
-
res.end();
|
|
1680
|
-
return;
|
|
1811
|
+
});
|
|
1812
|
+
return new Response(stream, {
|
|
1813
|
+
headers: { "Content-Type": "application/x-ndjson" }
|
|
1814
|
+
});
|
|
1681
1815
|
}
|
|
1682
1816
|
const result = await registry.execute(actionName, input);
|
|
1683
1817
|
if (result.success) {
|
|
1818
|
+
const headers = {};
|
|
1684
1819
|
if (requestMethod === "GET") {
|
|
1685
1820
|
const cacheHeaders = registry.getCacheHeaders(actionName);
|
|
1686
1821
|
if (cacheHeaders) {
|
|
1687
|
-
|
|
1822
|
+
headers["Cache-Control"] = cacheHeaders;
|
|
1688
1823
|
}
|
|
1689
1824
|
}
|
|
1690
|
-
jsonResponse(
|
|
1825
|
+
return jsonResponse(200, { success: true, data: result.data }, headers);
|
|
1691
1826
|
} else {
|
|
1692
1827
|
const statusCode = getStatusCode(result.error.code, result.error.isActionError);
|
|
1693
|
-
jsonResponse(
|
|
1828
|
+
return jsonResponse(statusCode, { success: false, error: result.error });
|
|
1694
1829
|
}
|
|
1695
1830
|
}
|
|
1831
|
+
function jsonResponse(status, body, extraHeaders = {}) {
|
|
1832
|
+
return new Response(JSON.stringify(body), {
|
|
1833
|
+
status,
|
|
1834
|
+
headers: { "Content-Type": "application/json", ...extraHeaders }
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1696
1837
|
async function registerActionsFromManifest(actions, buildDir, registry = actionRegistry) {
|
|
1697
1838
|
const logger = getLogger();
|
|
1698
1839
|
let count = 0;
|
|
@@ -1717,33 +1858,6 @@ async function registerActionsFromManifest(actions, buildDir, registry = actionR
|
|
|
1717
1858
|
}
|
|
1718
1859
|
logger.info(`[Server] Registered ${count} actions`);
|
|
1719
1860
|
}
|
|
1720
|
-
function jsonResponse(res, status, body) {
|
|
1721
|
-
const json = JSON.stringify(body);
|
|
1722
|
-
res.writeHead(status, {
|
|
1723
|
-
"Content-Type": "application/json",
|
|
1724
|
-
"Content-Length": Buffer.byteLength(json)
|
|
1725
|
-
});
|
|
1726
|
-
res.end(json);
|
|
1727
|
-
}
|
|
1728
|
-
function parseBody(req) {
|
|
1729
|
-
return new Promise((resolve, reject) => {
|
|
1730
|
-
const chunks = [];
|
|
1731
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
1732
|
-
req.on("end", () => {
|
|
1733
|
-
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
1734
|
-
if (!raw) {
|
|
1735
|
-
resolve({});
|
|
1736
|
-
return;
|
|
1737
|
-
}
|
|
1738
|
-
try {
|
|
1739
|
-
resolve(JSON.parse(raw));
|
|
1740
|
-
} catch {
|
|
1741
|
-
reject(new Error("Invalid JSON body"));
|
|
1742
|
-
}
|
|
1743
|
-
});
|
|
1744
|
-
req.on("error", reject);
|
|
1745
|
-
});
|
|
1746
|
-
}
|
|
1747
1861
|
function getStatusCode(code, isActionError) {
|
|
1748
1862
|
if (isActionError)
|
|
1749
1863
|
return 422;
|
|
@@ -1761,71 +1875,186 @@ function getStatusCode(code, isActionError) {
|
|
|
1761
1875
|
return 500;
|
|
1762
1876
|
}
|
|
1763
1877
|
}
|
|
1764
|
-
|
|
1878
|
+
const MIME_TYPES = {
|
|
1879
|
+
".js": "application/javascript",
|
|
1880
|
+
".css": "text/css",
|
|
1881
|
+
".json": "application/json",
|
|
1882
|
+
".html": "text/html",
|
|
1883
|
+
".svg": "image/svg+xml",
|
|
1884
|
+
".png": "image/png",
|
|
1885
|
+
".jpg": "image/jpeg",
|
|
1886
|
+
".jpeg": "image/jpeg",
|
|
1887
|
+
".gif": "image/gif",
|
|
1888
|
+
".ico": "image/x-icon",
|
|
1889
|
+
".woff2": "font/woff2",
|
|
1890
|
+
".woff": "font/woff",
|
|
1891
|
+
".ttf": "font/ttf",
|
|
1892
|
+
".webp": "image/webp"
|
|
1893
|
+
};
|
|
1894
|
+
async function fetchStaticFile(pathname, frontendDir) {
|
|
1895
|
+
const normalizedBase = path.resolve(frontendDir);
|
|
1896
|
+
for (const candidate of [
|
|
1897
|
+
path.join(frontendDir, pathname),
|
|
1898
|
+
path.join(frontendDir, "public", pathname)
|
|
1899
|
+
]) {
|
|
1900
|
+
const normalizedFile = path.resolve(candidate);
|
|
1901
|
+
if (!normalizedFile.startsWith(normalizedBase))
|
|
1902
|
+
continue;
|
|
1903
|
+
try {
|
|
1904
|
+
const content = await fs.readFile(candidate);
|
|
1905
|
+
const ext = path.extname(candidate);
|
|
1906
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
1907
|
+
const isHashed = /[-][a-zA-Z0-9_-]{6,}\./.test(path.basename(candidate));
|
|
1908
|
+
const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "public, max-age=3600";
|
|
1909
|
+
return new Response(content, {
|
|
1910
|
+
headers: {
|
|
1911
|
+
"Content-Type": contentType,
|
|
1912
|
+
"Content-Length": String(content.length),
|
|
1913
|
+
"Cache-Control": cacheControl
|
|
1914
|
+
}
|
|
1915
|
+
});
|
|
1916
|
+
} catch {
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
return null;
|
|
1920
|
+
}
|
|
1921
|
+
async function initializeServices(buildDir, projectRoot, label) {
|
|
1765
1922
|
const logger = getLogger();
|
|
1766
|
-
const buildDir = path.join(options.buildRoot, `v${options.version}`);
|
|
1767
|
-
const artifacts = new FilesystemArtifactStore(buildDir);
|
|
1768
|
-
const manifest = await artifacts.readManifest();
|
|
1769
|
-
logger.important(
|
|
1770
|
-
`[Server] Loaded manifest: ${manifest.routes.length} routes, v${manifest.version}`
|
|
1771
|
-
);
|
|
1772
1923
|
const { discoverPluginsWithInit, sortPluginsByDependencies } = await import("@jay-framework/stack-server-runtime");
|
|
1773
1924
|
try {
|
|
1774
1925
|
const pluginsWithInit = sortPluginsByDependencies(
|
|
1775
|
-
await discoverPluginsWithInit({ projectRoot
|
|
1926
|
+
await discoverPluginsWithInit({ projectRoot })
|
|
1776
1927
|
);
|
|
1777
1928
|
for (const pluginInit of pluginsWithInit) {
|
|
1778
1929
|
try {
|
|
1779
|
-
|
|
1930
|
+
let modulePath;
|
|
1931
|
+
if (pluginInit.isLocal) {
|
|
1932
|
+
const pluginDirName = path.basename(pluginInit.pluginPath);
|
|
1933
|
+
const initModule = pluginInit.initModule || "index";
|
|
1934
|
+
modulePath = path.join(
|
|
1935
|
+
buildDir,
|
|
1936
|
+
"server",
|
|
1937
|
+
"plugins",
|
|
1938
|
+
pluginDirName,
|
|
1939
|
+
initModule + ".js"
|
|
1940
|
+
);
|
|
1941
|
+
} else {
|
|
1942
|
+
modulePath = pluginInit.packageName;
|
|
1943
|
+
}
|
|
1944
|
+
const pluginModule = await import(modulePath);
|
|
1780
1945
|
const init = pluginModule.init || pluginModule[pluginInit.initExport || "init"];
|
|
1781
1946
|
if (init?._serverInit) {
|
|
1782
|
-
logger.info(`[
|
|
1947
|
+
logger.info(`[${label}] Running plugin init: ${pluginInit.name}`);
|
|
1783
1948
|
const data = await init._serverInit();
|
|
1784
1949
|
if (data)
|
|
1785
1950
|
setClientInitData(pluginInit.name, data);
|
|
1786
1951
|
}
|
|
1787
1952
|
} catch (err) {
|
|
1788
|
-
logger.warn(`[
|
|
1953
|
+
logger.warn(`[${label}] Plugin init failed: ${pluginInit.name}: ${err.message}`);
|
|
1789
1954
|
}
|
|
1790
1955
|
}
|
|
1791
1956
|
} catch {
|
|
1792
1957
|
}
|
|
1793
|
-
const
|
|
1794
|
-
|
|
1958
|
+
const initModulePath = path.join(buildDir, "server", "init.js");
|
|
1959
|
+
try {
|
|
1960
|
+
const initModule = await import(initModulePath);
|
|
1795
1961
|
const init = initModule.init || initModule.default;
|
|
1796
1962
|
if (init?._serverInit) {
|
|
1797
|
-
logger.
|
|
1963
|
+
logger.info(`[${label}] Running server init...`);
|
|
1798
1964
|
const data = await init._serverInit();
|
|
1799
1965
|
if (data)
|
|
1800
1966
|
setClientInitData("project", data);
|
|
1801
1967
|
}
|
|
1968
|
+
} catch {
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
function toFetchRequest(req) {
|
|
1972
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1973
|
+
const headers = new Headers();
|
|
1974
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
1975
|
+
if (value)
|
|
1976
|
+
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
|
1977
|
+
}
|
|
1978
|
+
const init = { method: req.method, headers };
|
|
1979
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1980
|
+
init.body = Readable.toWeb(req);
|
|
1981
|
+
init.duplex = "half";
|
|
1802
1982
|
}
|
|
1983
|
+
return new Request(url, init);
|
|
1984
|
+
}
|
|
1985
|
+
async function pipeFetchResponse(response, res) {
|
|
1986
|
+
const headers = {};
|
|
1987
|
+
response.headers.forEach((value, key) => {
|
|
1988
|
+
headers[key] = value;
|
|
1989
|
+
});
|
|
1990
|
+
res.writeHead(response.status, headers);
|
|
1991
|
+
if (response.body) {
|
|
1992
|
+
const reader = response.body.getReader();
|
|
1993
|
+
try {
|
|
1994
|
+
while (true) {
|
|
1995
|
+
const { done, value } = await reader.read();
|
|
1996
|
+
if (done)
|
|
1997
|
+
break;
|
|
1998
|
+
res.write(value);
|
|
1999
|
+
}
|
|
2000
|
+
} finally {
|
|
2001
|
+
reader.releaseLock();
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
res.end();
|
|
2005
|
+
}
|
|
2006
|
+
async function startMainServer(options) {
|
|
2007
|
+
const logger = getLogger();
|
|
2008
|
+
const buildDir = path.join(options.buildRoot, `v${options.version}`);
|
|
2009
|
+
const backendDir = path.join(buildDir, "backend");
|
|
2010
|
+
const frontendDir = path.join(buildDir, "frontend");
|
|
2011
|
+
const artifacts = new FilesystemArtifactStore(backendDir);
|
|
2012
|
+
const manifest = await artifacts.readManifest();
|
|
2013
|
+
logger.important(
|
|
2014
|
+
`[Server] Loaded manifest: ${manifest.routes.length} routes, v${manifest.version}`
|
|
2015
|
+
);
|
|
2016
|
+
await initializeServices(backendDir, process.cwd(), "Server");
|
|
1803
2017
|
if (manifest.actions.length > 0) {
|
|
1804
|
-
await registerActionsFromManifest(manifest.actions,
|
|
2018
|
+
await registerActionsFromManifest(manifest.actions, backendDir);
|
|
1805
2019
|
}
|
|
2020
|
+
const staticBaseUrl = options.publicBasePath ?? "/";
|
|
2021
|
+
const startTime = Date.now();
|
|
1806
2022
|
const server = http.createServer(async (req, res) => {
|
|
1807
2023
|
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1808
2024
|
try {
|
|
1809
|
-
if (
|
|
1810
|
-
|
|
2025
|
+
if (options.testMode && url.pathname === "/_jay/health") {
|
|
2026
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2027
|
+
res.end(
|
|
2028
|
+
JSON.stringify({
|
|
2029
|
+
status: "ready",
|
|
2030
|
+
port: options.port,
|
|
2031
|
+
uptime: (Date.now() - startTime) / 1e3
|
|
2032
|
+
})
|
|
2033
|
+
);
|
|
1811
2034
|
return;
|
|
1812
2035
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
res
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
2036
|
+
if (options.testMode && url.pathname === "/_jay/shutdown" && req.method === "POST") {
|
|
2037
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2038
|
+
res.end(JSON.stringify({ status: "shutting_down" }));
|
|
2039
|
+
setTimeout(() => {
|
|
2040
|
+
server.close();
|
|
2041
|
+
process.exit(0);
|
|
2042
|
+
}, 100);
|
|
1820
2043
|
return;
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
);
|
|
1827
|
-
if (handledInstances)
|
|
2044
|
+
}
|
|
2045
|
+
if (isActionRequest(url.pathname)) {
|
|
2046
|
+
const fetchReq = toFetchRequest(req);
|
|
2047
|
+
const response2 = await fetchActionRequest(fetchReq);
|
|
2048
|
+
await pipeFetchResponse(response2, res);
|
|
1828
2049
|
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (options.serveStatic !== false) {
|
|
2052
|
+
const staticResponse = await fetchStaticFile(url.pathname, frontendDir);
|
|
2053
|
+
if (staticResponse) {
|
|
2054
|
+
await pipeFetchResponse(staticResponse, res);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
1829
2058
|
const currentManifest = await artifacts.readManifest();
|
|
1830
2059
|
const match = matchRequest(currentManifest, url.pathname);
|
|
1831
2060
|
if (!match) {
|
|
@@ -1833,9 +2062,20 @@ async function startMainServer(options) {
|
|
|
1833
2062
|
res.end("Not Found");
|
|
1834
2063
|
return;
|
|
1835
2064
|
}
|
|
1836
|
-
|
|
2065
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
2066
|
+
const response = await fetchPageRequest(
|
|
2067
|
+
match,
|
|
2068
|
+
currentManifest,
|
|
2069
|
+
url,
|
|
2070
|
+
artifacts,
|
|
2071
|
+
staticBaseUrl,
|
|
2072
|
+
cookies
|
|
2073
|
+
);
|
|
2074
|
+
await pipeFetchResponse(response, res);
|
|
1837
2075
|
} catch (err) {
|
|
1838
2076
|
logger.error(`[Server] Error handling ${url.pathname}: ${err.message}`);
|
|
2077
|
+
if (err.stack)
|
|
2078
|
+
logger.error(err.stack);
|
|
1839
2079
|
if (!res.headersSent) {
|
|
1840
2080
|
res.writeHead(500);
|
|
1841
2081
|
res.end("Internal Server Error");
|
|
@@ -1846,9 +2086,505 @@ async function startMainServer(options) {
|
|
|
1846
2086
|
logger.important(
|
|
1847
2087
|
`[Server] Production server listening on http://localhost:${options.port}`
|
|
1848
2088
|
);
|
|
2089
|
+
if (options.testMode) {
|
|
2090
|
+
logger.important(`[Server] Test mode enabled`);
|
|
2091
|
+
logger.important(` Health: http://localhost:${options.port}/_jay/health`);
|
|
2092
|
+
logger.important(
|
|
2093
|
+
` Shutdown: curl -X POST http://localhost:${options.port}/_jay/shutdown`
|
|
2094
|
+
);
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
async function discoverWebhooks(projectRoot, serverBuildDir) {
|
|
2099
|
+
const logger = getLogger();
|
|
2100
|
+
const webhooks = [];
|
|
2101
|
+
try {
|
|
2102
|
+
const plugins = await scanPlugins({ projectRoot });
|
|
2103
|
+
for (const [packageName, plugin] of plugins) {
|
|
2104
|
+
if (plugin.isLocal)
|
|
2105
|
+
continue;
|
|
2106
|
+
const declaredWebhooks = plugin.manifest.webhooks;
|
|
2107
|
+
if (!declaredWebhooks || declaredWebhooks.length === 0)
|
|
2108
|
+
continue;
|
|
2109
|
+
try {
|
|
2110
|
+
const pluginModule = await import(packageName);
|
|
2111
|
+
for (const entry of declaredWebhooks) {
|
|
2112
|
+
const exportName = typeof entry === "string" ? entry : entry.name;
|
|
2113
|
+
const value = pluginModule[exportName];
|
|
2114
|
+
if (isJayWebhook(value)) {
|
|
2115
|
+
webhooks.push({
|
|
2116
|
+
name: value.webhookName,
|
|
2117
|
+
webhook: value,
|
|
2118
|
+
source: packageName
|
|
2119
|
+
});
|
|
2120
|
+
logger.info(
|
|
2121
|
+
`[Renderer] Webhook "${value.webhookName}" from ${plugin.manifest.name}`
|
|
2122
|
+
);
|
|
2123
|
+
} else {
|
|
2124
|
+
logger.warn(
|
|
2125
|
+
`[Renderer] plugin.yaml declares webhook "${exportName}" but export is not a JayWebhook in ${packageName}`
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
} catch (err) {
|
|
2130
|
+
logger.warn(
|
|
2131
|
+
`[Renderer] Failed to load webhooks from ${packageName}: ${err.message}`
|
|
2132
|
+
);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
} catch {
|
|
2136
|
+
}
|
|
2137
|
+
try {
|
|
2138
|
+
const plugins = await scanPlugins({ projectRoot });
|
|
2139
|
+
for (const [, plugin] of plugins) {
|
|
2140
|
+
if (!plugin.isLocal)
|
|
2141
|
+
continue;
|
|
2142
|
+
const declaredWebhooks = plugin.manifest.webhooks;
|
|
2143
|
+
if (!declaredWebhooks || declaredWebhooks.length === 0)
|
|
2144
|
+
continue;
|
|
2145
|
+
const pluginDirName = path.basename(plugin.pluginPath);
|
|
2146
|
+
for (const entry of declaredWebhooks) {
|
|
2147
|
+
const exportName = typeof entry === "string" ? entry : entry.name;
|
|
2148
|
+
const modulePath = path.join(serverBuildDir, "plugins", pluginDirName, "index.js");
|
|
2149
|
+
try {
|
|
2150
|
+
const mod = await import(modulePath);
|
|
2151
|
+
const value = mod[exportName];
|
|
2152
|
+
if (isJayWebhook(value)) {
|
|
2153
|
+
webhooks.push({
|
|
2154
|
+
name: value.webhookName,
|
|
2155
|
+
webhook: value,
|
|
2156
|
+
source: `local:${plugin.manifest.name}`
|
|
2157
|
+
});
|
|
2158
|
+
logger.info(
|
|
2159
|
+
`[Renderer] Webhook "${value.webhookName}" from local plugin ${plugin.manifest.name}`
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
} catch (err) {
|
|
2163
|
+
logger.warn(
|
|
2164
|
+
`[Renderer] Failed to load webhook "${exportName}" from local plugin ${plugin.manifest.name}: ${err.message}`
|
|
2165
|
+
);
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
} catch {
|
|
2170
|
+
}
|
|
2171
|
+
const webhooksDir = path.join(serverBuildDir, "webhooks");
|
|
2172
|
+
try {
|
|
2173
|
+
const files = await fs.readdir(webhooksDir);
|
|
2174
|
+
for (const file of files) {
|
|
2175
|
+
if (!file.endsWith(".js"))
|
|
2176
|
+
continue;
|
|
2177
|
+
try {
|
|
2178
|
+
const mod = await import(path.join(webhooksDir, file));
|
|
2179
|
+
for (const [, value] of Object.entries(mod)) {
|
|
2180
|
+
if (isJayWebhook(value)) {
|
|
2181
|
+
webhooks.push({
|
|
2182
|
+
name: value.webhookName,
|
|
2183
|
+
webhook: value,
|
|
2184
|
+
source: `project:${file}`
|
|
2185
|
+
});
|
|
2186
|
+
logger.info(`[Renderer] Webhook "${value.webhookName}" from project`);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
logger.warn(`[Renderer] Failed to load webhook ${file}: ${err.message}`);
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
} catch {
|
|
2194
|
+
}
|
|
2195
|
+
return webhooks;
|
|
2196
|
+
}
|
|
2197
|
+
function resolveContractToRoutes(manifest, contractName) {
|
|
2198
|
+
return manifest.routes.filter((r) => r.contracts && r.contracts.includes(contractName));
|
|
2199
|
+
}
|
|
2200
|
+
async function rebuild(options) {
|
|
2201
|
+
const logger = getLogger();
|
|
2202
|
+
const buildDir = path.join(options.buildRoot, `v${options.version}`);
|
|
2203
|
+
const manifestPath = path.join(buildDir, "backend", "route-manifest.json");
|
|
2204
|
+
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf-8"));
|
|
2205
|
+
const {
|
|
2206
|
+
routes: affectedRoutes,
|
|
2207
|
+
params: targetParams,
|
|
2208
|
+
label
|
|
2209
|
+
} = resolveTarget(manifest, options.target);
|
|
2210
|
+
logger.important(`[Rebuild] ${label} in v${options.version}`);
|
|
2211
|
+
if (affectedRoutes.length === 0) {
|
|
2212
|
+
logger.warn(`[Rebuild] No routes found for ${label}`);
|
|
2213
|
+
return { affected: 0, rebuilt: 0, errors: [] };
|
|
2214
|
+
}
|
|
2215
|
+
logger.important(
|
|
2216
|
+
`[Rebuild] Found ${affectedRoutes.length} route(s): ${affectedRoutes.map((r) => r.pattern).join(", ")}`
|
|
2217
|
+
);
|
|
2218
|
+
const backendDir = path.join(buildDir, "backend");
|
|
2219
|
+
const frontendDir = path.join(buildDir, "frontend");
|
|
2220
|
+
await initializeServices(backendDir, options.projectRoot, "Rebuild");
|
|
2221
|
+
const rebuildSuffix = Date.now().toString(36);
|
|
2222
|
+
const instanceCtx = {
|
|
2223
|
+
projectRoot: options.projectRoot,
|
|
2224
|
+
pagesRoot: options.pagesRoot,
|
|
2225
|
+
buildDir,
|
|
2226
|
+
backendDir,
|
|
2227
|
+
frontendDir,
|
|
2228
|
+
jayOptions: { tsConfigFilePath: options.tsConfigFilePath },
|
|
2229
|
+
tsConfigFilePath: options.tsConfigFilePath,
|
|
2230
|
+
minify: options.minify ?? true,
|
|
2231
|
+
rebuildSuffix
|
|
2232
|
+
};
|
|
2233
|
+
const result = { affected: 0, rebuilt: 0, errors: [] };
|
|
2234
|
+
const orphanedFiles = [];
|
|
2235
|
+
for (const route of affectedRoutes) {
|
|
2236
|
+
const instancesToRebuild = targetParams ? route.instances.filter((i) => paramsMatch(i.params, targetParams)) : [...route.instances];
|
|
2237
|
+
if (instancesToRebuild.length === 0 && targetParams) {
|
|
2238
|
+
instancesToRebuild.push({ params: targetParams });
|
|
2239
|
+
}
|
|
2240
|
+
for (const instance of instancesToRebuild) {
|
|
2241
|
+
result.affected++;
|
|
2242
|
+
const params = instance.params;
|
|
2243
|
+
const oldFiles = collectInstanceFiles(instance);
|
|
2244
|
+
let pageModule;
|
|
2245
|
+
try {
|
|
2246
|
+
pageModule = await loadRouteModule(route, buildDir);
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
result.errors.push({
|
|
2249
|
+
route: route.pattern,
|
|
2250
|
+
params,
|
|
2251
|
+
error: `Failed to load module: ${err.message}`
|
|
2252
|
+
});
|
|
2253
|
+
continue;
|
|
2254
|
+
}
|
|
2255
|
+
const jayRoute = await resolveJayRouteFromManifest(route, options);
|
|
2256
|
+
try {
|
|
2257
|
+
const buildResult = await buildInstance(jayRoute, params, pageModule, instanceCtx);
|
|
2258
|
+
if (buildResult.status !== "success") {
|
|
2259
|
+
result.errors.push({
|
|
2260
|
+
route: route.pattern,
|
|
2261
|
+
params,
|
|
2262
|
+
error: buildResult.reason
|
|
2263
|
+
});
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
const existingIdx = route.instances.findIndex((i) => paramsMatch(i.params, params));
|
|
2267
|
+
if (existingIdx >= 0) {
|
|
2268
|
+
route.instances[existingIdx] = buildResult.instanceEntry;
|
|
2269
|
+
orphanedFiles.push(...oldFiles);
|
|
2270
|
+
} else {
|
|
2271
|
+
route.instances.push(buildResult.instanceEntry);
|
|
2272
|
+
}
|
|
2273
|
+
result.rebuilt++;
|
|
2274
|
+
logger.important(`[Rebuild] ${route.pattern} (${JSON.stringify(params)}): rebuilt`);
|
|
2275
|
+
} catch (err) {
|
|
2276
|
+
result.errors.push({
|
|
2277
|
+
route: route.pattern,
|
|
2278
|
+
params,
|
|
2279
|
+
error: err.message
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
if (result.rebuilt > 0) {
|
|
2285
|
+
const tempPath = manifestPath + ".tmp";
|
|
2286
|
+
await fs.writeFile(tempPath, JSON.stringify(manifest, null, 2));
|
|
2287
|
+
await fs.rename(tempPath, manifestPath);
|
|
2288
|
+
const metadataPath = path.join(buildDir, "backend", "build-metadata.json");
|
|
2289
|
+
try {
|
|
2290
|
+
const metadata = JSON.parse(await fs.readFile(metadataPath, "utf-8"));
|
|
2291
|
+
metadata.buildTimestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2292
|
+
metadata.instanceCount = manifest.routes.reduce((n, r) => n + r.instances.length, 0);
|
|
2293
|
+
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
2294
|
+
} catch {
|
|
2295
|
+
await fs.writeFile(
|
|
2296
|
+
metadataPath,
|
|
2297
|
+
JSON.stringify(
|
|
2298
|
+
{
|
|
2299
|
+
version: options.version,
|
|
2300
|
+
sourceHash: "",
|
|
2301
|
+
buildTimestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2302
|
+
nodeVersion: process.version,
|
|
2303
|
+
instanceCount: manifest.routes.reduce((n, r) => n + r.instances.length, 0)
|
|
2304
|
+
},
|
|
2305
|
+
null,
|
|
2306
|
+
2
|
|
2307
|
+
)
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
logger.important(`[Rebuild] Manifest and metadata updated`);
|
|
2311
|
+
if (orphanedFiles.length > 0) {
|
|
2312
|
+
await appendCleanupManifest(buildDir, orphanedFiles);
|
|
2313
|
+
logger.info(`[Rebuild] ${orphanedFiles.length} orphaned file(s) queued for cleanup`);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
logger.important(
|
|
2317
|
+
`[Rebuild] Done: ${result.affected} affected, ${result.rebuilt} rebuilt, ${result.errors.length} errors`
|
|
2318
|
+
);
|
|
2319
|
+
return result;
|
|
2320
|
+
}
|
|
2321
|
+
function resolveTarget(manifest, target) {
|
|
2322
|
+
switch (target.mode) {
|
|
2323
|
+
case "contract": {
|
|
2324
|
+
const routes = resolveContractToRoutes(manifest, target.contractName);
|
|
2325
|
+
return {
|
|
2326
|
+
routes,
|
|
2327
|
+
params: target.params,
|
|
2328
|
+
label: `contract "${target.contractName}"${target.params ? ` (${JSON.stringify(target.params)})` : ""}`
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
case "route": {
|
|
2332
|
+
const route = manifest.routes.find((r) => r.pattern === target.routePattern);
|
|
2333
|
+
return {
|
|
2334
|
+
routes: route ? [route] : [],
|
|
2335
|
+
params: target.params,
|
|
2336
|
+
label: `route "${target.routePattern}"${target.params ? ` (${JSON.stringify(target.params)})` : ""}`
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
case "url": {
|
|
2340
|
+
const match = matchRequest(manifest, target.url);
|
|
2341
|
+
if (!match) {
|
|
2342
|
+
return { routes: [], label: `url "${target.url}"` };
|
|
2343
|
+
}
|
|
2344
|
+
return {
|
|
2345
|
+
routes: [match.route],
|
|
2346
|
+
params: match.params,
|
|
2347
|
+
label: `url "${target.url}" → ${match.route.pattern} (${JSON.stringify(match.params)})`
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
async function rebuildContract(options) {
|
|
2353
|
+
return rebuild({
|
|
2354
|
+
...options,
|
|
2355
|
+
target: { mode: "contract", contractName: options.contractName, params: options.params }
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
async function loadRouteModule(route, buildDir) {
|
|
2359
|
+
if (!route.serverModule)
|
|
2360
|
+
return {};
|
|
2361
|
+
if (route.isPlugin)
|
|
2362
|
+
return import(route.serverModule);
|
|
2363
|
+
return import(path.join(buildDir, route.serverModule));
|
|
2364
|
+
}
|
|
2365
|
+
async function resolveJayRouteFromManifest(route, options) {
|
|
2366
|
+
const routeDir = route.pattern.replace(/^\//, "") || "index";
|
|
2367
|
+
const jayHtmlPath = path.join(options.pagesRoot, routeDir, "page.jay-html");
|
|
2368
|
+
let resolvedJayHtmlPath = jayHtmlPath;
|
|
2369
|
+
if (route.isPlugin && route.serverModule) {
|
|
2370
|
+
try {
|
|
2371
|
+
const pluginModule = await import(route.serverModule);
|
|
2372
|
+
const comp = pluginModule[route.componentExport || "page"];
|
|
2373
|
+
if (comp?.jayHtmlPath) {
|
|
2374
|
+
resolvedJayHtmlPath = comp.jayHtmlPath;
|
|
2375
|
+
}
|
|
2376
|
+
} catch {
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
return {
|
|
2380
|
+
rawRoute: route.pattern,
|
|
2381
|
+
segments: route.segments.map((s) => {
|
|
2382
|
+
if (s.type === "static")
|
|
2383
|
+
return s.value;
|
|
2384
|
+
return { name: s.value, type: segmentTypeMap[s.type] };
|
|
2385
|
+
}),
|
|
2386
|
+
jayHtmlPath: resolvedJayHtmlPath,
|
|
2387
|
+
compPath: route.isPlugin ? route.serverModule : void 0,
|
|
2388
|
+
componentExport: route.componentExport
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
const segmentTypeMap = {
|
|
2392
|
+
param: 0,
|
|
2393
|
+
catchAll: 1,
|
|
2394
|
+
optional: 2
|
|
2395
|
+
};
|
|
2396
|
+
function paramsMatch(instanceParams, targetParams) {
|
|
2397
|
+
return Object.entries(targetParams).every(([key, value]) => instanceParams[key] === value);
|
|
2398
|
+
}
|
|
2399
|
+
function collectInstanceFiles(instance) {
|
|
2400
|
+
if (!instance.preRenderedPath)
|
|
2401
|
+
return [];
|
|
2402
|
+
const files = [
|
|
2403
|
+
instance.preRenderedPath,
|
|
2404
|
+
instance.preRenderedPath.replace(".jay-html", ".cache.json"),
|
|
2405
|
+
instance.serverElementPath,
|
|
2406
|
+
instance.clientBundlePath
|
|
2407
|
+
];
|
|
2408
|
+
if (instance.clientCssPath)
|
|
2409
|
+
files.push(instance.clientCssPath);
|
|
2410
|
+
return files.filter(Boolean);
|
|
2411
|
+
}
|
|
2412
|
+
async function appendCleanupManifest(buildDir, files) {
|
|
2413
|
+
const cleanupPath = path.join(buildDir, "cleanup-manifest.json");
|
|
2414
|
+
let existing = [];
|
|
2415
|
+
try {
|
|
2416
|
+
existing = JSON.parse(await fs.readFile(cleanupPath, "utf-8"));
|
|
2417
|
+
} catch {
|
|
2418
|
+
}
|
|
2419
|
+
existing.push(...files);
|
|
2420
|
+
await fs.writeFile(cleanupPath, JSON.stringify(existing, null, 2));
|
|
2421
|
+
}
|
|
2422
|
+
async function cleanupOrphanedFiles(buildRoot, version) {
|
|
2423
|
+
const logger = getLogger();
|
|
2424
|
+
const buildDir = path.join(buildRoot, `v${version}`);
|
|
2425
|
+
const cleanupPath = path.join(buildDir, "cleanup-manifest.json");
|
|
2426
|
+
let files;
|
|
2427
|
+
try {
|
|
2428
|
+
files = JSON.parse(await fs.readFile(cleanupPath, "utf-8"));
|
|
2429
|
+
} catch {
|
|
2430
|
+
logger.info("[Cleanup] No cleanup manifest found");
|
|
2431
|
+
return 0;
|
|
2432
|
+
}
|
|
2433
|
+
let deleted = 0;
|
|
2434
|
+
for (const file of files) {
|
|
2435
|
+
try {
|
|
2436
|
+
await fs.unlink(path.join(buildDir, file));
|
|
2437
|
+
deleted++;
|
|
2438
|
+
} catch {
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
await fs.unlink(cleanupPath);
|
|
2442
|
+
logger.important(`[Cleanup] Deleted ${deleted}/${files.length} orphaned files`);
|
|
2443
|
+
return deleted;
|
|
2444
|
+
}
|
|
2445
|
+
async function startRendererServer(options) {
|
|
2446
|
+
const logger = getLogger();
|
|
2447
|
+
const buildDir = path.join(options.buildRoot, `v${options.version}`);
|
|
2448
|
+
const serverBuildDir = path.join(buildDir, "server");
|
|
2449
|
+
logger.important(`[Renderer] Starting renderer server v${options.version}`);
|
|
2450
|
+
await initializeServices(buildDir, options.projectRoot, "Renderer");
|
|
2451
|
+
const webhooks = await discoverWebhooks(options.projectRoot, serverBuildDir);
|
|
2452
|
+
const webhookMap = /* @__PURE__ */ new Map();
|
|
2453
|
+
for (const wh of webhooks) {
|
|
2454
|
+
webhookMap.set(wh.name, wh);
|
|
2455
|
+
}
|
|
2456
|
+
logger.important(
|
|
2457
|
+
`[Renderer] ${webhookMap.size} webhook(s) registered: ${[...webhookMap.keys()].join(", ") || "none"}`
|
|
2458
|
+
);
|
|
2459
|
+
const createInvalidateForWebhook = () => {
|
|
2460
|
+
return async (contractName, params) => {
|
|
2461
|
+
await rebuildContract({
|
|
2462
|
+
projectRoot: options.projectRoot,
|
|
2463
|
+
pagesRoot: options.pagesRoot,
|
|
2464
|
+
buildRoot: options.buildRoot,
|
|
2465
|
+
version: options.version,
|
|
2466
|
+
contractName,
|
|
2467
|
+
params,
|
|
2468
|
+
tsConfigFilePath: options.tsConfigFilePath,
|
|
2469
|
+
minify: options.minify
|
|
2470
|
+
});
|
|
2471
|
+
};
|
|
2472
|
+
};
|
|
2473
|
+
const startTime = Date.now();
|
|
2474
|
+
let lastWebhook;
|
|
2475
|
+
const server = http.createServer(async (req, res) => {
|
|
2476
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
2477
|
+
try {
|
|
2478
|
+
const webhookMatch = url.pathname.match(/^\/_jay\/webhooks\/(.+)$/);
|
|
2479
|
+
if (webhookMatch && req.method === "POST") {
|
|
2480
|
+
const webhookName = webhookMatch[1];
|
|
2481
|
+
const discovered = webhookMap.get(webhookName);
|
|
2482
|
+
if (!discovered) {
|
|
2483
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2484
|
+
res.end(JSON.stringify({ error: `Unknown webhook: ${webhookName}` }));
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
const body = await readBody(req);
|
|
2488
|
+
const event = {
|
|
2489
|
+
type: webhookName,
|
|
2490
|
+
payload: JSON.parse(body || "{}"),
|
|
2491
|
+
headers: req.headers
|
|
2492
|
+
};
|
|
2493
|
+
const invalidate = createInvalidateForWebhook();
|
|
2494
|
+
const resolver = globalThis.__JAY_SERVICE_RESOLVER__;
|
|
2495
|
+
const services = resolver ? resolver(discovered.webhook.services) : [];
|
|
2496
|
+
await discovered.webhook.handler(event, invalidate, ...services);
|
|
2497
|
+
lastWebhook = { name: webhookName, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2498
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2499
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
if (url.pathname === "/_jay/rebuild" && req.method === "POST") {
|
|
2503
|
+
const body = JSON.parse(await readBody(req) || "{}");
|
|
2504
|
+
const { contract, route, url: rebuildUrl, params } = body;
|
|
2505
|
+
let target;
|
|
2506
|
+
if (contract) {
|
|
2507
|
+
target = { mode: "contract", contractName: contract, params };
|
|
2508
|
+
} else if (route) {
|
|
2509
|
+
target = { mode: "route", routePattern: route, params };
|
|
2510
|
+
} else if (rebuildUrl) {
|
|
2511
|
+
target = { mode: "url", url: rebuildUrl };
|
|
2512
|
+
} else {
|
|
2513
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2514
|
+
res.end(
|
|
2515
|
+
JSON.stringify({
|
|
2516
|
+
error: 'One of "contract", "route", or "url" is required'
|
|
2517
|
+
})
|
|
2518
|
+
);
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
const result = await rebuild({
|
|
2522
|
+
projectRoot: options.projectRoot,
|
|
2523
|
+
pagesRoot: options.pagesRoot,
|
|
2524
|
+
buildRoot: options.buildRoot,
|
|
2525
|
+
version: options.version,
|
|
2526
|
+
target,
|
|
2527
|
+
tsConfigFilePath: options.tsConfigFilePath,
|
|
2528
|
+
minify: options.minify
|
|
2529
|
+
});
|
|
2530
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2531
|
+
res.end(JSON.stringify(result));
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
if (url.pathname === "/_jay/status" && req.method === "GET") {
|
|
2535
|
+
const manifest = JSON.parse(
|
|
2536
|
+
await fs.readFile(path.join(buildDir, "route-manifest.json"), "utf-8")
|
|
2537
|
+
);
|
|
2538
|
+
const status = {
|
|
2539
|
+
version: options.version,
|
|
2540
|
+
buildTimestamp: manifest.buildTimestamp,
|
|
2541
|
+
instanceCount: manifest.routes.reduce((n, r) => n + r.instances.length, 0),
|
|
2542
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
2543
|
+
webhooks: [...webhookMap.keys()],
|
|
2544
|
+
lastWebhook
|
|
2545
|
+
};
|
|
2546
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2547
|
+
res.end(JSON.stringify(status, null, 2));
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
res.writeHead(404);
|
|
2551
|
+
res.end("Not Found");
|
|
2552
|
+
} catch (err) {
|
|
2553
|
+
logger.error(`[Renderer] Error: ${err.message}`);
|
|
2554
|
+
if (!res.headersSent) {
|
|
2555
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2556
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
server.listen(options.port, () => {
|
|
2561
|
+
logger.important(
|
|
2562
|
+
`[Renderer] Renderer server listening on http://localhost:${options.port}`
|
|
2563
|
+
);
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
function readBody(req) {
|
|
2567
|
+
return new Promise((resolve, reject) => {
|
|
2568
|
+
const chunks = [];
|
|
2569
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
2570
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
2571
|
+
req.on("error", reject);
|
|
1849
2572
|
});
|
|
1850
2573
|
}
|
|
1851
2574
|
export {
|
|
2575
|
+
FilesystemArtifactStore,
|
|
1852
2576
|
buildVersion,
|
|
1853
|
-
|
|
2577
|
+
cleanupOrphanedFiles,
|
|
2578
|
+
fetchActionRequest,
|
|
2579
|
+
fetchPageRequest,
|
|
2580
|
+
fetchStaticFile,
|
|
2581
|
+
initializeServices,
|
|
2582
|
+
isActionRequest,
|
|
2583
|
+
matchRequest,
|
|
2584
|
+
rebuild,
|
|
2585
|
+
rebuildContract,
|
|
2586
|
+
registerActionsFromManifest,
|
|
2587
|
+
resolveContractToRoutes,
|
|
2588
|
+
startMainServer,
|
|
2589
|
+
startRendererServer
|
|
1854
2590
|
};
|