@jay-framework/dev-server 0.16.0 → 0.16.2

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 +13 -3
  2. package/dist/index.js +203 -19
  3. package/package.json +16 -14
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) => {
@@ -2018,8 +2173,10 @@ async function scanPluginRoutes(projectRoot, projectRoutes) {
2018
2173
  );
2019
2174
  continue;
2020
2175
  }
2021
- const compPath = route.component.startsWith(".") ? path__default.resolve(plugin.pluginPath, route.component) : resolvePluginModule(plugin);
2022
- pluginRoutes.push(createRoute(route.path, jayHtmlPath, compPath));
2176
+ const isLocalComponent = route.component.startsWith(".");
2177
+ const compPath = isLocalComponent ? path__default.resolve(plugin.pluginPath, route.component) : resolvePluginModule(plugin);
2178
+ const componentExport = isLocalComponent ? void 0 : route.component;
2179
+ pluginRoutes.push(createRoute(route.path, jayHtmlPath, compPath, componentExport));
2023
2180
  getLogger().info(`[Routes] Plugin "${plugin.name}" provides route ${route.path}`);
2024
2181
  }
2025
2182
  }
@@ -2184,7 +2341,11 @@ function mkRoute(route, vite, slowlyPhase, options, slowRenderCache, freezeStore
2184
2341
  query
2185
2342
  );
