@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 };
|
package/app/stores/back.ts
CHANGED
|
@@ -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
|
|
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 = {}
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
63
|
+
|
|
64
|
+
if (chunkIndex < totalChunks - 1) {
|
|
65
|
+
return { statusCode: CODE_200 };
|
|
91
66
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}),
|
|
96
|
-
);
|
|
67
|
+
|
|
68
|
+
await fs.promises.rename(partPath, targetPath);
|
|
69
|
+
await registerExtensionFile(projectName, targetPath);
|
|
97
70
|
return { statusCode: CODE_201 };
|
|
98
71
|
});
|
package/shared/utils/file.ts
CHANGED
|
@@ -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 };
|