@jay-framework/dev-server 0.15.6 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +73 -3
  2. package/dist/index.js +547 -30
  3. package/package.json +16 -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
 
@@ -160,10 +220,20 @@ interface ActionRouterOptions {
160
220
  */
161
221
  declare function createActionRouter(options?: ActionRouterOptions): RequestHandler$1;
162
222
  /**
163
- * Express middleware to parse JSON body for action requests.
223
+ * Options for the action body parser middleware.
224
+ */
225
+ interface ActionBodyParserOptions {
226
+ /** Build folder for temp file storage (DL#131) */
227
+ buildFolder: string;
228
+ /** Action registry to check for acceptsFiles (default: global actionRegistry) */
229
+ registry?: ActionRegistry;
230
+ }
231
+ /**
232
+ * Express middleware to parse request body for action requests.
233
+ * Supports JSON (default) and multipart/form-data (for actions with .withFiles()).
164
234
  * Should be applied before the action router.
165
235
  */
166
- declare function actionBodyParser(): RequestHandler$1;
236
+ declare function actionBodyParser(options: ActionBodyParserOptions): RequestHandler$1;
167
237
 
168
238
  /**
169
239
  * Vite Factory
@@ -207,4 +277,4 @@ declare function createViteForCli(options: {
207
277
  tsConfigFilePath?: string;
208
278
  }): Promise<ViteDevServer>;
209
279
 
210
- export { ACTION_ENDPOINT_BASE, type ActionRouterOptions, type CreateViteServerOptions, type DevServer, type DevServerOptions, type DevServerRoute, actionBodyParser, createActionRouter, createViteForCli, createViteServer, mkDevServer };
280
+ export { ACTION_ENDPOINT_BASE, type ActionBodyParserOptions, 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
@@ -14,10 +14,18 @@ import { JAY_IMPORT_RESOLVER, injectHeadfullFSTemplates, parseContract, slowRend
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, mergeHeadTags, 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 Busboy from "busboy";
24
+ import fs$2 from "fs";
25
+ import path$1 from "path";
26
+ import crypto from "crypto";
27
+ import { randomUUID } from "node:crypto";
28
+ import { createJayService } from "@jay-framework/fullstack-component";
21
29
  const s$1 = createRequire(import.meta.url), e$1 = s$1("typescript"), c$1 = new Proxy(e$1, {
22
30
  get(t, r) {
23
31
  return t[r];
@@ -911,6 +919,7 @@ function extractActionFromExpression(node) {
911
919
  let current = node;
912
920
  let method = "POST";
913
921
  let explicitMethod = null;
922
+ let acceptsFiles = false;
914
923
  while (c$1.isCallExpression(current)) {
915
924
  const expr = current.expression;
916
925
  if (c$1.isPropertyAccessExpression(expr) && expr.name.text === "withMethod") {
@@ -921,22 +930,36 @@ function extractActionFromExpression(node) {
921
930
  current = expr.expression;
922
931
  continue;
923
932
  }
933
+ if (c$1.isPropertyAccessExpression(expr) && expr.name.text === "withFiles") {
934
+ acceptsFiles = true;
935
+ current = expr.expression;
936
+ continue;
937
+ }
924
938
  if (c$1.isPropertyAccessExpression(expr) && ["withServices", "withCaching", "withHandler", "withTimeout"].includes(expr.name.text)) {
925
939
  current = expr.expression;
926
940
  continue;
927
941
  }
928
942
  if (c$1.isIdentifier(expr)) {
929
943
  const funcName = expr.text;
930
- if (funcName === "makeJayAction" || funcName === "makeJayQuery") {
944
+ if (funcName === "makeJayAction" || funcName === "makeJayQuery" || funcName === "makeJayStream") {
931
945
  const nameArg = current.arguments[0];
932
946
  if (nameArg && c$1.isStringLiteral(nameArg)) {
947
+ if (funcName === "makeJayStream") {
948
+ return {
949
+ actionName: nameArg.text,
950
+ method: "POST",
951
+ isStreaming: true,
952
+ ...acceptsFiles && { acceptsFiles: true }
953
+ };
954
+ }
933
955
  method = funcName === "makeJayQuery" ? "GET" : "POST";
934
956
  if (explicitMethod) {
935
957
  method = explicitMethod;
936
958
  }
937
959
  return {
938
960
  actionName: nameArg.text,
939
- method
961
+ method,
962
+ ...acceptsFiles && { acceptsFiles: true }
940
963
  };
941
964
  }
942
965
  }
@@ -1331,14 +1354,28 @@ function jayStackCompiler(options = {}) {
1331
1354
  getLogger().warn(`[action-transform] No actions found in ${actualPath}`);
1332
1355
  return null;
1333
1356
  }
1357
+ const hasRegularActions = actions.some((a) => !a.isStreaming);
1358
+ const hasStreamActions = actions.some((a) => a.isStreaming);
1359
+ const importNames = [];
1360
+ if (hasRegularActions)
1361
+ importNames.push("createActionCaller");
1362
+ if (hasStreamActions)
1363
+ importNames.push("createStreamCaller");
1334
1364
  const lines = [
1335
- `import { createActionCaller } from '@jay-framework/stack-client-runtime';`,
1365
+ `import { ${importNames.join(", ")} } from '@jay-framework/stack-client-runtime';`,
1336
1366
  ""
1337
1367
  ];
1338
1368
  for (const action of actions) {
1339
- lines.push(
1340
- `export const ${action.exportName} = createActionCaller('${action.actionName}', '${action.method}');`
1341
- );
1369
+ const filesOpt = action.acceptsFiles ? ", { acceptsFiles: true }" : "";
1370
+ if (action.isStreaming) {
1371
+ lines.push(
1372
+ `export const ${action.exportName} = createStreamCaller('${action.actionName}'${filesOpt});`
1373
+ );
1374
+ } else {
1375
+ lines.push(
1376
+ `export const ${action.exportName} = createActionCaller('${action.actionName}', '${action.method}'${filesOpt});`
1377
+ );
1378
+ }
1342
1379
  }
1343
1380
  if (code.includes("ActionError")) {
1344
1381
  lines.push(
@@ -1758,7 +1795,30 @@ function createActionRouter(options) {
1758
1795
  requestMethod,
1759
1796
  ACTION_ENDPOINT_BASE + "/" + actionName
1760
1797
  );
1798
+ const tempDir = req._jayTempDir;
1799
+ const cleanup = () => {
1800
+ if (tempDir)
1801
+ cleanupTempDir(tempDir);
1802
+ };
1803
+ if (registry.isStreaming(actionName)) {
1804
+ res.setHeader("Content-Type", "application/x-ndjson");
1805
+ res.setHeader("Transfer-Encoding", "chunked");
1806
+ try {
1807
+ const generator = registry.executeStream(actionName, input);
1808
+ for await (const chunk of generator) {
1809
+ res.write(JSON.stringify({ chunk }) + "\n");
1810
+ }
1811
+ res.write(JSON.stringify({ done: true }) + "\n");
1812
+ } catch (err) {
1813
+ res.write(JSON.stringify({ error: err.message }) + "\n");
1814
+ }
1815
+ cleanup();
1816
+ res.end();
1817
+ timing?.end();
1818
+ return;
1819
+ }
1761
1820
  const result = await registry.execute(actionName, input);
1821
+ cleanup();
1762
1822
  if (requestMethod === "GET" && result.success) {
1763
1823
  const cacheHeaders = registry.getCacheHeaders(actionName);
1764
1824
  if (cacheHeaders) {
@@ -1799,7 +1859,106 @@ function getStatusCodeForError(code, isActionError) {
1799
1859
  return 500;
1800
1860
  }
1801
1861
  }
1802
- function actionBodyParser() {
1862
+ const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
1863
+ const DEFAULT_MAX_FILES = 10;
1864
+ function parseMultipart(req, tempDir, maxFileSize, maxFiles) {
1865
+ return new Promise((resolve, reject) => {
1866
+ fs$2.mkdirSync(tempDir, { recursive: true });
1867
+ const files = {};
1868
+ let jsonData = {};
1869
+ let fileCount = 0;
1870
+ let errored = false;
1871
+ const pendingWrites = [];
1872
+ const bb = Busboy({
1873
+ headers: req.headers,
1874
+ limits: {
1875
+ fileSize: maxFileSize,
1876
+ files: maxFiles
1877
+ }
1878
+ });
1879
+ bb.on("file", (fieldname, stream, info) => {
1880
+ if (errored)
1881
+ return;
1882
+ fileCount++;
1883
+ const filename = info.filename || `upload-${fileCount}`;
1884
+ const tempPath = path$1.join(tempDir, `${fileCount}-${filename}`);
1885
+ let size = 0;
1886
+ let truncated = false;
1887
+ const writeStream = fs$2.createWriteStream(tempPath);
1888
+ stream.pipe(writeStream);
1889
+ stream.on("data", (data) => {
1890
+ size += data.length;
1891
+ });
1892
+ stream.on("limit", () => {
1893
+ truncated = true;
1894
+ });
1895
+ pendingWrites.push(
1896
+ new Promise((resolveWrite, rejectWrite) => {
1897
+ writeStream.on("close", () => {
1898
+ if (truncated) {
1899
+ rejectWrite(
1900
+ new Error(
1901
+ `File "${filename}" exceeds maximum size of ${maxFileSize} bytes`
1902
+ )
1903
+ );
1904
+ return;
1905
+ }
1906
+ const jayFile = {
1907
+ name: filename,
1908
+ type: info.mimeType,
1909
+ size,
1910
+ path: tempPath
1911
+ };
1912
+ if (files[fieldname]) {
1913
+ const existing = files[fieldname];
1914
+ if (Array.isArray(existing)) {
1915
+ existing.push(jayFile);
1916
+ } else {
1917
+ files[fieldname] = [existing, jayFile];
1918
+ }
1919
+ } else {
1920
+ files[fieldname] = jayFile;
1921
+ }
1922
+ resolveWrite();
1923
+ });
1924
+ })
1925
+ );
1926
+ });
1927
+ bb.on("field", (fieldname, value) => {
1928
+ if (errored)
1929
+ return;
1930
+ if (fieldname === "_json") {
1931
+ try {
1932
+ jsonData = JSON.parse(value);
1933
+ } catch {
1934
+ errored = true;
1935
+ reject(new Error("Invalid JSON in _json field"));
1936
+ }
1937
+ }
1938
+ });
1939
+ bb.on("close", () => {
1940
+ if (errored)
1941
+ return;
1942
+ Promise.all(pendingWrites).then(() => resolve({ body: { ...jsonData, ...files }, tempDir })).catch((err) => reject(err));
1943
+ });
1944
+ bb.on("error", (err) => {
1945
+ if (!errored) {
1946
+ errored = true;
1947
+ reject(err);
1948
+ }
1949
+ });
1950
+ req.pipe(bb);
1951
+ });
1952
+ }
1953
+ function cleanupTempDir(dir) {
1954
+ try {
1955
+ fs$2.rmSync(dir, { recursive: true, force: true });
1956
+ } catch {
1957
+ }
1958
+ }
1959
+ function actionBodyParser(options) {
1960
+ const { buildFolder, registry: reg } = options;
1961
+ const registryToUse = reg ?? actionRegistry;
1803
1962
  return (req, res, next) => {
1804
1963
  if (!req.path.startsWith(ACTION_ENDPOINT_BASE)) {
1805
1964
  next();
@@ -1809,6 +1968,42 @@ function actionBodyParser() {
1809
1968
  next();
1810
1969
  return;
1811
1970
  }
1971
+ const contentType = req.headers["content-type"] || "";
1972
+ if (contentType.startsWith("multipart/form-data")) {
1973
+ const actionName = req.path.slice(ACTION_ENDPOINT_BASE.length + 1);
1974
+ const action = registryToUse.get(actionName);
1975
+ if (!action?.acceptsFiles) {
1976
+ res.status(400).json({
1977
+ success: false,
1978
+ error: {
1979
+ code: "FILES_NOT_ACCEPTED",
1980
+ message: `Action '${actionName}' does not accept file uploads. Use .withFiles() on the action builder.`,
1981
+ isActionError: false
1982
+ }
1983
+ });
1984
+ return;
1985
+ }
1986
+ const requestId = crypto.randomUUID();
1987
+ const tempDir = path$1.join(buildFolder, ".tmp", "actions", requestId);
1988
+ const maxFileSize = action.fileOptions?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
1989
+ const maxFiles = action.fileOptions?.maxFiles ?? DEFAULT_MAX_FILES;
1990
+ parseMultipart(req, tempDir, maxFileSize, maxFiles).then(({ body: body2, tempDir: td }) => {
1991
+ req.body = body2;
1992
+ req._jayTempDir = td;
1993
+ next();
1994
+ }).catch((err) => {
1995
+ cleanupTempDir(tempDir);
1996
+ res.status(400).json({
1997
+ success: false,
1998
+ error: {
1999
+ code: "MULTIPART_PARSE_ERROR",
2000
+ message: err.message,
2001
+ isActionError: false
2002
+ }
2003
+ });
2004
+ });
2005
+ return;
2006
+ }
1812
2007
  let body = "";
1813
2008
  req.setEncoding("utf8");
1814
2009
  req.on("data", (chunk) => {
@@ -1831,6 +2026,124 @@ function actionBodyParser() {
1831
2026
  });
1832
2027
  };
1833
2028
  }
2029
+ class FreezeStore {
2030
+ constructor(buildFolder) {
2031
+ __publicField(this, "dir");
2032
+ this.dir = path.join(buildFolder, "freezes");
2033
+ }
2034
+ async save(route, viewState, routePattern) {
2035
+ await fs$1.mkdir(this.dir, { recursive: true });
2036
+ const entry = {
2037
+ id: randomUUID().slice(0, 8),
2038
+ route,
2039
+ ...routePattern && { routePattern },
2040
+ viewState,
2041
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2042
+ };
2043
+ await fs$1.writeFile(
2044
+ path.join(this.dir, `${entry.id}.json`),
2045
+ JSON.stringify(entry, null, 2),
2046
+ "utf-8"
2047
+ );
2048
+ return entry;
2049
+ }
2050
+ async get(id) {
2051
+ try {
2052
+ const content = await fs$1.readFile(path.join(this.dir, `${id}.json`), "utf-8");
2053
+ return JSON.parse(content);
2054
+ } catch {
2055
+ return void 0;
2056
+ }
2057
+ }
2058
+ async list(route) {
2059
+ try {
2060
+ const files = await fs$1.readdir(this.dir);
2061
+ const entries = [];
2062
+ for (const file of files) {
2063
+ if (!file.endsWith(".json"))
2064
+ continue;
2065
+ try {
2066
+ const content = await fs$1.readFile(path.join(this.dir, file), "utf-8");
2067
+ const entry = JSON.parse(content);
2068
+ if (!route || entry.routePattern === route || entry.route === route) {
2069
+ entries.push(entry);
2070
+ }
2071
+ } catch {
2072
+ }
2073
+ }
2074
+ return entries.sort(
2075
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
2076
+ );
2077
+ } catch {
2078
+ return [];
2079
+ }
2080
+ }
2081
+ async rename(id, name) {
2082
+ const entry = await this.get(id);
2083
+ if (!entry)
2084
+ return false;
2085
+ entry.name = name;
2086
+ await fs$1.writeFile(
2087
+ path.join(this.dir, `${id}.json`),
2088
+ JSON.stringify(entry, null, 2),
2089
+ "utf-8"
2090
+ );
2091
+ return true;
2092
+ }
2093
+ async delete(id) {
2094
+ try {
2095
+ await fs$1.unlink(path.join(this.dir, `${id}.json`));
2096
+ return true;
2097
+ } catch {
2098
+ return false;
2099
+ }
2100
+ }
2101
+ }
2102
+ const DEV_SERVER_SERVICE = createJayService("DevServerService");
2103
+ class DevServerService {
2104
+ constructor(routes, vite, pagesBase, projectBase, jayRollupConfig, _freezeStore) {
2105
+ this.routes = routes;
2106
+ this.vite = vite;
2107
+ this.pagesBase = pagesBase;
2108
+ this.projectBase = projectBase;
2109
+ this.jayRollupConfig = jayRollupConfig;
2110
+ this._freezeStore = _freezeStore;
2111
+ }
2112
+ get freezeStore() {
2113
+ return this._freezeStore;
2114
+ }
2115
+ /** List all page routes in the project. */
2116
+ listRoutes() {
2117
+ return this.routes.map((r) => ({
2118
+ path: r.path,
2119
+ jayHtmlPath: r.fsRoute.jayHtmlPath,
2120
+ compPath: r.fsRoute.compPath
2121
+ }));
2122
+ }
2123
+ /**
2124
+ * Run loadParams for a route, yielding param batches as an async generator.
2125
+ * Loads all page parts (page component + keyed headless components) and
2126
+ * calls loadParams on each one that defines it.
2127
+ */
2128
+ async *loadRouteParams(routePath) {
2129
+ const matched = this.routes.find((r) => r.path === routePath);
2130
+ if (!matched) {
2131
+ getLogger().error(`[loadRouteParams] Route [${routePath}] not found`);
2132
+ throw new Error(`Route "${routePath}" not found`);
2133
+ }
2134
+ const loaded = await loadPageParts(
2135
+ this.vite,
2136
+ matched.fsRoute,
2137
+ this.pagesBase,
2138
+ this.projectBase,
2139
+ this.jayRollupConfig
2140
+ );
2141
+ if (!loaded.val) {
2142
+ return;
2143
+ }
2144
+ yield* runLoadParams(loaded.val.parts);
2145
+ }
2146
+ }
1834
2147
  let _watchLinkedFiles = () => {
1835
2148
  };
