@jay-framework/dev-server 0.16.0 → 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.
package/dist/index.d.ts CHANGED
@@ -220,10 +220,20 @@ interface ActionRouterOptions {
220
220
  */
221
221
  declare function createActionRouter(options?: ActionRouterOptions): RequestHandler$1;
222
222
  /**
223
- * 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()).
224
234
  * Should be applied before the action router.
225
235
  */
226
- declare function actionBodyParser(): RequestHandler$1;
236
+ declare function actionBodyParser(options: ActionBodyParserOptions): RequestHandler$1;
227
237
 
228
238
  /**
229
239
  * Vite Factory
@@ -267,4 +277,4 @@ declare function createViteForCli(options: {
267
277
  tsConfigFilePath?: string;
268
278
  }): Promise<ViteDevServer>;
269
279
 
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 };
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
@@ -20,6 +20,10 @@ import { discoverPluginsWithInit, sortPluginsByDependencies, executePluginServer
20
20
  import * as fs$1 from "node:fs/promises";
21
21
  import fs__default from "node:fs/promises";
22
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";
23
27
  import { randomUUID } from "node:crypto";
24
28
  import { createJayService } from "@jay-framework/fullstack-component";
25
29
  const s$1 = createRequire(import.meta.url), e$1 = s$1("typescript"), c$1 = new Proxy(e$1, {
@@ -915,6 +919,7 @@ function extractActionFromExpression(node) {
915
919
  let current = node;
916
920
  let method = "POST";
917
921
  let explicitMethod = null;
922
+ let acceptsFiles = false;
918
923
  while (c$1.isCallExpression(current)) {
919
924
  const expr = current.expression;
920
925
  if (c$1.isPropertyAccessExpression(expr) && expr.name.text === "withMethod") {
@@ -925,6 +930,11 @@ function extractActionFromExpression(node) {
925
930
  current = expr.expression;
926
931
  continue;
927
932
  }
933
+ if (c$1.isPropertyAccessExpression(expr) && expr.name.text === "withFiles") {
934
+ acceptsFiles = true;
935
+ current = expr.expression;
936
+ continue;
937
+ }
928
938
  if (c$1.isPropertyAccessExpression(expr) && ["withServices", "withCaching", "withHandler", "withTimeout"].includes(expr.name.text)) {
929
939
  current = expr.expression;
930
940
  continue;
@@ -938,7 +948,8 @@ function extractActionFromExpression(node) {
938
948
  return {
939
949
  actionName: nameArg.text,
940
950
  method: "POST",
941
- isStreaming: true
951
+ isStreaming: true,
952
+ ...acceptsFiles && { acceptsFiles: true }
942
953
  };
943
954
  }
944
955
  method = funcName === "makeJayQuery" ? "GET" : "POST";
@@ -947,7 +958,8 @@ function extractActionFromExpression(node) {
947
958
  }
948
959
  return {
949
960
  actionName: nameArg.text,
950
- method
961
+ method,
962
+ ...acceptsFiles && { acceptsFiles: true }
951
963
  };
952
964
  }
953
965
  }
@@ -1354,13 +1366,14 @@ function jayStackCompiler(options = {}) {
1354
1366
  ""
1355
1367
  ];
1356
1368
  for (const action of actions) {
1369
+ const filesOpt = action.acceptsFiles ? ", { acceptsFiles: true }" : "";
1357
1370
  if (action.isStreaming) {
1358
1371
  lines.push(
1359
- `export const ${action.exportName} = createStreamCaller('${action.actionName}');`
1372
+ `export const ${action.exportName} = createStreamCaller('${action.actionName}'${filesOpt});`
1360
1373
  );
1361
1374
  } else {
1362
1375
  lines.push(
1363
- `export const ${action.exportName} = createActionCaller('${action.actionName}', '${action.method}');`
1376
+ `export const ${action.exportName} = createActionCaller('${action.actionName}', '${action.method}'${filesOpt});`
1364
1377
  );
1365
1378
  }
1366
1379
  }
@@ -1782,6 +1795,11 @@ function createActionRouter(options) {
1782
1795
  requestMethod,
1783
1796
  ACTION_ENDPOINT_BASE + "/" + actionName
1784
1797
  );
1798
+ const tempDir = req._jayTempDir;
1799
+ const cleanup = () => {
1800
+ if (tempDir)
1801
+ cleanupTempDir(tempDir);
1802
+ };
1785
1803
  if (registry.isStreaming(actionName)) {
1786
1804
  res.setHeader("Content-Type", "application/x-ndjson");
1787
1805
  res.setHeader("Transfer-Encoding", "chunked");
@@ -1794,11 +1812,13 @@ function createActionRouter(options) {
1794
1812
  } catch (err) {
1795
1813
  res.write(JSON.stringify({ error: err.message }) + "\n");
1796
1814
  }
1815
+ cleanup();
1797
1816
  res.end();
1798
1817
  timing?.end();
1799
1818
  return;
1800
1819
  }
1801
1820
  const result = await registry.execute(actionName, input);
1821
+ cleanup();
1802
1822
  if (requestMethod === "GET" && result.success) {
1803
1823
  const cacheHeaders = registry.getCacheHeaders(actionName);
1804
1824
  if (cacheHeaders) {
@@ -1839,7 +1859,106 @@ function getStatusCodeForError(code, isActionError) {
1839
1859
  return 500;
1840
1860
  }
1841
1861
  }
1842
- 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;
1843
1962
  return (req, res, next) => {
1844
1963
  if (!req.path.startsWith(ACTION_ENDPOINT_BASE)) {
1845
1964
  next();
@@ -1849,6 +1968,42 @@ function actionBodyParser() {
1849
1968
  next();
1850
1969
  return;
1851
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
+ }
1852
2007
  let body = "";
1853
2008
  req.setEncoding("utf8");
1854
2009
  req.on("data", (chunk) => {
@@ -2782,7 +2937,7 @@ async function mkDevServer(rawOptions) {
2782
2937
  await lifecycleManager.initialize();
2783
2938
  await materializeDynamicContracts(projectRootFolder, vite);
2784
2939
  setupServiceHotReload(vite, lifecycleManager);
2785
- setupActionRouter(vite);
2940
+ setupActionRouter(vite, buildFolder);
2786
2941
  const projectRoutes = await initRoutes(pagesRootFolder);
2787
2942
  const filteredProjectRoutes = buildFolder ? projectRoutes.filter((route) => !route.jayHtmlPath.startsWith(buildFolder)) : projectRoutes;
2788
2943
  const pluginRoutes = await scanPluginRoutes(projectRootFolder, filteredProjectRoutes);
@@ -2864,8 +3019,8 @@ function setupServiceHotReload(vite, lifecycleManager) {
2864
3019
  }
2865
3020
  });
2866
3021
  }
2867
- function setupActionRouter(vite) {
2868
- vite.middlewares.use(actionBodyParser());
3022
+ function setupActionRouter(vite, buildFolder) {
3023
+ vite.middlewares.use(actionBodyParser({ buildFolder }));
2869
3024
  vite.middlewares.use(ACTION_ENDPOINT_BASE, createActionRouter());
2870
3025
  getLogger().info(`[Actions] Action router mounted at ${ACTION_ENDPOINT_BASE}`);
2871
3026
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jay-framework/dev-server",
3
- "version": "0.16.0",
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.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",
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.16.0",
40
- "@jay-framework/jay-cli": "^0.16.0",
41
- "@jay-framework/stack-client-runtime": "^0.16.0",
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",