2186
2343
  } else {
2187
- const cachedEntry = await slowRenderCache.get(route.jayHtmlPath, pageParams);
2344
+ const cachedEntry = await slowRenderCache.get(
2345
+ route.jayHtmlPath,
2346
+ pageParams,
2347
+ getRouteDir(route)
2348
+ );
2188
2349
  if (cachedEntry) {
2189
2350
  await handleCachedRequest(
2190
2351
  vite,
@@ -2300,6 +2461,7 @@ async function handleCachedRequest(vite, route, options, cachedEntry, pageParams
2300
2461
  pluginsForPage,
2301
2462
  options,
2302
2463
  routeToExpressRoute(route),
2464
+ getRouteDir(route),
2303
2465
  cachedEntry.slowViewState,
2304
2466
  timing,
2305
2467
  cachedEntry.preRenderedContent,
@@ -2374,7 +2536,8 @@ async function handlePreRenderRequest(vite, route, options, slowlyPhase, slowRen
2374
2536
  pageParams,
2375
2537
  preRenderResult.preRenderedJayHtml,
2376
2538
  renderedSlowly.rendered,
2377
- carryForward
2539
+ carryForward,
2540
+ getRouteDir(route)
2378
2541
  );
2379
2542
  getLogger().info(`[SlowRender] Cached pre-rendered jay-html at ${cachedEntry.preRenderedPath}`);
2380
2543
  await handleCachedRequest(
@@ -2495,9 +2658,8 @@ async function handleClientOnlyRequest(vite, route, options, slowlyPhase, pagePa
2495
2658
  res.status(200).set({ "Content-Type": "text/html" }).send(compiledPageHtml);
2496
2659
  timing?.end();
2497
2660
  }
2498
- async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, pageParts, viewState, carryForward, clientTrackByMap, projectInit, pluginsForPage, options, routePattern, slowViewState, timing, preLoadedContent, headTags) {
2661
+ async function sendResponse(vite, res, url, jayHtmlPath, sourceJayHtmlPath, pageParts, viewState, carryForward, clientTrackByMap, projectInit, pluginsForPage, options, routePattern, routeDir, slowViewState, timing, preLoadedContent, headTags) {
2499
2662
  let pageHtml;
2500
- const routeDir = path__default.dirname(path__default.relative(options.pagesRootFolder, sourceJayHtmlPath));
2501
2663
  try {
2502
2664
  let jayHtmlContent = preLoadedContent ?? await fs__default.readFile(jayHtmlPath, "utf-8");
2503
2665
  const jayHtmlFilename = path__default.basename(jayHtmlPath);
@@ -2571,13 +2733,13 @@ async function handleFrozenRequest(vite, route, options, freezeStore, slowRender
2571
2733
  const label = entry.name ? `"${entry.name}" (${freezeId})` : freezeId;
2572
2734
  getLogger().info(`[Freeze] Serving frozen page ${label} for ${route.rawRoute} [${format}]`);
2573
2735
  try {
2574
- const cachedEntry = await slowRenderCache.get(route.jayHtmlPath, pageParams);
2736
+ const routeDir = getRouteDir(route);
2737
+ const cachedEntry = await slowRenderCache.get(route.jayHtmlPath, pageParams, routeDir);
2575
2738
  const jayHtmlPath = cachedEntry?.preRenderedPath ?? route.jayHtmlPath;
2576
2739
  const jayHtmlContent = cachedEntry?.preRenderedContent ?? await fs__default.readFile(jayHtmlPath, "utf-8");
2577
2740
  const jayHtmlFilename = path__default.basename(jayHtmlPath);
2578
2741
  const jayHtmlDir = path__default.dirname(jayHtmlPath);
2579
2742
  const sourceDir = path__default.dirname(route.jayHtmlPath);
2580
- const routeDir = path__default.dirname(path__default.relative(options.pagesRootFolder, route.jayHtmlPath));
2581
2743
  const { injectHeadfullFSTemplates: injectHeadfullFSTemplates2 } = await import("@jay-framework/compiler-jay-html");
2582
2744
  const { JAY_IMPORT_RESOLVER: JAY_IMPORT_RESOLVER2 } = await import("@jay-framework/compiler-jay-html");
2583
2745
  const fullJayHtml = injectHeadfullFSTemplates2(
@@ -2782,7 +2944,7 @@ async function mkDevServer(rawOptions) {
2782
2944
  await lifecycleManager.initialize();
2783
2945
  await materializeDynamicContracts(projectRootFolder, vite);
2784
2946
  setupServiceHotReload(vite, lifecycleManager);
2785
- setupActionRouter(vite);
2947
+ setupActionRouter(vite, buildFolder);
2786
2948
  const projectRoutes = await initRoutes(pagesRootFolder);
2787
2949
  const filteredProjectRoutes = buildFolder ? projectRoutes.filter((route) => !route.jayHtmlPath.startsWith(buildFolder)) : projectRoutes;
2788
2950
  const pluginRoutes = await scanPluginRoutes(projectRootFolder, filteredProjectRoutes);
@@ -2864,8 +3026,8 @@ function setupServiceHotReload(vite, lifecycleManager) {
2864
3026
  }
2865
3027
  });
2866
3028
  }
2867
- function setupActionRouter(vite) {
2868
- vite.middlewares.use(actionBodyParser());
3029
+ function setupActionRouter(vite, buildFolder) {
3030
+ vite.middlewares.use(actionBodyParser({ buildFolder }));
2869
3031
  vite.middlewares.use(ACTION_ENDPOINT_BASE, createActionRouter());
2870
3032
  getLogger().info(`[Actions] Action router mounted at ${ACTION_ENDPOINT_BASE}`);
2871
3033
  }
@@ -2898,6 +3060,28 @@ function setupFreezeEndpoint(vite, freezeStore) {
2898
3060
  });
2899
3061
  getLogger().info("[Freeze] Freeze endpoint mounted at /_jay/freeze");
2900
3062
  }
3063
+ function getRouteDir(route) {
3064
+ return route.rawRoute.replace(/^\//, "") || "index";
3065
+ }
3066
+ function getRoutePrefix(jayHtmlPath, pagesRootFolder) {
3067
+ const rel = path__default.relative(pagesRootFolder, path__default.dirname(jayHtmlPath));
3068
+ const segments = rel.split(path__default.sep).filter(Boolean);
3069
+ const staticSegments = [];
3070
+ for (const seg of segments) {
3071
+ if (seg.startsWith("["))
3072
+ break;
3073
+ staticSegments.push(seg);
3074
+ }
3075
+ return "/" + staticSegments.join("/");
3076
+ }
3077
+ function sendPageReload(vite, jayHtmlPath, pagesRootFolder) {
3078
+ const routePrefix = getRoutePrefix(jayHtmlPath, pagesRootFolder);
3079
+ vite.ws.send({
3080
+ type: "custom",
3081
+ event: "jay:page-reload",
3082
+ data: { routePrefix }
3083
+ });
3084
+ }
2901
3085
  function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectRootFolder) {
2902
3086
  const watchedFiles = /* @__PURE__ */ new Set();
2903
3087
  const watchLinkedFiles = (files) => {
@@ -2924,7 +3108,7 @@ function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectR
2924
3108
  clearServerElementCache();
2925
3109
  cache.clear().then(() => {
2926
3110
  getLogger().info(`[SlowRender] Cache cleared (jay-html changed: ${changedPath})`);
2927
- vite.ws.send({ type: "full-reload" });
3111
+ sendPageReload(vite, changedPath, pagesRootFolder);
2928
3112
  });
2929
3113
  return;
2930
3114
  }
@@ -2939,7 +3123,7 @@ function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectR
2939
3123
  getLogger().info(
2940
3124
  `[SlowRender] Cache invalidated for ${jayHtmlPath} (page.ts changed)`
2941
3125
  );
2942
- vite.ws.send({ type: "full-reload" });
3126
+ sendPageReload(vite, jayHtmlPath, pagesRootFolder);
2943
3127
  });
2944
3128
  return;
2945
3129
  }
@@ -2950,7 +3134,7 @@ function setupSlowRenderCacheInvalidation(vite, cache, pagesRootFolder, projectR
2950
3134
  getLogger().info(
2951
3135
  `[SlowRender] Cache invalidated for ${jayHtmlPath} (contract changed)`
2952
3136
  );
2953
- vite.ws.send({ type: "full-reload" });
3137
+ sendPageReload(vite, jayHtmlPath, pagesRootFolder);
2954
3138
  });
2955
3139
  return;
2956
3140
  }
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.2",
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.2",
27
+ "@jay-framework/compiler-shared": "^0.16.2",
28
+ "@jay-framework/component": "^0.16.2",
29
+ "@jay-framework/fullstack-component": "^0.16.2",
30
+ "@jay-framework/logger": "^0.16.2",
31
+ "@jay-framework/runtime": "^0.16.2",
32
+ "@jay-framework/stack-client-runtime": "^0.16.2",
33
+ "@jay-framework/stack-route-scanner": "^0.16.2",
34
+ "@jay-framework/stack-server-runtime": "^0.16.2",
35
+ "@jay-framework/view-state-merge": "^0.16.2",
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.2",
41
+ "@jay-framework/jay-cli": "^0.16.2",
42
+ "@jay-framework/stack-client-runtime": "^0.16.2",
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",