1836
2149
  async function initRoutes(pagesBaseFolder) {
@@ -1839,6 +2152,82 @@ async function initRoutes(pagesBaseFolder) {
1839
2152
  compFilename: "page.ts"
1840
2153
  });
1841
2154
  }
2155
+ async function scanPluginRoutes(projectRoot, projectRoutes) {
2156
+ const plugins = await scanPlugins({ projectRoot, includeDevDeps: true });
2157
+ const projectPaths = new Set(projectRoutes.map((r) => r.rawRoute));
2158
+ const pluginRoutes = [];
2159
+ for (const [, plugin] of plugins) {
2160
+ if (!plugin.manifest.routes)
2161
+ continue;
2162
+ for (const route of plugin.manifest.routes) {
2163
+ if (projectPaths.has(route.path)) {
2164
+ getLogger().info(
2165
+ `[Routes] Plugin "${plugin.name}" route ${route.path} skipped — project route takes precedence`
2166
+ );
2167
+ continue;
2168
+ }
2169
+ const jayHtmlPath = resolvePluginExport(plugin.pluginPath, route.jayHtml);
2170
+ if (!jayHtmlPath) {
2171
+ getLogger().warn(
2172
+ `[Routes] Plugin "${plugin.name}" route ${route.path}: jayHtml "${route.jayHtml}" not found`
2173
+ );
2174
+ continue;
2175
+ }
2176
+ const compPath = route.component.startsWith(".") ? path__default.resolve(plugin.pluginPath, route.component) : resolvePluginModule(plugin);
2177
+ pluginRoutes.push(createRoute(route.path, jayHtmlPath, compPath));
2178
+ getLogger().info(`[Routes] Plugin "${plugin.name}" provides route ${route.path}`);
2179
+ }
2180
+ }
2181
+ return pluginRoutes;
2182
+ }
2183
+ function resolvePluginExport(pluginPath, exportSubpath) {
2184
+ const normalized = exportSubpath.replace(/^\.\//, "");
2185
+ const packageJsonPath = path__default.join(pluginPath, "package.json");
2186
+ try {
2187
+ const packageJson = JSON.parse(fs__default$1.readFileSync(packageJsonPath, "utf-8"));
2188
+ if (packageJson.exports) {
2189
+ const exportKey = "./" + normalized;
2190
+ const exportValue = packageJson.exports[exportKey];
2191
+ if (exportValue) {
2192
+ const resolved = typeof exportValue === "string" ? exportValue : exportValue.default || exportValue.import || exportValue.require;
2193
+ if (resolved) {
2194
+ const fullPath = path__default.join(pluginPath, resolved);
2195
+ return fullPath;
2196
+ }
2197
+ }
2198
+ }
2199
+ } catch {
2200
+ }
2201
+ for (const dir of ["dist", "lib", ""]) {
2202
+ const candidate = path__default.join(pluginPath, dir, normalized);
2203
+ try {
2204
+ fs__default$1.accessSync(candidate);
2205
+ return candidate;
2206
+ } catch {
2207
+ }
2208
+ }
2209
+ return void 0;
2210
+ }
2211
+ function resolvePluginModule(plugin) {
2212
+ const modulePath = plugin.manifest.module || "index";
2213
+ for (const ext of [".ts", ".js", "/index.ts", "/index.js"]) {
2214
+ const candidate = path__default.join(plugin.pluginPath, modulePath + ext);
2215
+ try {
2216
+ fs__default$1.accessSync(candidate);
2217
+ return candidate;
2218
+ } catch {
2219
+ }
2220
+ }
2221
+ for (const ext of [".ts", ".js"]) {
2222
+ const candidate = path__default.join(plugin.pluginPath, "lib", path__default.basename(modulePath) + ext);
2223
+ try {
2224
+ fs__default$1.accessSync(candidate);
2225
+ return candidate;
2226
+ } catch {
2227
+ }
2228
+ }
2229
+ return path__default.join(plugin.pluginPath, modulePath);
2230
+ }
1842
2231
  function defaults(options) {
1843
2232
  const publicBaseUrlPath = options.publicBaseUrlPath || process.env.BASE || "/";
1844
2233
  const projectRootFolder = options.projectRootFolder || ".";
@@ -1900,7 +2289,7 @@ function filterPluginsForPage(allPluginClientInits, allPluginsWithInit, usedPack
1900
2289
  return pluginInfo && expandedPackages.has(pluginInfo.packageName);
1901
2290
  });
1902
2291
  }
