@jay-framework/dev-server 0.15.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +61 -1
  2. package/dist/index.js +457 -44
  3. package/package.json +14 -14
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import { JayRollupConfig } from '@jay-framework/rollup-plugin';
5
5
  import { LogLevel } from '@jay-framework/logger';
6
6
  import { Server } from 'node:http';
7
7
  import { ProjectClientInitInfo, PluginWithInit, ActionRegistry } from '@jay-framework/stack-server-runtime';
8
+ import * as _jay_framework_fullstack_component from '@jay-framework/fullstack-component';
8
9
  import { RequestHandler as RequestHandler$1 } from 'express';
9
10
  import { JayRollupConfig as JayRollupConfig$1 } from '@jay-framework/compiler-jay-stack';
10
11
 
@@ -103,6 +104,62 @@ declare class ServiceLifecycleManager {
103
104
  isReady(): boolean;
104
105
  }
105
106
 
107
+ /**
108
+ * Page Freeze — Design Log #127
109
+ *
110
+ * Captures a page's ViewState at a point in time and serves it as a
111
+ * static SSR snapshot. Enables designers to create multiple frozen
112
+ * views of a page in different states for side-by-side comparison.
113
+ */
114
+ interface FreezeEntry {
115
+ id: string;
116
+ name?: string;
117
+ /** The concrete URL path (e.g., /products/kitan) */
118
+ route: string;
119
+ /** The route pattern (e.g., /products/kitan{/:category}) */
120
+ routePattern?: string;
121
+ viewState: object;
122
+ createdAt: string;
123
+ }
124
+ declare class FreezeStore {
125
+ private readonly dir;
126
+ constructor(buildFolder: string);
127
+ save(route: string, viewState: object, routePattern?: string): Promise<FreezeEntry>;
128
+ get(id: string): Promise<FreezeEntry | undefined>;
129
+ list(route?: string): Promise<FreezeEntry[]>;
130
+ rename(id: string, name: string): Promise<boolean>;
131
+ delete(id: string): Promise<boolean>;
132
+ }
133
+
134
+ /**
135
+ * Service marker for DevServerService.
136
+ * Use with `.withServices(DEV_SERVER_SERVICE)` in actions and components.
137
+ */
138
+ declare const DEV_SERVER_SERVICE: _jay_framework_fullstack_component.ServiceMarker<DevServerService>;
139
+ interface RouteInfo {
140
+ path: string;
141
+ jayHtmlPath: string;
142
+ compPath: string;
143
+ }
144
+ declare class DevServerService {
145
+ private routes;
146
+ private vite;
147
+ private pagesBase;
148
+ private projectBase;
149
+ private jayRollupConfig;
150
+ private _freezeStore?;
151
+ constructor(routes: DevServerRoute[], vite: ViteDevServer, pagesBase: string, projectBase: string, jayRollupConfig: JayRollupConfig, _freezeStore?: FreezeStore);
152
+ get freezeStore(): FreezeStore | undefined;
153
+ /** List all page routes in the project. */
154
+ listRoutes(): RouteInfo[];
155
+ /**
156
+ * Run loadParams for a route, yielding param batches as an async generator.
157
+ * Loads all page parts (page component + keyed headless components) and
158
+ * calls loadParams on each one that defines it.
159
+ */
160
+ loadRouteParams(routePath: string): AsyncGenerator<Record<string, string>[]>;
161
+ }
162
+
106
163
  interface DevServerRoute {
107
164
  path: string;
108
165
  handler: RequestHandler;
@@ -113,6 +170,9 @@ interface DevServer {
113
170
  viteServer: ViteDevServer;
114
171
  routes: DevServerRoute[];
115
172
  lifecycleManager: ServiceLifecycleManager;
173
+ freezeStore?: FreezeStore;
174
+ /** Public API for design board applications and CLI (DL#128) */
175
+ service: DevServerService;
116
176
  }
117
177
  declare function mkDevServer(rawOptions: DevServerOptions): Promise<DevServer>;
118
178
 
@@ -207,4 +267,4 @@ declare function createViteForCli(options: {
207
267
  tsConfigFilePath?: string;
208
268
  }): Promise<ViteDevServer>;
209
269
 
210
- export { ACTION_ENDPOINT_BASE, type ActionRouterOptions, type CreateViteServerOptions, type DevServer, type DevServerOptions, type DevServerRoute, actionBodyParser, createActionRouter, createViteForCli, createViteServer, mkDevServer };
270
+ export { ACTION_ENDPOINT_BASE, type ActionRouterOptions, type CreateViteServerOptions, DEV_SERVER_SERVICE, type DevServer, type DevServerOptions, type DevServerRoute, DevServerService, type FreezeEntry, FreezeStore, type RouteInfo, actionBodyParser, createActionRouter, createViteForCli, createViteServer, mkDevServer };
package/dist/index.js CHANGED
@@ -10,14 +10,18 @@ import { createRequire } from "module";
10
10
  import "@jay-framework/compiler-shared";
11
11
  import * as path from "node:path";
12
12
  import path__default from "node:path";
13
- import { JAY_IMPORT_RESOLVER, injectHeadfullFSTemplates, parseContract, slowRenderTransform, discoverHeadlessInstances, resolveHeadlessInstances } from "@jay-framework/compiler-jay-html";
13
+ import { JAY_IMPORT_RESOLVER, injectHeadfullFSTemplates, parseContract, slowRenderTransform, discoverHeadlessInstances, assignCoordinatesToJayHtml, resolveHeadlessInstances } from "@jay-framework/compiler-jay-html";
14
14
  import { getLogger, getDevLogger } from "@jay-framework/logger";
15
15
  import { createRequire as createRequire$1 } from "node:module";
16
16
  import * as fs from "node:fs";
17
- import { scanRoutes, routeToExpressRoute } from "@jay-framework/stack-route-scanner";
18
- import { discoverPluginsWithInit, sortPluginsByDependencies, executePluginServerInits, runInitCallbacks, actionRegistry, discoverAndRegisterActions, discoverAllPluginActions, runShutdownCallbacks, clearLifecycleCallbacks, clearServiceRegistry, clearClientInitData, DevSlowlyChangingPhase, SlowRenderCache, preparePluginClientInits, clearServerElementCache, getServiceRegistry, materializeContracts, loadPageParts, renderFastChangingData, generateClientScript, getClientInitData, generateSSRPageHtml, validateForEachInstances, slowRenderInstances } from "@jay-framework/stack-server-runtime";
19
- import fs$1 from "node:fs/promises";
17
+ import fs__default$1 from "node:fs";
18
+ import { scanRoutes, createRoute, routeToExpressRoute } from "@jay-framework/stack-route-scanner";
19
+ import { discoverPluginsWithInit, sortPluginsByDependencies, executePluginServerInits, runInitCallbacks, actionRegistry, discoverAndRegisterActions, discoverAllPluginActions, runShutdownCallbacks, clearLifecycleCallbacks, clearServiceRegistry, clearClientInitData, loadPageParts, runLoadParams, DevSlowlyChangingPhase, SlowRenderCache, preparePluginClientInits, registerService, clearServerElementCache, scanPlugins, getServiceRegistry, materializeContracts, renderFastChangingData, mergeHeadTags, generateClientScript, getClientInitData, generateSSRPageHtml, generateFrozenPageHtml, validateForEachInstances, slowRenderInstances } from "@jay-framework/stack-server-runtime";
20
+ import * as fs$1 from "node:fs/promises";
21
+ import fs__default from "node:fs/promises";
20
22
  import { pathToFileURL } from "node:url";
23
+ import { randomUUID } from "node:crypto";
24
+ import { createJayService } from "@jay-framework/fullstack-component";
21
25
  const s$1 = createRequire(import.meta.url), e$1 = s$1("typescript"), c$1 = new Proxy(e$1, {
22
26
  get(t, r) {
23
27
  return t[r];
@@ -927,9 +931,16 @@ function extractActionFromExpression(node) {
927
931
  }
928
932
  if (c$1.isIdentifier(expr)) {
929
933
  const funcName = expr.text;
930
- if (funcName === "makeJayAction" || funcName === "makeJayQuery") {
934
+ if (funcName === "makeJayAction" || funcName === "makeJayQuery" || funcName === "makeJayStream") {
931
935
  const nameArg = current.arguments[0];
932
936
  if (nameArg && c$1.isStringLiteral(nameArg)) {
937
+ if (funcName === "makeJayStream") {
938
+ return {
939
+ actionName: nameArg.text,
940
+ method: "POST",
941
+ isStreaming: true
942
+ };
943
+ }
933
944
  method = funcName === "makeJayQuery" ? "GET" : "POST";
934
945
  if (explicitMethod) {
935
946
  method = explicitMethod;
@@ -1200,7 +1211,7 @@ function createPluginClientImportResolver(options = {}) {
1200
1211
  const pluginDetector = options.pluginDetector || createDefaultPluginDetector();
1201
1212
  return {
1202
1213
  name: "jay-stack:plugin-client-import",
1203
- enforce: "pre",
1214
+ enforce: "post",
1204
1215
  configResolved(config) {
1205
1216
  projectRoot = config.root || projectRoot;
1206
1217
  isSSRBuild = !!config.build?.ssr;
@@ -1331,14 +1342,27 @@ function jayStackCompiler(options = {}) {
1331
1342
  getLogger().warn(`[action-transform] No actions found in ${actualPath}`);
1332
1343
  return null;
1333
1344
  }
1345
+ const hasRegularActions = actions.some((a) => !a.isStreaming);
1346
+ const hasStreamActions = actions.some((a) => a.isStreaming);
1347
+ const importNames = [];
1348
+ if (hasRegularActions)
1349
+ importNames.push("createActionCaller");
1350
+ if (hasStreamActions)
1351
+ importNames.push("createStreamCaller");
1334
1352
  const lines = [
1335
- `import { createActionCaller } from '@jay-framework/stack-client-runtime';`,
1353
+ `import { ${importNames.join(", ")} } from '@jay-framework/stack-client-runtime';`,
1336
1354
  ""
1337
1355
  ];
1338
1356
  for (const action of actions) {
1339
- lines.push(
1340
- `export const ${action.exportName} = createActionCaller('${action.actionName}', '${action.method}');`
1341
- );
1357
+ if (action.isStreaming) {
1358
+ lines.push(
1359
+ `export const ${action.exportName} = createStreamCaller('${action.actionName}');`
1360
+ );
1361
+ } else {
1362
+ lines.push(
1363
+ `export const ${action.exportName} = createActionCaller('${action.actionName}', '${action.method}');`
1364
+ );
1365
+ }
1342
1366
  }
1343
1367
  if (code.includes("ActionError")) {
1344
1368
  lines.push(
@@ -1758,6 +1782,22 @@ function createActionRouter(options) {
1758
1782
  requestMethod,
1759
1783
  ACTION_ENDPOINT_BASE + "/" + actionName
1760
1784
  );
1785
+ if (registry.isStreaming(actionName)) {
1786
+ res.setHeader("Content-Type", "application/x-ndjson");
1787
+ res.setHeader("Transfer-Encoding", "chunked");
1788
+ try {
1789
+ const generator = registry.executeStream(actionName, input);
1790
+ for await (const chunk of generator) {
1791
+ res.write(JSON.stringify({ chunk }) + "\n");
1792
+ }
1793
+ res.write(JSON.stringify({ done: true }) + "\n");
1794
+ } catch (err) {
1795
+ res.write(JSON.stringify({ error: err.message }) + "\n");
1796
+ }
1797
+ res.end();
1798
+ timing?.end();
1799
+ return;
1800
+ }
1761
1801
  const result = await registry.execute(actionName, input);
1762
1802
  if (requestMethod === "GET" && result.success) {
1763
1803
  const cacheHeaders = registry.getCacheHeaders(actionName);
@@ -1831,12 +1871,208 @@ function actionBodyParser() {
1831
1871
  });
1832
1872
  };
1833
1873
  }
1874
+ class FreezeStore {
1875
+ constructor(buildFolder) {
1876
+ __publicField(this, "dir");
1877
+ this.dir = path.join(buildFolder, "freezes");
1878
+ }
1879
+ async save(route, viewState, routePattern) {
1880
+ await fs$1.mkdir(this.dir, { recursive: true });
1881
+ const entry = {
1882
+ id: randomUUID().slice(0, 8),
1883
+ route,
1884
+ ...routePattern && { routePattern },
1885
+ viewState,
1886
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1887
+ };
1888
+ await fs$1.writeFile(
1889
+ path.join(this.dir, `${entry.id}.json`),
1890
+ JSON.stringify(entry, null, 2),
1891
+ "utf-8"
1892
+ );
1893
+ return entry;
1894
+ }
1895
+ async get(id) {
1896
+ try {
1897
+ const content = await fs$1.readFile(path.join(this.dir, `${id}.json`), "utf-8");
1898
+ return JSON.parse(content);
1899
+ } catch {
1900
+ return void 0;
1901
+ }
1902
+ }
1903
+ async list(route) {
1904
+ try {
1905
+ const files = await fs$1.readdir(this.dir);
1906
+ const entries = [];
1907
+ for (const file of files) {
1908
+ if (!file.endsWith(".json"))
1909
+ continue;
1910
+ try {
1911
+ const content = await fs$1.readFile(path.join(this.dir, file), "utf-8");
1912
+ const entry = JSON.parse(content);
1913
+ if (!route || entry.routePattern === route || entry.route === route) {
1914
+ entries.push(entry);
1915
+ }
1916
+ } catch {
1917
+ }
1918
+ }
1919
+ return entries.sort(
1920
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
1921
+ );
1922
+ } catch {
1923
+ return [];
1924
+ }
1925
+ }
1926
+ async rename(id, name) {
1927
+ const entry = await this.get(id);
1928
+ if (!entry)
1929
+ return false;
1930
+ entry.name = name;
1931
+ await fs$1.writeFile(
1932
+ path.join(this.dir, `${id}.json`),
1933
+ JSON.stringify(entry, null, 2),
1934
+ "utf-8"
1935
+ );
1936
+ return true;
1937
+ }
1938
+ async delete(id) {
1939
+ try {
1940
+ await fs$1.unlink(path.join(this.dir, `${id}.json`));
1941
+ return true;
1942
+ } catch {
1943
+ return false;
1944
+ }
1945
+ }
1946
+ }
1947
+ const DEV_SERVER_SERVICE = createJayService("DevServerService");
1948
+ class DevServerService {
1949
+ constructor(routes, vite, pagesBase, projectBase, jayRollupConfig, _freezeStore) {
1950
+ this.routes = routes;
1951
+ this.vite = vite;
1952
+ this.pagesBase = pagesBase;
1953
+ this.projectBase = projectBase;
1954
+ this.jayRollupConfig = jayRollupConfig;
1955
+ this._freezeStore = _freezeStore;
1956
+ }
1957
+ get freezeStore() {
1958
+ return this._freezeStore;
1959
+ }
1960
+ /** List all page routes in the project. */
1961
+ listRoutes() {
1962
+ return this.routes.map((r) => ({
1963
+ path: r.path,
1964
+ jayHtmlPath: r.fsRoute.jayHtmlPath,
1965
+ compPath: r.fsRoute.compPath
1966
+ }));
1967
+ }
1968
+ /**
1969
+ * Run loadParams for a route, yielding param batches as an async generator.
1970
+ * Loads all page parts (page component + keyed headless components) and
1971
+ * calls loadParams on each one that defines it.
1972
+ */
1973
+ async *loadRouteParams(routePath) {
1974
+ const matched = this.routes.find((r) => r.path === routePath);
1975
+ if (!matched) {
1976
+ getLogger().error(`[loadRouteParams] Route [${routePath}] not found`);
1977
+ throw new Error(`Route "${routePath}" not found`);
1978
+ }
1979
+ const loaded = await loadPageParts(
1980
+ this.vite,
1981
+ matched.fsRoute,
1982
+ this.pagesBase,
1983
+ this.projectBase,
1984
+ this.jayRollupConfig
1985
+ );
1986
+ if (!loaded.val) {
1987
+ return;
1988
+ }
1989
+ yield* runLoadParams(loaded.val.parts);
1990
+ }
1991
+ }
1992
+ let _watchLinkedFiles = () => {
1993
+ };
1834
1994
  async function initRoutes(pagesBaseFolder) {
1835
1995
  return await scanRoutes(pagesBaseFolder, {
1836
1996
  jayHtmlFilename: "page.jay-html",
1837
1997
  compFilename: "page.ts"
1838
1998
  });
1839
1999
  }
2000
+ async function scanPluginRoutes(projectRoot, projectRoutes) {
2001
+ const plugins = await scanPlugins({ projectRoot, includeDevDeps: true });
2002
+ const projectPaths = new Set(projectRoutes.map((r) => r.rawRoute));
2003
+ const pluginRoutes = [];
2004
+ for (const [, plugin] of plugins) {
2005
+ if (!plugin.manifest.routes)
2006
+ continue;
2007
+ for (const route of plugin.manifest.routes) {
2008
+ if (projectPaths.has(route.path)) {
2009
+ getLogger().info(
2010
+ `[Routes] Plugin "${plugin.name}" route ${route.path} skipped — project route takes precedence`
2011
+ );
2012
+ continue;
2013
+ }
2014
+ const jayHtmlPath = resolvePluginExport(plugin.pluginPath, route.jayHtml);
2015
+ if (!jayHtmlPath) {
2016
+ getLogger().warn(
2017
+ `[Routes] Plugin "${plugin.name}" route ${route.path}: jayHtml "${route.jayHtml}" not found`
2018
+ );
2019
+ continue;
2020
+ }
2021
+ const compPath = route.component.startsWith(".") ? path__default.resolve(plugin.pluginPath, route.component) : resolvePluginModule(plugin);
2022
+ pluginRoutes.push(createRoute(route.path, jayHtmlPath, compPath));
2023
+ getLogger().info(`[Routes] Plugin "${plugin.name}" provides route ${route.path}`);
2024
+ }
2025
+ }
2026
+ return pluginRoutes;
2027
+ }
2028
+ function resolvePluginExport(pluginPath, exportSubpath) {
2029
+ const normalized = exportSubpath.replace(/^\.\//, "");
2030
+ const packageJsonPath = path__default.join(pluginPath, "package.json");
2031
+ try {
2032
+ const packageJson = JSON.parse(fs__default$1.readFileSync(packageJsonPath, "utf-8"));
2033
+ if (packageJson.exports) {
2034
+ const exportKey = "./" + normalized;
2035
+ const exportValue = packageJson.exports[exportKey];
2036
+ if (exportValue) {
2037
+ const resolved = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
2038
+ if (resolved) {
2039
+ const fullPath = path__default.join(pluginPath, resolved);
2040
+ return fullPath;
2041
+ }
2042
+ }
2043
+ }
2044
+ } catch {
2045
+ }
2046
+ for (const dir of ["dist", "lib", ""]) {
2047
+ const candidate = path__default.join(pluginPath, dir, normalized);
2048
+ try {
2049
+ fs__default$1.accessSync(candidate);
2050
+ return candidate;
2051
+ } catch {
2052
+ }
2053
+ }
2054
+ return void 0;
2055
+ }
2056
+ function resolvePluginModule(plugin) {
2057
+ const modulePath = plugin.manifest.module || "index";
2058
+ for (const ext of [".ts", ".js", "/index.ts", "/index.js"]) {
2059
+ const candidate = path__default.join(plugin.pluginPath, modulePath + ext);
2060
+ try {
2061
+ fs__default$1.accessSync(candidate);
2062
+ return candidate;
2063
+ } catch {
2064
+ }
2065
+ }
2066
+ for (const ext of [".ts", ".js"]) {
2067
+ const candidate = path__default.join(plugin.pluginPath, "lib", path__default.basename(modulePath) + ext);
2068
+ try {
2069
+ fs__default$1.accessSync(candidate);
2070
+ return candidate;
2071
+ } catch {
2072
+ }
2073
+ }
2074
+ return path__default.join(plugin.pluginPath, modulePath);
2075
+ }
1840
2076
  function defaults(options) {
1841
2077
  const publicBaseUrlPath = options.publicBaseUrlPath || process.env.BASE || "/";
1842
2078
  const projectRootFolder = options.projectRootFolder || ".";
@@ -1854,7 +2090,9 @@ function defaults(options) {
1854
2090
  disableSSR: options.disableSSR,
1855
2091
  jayRollupConfig: {
1856
2092
  ...options.jayRollupConfig || {},
1857
- tsConfigFilePath
2093
+ tsConfigFilePath,
2094
+ pagesRoot: pagesRootFolder,
2095
+ buildFolder
1858
2096
  },
1859
2097
  httpServer: options.httpServer
1860
2098
  };
@@ -1896,7 +2134,7 @@ function filterPluginsForPage(allPluginClientInits, allPluginsWithInit, usedPack
1896
2134
  return pluginInfo && expandedPackages.has(pluginInfo.packageName);
1897
2135
  });
1898
2136
  }
1899
- function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, projectInit, allPluginsWithInit = [], allPluginClientInits = []) {
2137
+ function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, freezeStore, projectInit, allPluginsWithInit = [], allPluginClientInits = []) {
1900
2138
  const routePath = routeToExpressRoute(route);
1901
2139
  const handler = async (req, res) => {
1902
2140
  const timing = getDevLogger()?.startRequest(req.method, req.path);
@@ -1912,6 +2150,23 @@ function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, projectInit
1912
2150
  for (const [key, value] of urlObj.searchParams) {
1913
2151
  query[key] = value;
1914
2152
  }
2153
+ const freezeId = query["_jay_freeze"];
2154
+ if (freezeId && freezeStore) {
2155
+ timing?.annotate("[FROZEN]");
2156
+ await handleFrozenRequest(
2157
+ vite,
2158
+ route,
2159
+ options,
2160
+ freezeStore,
2161
+ slowRenderCache,
2162
+ freezeId,
2163
+ pageParams,
2164
+ query["format"] === "fragment" ? "fragment" : "page",
2165
+ res,
2166
+ timing
2167
+ );
2168
+ return;
2169
+ }
1915
2170
  if (options.disableSSR) {
1916
2171
  await handleClientOnlyRequest(
1917
2172
  vite,
@@ -1994,7 +2249,14 @@ async function handleCachedRequest(vite, route, options, cachedEntry, pageParams
1994
2249
  timing?.end();
1995
2250
  return;
1996
2251
  }
1997
- const { parts: pageParts, clientTrackByMap, usedPackages } = pagePartsResult.val;
2252
+ const {
2253
+ parts: pageParts,
2254
+ clientTrackByMap,
2255
+ usedPackages,
2256
+ linkedCssFiles,
2257
+ linkedComponentFiles
2258
+ } = pagePartsResult.val;
2259
+ _watchLinkedFiles([...linkedCssFiles || [], ...linkedComponentFiles || []]);
1998
2260
  const pluginsForPage = filterPluginsForPage(
1999
2261
  allPluginClientInits,
2000
2262
  allPluginsWithInit,
@@ -2023,6 +2285,7 @@ async function handleCachedRequest(vite, route, options, cachedEntry, pageParams
2023
2285
  }
2024
2286
  const fastViewState = renderedFast.rendered;
2025
2287
  const fastCarryForward = renderedFast.carryForward;
2288
+ const headTags = renderedFast.headTags ?? mergeHeadTags(cachedEntry.carryForward?.__slowHeadTags ?? []);
2026
2289
  await sendResponse(
2027
2290
  vite,
2028
2291
  res,
@@ -2036,9 +2299,11 @@ async function handleCachedRequest(vite, route, options, cachedEntry, pageParams
2036
2299
  projectInit,
2037
2300
  pluginsForPage,
2038
2301
  options,
2302
+ routeToExpressRoute(route),
2039
2303
  cachedEntry.slowViewState,
2040
2304
  timing,
2041
- cachedEntry.preRenderedContent
2305
+ cachedEntry.preRenderedContent,
2306
+ headTags
2042
2307
  );
2043
2308
  }
2044
2309
  async function handlePreRenderRequest(vite, route, options, slowlyPhase, slowRenderCache, pageParams, pageProps, allPluginClientInits, allPluginsWithInit, projectInit, res, url, timing, query = {}) {
@@ -2057,6 +2322,8 @@ async function handlePreRenderRequest(vite, route, options, slowlyPhase, slowRen
2057
2322
  timing?.end();
2058
2323
  return;
2059
2324
  }
2325
+ const { linkedCssFiles: initCss, linkedComponentFiles: initComps } = initialPartsResult.val;
2326
+ _watchLinkedFiles([...initCss || [], ...initComps || []]);
2060
2327
  const slowStart = Date.now();
2061
2328
  const renderedSlowly = await slowlyPhase.runSlowlyForPage(
2062
2329
  pageParams,
@@ -2149,8 +2416,11 @@ async function handleClientOnlyRequest(vite, route, options, slowlyPhase, pagePa
2149
2416
  usedPackages,
2150
2417
  headlessInstanceComponents,
2151
2418
  discoveredInstances,
2152
- forEachInstances
2419
+ forEachInstances,
2420
+ linkedCssFiles,
2421
+ linkedComponentFiles
2153
2422
  } = pagePartsResult.val;
2423
+ _watchLinkedFiles([...linkedCssFiles || [], ...linkedComponentFiles || []]);
2154
2424
  const pluginsForPage = filterPluginsForPage(
2155
2425
  allPluginClientInits,
2156
2426
  allPluginsWithInit,
@@ -2209,14 +2479,15 @@ async function handleClientOnlyRequest(vite, route, options, slowlyPhase, pagePa
2209
2479
  projectInit,
2210
2480
  pluginsForPage,
2211
2481
  {
2212
- enableAutomation: !options.disableAutomation
2482
+ enableAutomation: !options.disableAutomation,
2483
+ routePattern: routeToExpressRoute(route)
2213
2484
  }
2214
2485
  );
2215
2486
  if (options.buildFolder) {
2216
2487
  const pageName = !url || url === "/" ? "index" : url.replace(/^\//, "").replace(/\//g, "-");
2217
2488
  const clientScriptDir = path__default.join(options.buildFolder, "debug", "client-entry");
2218
- await fs$1.mkdir(clientScriptDir, { recursive: true });
2219
- await fs$1.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2489
+ await fs__default.mkdir(clientScriptDir, { recursive: true });
2490
+ await fs__default.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2220
2491
  }
2221
2492
  const viteStart = Date.now();
2222
2493
  const compiledPageHtml = await vite.transformIndexHtml(!!url ? url : "/", pageHtml);
@@ -2224,11 +2495,11 @@ async function handleClientOnlyRequest(vite, route, options, slowlyPhase, pagePa
2224
2495
  res.status(200).set({ "Content-Type": "text/html" }).send(compiledPageHtml);
2225
2496
  timing?.end();
2226
2497
  }
2227
- async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, pageParts, viewState, carryForward, clientTrackByMap, projectInit, pluginsForPage, options, slowViewState, timing, preLoadedContent) {
2498
+ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, pageParts, viewState, carryForward, clientTrackByMap, projectInit, pluginsForPage, options, routePattern, slowViewState, timing, preLoadedContent, headTags) {
2228
2499
  let pageHtml;
2229
2500
  const routeDir = path__default.dirname(path__default.relative(options.pagesRootFolder, sourceJayHtmlPath));
2230
2501
  try {
2231
- let jayHtmlContent = preLoadedContent ?? await fs$1.readFile(jayHtmlPath, "utf-8");
2502
+ let jayHtmlContent = preLoadedContent ?? await fs__default.readFile(jayHtmlPath, "utf-8");
2232
2503
  const jayHtmlFilename = path__default.basename(jayHtmlPath);
2233
2504
  const jayHtmlDir = path__default.dirname(jayHtmlPath);
2234
2505
  const sourceDir = path__default.dirname(sourceJayHtmlPath);
@@ -2252,8 +2523,12 @@ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, page
2252
2523
  pluginsForPage,
2253
2524
  {
2254
2525
  enableAutomation: !options.disableAutomation,
2255
- slowViewState
2256
- }
2526
+ slowViewState,
2527
+ routePattern
2528
+ },
2529
+ // Pass source directory for headfull FS file resolution when using pre-rendered path
2530
+ jayHtmlDir !== sourceDir ? sourceDir : void 0,
2531
+ headTags
2257
2532
  );
2258
2533
  } catch (err) {
2259
2534
  getLogger().warn(`[SSR] Failed, falling back to client rendering: ${err.message}`);
@@ -2268,15 +2543,16 @@ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, page
2268
2543
  pluginsForPage,
2269
2544
  {
2270
2545
  enableAutomation: !options.disableAutomation,
2271
- slowViewState
2546
+ slowViewState,
2547
+ routePattern
2272
2548
  }
2273
2549
  );
2274
2550
  }
2275
2551
  if (options.buildFolder) {
2276
2552
  const pageName = !url || url === "/" ? "index" : url.replace(/^\//, "").replace(/\//g, "-");
2277
2553
  const clientScriptDir = path__default.join(options.buildFolder, "debug", "client-entry");
2278
- await fs$1.mkdir(clientScriptDir, { recursive: true });
2279
- await fs$1.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2554
+ await fs__default.mkdir(clientScriptDir, { recursive: true });
2555
+ await fs__default.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2280
2556
  }
2281
2557
  const viteStart = Date.now();
2282
2558
  const compiledPageHtml = await vite.transformIndexHtml(!!url ? url : "/", pageHtml);
@@ -2284,12 +2560,62 @@ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, page
2284
2560
  res.status(200).set({ "Content-Type": "text/html" }).send(compiledPageHtml);
2285
2561
  timing?.end();
2286
2562
  }
2563
+ async function handleFrozenRequest(vite, route, options, freezeStore, slowRenderCache, freezeId, pageParams, format, res, timing) {
2564
+ const entry = await freezeStore.get(freezeId);
2565
+ if (!entry) {
2566
+ getLogger().warn(`[Freeze] Freeze "${freezeId}" not found`);
2567
+ res.status(404).send(`Freeze "${freezeId}" not found`);
2568
+ timing?.end();
2569
+ return;
2570
+ }
2571
+ const label = entry.name ? `"${entry.name}" (${freezeId})` : freezeId;
2572
+ getLogger().info(`[Freeze] Serving frozen page ${label} for ${route.rawRoute} [${format}]`);
2573
+ try {
2574
+ const cachedEntry = await slowRenderCache.get(route.jayHtmlPath, pageParams);
2575
+ const jayHtmlPath = cachedEntry?.preRenderedPath ?? route.jayHtmlPath;
2576
+ const jayHtmlContent = cachedEntry?.preRenderedContent ?? await fs__default.readFile(jayHtmlPath, "utf-8");
2577
+ const jayHtmlFilename = path__default.basename(jayHtmlPath);
2578
+ const jayHtmlDir = path__default.dirname(jayHtmlPath);
2579
+ const sourceDir = path__default.dirname(route.jayHtmlPath);
2580
+ const routeDir = path__default.dirname(path__default.relative(options.pagesRootFolder, route.jayHtmlPath));
2581
+ const { injectHeadfullFSTemplates: injectHeadfullFSTemplates2 } = await import("@jay-framework/compiler-jay-html");
2582
+ const { JAY_IMPORT_RESOLVER: JAY_IMPORT_RESOLVER2 } = await import("@jay-framework/compiler-jay-html");
2583
+ const fullJayHtml = injectHeadfullFSTemplates2(
2584
+ jayHtmlContent,
2585
+ sourceDir,
2586
+ JAY_IMPORT_RESOLVER2
2587
+ );
2588
+ const html = await generateFrozenPageHtml(
2589
+ vite,
2590
+ fullJayHtml,
2591
+ jayHtmlFilename,
2592
+ jayHtmlDir,
2593
+ entry.viewState,
2594
+ options.buildFolder,
2595
+ options.projectRootFolder,
2596
+ routeDir,
2597
+ options.jayRollupConfig?.tsConfigFilePath,
2598
+ void 0,
2599
+ format,
2600
+ entry.name
2601
+ );
2602
+ const headers = { "Content-Type": "text/html" };
2603
+ if (format === "fragment") {
2604
+ headers["Access-Control-Allow-Origin"] = "*";
2605
+ }
2606
+ res.status(200).set(headers).send(html);
2607
+ } catch (err) {
2608
+ getLogger().warn(`[Freeze] Failed to render frozen page: ${err.message}`);
2609
+ res.status(500).send(`Failed to render frozen page: ${err.message}`);
2610
+ }
2611
+ timing?.end();
2612
+ }
2287
2613
  async function preRenderJayHtml(route, slowViewState, headlessContracts, headlessInstanceComponents, partKeys = []) {
2288
- const jayHtmlContent = await fs$1.readFile(route.jayHtmlPath, "utf-8");
2614
+ const jayHtmlContent = await fs__default.readFile(route.jayHtmlPath, "utf-8");
2289
2615
  const contractPath = route.jayHtmlPath.replace(".jay-html", ".jay-contract");
2290
2616
  let contract;
2291
2617
  try {
2292
- const contractContent = await fs$1.readFile(contractPath, "utf-8");
2618
+ const contractContent = await fs__default.readFile(contractPath, "utf-8");
2293
2619
  const parseResult = parseContract(contractContent, path__default.basename(contractPath));
2294
2620
  if (parseResult.val) {
2295
2621
  contract = parseResult.val;
@@ -2338,10 +2664,15 @@ async function preRenderJayHtml(route, slowViewState, headlessContracts, headles
2338
2664
  let forEachInstances;
2339
2665
  if (headlessInstanceComponents.length > 0) {
2340
2666
  const discoveryResult = discoverHeadlessInstances(preRenderedJayHtml);
2341
- preRenderedJayHtml = discoveryResult.preRenderedJayHtml;
2342
- if (discoveryResult.forEachInstances.length > 0) {
2667
+ const htmlWithRefs = discoveryResult.preRenderedJayHtml;
2668
+ const headlessContractNameSet = new Set(
2669
+ headlessInstanceComponents.map((c2) => c2.contractName)
2670
+ );
2671
+ preRenderedJayHtml = assignCoordinatesToJayHtml(htmlWithRefs, headlessContractNameSet);
2672
+ const finalDiscovery = discoverHeadlessInstances(preRenderedJayHtml);
2673
+ if (finalDiscovery.forEachInstances.length > 0) {
2343
2674
  const validationErrors = validateForEachInstances(
2344
- discoveryResult.forEachInstances,
2675
+ finalDiscovery.forEachInstances,
2345
2676
  headlessInstanceComponents
2346
2677
  );
2347
2678
  if (validationErrors.length > 0) {
@@ -2350,11 +2681,11 @@ async function preRenderJayHtml(route, slowViewState, headlessContracts, headles
2350
2681
  );
2351
2682
  return void 0;
2352
2683
  }
2353
- forEachInstances = discoveryResult.forEachInstances;
2684
+ forEachInstances = finalDiscovery.forEachInstances;
2354
2685
  }
2355
- if (discoveryResult.instances.length > 0) {
2686
+ if (finalDiscovery.instances.length > 0) {
2356
2687
  const slowResult = await slowRenderInstances(
2357
- discoveryResult.instances,
2688
+ finalDiscovery.instances,
2358
2689
  headlessInstanceComponents
2359
2690
  );
2360
2691
  if (slowResult) {
@@ -2426,8 +2757,15 @@ async function mkDevServer(rawOptions) {
2426
2757
  const options = defaults(rawOptions);
2427
2758
  const { publicBaseUrlPath, pagesRootFolder, projectRootFolder, buildFolder, jayRollupConfig } = options;
2428
2759
  if (buildFolder) {
2429
- await fs$1.rm(buildFolder, { recursive: true, force: true }).catch(() => {
2430
- });
2760
+ try {
2761
+ const entries = await fs__default.readdir(buildFolder).catch(() => []);
2762
+ for (const entry of entries) {
2763
+ if (entry === "freezes")
2764
+ continue;
2765
+ await fs__default.rm(path__default.join(buildFolder, entry), { recursive: true, force: true });
2766
+ }
2767
+ } catch {
2768
+ }
2431
2769
  }
2432
2770
  const viteLogLevel = options.logLevel === "silent" ? "silent" : options.logLevel === "verbose" ? "info" : "warn";
2433
2771
  const lifecycleManager = new ServiceLifecycleManager(projectRootFolder);
@@ -2445,15 +2783,25 @@ async function mkDevServer(rawOptions) {
2445
2783
  await materializeDynamicContracts(projectRootFolder, vite);
2446
2784
  setupServiceHotReload(vite, lifecycleManager);
2447
2785
  setupActionRouter(vite);
2448
- const allRoutes = await initRoutes(pagesRootFolder);
2449
- const routes = buildFolder ? allRoutes.filter((route) => !route.jayHtmlPath.startsWith(buildFolder)) : allRoutes;
2786
+ const projectRoutes = await initRoutes(pagesRootFolder);
2787
+ const filteredProjectRoutes = buildFolder ? projectRoutes.filter((route) => !route.jayHtmlPath.startsWith(buildFolder)) : projectRoutes;
2788
+ const pluginRoutes = await scanPluginRoutes(projectRootFolder, filteredProjectRoutes);
2789
+ const routes = [...filteredProjectRoutes, ...pluginRoutes];
2450
2790
  const slowlyPhase = new DevSlowlyChangingPhase();
2451
2791
  const slowRenderCacheDir = path__default.join(buildFolder, "pre-rendered");
2452
2792
  const slowRenderCache = new SlowRenderCache(slowRenderCacheDir, pagesRootFolder);
2453
- setupSlowRenderCacheInvalidation(vite, slowRenderCache, pagesRootFolder);
2793
+ _watchLinkedFiles = setupSlowRenderCacheInvalidation(
2794
+ vite,
2795
+ slowRenderCache,
2796
+ pagesRootFolder
2797
+ );
2454
2798
  const projectInit = lifecycleManager.getProjectInit() ?? void 0;
2455
2799
  const pluginsWithInit = lifecycleManager.getPluginsWithInit();
2456
2800
  const pluginClientInits = preparePluginClientInits(pluginsWithInit);
2801
+ const freezeStore = buildFolder ? new FreezeStore(buildFolder) : void 0;
2802
+ if (freezeStore) {
2803
+ setupFreezeEndpoint(vite, freezeStore);
2804
+ }
2457
2805
  const devServerRoutes = routes.map(
2458
2806
  (route) => mkRoute(
2459
2807
  route,
@@ -2461,16 +2809,28 @@ async function mkDevServer(rawOptions) {
2461
2809
  slowlyPhase,
2462
2810
  options,
2463
2811
  slowRenderCache,
2812
+ freezeStore,
2464
2813
  projectInit,
2465
2814
  pluginsWithInit,
2466
2815
  pluginClientInits
2467
2816
  )
2468
2817
  );
2818
+ const service = new DevServerService(
2819
+ devServerRoutes,
2820
+ vite,
2821
+ options.pagesRootFolder,
2822
+ options.projectRootFolder,
2823
+ options.jayRollupConfig,
2824
+ freezeStore
2825
+ );
2826
+ registerService(DEV_SERVER_SERVICE, service);
2469
2827
  return {
2470
2828
  server: vite.middlewares,
2471
2829
  viteServer: vite,
2472
2830
  routes: devServerRoutes,
2473
- lifecycleManager
2831
+ lifecycleManager,
2832
+ freezeStore,
2833
+ service
2474
2834
  };
2475
2835
  }
2476
2836
  function setupGracefulShutdown(lifecycleManager) {
@@ -2509,19 +2869,68 @@ function setupActionRouter(vite) {
2509
2869
  vite.middlewares.use(ACTION_ENDPOINT_BASE, createActionRouter());
2510
2870
  getLogger().info(`[Actions] Action router mounted at ${ACTION_ENDPOINT_BASE}`);
2511
2871
  }
2512
- function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder) {
2872
+ function setupFreezeEndpoint(vite, freezeStore) {
2873
+ vite.middlewares.use((req, res, next) => {
2874
+ if (req.method === "POST" && (req.url === "/_jay/freeze" || req.originalUrl === "/_jay/freeze")) {
2875
+ let body = "";
2876
+ req.on("data", (chunk) => body += chunk);
2877
+ req.on("end", async () => {
2878
+ try {
2879
+ const { route, routePattern, viewState } = JSON.parse(body);
2880
+ if (!route || !viewState) {
2881
+ res.writeHead(400, { "Content-Type": "application/json" });
2882
+ res.end(JSON.stringify({ error: "Missing route or viewState" }));
2883
+ return;
2884
+ }
2885
+ const entry = await freezeStore.save(route, viewState, routePattern);
2886
+ getLogger().info(`[Freeze] Saved freeze "${entry.id}" for ${route}`);
2887
+ res.writeHead(200, { "Content-Type": "application/json" });
2888
+ res.end(JSON.stringify(entry));
2889
+ } catch (err) {
2890
+ getLogger().warn(`[Freeze] Failed to save: ${err.message}`);
2891
+ res.writeHead(500, { "Content-Type": "application/json" });
2892
+ res.end(JSON.stringify({ error: err.message }));
2893
+ }
2894
+ });
2895
+ } else {
2896
+ next();
2897
+ }
2898
+ });
2899
+ getLogger().info("[Freeze] Freeze endpoint mounted at /_jay/freeze");
2900
+ }
2901
+ function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectRootFolder) {
2902
+ const watchedFiles = /* @__PURE__ */ new Set();
2903
+ const watchLinkedFiles = (files) => {
2904
+ for (const file of files) {
2905
+ if (watchedFiles.has(file))
2906
+ continue;
2907
+ watchedFiles.add(file);
2908
+ vite.watcher.add(file);
2909
+ getLogger().info(`[SlowRender] Watching: ${file}`);
2910
+ }
2911
+ };
2513
2912
  vite.watcher.on("change", (changedPath) => {
2514
- if (!changedPath.startsWith(pagesRootFolder)) {
2913
+ if (watchedFiles.has(changedPath)) {
2914
+ clearServerElementCache();
2915
+ cache.clear().then(() => {
2916
+ getLogger().info(
2917
+ `[SlowRender] Cache cleared (linked file changed: ${changedPath})`
2918
+ );
2919
+ vite.ws.send({ type: "full-reload" });
2920
+ });
2515
2921
  return;
2516
2922
  }
2517
- if (changedPath.endsWith(".jay-html")) {
2923
+ if (changedPath.endsWith(".jay-html") && changedPath.startsWith(pagesRootFolder)) {
2518
2924
  clearServerElementCache();
2519
- cache.invalidate(changedPath).then(() => {
2520
- getLogger().info(`[SlowRender] Cache invalidated for ${changedPath}`);
2925
+ cache.clear().then(() => {
2926
+ getLogger().info(`[SlowRender] Cache cleared (jay-html changed: ${changedPath})`);
2521
2927
  vite.ws.send({ type: "full-reload" });
2522
2928
  });
2523
2929
  return;
2524
2930
  }
2931
+ if (!changedPath.startsWith(pagesRootFolder)) {
2932
+ return;
2933
+ }
2525
2934
  if (changedPath.endsWith("page.ts")) {
2526
2935
  const dir = path__default.dirname(changedPath);
2527
2936
  const jayHtmlPath = path__default.join(dir, "page.jay-html");
@@ -2546,9 +2955,13 @@ function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder) {
2546
2955
  return;
2547
2956
  }
2548
2957
  });
2958
+ return watchLinkedFiles;
2549
2959
  }
2550
2960
  export {
2551
2961
  ACTION_ENDPOINT_BASE,
2962
+ DEV_SERVER_SERVICE,
2963
+ DevServerService,
2964
+ FreezeStore,
2552
2965
  actionBodyParser,
2553
2966
  createActionRouter,
2554
2967
  createViteForCli,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jay-framework/dev-server",
3
- "version": "0.15.5",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.js",
@@ -23,22 +23,22 @@
23
23
  "test:watch": "vitest"
24
24
  },
25
25
  "dependencies": {
26
- "@jay-framework/compiler-jay-stack": "^0.15.5",
27
- "@jay-framework/compiler-shared": "^0.15.5",
28
- "@jay-framework/component": "^0.15.5",
29
- "@jay-framework/fullstack-component": "^0.15.5",
30
- "@jay-framework/logger": "^0.15.5",
31
- "@jay-framework/runtime": "^0.15.5",
32
- "@jay-framework/stack-client-runtime": "^0.15.5",
33
- "@jay-framework/stack-route-scanner": "^0.15.5",
34
- "@jay-framework/stack-server-runtime": "^0.15.5",
35
- "@jay-framework/view-state-merge": "^0.15.5",
26
+ "@jay-framework/compiler-jay-stack": "^0.16.0",
27
+ "@jay-framework/compiler-shared": "^0.16.0",
28
+ "@jay-framework/component": "^0.16.0",
29
+ "@jay-framework/fullstack-component": "^0.16.0",
30
+ "@jay-framework/logger": "^0.16.0",
31
+ "@jay-framework/runtime": "^0.16.0",
32
+ "@jay-framework/stack-client-runtime": "^0.16.0",
33
+ "@jay-framework/stack-route-scanner": "^0.16.0",
34
+ "@jay-framework/stack-server-runtime": "^0.16.0",
35
+ "@jay-framework/view-state-merge": "^0.16.0",
36
36
  "vite": "^5.0.11"
37
37
  },
38
38
  "devDependencies": {
39
- "@jay-framework/dev-environment": "^0.15.5",
40
- "@jay-framework/jay-cli": "^0.15.5",
41
- "@jay-framework/stack-client-runtime": "^0.15.5",
39
+ "@jay-framework/dev-environment": "^0.16.0",
40
+ "@jay-framework/jay-cli": "^0.16.0",
41
+ "@jay-framework/stack-client-runtime": "^0.16.0",
42
42
  "@playwright/test": "^1.58.2",
43
43
  "@types/express": "^5.0.2",
44
44
  "@types/node": "^22.15.21",