@geode/opengeodeweb-front 10.34.0-rc.20 → 10.34.0-rc.21

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.
@@ -1,5 +1,7 @@
1
1
  import _ from "lodash";
2
2
  //#region shared/utils/file.ts
3
+ const BYTES_PER_KIBIBYTE = 1024;
4
+ const CHUNK_SIZE_BYTES = 8192 * BYTES_PER_KIBIBYTE;
3
5
  function assertFile(file) {
4
6
  console.log("[ASSERT_FILE] Asserting file", { file });
5
7
  if (!(file instanceof File)) throw new Error("file must be an instance of File");
@@ -9,4 +11,4 @@ function hasBody(params) {
9
11
  return !_.isEmpty(params);
10
12
  }
11
13
  //#endregion
12
- export { assertFile, hasBody };
14
+ export { BYTES_PER_KIBIBYTE, CHUNK_SIZE_BYTES, assertFile, hasBody };
@@ -133,7 +133,6 @@ export const useBackStore = defineStore("back", {
133
133
  {
134
134
  schema,
135
135
  file,
136
- raw: true,
137
136
  },
138
137
  {
139
138
  ...callbacks,
@@ -1,4 +1,5 @@
1
1
  import type { JsonRpcSchema, RequestHandlers } from "@ogw_shared/utils/types.js";
2
+ import { CHUNK_SIZE_BYTES } from "@ogw_shared/utils/file.js";
2
3
  import type { Microservice } from "./api_fetch.js";
3
4
  import { fetchRaw } from "@ogw_shared/utils/fetch_raw.js";
4
5
  import { useFeedbackStore } from "@ogw_front/stores/feedback.js";
@@ -8,11 +9,7 @@ interface UploadFileParams {
8
9
  // HTTP-flavored ("front"/"back") schemas.
9
10
  schema: JsonRpcSchema & { methods: string[] };
10
11
  file: File;
11
- params?: Record<string, string | Blob>;
12
- // When true, the file is sent as the raw request body instead of multipart
13
- // FormData, with `params`/filename moved into the query string. See
14
- // OpenGeodeWeb-Back's upload_file route, which supports both request shapes.
15
- raw?: boolean;
12
+ params?: Record<string, string>;
16
13
  }
17
14
 
18
15
  // Loosely-typed shapes for the dynamic error/response values reported by fetchRaw.
@@ -38,7 +35,7 @@ function isFetchErrorResponseLike(value: unknown): value is FetchErrorResponseLi
38
35
 
39
36
  async function upload_file(
40
37
  microservice: Microservice,
41
- { schema, file, params = {}, raw = false }: UploadFileParams,
38
+ { schema, file, params = {} }: UploadFileParams,
42
39
  { request_error_function, response_function, response_error_function }: RequestHandlers = {},
43
40
  ): Promise<unknown> {
44
41
  console.log("[UPLOAD_FILE] Uploading file", { schema, file });
@@ -48,68 +45,70 @@ async function upload_file(
48
45
  throw new Error("file must be an instance of File");
49
46
  }
50
47
 
51
- let route = schema.$id;
52
- let body: FormData | File = file;
48
+ const route = schema.$id;
49
+ const method = schema.methods.find((candidate) => candidate !== "OPTIONS");
50
+ const totalChunks = Math.max(1, Math.ceil(file.size / CHUNK_SIZE_BYTES));
53
51
 
54
- if (raw) {
55
- const queryEntries = Object.entries(params).filter(
56
- (entry): entry is [string, string] => typeof entry[1] === "string",
52
+ function onRequestError(error: unknown): void {
53
+ microservice.stop_request();
54
+ const typedError = isFetchErrorLike(error) ? error : {};
55
+ feedbackStore.add_error(
56
+ typedError.code ?? 0,
57
+ route,
58
+ typedError.message ?? "",
59
+ typedError.stack ?? "",
57
60
  );
58
- const query = new URLSearchParams([...queryEntries, ["filename", file.name]]);
59
- route = `${route}?${query.toString()}`;
60
- } else {
61
- const formData = new FormData();
62
- for (const [key, value] of Object.entries(params)) {
63
- formData.append(key, value);
61
+ if (request_error_function) {
62
+ request_error_function(error);
63
+ }
64
+ }
65
+
66
+ function onResponseError(response: unknown): void {
67
+ microservice.stop_request();
68
+ const typedResponse = isFetchErrorResponseLike(response) ? response : {};
69
+ feedbackStore.add_error(
70
+ typedResponse.status ?? 0,
71
+ route,
72
+ typedResponse.name ?? "",
73
+ typedResponse.description ?? "",
74
+ );
75
+ if (response_error_function) {
76
+ response_error_function(response);
64
77
  }
65
- formData.append("file", file);
66
- body = formData;
67
78
  }
68
79
 
69
80
  microservice.start_request();
70
81
 
71
- const result = await fetchRaw(
72
- {
73
- route,
74
- method: schema.methods.find((method) => method !== "OPTIONS"),
75
- params: body,
76
- baseURL: microservice.base_url,
77
- },
78
- {
79
- request_error_function(error: unknown) {
80
- microservice.stop_request();
81
- const typedError = isFetchErrorLike(error) ? error : {};
82
- feedbackStore.add_error(
83
- typedError.code ?? 0,
84
- route,
85
- typedError.message ?? "",
86
- typedError.stack ?? "",
87
- );
88
- if (request_error_function) {
89
- request_error_function(error);
90
- }
91
- },
92
- response_function(data: unknown) {
93
- microservice.stop_request();
94
- if (response_function) {
95
- response_function(data);
96
- }
82
+ let result: unknown = undefined;
83
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
84
+ const start = chunkIndex * CHUNK_SIZE_BYTES;
85
+ const chunk = file.slice(start, start + CHUNK_SIZE_BYTES);
86
+ const query = new URLSearchParams({
87
+ ...params,
88
+ filename: file.name,
89
+ chunk_index: String(chunkIndex),
90
+ total_chunks: String(totalChunks),
91
+ });
92
+
93
+ // oxlint-disable-next-line no-await-in-loop -- chunks must be sent in order; the server appends each one to the file as it arrives.
94
+ result = await fetchRaw(
95
+ {
96
+ route: `${route}?${query.toString()}`,
97
+ method,
98
+ params: chunk,
99
+ baseURL: microservice.base_url,
97
100
  },
98
- response_error_function(response: unknown) {
99
- microservice.stop_request();
100
- const typedResponse = isFetchErrorResponseLike(response) ? response : {};
101
- feedbackStore.add_error(
102
- typedResponse.status ?? 0,
103
- route,
104
- typedResponse.name ?? "",
105
- typedResponse.description ?? "",
106
- );
107
- if (response_error_function) {
108
- response_error_function(response);
109
- }
101
+ {
102
+ request_error_function: onRequestError,
103
+ response_error_function: onResponseError,
110
104
  },
111
- },
112
- );
105
+ );
106
+ }
107
+
108
+ microservice.stop_request();
109
+ if (response_function) {
110
+ response_function(result);
111
+ }
113
112
  return result;
114
113
  }
115
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geode/opengeodeweb-front",
3
- "version": "10.34.0-rc.20",
3
+ "version": "10.34.0-rc.21",
4
4
  "description": "OpenSource Vue/Nuxt/Pinia/Vuetify framework for web applications",
5
5
  "homepage": "https://github.com/Geode-solutions/OpenGeodeWeb-Front",
6
6
  "bugs": {
@@ -1,98 +1,71 @@
1
1
  // Node imports
2
- import { finished, pipeline } from "node:stream/promises";
3
- import type { IncomingHttpHeaders } from "node:http";
4
2
  import { Readable } from "node:stream";
5
3
  import fs from "node:fs";
4
+ import { pipeline } from "node:stream/promises";
6
5
 
7
6
  // Third party imports
8
- import {
9
- type H3Event,
10
- createError,
11
- defineEventHandler,
12
- getRequestHeaders,
13
- getRequestWebStream,
14
- } from "h3";
15
- import busboy from "busboy";
7
+ import { type H3Event, createError, defineEventHandler, getQuery, getRequestWebStream } from "h3";
16
8
 
17
9
  // Local imports
18
10
  import {
19
11
  registerExtensionFile,
20
12
  targetExtensionFilePath,
21
13
  } from "@geode/opengeodeweb-front/server/utils/app_config.ts";
14
+ import { BYTES_PER_KIBIBYTE } from "@ogw_shared/utils/file.js";
22
15
  import { toNodeWebStream } from "@geode/opengeodeweb-front/server/utils/stream.ts";
23
16
 
17
+ const CODE_200 = 200;
24
18
  const CODE_201 = 201;
25
- const BYTES_PER_KIBIBYTE = 1024;
26
19
  const MAX_FILE_MEGABYTES = 500;
27
20
  const FILE_SIZE_LIMIT = MAX_FILE_MEGABYTES * BYTES_PER_KIBIBYTE * BYTES_PER_KIBIBYTE;
28
21
 
29
- export default defineEventHandler(async (event: H3Event) => {
30
- const writePromises: Promise<void>[] = [];
31
- const savedFiles: string[] = [];
32
-
33
- const headers: IncomingHttpHeaders = Object.fromEntries(
34
- Object.entries(getRequestHeaders(event)).filter(
35
- (entry: readonly [string, string | undefined]) => entry[1] !== undefined,
36
- ),
37
- );
38
- const busboyInstance = busboy({
39
- headers,
40
- limits: {
41
- fileSize: FILE_SIZE_LIMIT,
42
- files: 1,
43
- },
44
- });
45
- let projectName = "";
46
- busboyInstance.on("field", (name, value) => {
47
- console.log(`Field ${name}: ${value}`);
48
- if (name === "projectName") {
49
- projectName = value;
50
- }
51
- });
22
+ function requiredQueryString(event: H3Event, key: string): string {
23
+ const value = getQuery(event)[key];
24
+ if (typeof value !== "string" || value.length === 0) {
25
+ throw createError({ statusCode: 400, message: `Missing "${key}" query parameter` });
26
+ }
27
+ return value;
28
+ }
52
29
 
53
- busboyInstance.on("file", (fieldname, fileStream, info) => {
54
- if (fieldname !== "file") {
55
- // Drain & ignore unwanted fields
56
- fileStream.resume();
57
- return;
58
- }
59
- const targetPath = targetExtensionFilePath(projectName, info.filename);
60
- const writePromise = (async (): Promise<void> => {
61
- const writeStream = fs.createWriteStream(targetPath);
62
- await pipeline(fileStream, writeStream);
63
- savedFiles.push(targetPath);
64
- console.log("File written:", targetPath);
65
- })();
66
- writePromises.push(writePromise);
67
- fileStream.on("limit", () => {
68
- busboyInstance.destroy(new Error("File too large"));
69
- });
70
- });
30
+ function requiredQueryInt(event: H3Event, key: string): number {
31
+ const parsed = Math.trunc(Number(requiredQueryString(event, key)));
32
+ if (!Number.isInteger(parsed) || parsed < 0) {
33
+ throw createError({ statusCode: 400, message: `Invalid "${key}" query parameter` });
34
+ }
35
+ return parsed;
36
+ }
71
37
 
72
- busboyInstance.on("filesLimit", () => {
73
- busboyInstance.destroy(new Error("Too many files"));
74
- });
75
- busboyInstance.on("partsLimit", () => {
76
- busboyInstance.destroy(new Error("Too many parts"));
77
- });
38
+ // The file is sent as a raw (non-multipart) body, split into sequential chunks the client uploads in order (see OpenGeodeWeb-Front's upload_file.ts and OpenGeodeWeb-Back's upload_file route, which use the same protocol). Each chunk is appended to a `.part` file; the last one triggers the existing zip-metadata registration.
39
+ export default defineEventHandler(async (event: H3Event) => {
40
+ const projectName = requiredQueryString(event, "projectName");
41
+ const filename = requiredQueryString(event, "filename");
42
+ const totalChunks = requiredQueryInt(event, "total_chunks");
43
+ const chunkIndex = requiredQueryInt(event, "chunk_index");
44
+ if (chunkIndex >= totalChunks) {
45
+ throw createError({ statusCode: 400, message: "chunk_index must be less than total_chunks" });
46
+ }
78
47
 
79
48
  const webStream = getRequestWebStream(event);
80
49
  if (!webStream) {
81
50
  throw createError({ statusCode: 400, message: "No request body received" });
82
51
  }
83
- Readable.fromWeb(toNodeWebStream(webStream)).pipe(busboyInstance);
84
- await finished(busboyInstance);
85
- if (writePromises.length > 0) {
86
- await Promise.all(writePromises);
87
- console.log("All disk writes completed");
52
+
53
+ const targetPath = targetExtensionFilePath(projectName, filename);
54
+ const partPath = `${targetPath}.part`;
55
+ const writeStream = fs.createWriteStream(partPath, { flags: chunkIndex === 0 ? "w" : "a" });
56
+ await pipeline(Readable.fromWeb(toNodeWebStream(webStream)), writeStream);
57
+
58
+ const { size } = await fs.promises.stat(partPath);
59
+ if (size > FILE_SIZE_LIMIT) {
60
+ await fs.promises.unlink(partPath);
61
+ throw createError({ statusCode: 400, message: "File too large" });
88
62
  }
89
- if (savedFiles.length === 0) {
90
- throw createError({ statusCode: 400, message: "No file received" });
63
+
64
+ if (chunkIndex < totalChunks - 1) {
65
+ return { statusCode: CODE_200 };
91
66
  }
92
- await Promise.all(
93
- savedFiles.map(async (file) => {
94
- await registerExtensionFile(projectName, file);
95
- }),
96
- );
67
+
68
+ await fs.promises.rename(partPath, targetPath);
69
+ await registerExtensionFile(projectName, targetPath);
97
70
  return { statusCode: CODE_201 };
98
71
  });
@@ -1,5 +1,11 @@
1
1
  import _ from "lodash";
2
2
 
3
+ const BYTES_PER_KIBIBYTE = 1024;
4
+ const KIBIBYTES_PER_MEBIBYTE = 1024;
5
+ const CHUNK_SIZE_MEBIBYTES = 8;
6
+ // Cloud hosting in front of the upload routes (Cloud Run/Firebase Hosting) enforces a hard ~32MB request body limit that no amount of server-side streaming can bypass, so uploads are always split into chunks comfortably under that cap. The single source of truth here keeps every uploader (OpenGeodeWeb-Front, Vease) and the routes that assemble the chunks in agreement.
7
+ const CHUNK_SIZE_BYTES = CHUNK_SIZE_MEBIBYTES * KIBIBYTES_PER_MEBIBYTE * BYTES_PER_KIBIBYTE;
8
+
3
9
  function assertFile(file: unknown): asserts file is File {
4
10
  console.log("[ASSERT_FILE] Asserting file", { file });
5
11
  if (!(file instanceof File)) {
@@ -13,4 +19,4 @@ function hasBody(params: unknown): params is Blob | FormData | Record<string, un
13
19
  }
14
20
  return !_.isEmpty(params);
15
21
  }
16
- export { assertFile, hasBody };
22
+ export { assertFile, BYTES_PER_KIBIBYTE, CHUNK_SIZE_BYTES, hasBody };