1903
- function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, projectInit, allPluginsWithInit = [], allPluginClientInits = []) {
2292
+ function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, freezeStore, projectInit, allPluginsWithInit = [], allPluginClientInits = []) {
1904
2293
  const routePath = routeToExpressRoute(route);
1905
2294
  const handler = async (req, res) => {
1906
2295
  const timing = getDevLogger()?.startRequest(req.method, req.path);
@@ -1916,6 +2305,23 @@ function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, projectInit
1916
2305
  for (const [key, value] of urlObj.searchParams) {
1917
2306
  query[key] = value;
1918
2307
  }
2308
+ const freezeId = query["_jay_freeze"];
2309
+ if (freezeId && freezeStore) {
2310
+ timing?.annotate("[FROZEN]");
2311
+ await handleFrozenRequest(
2312
+ vite,
2313
+ route,
2314
+ options,
2315
+ freezeStore,
2316
+ slowRenderCache,
2317
+ freezeId,
2318
+ pageParams,
2319
+ query["format"] === "fragment" ? "fragment" : "page",
2320
+ res,
2321
+ timing
2322
+ );
2323
+ return;
2324
+ }
1919
2325
  if (options.disableSSR) {
1920
2326
  await handleClientOnlyRequest(
1921
2327
  vite,
@@ -2048,6 +2454,7 @@ async function handleCachedRequest(vite, route, options, cachedEntry, pageParams
2048
2454
  projectInit,
2049
2455
  pluginsForPage,
2050
2456
  options,
2457
+ routeToExpressRoute(route),
2051
2458
  cachedEntry.slowViewState,
2052
2459
  timing,
2053
2460
  cachedEntry.preRenderedContent,
@@ -2227,14 +2634,15 @@ async function handleClientOnlyRequest(vite, route, options, slowlyPhase, pagePa
2227
2634
  projectInit,
2228
2635
  pluginsForPage,
2229
2636
  {
2230
- enableAutomation: !options.disableAutomation
2637
+ enableAutomation: !options.disableAutomation,
2638
+ routePattern: routeToExpressRoute(route)
2231
2639
  }
2232
2640
  );
2233
2641
  if (options.buildFolder) {
2234
2642
  const pageName = !url || url === "/" ? "index" : url.replace(/^\//, "").replace(/\//g, "-");
2235
2643
  const clientScriptDir = path__default.join(options.buildFolder, "debug", "client-entry");
2236
- await fs$1.mkdir(clientScriptDir, { recursive: true });
2237
- await fs$1.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2644
+ await fs__default.mkdir(clientScriptDir, { recursive: true });
2645
+ await fs__default.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2238
2646
  }
2239
2647
  const viteStart = Date.now();
2240
2648
  const compiledPageHtml = await vite.transformIndexHtml(!!url ? url : "/", pageHtml);
@@ -2242,11 +2650,11 @@ async function handleClientOnlyRequest(vite, route, options, slowlyPhase, pagePa
2242
2650
  res.status(200).set({ "Content-Type": "text/html" }).send(compiledPageHtml);
2243
2651
  timing?.end();
2244
2652
  }
2245
- async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, pageParts, viewState, carryForward, clientTrackByMap, projectInit, pluginsForPage, options, slowViewState, timing, preLoadedContent, headTags) {
2653
+ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, pageParts, viewState, carryForward, clientTrackByMap, projectInit, pluginsForPage, options, routePattern, slowViewState, timing, preLoadedContent, headTags) {
2246
2654
  let pageHtml;
2247
2655
  const routeDir = path__default.dirname(path__default.relative(options.pagesRootFolder, sourceJayHtmlPath));
2248
2656
  try {
2249
- let jayHtmlContent = preLoadedContent ?? await fs$1.readFile(jayHtmlPath, "utf-8");
2657
+ let jayHtmlContent = preLoadedContent ?? await fs__default.readFile(jayHtmlPath, "utf-8");
2250
2658
  const jayHtmlFilename = path__default.basename(jayHtmlPath);
2251
2659
  const jayHtmlDir = path__default.dirname(jayHtmlPath);
2252
2660
  const sourceDir = path__default.dirname(sourceJayHtmlPath);
@@ -2270,7 +2678,8 @@ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, page
2270
2678
  pluginsForPage,
2271
2679
  {
2272
2680
  enableAutomation: !options.disableAutomation,
2273
- slowViewState
2681
+ slowViewState,
2682
+ routePattern
2274
2683
  },
2275
2684
  // Pass source directory for headfull FS file resolution when using pre-rendered path
2276
2685
  jayHtmlDir !== sourceDir ? sourceDir : void 0,
@@ -2289,15 +2698,16 @@ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, page
2289
2698
  pluginsForPage,
2290
2699
  {
2291
2700
  enableAutomation: !options.disableAutomation,
2292
- slowViewState
2701
+ slowViewState,
2702
+ routePattern
2293
2703
  }
2294
2704
  );
2295
2705
  }
2296
2706
  if (options.buildFolder) {
2297
2707
  const pageName = !url || url === "/" ? "index" : url.replace(/^\//, "").replace(/\//g, "-");
2298
2708
  const clientScriptDir = path__default.join(options.buildFolder, "debug", "client-entry");
2299
- await fs$1.mkdir(clientScriptDir, { recursive: true });
2300
- await fs$1.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2709
+ await fs__default.mkdir(clientScriptDir, { recursive: true });
2710
+ await fs__default.writeFile(path__default.join(clientScriptDir, `${pageName}.html`), pageHtml, "utf-8");
2301
2711
  }
2302
2712
  const viteStart = Date.now();
2303
2713
  const compiledPageHtml = await vite.transformIndexHtml(!!url ? url : "/", pageHtml);
@@ -2305,12 +2715,62 @@ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, page
2305
2715
  res.status(200).set({ "Content-Type": "text/html" }).send(compiledPageHtml);
2306
2716
  timing?.end();
2307
2717
  }
2718
+ async function handleFrozenRequest(vite, route, options, freezeStore, slowRenderCache, freezeId, pageParams, format, res, timing) {
2719
+ const entry = await freezeStore.get(freezeId);
2720
+ if (!entry) {
2721
+ getLogger().warn(`[Freeze] Freeze "${freezeId}" not found`);
2722
+ res.status(404).send(`Freeze "${freezeId}" not found`);
2723
+ timing?.end();
2724
+ return;
2725
+ }
2726
+ const label = entry.name ? `"${entry.name}" (${freezeId})` : freezeId;
2727
+ getLogger().info(`[Freeze] Serving frozen page ${label} for ${route.rawRoute} [${format}]`);
2728
+ try {
2729
+ const cachedEntry = await slowRenderCache.get(route.jayHtmlPath, pageParams);
2730
+ const jayHtmlPath = cachedEntry?.preRenderedPath ?? route.jayHtmlPath;
2731
+ const jayHtmlContent = cachedEntry?.preRenderedContent ?? await fs__default.readFile(jayHtmlPath, "utf-8");
2732
+ const jayHtmlFilename = path__default.basename(jayHtmlPath);
2733
+ const jayHtmlDir = path__default.dirname(jayHtmlPath);
2734
+ const sourceDir = path__default.dirname(route.jayHtmlPath);
2735
+ const routeDir = path__default.dirname(path__default.relative(options.pagesRootFolder, route.jayHtmlPath));
2736
+ const { injectHeadfullFSTemplates: injectHeadfullFSTemplates2 } = await import("@jay-framework/compiler-jay-html");
2737
+ const { JAY_IMPORT_RESOLVER: JAY_IMPORT_RESOLVER2 } = await import("@jay-framework/compiler-jay-html");
2738
+ const fullJayHtml = injectHeadfullFSTemplates2(
2739
+ jayHtmlContent,
2740
+ sourceDir,
2741
+ JAY_IMPORT_RESOLVER2
2742
+ );
2743
+ const html = await generateFrozenPageHtml(
2744
+ vite,
2745
+ fullJayHtml,
2746
+ jayHtmlFilename,
2747
+ jayHtmlDir,
2748
+ entry.viewState,
2749
+ options.buildFolder,
2750
+ options.projectRootFolder,
2751
+ routeDir,
2752
+ options.jayRollupConfig?.tsConfigFilePath,
2753
+ void 0,
2754
+ format,
2755
+ entry.name
2756
+ );
2757
+ const headers = { "Content-Type": "text/html" };
2758
+ if (format === "fragment") {
2759
+ headers["Access-Control-Allow-Origin"] = "*";
2760
+ }
2761
+ res.status(200).set(headers).send(html);
2762
+ } catch (err) {
2763
+ getLogger().warn(`[Freeze] Failed to render frozen page: ${err.message}`);
2764
+ res.status(500).send(`Failed to render frozen page: ${err.message}`);
2765
+ }
2766
+ timing?.end();
2767
+ }
2308
2768
  async function preRenderJayHtml(route, slowViewState, headlessContracts, headlessInstanceComponents, partKeys = []) {
2309
- const jayHtmlContent = await fs$1.readFile(route.jayHtmlPath, "utf-8");
2769
+ const jayHtmlContent = await fs__default.readFile(route.jayHtmlPath, "utf-8");
2310
2770
  const contractPath = route.jayHtmlPath.replace(".jay-html", ".jay-contract");
2311
2771
  let contract;
2312
2772
  try {
2313
- const contractContent = await fs$1.readFile(contractPath, "utf-8");
2773
+ const contractContent = await fs__default.readFile(contractPath, "utf-8");
2314
2774
  const parseResult = parseContract(contractContent, path__default.basename(contractPath));
2315
2775
  if (parseResult.val) {
2316
2776
  contract = parseResult.val;
@@ -2452,8 +2912,15 @@ async function mkDevServer(rawOptions) {
2452
2912
  const options = defaults(rawOptions);
2453
2913
  const { publicBaseUrlPath, pagesRootFolder, projectRootFolder, buildFolder, jayRollupConfig } = options;
2454
2914
  if (buildFolder) {
2455
- await fs$1.rm(buildFolder, { recursive: true, force: true }).catch(() => {
2456
- });
2915
+ try {
2916
+ const entries = await fs__default.readdir(buildFolder).catch(() => []);
2917
+ for (const entry of entries) {
2918
+ if (entry === "freezes")
2919
+ continue;
2920
+ await fs__default.rm(path__default.join(buildFolder, entry), { recursive: true, force: true });
2921
+ }
2922
+ } catch {
2923
+ }
2457
2924
  }
2458
2925
  const viteLogLevel = options.logLevel === "silent" ? "silent" : options.logLevel === "verbose" ? "info" : "warn";
2459
2926
  const lifecycleManager = new ServiceLifecycleManager(projectRootFolder);
@@ -2470,9 +2937,11 @@ async function mkDevServer(rawOptions) {
2470
2937
  await lifecycleManager.initialize();
2471
2938
  await materializeDynamicContracts(projectRootFolder, vite);
2472
2939
  setupServiceHotReload(vite, lifecycleManager);
2473
- setupActionRouter(vite);
2474
- const allRoutes = await initRoutes(pagesRootFolder);
2475
- const routes = buildFolder ? allRoutes.filter((route) => !route.jayHtmlPath.startsWith(buildFolder)) : allRoutes;
2940
+ setupActionRouter(vite, buildFolder);
2941
+ const projectRoutes = await initRoutes(pagesRootFolder);
2942
+ const filteredProjectRoutes = buildFolder ? projectRoutes.filter((route) => !route.jayHtmlPath.startsWith(buildFolder)) : projectRoutes;
2943
+ const pluginRoutes = await scanPluginRoutes(projectRootFolder, filteredProjectRoutes);
2944
+ const routes = [...filteredProjectRoutes, ...pluginRoutes];
2476
2945
  const slowlyPhase = new DevSlowlyChangingPhase();
2477
2946
  const slowRenderCacheDir = path__default.join(buildFolder, "pre-rendered");
2478
2947
  const slowRenderCache = new SlowRenderCache(slowRenderCacheDir, pagesRootFolder);
@@ -2484,6 +2953,10 @@ async function mkDevServer(rawOptions) {
2484
2953
  const projectInit = lifecycleManager.getProjectInit() ?? void 0;
2485
2954
  const pluginsWithInit = lifecycleManager.getPluginsWithInit();
2486
2955
  const pluginClientInits = preparePluginClientInits(pluginsWithInit);
2956
+ const freezeStore = buildFolder ? new FreezeStore(buildFolder) : void 0;
2957
+ if (freezeStore) {
2958
+ setupFreezeEndpoint(vite, freezeStore);
2959
+ }
2487
2960
  const devServerRoutes = routes.map(
2488
2961
  (route) => mkRoute(
2489
2962
  route,
@@ -2491,16 +2964,28 @@ async function mkDevServer(rawOptions) {
2491
2964
  slowlyPhase,
2492
2965
  options,
2493
2966
  slowRenderCache,
2967
+ freezeStore,
2494
2968
  projectInit,
2495
2969
  pluginsWithInit,
2496
2970
  pluginClientInits
2497
2971
  )
2498
2972
  );
2973
+ const service = new DevServerService(
2974
+ devServerRoutes,
2975
+ vite,
2976
+ options.pagesRootFolder,
2977
+ options.projectRootFolder,
2978
+ options.jayRollupConfig,
2979
+ freezeStore
2980
+ );
2981
+ registerService(DEV_SERVER_SERVICE, service);
2499
2982
  return {
2500
2983
  server: vite.middlewares,
2501
2984
  viteServer: vite,
2502
2985
  routes: devServerRoutes,
2503
- lifecycleManager
2986
+ lifecycleManager,
2987
+ freezeStore,
2988
+ service
2504
2989
  };
2505
2990
  }
2506
2991
  function setupGracefulShutdown(lifecycleManager) {
@@ -2534,11 +3019,40 @@ function setupServiceHotReload(vite, lifecycleManager) {
2534
3019
  }
2535
3020
  });
2536
3021
  }
2537
- function setupActionRouter(vite) {
2538
- vite.middlewares.use(actionBodyParser());
3022
+ function setupActionRouter(vite, buildFolder) {
3023
+ vite.middlewares.use(actionBodyParser({ buildFolder }));
2539
3024
  vite.middlewares.use(ACTION_ENDPOINT_BASE, createActionRouter());
2540
3025
  getLogger().info(`[Actions] Action router mounted at ${ACTION_ENDPOINT_BASE}`);
2541
3026
  }
3027
+ function setupFreezeEndpoint(vite, freezeStore) {
3028
+ vite.middlewares.use((req, res, next) => {
3029
+ if (req.method === "POST" && (req.url === "/_jay/freeze" || req.originalUrl === "/_jay/freeze")) {
3030
+ let body = "";
3031
+ req.on("data", (chunk) => body += chunk);
3032
+ req.on("end", async () => {
3033
+ try {
3034
+ const { route, routePattern, viewState } = JSON.parse(body);
3035
+ if (!route || !viewState) {
3036
+ res.writeHead(400, { "Content-Type": "application/json" });
3037
+ res.end(JSON.stringify({ error: "Missing route or viewState" }));
3038
+ return;
3039
+ }
3040
+ const entry = await freezeStore.save(route, viewState, routePattern);
3041
+ getLogger().info(`[Freeze] Saved freeze "${entry.id}" for ${route}`);
3042
+ res.writeHead(200, { "Content-Type": "application/json" });
3043
+ res.end(JSON.stringify(entry));
3044
+ } catch (err) {
3045
+ getLogger().warn(`[Freeze] Failed to save: ${err.message}`);
3046
+ res.writeHead(500, { "Content-Type": "application/json" });
3047
+ res.end(JSON.stringify({ error: err.message }));
3048
+ }
3049
+ });
3050
+ } else {
3051
+ next();
3052
+ }
3053
+ });
3054
+ getLogger().info("[Freeze] Freeze endpoint mounted at /_jay/freeze");
3055
+ }
2542
3056
  function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectRootFolder) {
2543
3057
  const watchedFiles = /* @__PURE__ */ new Set();
2544
3058
  const watchLinkedFiles = (files) => {
@@ -2600,6 +3114,9 @@ function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectR
2600
3114
  }
2601
3115
  export {
2602
3116
  ACTION_ENDPOINT_BASE,
3117
+ DEV_SERVER_SERVICE,
3118
+ DevServerService,
3119
+ FreezeStore,
2603
3120
  actionBodyParser,
2604
3121
  createActionRouter,
2605
3122
  createViteForCli,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jay-framework/dev-server",
3
- "version": "0.15.6",
3
+ "version": "0.16.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.js",
@@ -23,23 +23,25 @@
23
23
  "test:watch": "vitest"
24
24
  },
25
25
  "dependencies": {
26
- "@jay-framework/compiler-jay-stack": "^0.15.6",
27
- "@jay-framework/compiler-shared": "^0.15.6",
28
- "@jay-framework/component": "^0.15.6",
29
- "@jay-framework/fullstack-component": "^0.15.6",
30
- "@jay-framework/logger": "^0.15.6",
31
- "@jay-framework/runtime": "^0.15.6",
32
- "@jay-framework/stack-client-runtime": "^0.15.6",
33
- "@jay-framework/stack-route-scanner": "^0.15.6",
34
- "@jay-framework/stack-server-runtime": "^0.15.6",
35
- "@jay-framework/view-state-merge": "^0.15.6",
26
+ "@jay-framework/compiler-jay-stack": "^0.16.1",
27
+ "@jay-framework/compiler-shared": "^0.16.1",
28
+ "@jay-framework/component": "^0.16.1",
29
+ "@jay-framework/fullstack-component": "^0.16.1",
30
+ "@jay-framework/logger": "^0.16.1",
31
+ "@jay-framework/runtime": "^0.16.1",
32
+ "@jay-framework/stack-client-runtime": "^0.16.1",
33
+ "@jay-framework/stack-route-scanner": "^0.16.1",
34
+ "@jay-framework/stack-server-runtime": "^0.16.1",
35
+ "@jay-framework/view-state-merge": "^0.16.1",
36
+ "busboy": "^1.6.0",
36
37
  "vite": "^5.0.11"
37
38
  },
38
39
  "devDependencies": {
39
- "@jay-framework/dev-environment": "^0.15.6",
40
- "@jay-framework/jay-cli": "^0.15.6",
41
- "@jay-framework/stack-client-runtime": "^0.15.6",
40
+ "@jay-framework/dev-environment": "^0.16.1",
41
+ "@jay-framework/jay-cli": "^0.16.1",
42
+ "@jay-framework/stack-client-runtime": "^0.16.1",
42
43
  "@playwright/test": "^1.58.2",
44
+ "@types/busboy": "^1.5.4",
43
45
  "@types/express": "^5.0.2",
44
46
  "@types/node": "^22.15.21",
45
47
  "jsdom": "^23.2.0",