@jskit-ai/uploads-runtime 0.1.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.
@@ -0,0 +1,89 @@
1
+ export default Object.freeze({
2
+ packageVersion: 1,
3
+ packageId: "@jskit-ai/uploads-runtime",
4
+ version: "0.1.1",
5
+ kind: "runtime",
6
+ description: "Reusable upload runtime primitives for multipart parsing, policy validation, and blob storage.",
7
+ dependsOn: [
8
+ "@jskit-ai/kernel"
9
+ ],
10
+ capabilities: {
11
+ provides: [
12
+ "runtime.uploads"
13
+ ],
14
+ requires: []
15
+ },
16
+ runtime: {
17
+ server: {
18
+ providerEntrypoint: "src/server/providers/UploadsRuntimeServiceProvider.js",
19
+ providers: [
20
+ {
21
+ entrypoint: "src/server/providers/UploadsRuntimeServiceProvider.js",
22
+ export: "UploadsRuntimeServiceProvider"
23
+ }
24
+ ]
25
+ },
26
+ client: {
27
+ providers: []
28
+ }
29
+ },
30
+ metadata: {
31
+ apiSummary: {
32
+ surfaces: [
33
+ {
34
+ subpath: "./server/providers/UploadsRuntimeServiceProvider",
35
+ summary: "Exports uploads runtime server provider."
36
+ },
37
+ {
38
+ subpath: "./server/multipart/registerMultipartSupport",
39
+ summary: "Exports Fastify multipart registration helper."
40
+ },
41
+ {
42
+ subpath: "./server/multipart/readMultipartFiles",
43
+ summary: "Exports normalized multipart file parsing helpers."
44
+ },
45
+ {
46
+ subpath: "./server/multipart/readSingleMultipartFile",
47
+ summary: "Exports a convenience helper for single-file multipart uploads."
48
+ },
49
+ {
50
+ subpath: "./server/policy/uploadPolicy",
51
+ summary: "Exports upload policy normalization and stream validation helpers."
52
+ },
53
+ {
54
+ subpath: "./server/storage/createUploadStorageService",
55
+ summary: "Exports raw upload storage helpers backed by jskit.storage."
56
+ },
57
+ {
58
+ subpath: "./shared",
59
+ summary: "Exports shared upload policy defaults and normalization helpers."
60
+ },
61
+ {
62
+ subpath: "./client",
63
+ summary: "Exports no client runtime API today (reserved client entrypoint)."
64
+ }
65
+ ],
66
+ containerTokens: {
67
+ server: [
68
+ "runtime.uploads"
69
+ ],
70
+ client: []
71
+ }
72
+ }
73
+ },
74
+ mutations: {
75
+ dependencies: {
76
+ runtime: {
77
+ "@fastify/multipart": "^9.4.0",
78
+ "@jskit-ai/kernel": "0.1.23"
79
+ },
80
+ dev: {}
81
+ },
82
+ packageJson: {
83
+ scripts: {}
84
+ },
85
+ procfile: {},
86
+ files: [],
87
+ text: []
88
+ }
89
+ });
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@jskit-ai/uploads-runtime",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test"
7
+ },
8
+ "exports": {
9
+ "./client": "./src/client/index.js",
10
+ "./shared": "./src/shared/index.js",
11
+ "./server/providers/UploadsRuntimeServiceProvider": "./src/server/providers/UploadsRuntimeServiceProvider.js",
12
+ "./server/multipart/registerMultipartSupport": "./src/server/multipart/registerMultipartSupport.js",
13
+ "./server/multipart/readMultipartFiles": "./src/server/multipart/readMultipartFiles.js",
14
+ "./server/multipart/readSingleMultipartFile": "./src/server/multipart/readSingleMultipartFile.js",
15
+ "./server/policy/uploadPolicy": "./src/server/policy/uploadPolicy.js",
16
+ "./server/storage/createUploadStorageService": "./src/server/storage/createUploadStorageService.js"
17
+ },
18
+ "dependencies": {
19
+ "@fastify/multipart": "^9.4.0",
20
+ "@jskit-ai/kernel": "0.1.23"
21
+ }
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import { createUploadFieldError } from "../policy/uploadPolicy.js";
2
+
3
+ function normalizeFieldName(value) {
4
+ return String(value || "").trim();
5
+ }
6
+
7
+ function appendField(fields, fieldName, value) {
8
+ const normalizedFieldName = normalizeFieldName(fieldName);
9
+ if (!normalizedFieldName) {
10
+ return;
11
+ }
12
+
13
+ const normalizedValue = value == null ? "" : String(value);
14
+ if (!Object.hasOwn(fields, normalizedFieldName)) {
15
+ fields[normalizedFieldName] = {
16
+ value: normalizedValue,
17
+ values: [normalizedValue]
18
+ };
19
+ return;
20
+ }
21
+
22
+ fields[normalizedFieldName].values.push(normalizedValue);
23
+ }
24
+
25
+ function toAllowedFieldSet(fieldNames = []) {
26
+ const normalizedFieldNames = Array.isArray(fieldNames)
27
+ ? fieldNames.map(normalizeFieldName).filter(Boolean)
28
+ : [];
29
+ return normalizedFieldNames.length > 0 ? new Set(normalizedFieldNames) : null;
30
+ }
31
+
32
+ async function readMultipartFiles(
33
+ request,
34
+ {
35
+ fieldNames = [],
36
+ maxFiles = Number.POSITIVE_INFINITY,
37
+ required = false,
38
+ fieldErrorKey = "",
39
+ label = "File"
40
+ } = {}
41
+ ) {
42
+ if (!request || typeof request.parts !== "function") {
43
+ throw new TypeError("readMultipartFiles requires a request with parts().");
44
+ }
45
+
46
+ const allowedFieldNames = toAllowedFieldSet(fieldNames);
47
+ const normalizedFieldErrorKey =
48
+ normalizeFieldName(fieldErrorKey) ||
49
+ (allowedFieldNames && allowedFieldNames.size === 1 ? [...allowedFieldNames][0] : "file");
50
+ const normalizedMaxFiles =
51
+ Number.isInteger(maxFiles) && maxFiles > 0 ? maxFiles : Number.POSITIVE_INFINITY;
52
+
53
+ const fields = {};
54
+ const files = [];
55
+
56
+ for await (const part of request.parts()) {
57
+ if (part?.type === "field") {
58
+ appendField(fields, part.fieldname, part.value);
59
+ continue;
60
+ }
61
+
62
+ if (part?.type !== "file") {
63
+ continue;
64
+ }
65
+
66
+ const fieldName = normalizeFieldName(part.fieldname);
67
+ if (allowedFieldNames && !allowedFieldNames.has(fieldName)) {
68
+ if (part.file && typeof part.file.resume === "function") {
69
+ part.file.resume();
70
+ }
71
+ throw createUploadFieldError(
72
+ normalizedFieldErrorKey,
73
+ `${label} field "${fieldName || "unknown"}" is not allowed.`
74
+ );
75
+ }
76
+
77
+ if (files.length >= normalizedMaxFiles) {
78
+ if (part.file && typeof part.file.resume === "function") {
79
+ part.file.resume();
80
+ }
81
+ throw createUploadFieldError(
82
+ normalizedFieldErrorKey,
83
+ normalizedMaxFiles === 1
84
+ ? `${label} upload allows only one file.`
85
+ : `${label} upload allows at most ${normalizedMaxFiles} files.`
86
+ );
87
+ }
88
+
89
+ files.push(
90
+ Object.freeze({
91
+ fieldName,
92
+ fileName: String(part.filename || "").trim(),
93
+ mimeType: String(part.mimetype || "").trim().toLowerCase(),
94
+ encoding: String(part.encoding || "").trim().toLowerCase(),
95
+ stream: part.file,
96
+ fields
97
+ })
98
+ );
99
+ }
100
+
101
+ if (required && files.length === 0) {
102
+ throw createUploadFieldError(normalizedFieldErrorKey, `${label} file is required.`);
103
+ }
104
+
105
+ return Object.freeze({
106
+ fields: Object.freeze(fields),
107
+ files: Object.freeze(files)
108
+ });
109
+ }
110
+
111
+ export { readMultipartFiles };
@@ -0,0 +1,11 @@
1
+ import { readMultipartFiles } from "./readMultipartFiles.js";
2
+
3
+ async function readSingleMultipartFile(request, options = {}) {
4
+ const result = await readMultipartFiles(request, {
5
+ ...options,
6
+ maxFiles: 1
7
+ });
8
+ return result.files[0] || null;
9
+ }
10
+
11
+ export { readSingleMultipartFile };
@@ -0,0 +1,40 @@
1
+ import fastifyMultipart from "@fastify/multipart";
2
+
3
+ async function registerMultipartSupport(app) {
4
+ if (!app || typeof app.has !== "function" || typeof app.make !== "function") {
5
+ throw new Error("registerMultipartSupport requires application has()/make().");
6
+ }
7
+
8
+ if (!app.has("jskit.fastify")) {
9
+ return;
10
+ }
11
+
12
+ const fastify = app.make("jskit.fastify");
13
+ if (!fastify || typeof fastify.register !== "function") {
14
+ throw new Error("registerMultipartSupport requires Fastify register().");
15
+ }
16
+
17
+ if (fastify["jskit.uploads-runtime.multipart.support"] === true) {
18
+ return;
19
+ }
20
+
21
+ if (typeof fastify.hasContentTypeParser === "function" && fastify.hasContentTypeParser("multipart")) {
22
+ Object.defineProperty(fastify, "jskit.uploads-runtime.multipart.support", {
23
+ value: true,
24
+ configurable: false,
25
+ enumerable: false,
26
+ writable: false
27
+ });
28
+ return;
29
+ }
30
+
31
+ await fastify.register(fastifyMultipart);
32
+ Object.defineProperty(fastify, "jskit.uploads-runtime.multipart.support", {
33
+ value: true,
34
+ configurable: false,
35
+ enumerable: false,
36
+ writable: false
37
+ });
38
+ }
39
+
40
+ export { registerMultipartSupport };
@@ -0,0 +1,76 @@
1
+ import { createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import {
3
+ DEFAULT_IMAGE_UPLOAD_POLICY,
4
+ normalizeMimeType,
5
+ normalizeUploadPolicy
6
+ } from "../../shared/uploadPolicy.js";
7
+
8
+ function createUploadFieldError(fieldName, message) {
9
+ const normalizedFieldName = String(fieldName || "").trim() || "file";
10
+ return createValidationError({
11
+ [normalizedFieldName]: String(message || "").trim() || "Upload is invalid."
12
+ });
13
+ }
14
+
15
+ async function readUploadBuffer(
16
+ stream,
17
+ {
18
+ maxBytes = DEFAULT_IMAGE_UPLOAD_POLICY.maxUploadBytes,
19
+ fieldName = "file",
20
+ label = "File"
21
+ } = {}
22
+ ) {
23
+ if (!stream || typeof stream.on !== "function") {
24
+ throw new TypeError("Upload stream is required.");
25
+ }
26
+
27
+ const chunks = [];
28
+ let total = 0;
29
+
30
+ for await (const chunk of stream) {
31
+ const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
32
+ total += bufferChunk.length;
33
+
34
+ if (total > maxBytes) {
35
+ throw createUploadFieldError(
36
+ fieldName,
37
+ `${label} file is too large. Maximum allowed size is ${Math.floor(maxBytes / (1024 * 1024))}MB.`
38
+ );
39
+ }
40
+
41
+ chunks.push(bufferChunk);
42
+ }
43
+
44
+ if (chunks.length === 0) {
45
+ throw createUploadFieldError(fieldName, `${label} file is empty.`);
46
+ }
47
+
48
+ return Buffer.concat(chunks);
49
+ }
50
+
51
+ function validateUploadMimeType(
52
+ mimeType,
53
+ policy = DEFAULT_IMAGE_UPLOAD_POLICY,
54
+ {
55
+ fieldName = "file",
56
+ label = "File"
57
+ } = {}
58
+ ) {
59
+ const normalizedMimeType = normalizeMimeType(mimeType);
60
+ const resolvedPolicy = normalizeUploadPolicy(policy);
61
+ if (resolvedPolicy.allowedMimeTypes.length > 0 && !resolvedPolicy.allowedMimeTypes.includes(normalizedMimeType)) {
62
+ throw createUploadFieldError(
63
+ fieldName,
64
+ `${label} must be one of: ${resolvedPolicy.allowedMimeTypes.join(", ")}.`
65
+ );
66
+ }
67
+
68
+ return normalizedMimeType;
69
+ }
70
+
71
+ export {
72
+ createUploadFieldError,
73
+ normalizeUploadPolicy,
74
+ readUploadBuffer,
75
+ validateUploadMimeType
76
+ };
@@ -0,0 +1,45 @@
1
+ import { registerMultipartSupport } from "../multipart/registerMultipartSupport.js";
2
+ import { readMultipartFiles } from "../multipart/readMultipartFiles.js";
3
+ import { readSingleMultipartFile } from "../multipart/readSingleMultipartFile.js";
4
+ import {
5
+ createUploadFieldError,
6
+ readUploadBuffer,
7
+ validateUploadMimeType
8
+ } from "../policy/uploadPolicy.js";
9
+ import {
10
+ createUploadStorageService,
11
+ detectCommonMimeTypeFromBuffer,
12
+ normalizeStorageKey
13
+ } from "../storage/createUploadStorageService.js";
14
+
15
+ const UPLOADS_RUNTIME_SERVER_API = Object.freeze({
16
+ registerMultipartSupport,
17
+ readMultipartFiles,
18
+ readSingleMultipartFile,
19
+ createUploadFieldError,
20
+ readUploadBuffer,
21
+ validateUploadMimeType,
22
+ createUploadStorageService,
23
+ detectCommonMimeTypeFromBuffer,
24
+ normalizeStorageKey
25
+ });
26
+
27
+ class UploadsRuntimeServiceProvider {
28
+ static id = "runtime.uploads";
29
+
30
+ static dependsOn = ["runtime.server"];
31
+
32
+ register(app) {
33
+ if (!app || typeof app.singleton !== "function") {
34
+ throw new Error("UploadsRuntimeServiceProvider requires application singleton().");
35
+ }
36
+
37
+ app.singleton("runtime.uploads", () => UPLOADS_RUNTIME_SERVER_API);
38
+ }
39
+
40
+ async boot(app) {
41
+ await registerMultipartSupport(app);
42
+ }
43
+ }
44
+
45
+ export { UploadsRuntimeServiceProvider };
@@ -0,0 +1,125 @@
1
+ const MIME_TYPE_JPEG = "image/jpeg";
2
+ const MIME_TYPE_PNG = "image/png";
3
+ const MIME_TYPE_WEBP = "image/webp";
4
+ const MIME_TYPE_FALLBACK = "application/octet-stream";
5
+
6
+ function normalizeStorageKey(value) {
7
+ const normalized = String(value || "").trim();
8
+ if (!normalized) {
9
+ return "";
10
+ }
11
+ if (normalized.startsWith("/") || normalized.includes("..")) {
12
+ return "";
13
+ }
14
+ return normalized;
15
+ }
16
+
17
+ function detectCommonMimeTypeFromBuffer(buffer) {
18
+ if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
19
+ return MIME_TYPE_FALLBACK;
20
+ }
21
+
22
+ if (
23
+ buffer.length >= 3 &&
24
+ buffer[0] === 0xff &&
25
+ buffer[1] === 0xd8 &&
26
+ buffer[2] === 0xff
27
+ ) {
28
+ return MIME_TYPE_JPEG;
29
+ }
30
+
31
+ if (
32
+ buffer.length >= 8 &&
33
+ buffer[0] === 0x89 &&
34
+ buffer[1] === 0x50 &&
35
+ buffer[2] === 0x4e &&
36
+ buffer[3] === 0x47 &&
37
+ buffer[4] === 0x0d &&
38
+ buffer[5] === 0x0a &&
39
+ buffer[6] === 0x1a &&
40
+ buffer[7] === 0x0a
41
+ ) {
42
+ return MIME_TYPE_PNG;
43
+ }
44
+
45
+ if (
46
+ buffer.length >= 12 &&
47
+ buffer[0] === 0x52 &&
48
+ buffer[1] === 0x49 &&
49
+ buffer[2] === 0x46 &&
50
+ buffer[3] === 0x46 &&
51
+ buffer[8] === 0x57 &&
52
+ buffer[9] === 0x45 &&
53
+ buffer[10] === 0x42 &&
54
+ buffer[11] === 0x50
55
+ ) {
56
+ return MIME_TYPE_WEBP;
57
+ }
58
+
59
+ return MIME_TYPE_FALLBACK;
60
+ }
61
+
62
+ function createUploadStorageService({ storage, mimeTypeDetector = detectCommonMimeTypeFromBuffer } = {}) {
63
+ if (!storage || typeof storage.getItemRaw !== "function" || typeof storage.setItemRaw !== "function") {
64
+ throw new TypeError("createUploadStorageService requires a storage binding with getItemRaw()/setItemRaw().");
65
+ }
66
+
67
+ async function saveFile({ storageKey, buffer }) {
68
+ const normalizedStorageKey = normalizeStorageKey(storageKey);
69
+ if (!normalizedStorageKey) {
70
+ throw new TypeError("Upload storage key is required.");
71
+ }
72
+ if (!Buffer.isBuffer(buffer)) {
73
+ throw new TypeError("Upload buffer must be a Buffer instance.");
74
+ }
75
+
76
+ await storage.setItemRaw(normalizedStorageKey, buffer);
77
+ return Object.freeze({
78
+ storageKey: normalizedStorageKey
79
+ });
80
+ }
81
+
82
+ async function readFile(storageKey) {
83
+ const normalizedStorageKey = normalizeStorageKey(storageKey);
84
+ if (!normalizedStorageKey) {
85
+ return null;
86
+ }
87
+
88
+ const value = await storage.getItemRaw(normalizedStorageKey);
89
+ if (value == null) {
90
+ return null;
91
+ }
92
+
93
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
94
+ return Object.freeze({
95
+ storageKey: normalizedStorageKey,
96
+ buffer,
97
+ mimeType: mimeTypeDetector(buffer)
98
+ });
99
+ }
100
+
101
+ async function deleteFile(storageKey) {
102
+ const normalizedStorageKey = normalizeStorageKey(storageKey);
103
+ if (!normalizedStorageKey || typeof storage.removeItem !== "function") {
104
+ return;
105
+ }
106
+
107
+ await storage.removeItem(normalizedStorageKey);
108
+ }
109
+
110
+ return Object.freeze({
111
+ saveFile,
112
+ readFile,
113
+ deleteFile
114
+ });
115
+ }
116
+
117
+ export {
118
+ MIME_TYPE_FALLBACK,
119
+ MIME_TYPE_JPEG,
120
+ MIME_TYPE_PNG,
121
+ MIME_TYPE_WEBP,
122
+ createUploadStorageService,
123
+ detectCommonMimeTypeFromBuffer,
124
+ normalizeStorageKey
125
+ };
@@ -0,0 +1,7 @@
1
+ const DEFAULT_IMAGE_UPLOAD_ALLOWED_MIME_TYPES = Object.freeze(["image/jpeg", "image/png", "image/webp"]);
2
+ const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
3
+
4
+ export {
5
+ DEFAULT_IMAGE_UPLOAD_ALLOWED_MIME_TYPES,
6
+ DEFAULT_IMAGE_UPLOAD_MAX_BYTES
7
+ };
@@ -0,0 +1,9 @@
1
+ export {
2
+ DEFAULT_IMAGE_UPLOAD_ALLOWED_MIME_TYPES,
3
+ DEFAULT_IMAGE_UPLOAD_MAX_BYTES,
4
+ DEFAULT_IMAGE_UPLOAD_POLICY,
5
+ normalizeAllowedMimeTypes,
6
+ normalizeMaxUploadBytes,
7
+ normalizeMimeType,
8
+ normalizeUploadPolicy
9
+ } from "./uploadPolicy.js";
@@ -0,0 +1,60 @@
1
+ import {
2
+ DEFAULT_IMAGE_UPLOAD_ALLOWED_MIME_TYPES,
3
+ DEFAULT_IMAGE_UPLOAD_MAX_BYTES
4
+ } from "./imageUploadDefaults.js";
5
+
6
+ function normalizeMimeType(value) {
7
+ return String(value || "").trim().toLowerCase();
8
+ }
9
+
10
+ function normalizeAllowedMimeTypes(values = [], fallback = []) {
11
+ const source = Array.isArray(values) ? values : [];
12
+ const normalized = source
13
+ .map(normalizeMimeType)
14
+ .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index);
15
+
16
+ if (normalized.length > 0) {
17
+ return Object.freeze(normalized);
18
+ }
19
+
20
+ const fallbackValues = Array.isArray(fallback) ? fallback : [];
21
+ return Object.freeze(
22
+ fallbackValues
23
+ .map(normalizeMimeType)
24
+ .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
25
+ );
26
+ }
27
+
28
+ function normalizeMaxUploadBytes(value, fallback = DEFAULT_IMAGE_UPLOAD_MAX_BYTES) {
29
+ const normalized = Number(value);
30
+ if (Number.isInteger(normalized) && normalized > 0) {
31
+ return normalized;
32
+ }
33
+
34
+ return Number.isInteger(fallback) && fallback > 0 ? fallback : DEFAULT_IMAGE_UPLOAD_MAX_BYTES;
35
+ }
36
+
37
+ function normalizeUploadPolicy(policy = {}, defaults = {}) {
38
+ const source = policy && typeof policy === "object" ? policy : {};
39
+ const fallback = defaults && typeof defaults === "object" ? defaults : {};
40
+
41
+ return Object.freeze({
42
+ allowedMimeTypes: normalizeAllowedMimeTypes(source.allowedMimeTypes, fallback.allowedMimeTypes),
43
+ maxUploadBytes: normalizeMaxUploadBytes(source.maxUploadBytes, fallback.maxUploadBytes)
44
+ });
45
+ }
46
+
47
+ const DEFAULT_IMAGE_UPLOAD_POLICY = Object.freeze({
48
+ allowedMimeTypes: DEFAULT_IMAGE_UPLOAD_ALLOWED_MIME_TYPES,
49
+ maxUploadBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES
50
+ });
51
+
52
+ export {
53
+ DEFAULT_IMAGE_UPLOAD_ALLOWED_MIME_TYPES,
54
+ DEFAULT_IMAGE_UPLOAD_MAX_BYTES,
55
+ DEFAULT_IMAGE_UPLOAD_POLICY,
56
+ normalizeAllowedMimeTypes,
57
+ normalizeMaxUploadBytes,
58
+ normalizeMimeType,
59
+ normalizeUploadPolicy
60
+ };
@@ -0,0 +1,32 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import { evaluatePackageExportsContract } from "../../../tooling/test-support/exportsContract.mjs";
6
+
7
+ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
8
+ const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
9
+ const PACKAGE_DIR = path.join(REPO_ROOT, "packages", "uploads-runtime");
10
+
11
+ test("uploads-runtime exports are explicit and aligned with usage", () => {
12
+ const result = evaluatePackageExportsContract({
13
+ repoRoot: REPO_ROOT,
14
+ packageDir: PACKAGE_DIR,
15
+ packageId: "@jskit-ai/uploads-runtime",
16
+ requiredExports: [
17
+ "./client",
18
+ "./shared",
19
+ "./server/providers/UploadsRuntimeServiceProvider",
20
+ "./server/multipart/registerMultipartSupport",
21
+ "./server/multipart/readMultipartFiles",
22
+ "./server/multipart/readSingleMultipartFile",
23
+ "./server/policy/uploadPolicy",
24
+ "./server/storage/createUploadStorageService"
25
+ ]
26
+ });
27
+
28
+ assert.deepEqual(result.wildcardExports, []);
29
+ assert.deepEqual(result.missingRequiredExports, []);
30
+ assert.deepEqual(result.missingExports, []);
31
+ assert.deepEqual(result.staleExports, []);
32
+ });
@@ -0,0 +1,73 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { UploadsRuntimeServiceProvider } from "../src/server/providers/UploadsRuntimeServiceProvider.js";
4
+
5
+ function createAppStub({ hasFastify = true, fastify = null } = {}) {
6
+ const singletons = new Map();
7
+ const singletonInstances = new Map();
8
+ const resolvedFastify =
9
+ fastify ||
10
+ {
11
+ register: async () => {},
12
+ hasContentTypeParser: () => false
13
+ };
14
+
15
+ return {
16
+ has(token) {
17
+ if (token === "jskit.fastify") {
18
+ return hasFastify;
19
+ }
20
+ return singletons.has(token) || singletonInstances.has(token);
21
+ },
22
+ singleton(token, factory) {
23
+ singletons.set(token, factory);
24
+ },
25
+ make(token) {
26
+ if (token === "jskit.fastify") {
27
+ return resolvedFastify;
28
+ }
29
+ if (singletonInstances.has(token)) {
30
+ return singletonInstances.get(token);
31
+ }
32
+ const factory = singletons.get(token);
33
+ if (!factory) {
34
+ throw new Error(`Unknown token ${String(token)}`);
35
+ }
36
+ const instance = factory(this);
37
+ singletonInstances.set(token, instance);
38
+ return instance;
39
+ }
40
+ };
41
+ }
42
+
43
+ test("UploadsRuntimeServiceProvider registers runtime uploads api", async () => {
44
+ const app = createAppStub();
45
+ const provider = new UploadsRuntimeServiceProvider();
46
+
47
+ provider.register(app);
48
+
49
+ assert.equal(app.has("runtime.uploads"), true);
50
+ const runtimeUploads = app.make("runtime.uploads");
51
+ assert.equal(typeof runtimeUploads.registerMultipartSupport, "function");
52
+ assert.equal(typeof runtimeUploads.readSingleMultipartFile, "function");
53
+ assert.equal(typeof runtimeUploads.createUploadStorageService, "function");
54
+ });
55
+
56
+ test("UploadsRuntimeServiceProvider boots multipart support once", async () => {
57
+ let registerCount = 0;
58
+ const app = createAppStub({
59
+ fastify: {
60
+ register: async () => {
61
+ registerCount += 1;
62
+ },
63
+ hasContentTypeParser: () => false
64
+ }
65
+ });
66
+
67
+ const provider = new UploadsRuntimeServiceProvider();
68
+ provider.register(app);
69
+ await provider.boot(app);
70
+ await provider.boot(app);
71
+
72
+ assert.equal(registerCount, 1);
73
+ });
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Readable } from "node:stream";
4
+ import { readMultipartFiles } from "../src/server/multipart/readMultipartFiles.js";
5
+ import { readSingleMultipartFile } from "../src/server/multipart/readSingleMultipartFile.js";
6
+
7
+ function createRequestStub(parts) {
8
+ return {
9
+ async *parts() {
10
+ for (const part of parts) {
11
+ yield part;
12
+ }
13
+ }
14
+ };
15
+ }
16
+
17
+ test("readMultipartFiles normalizes fields and files", async () => {
18
+ const request = createRequestStub([
19
+ { type: "field", fieldname: "uploadDimension", value: "256" },
20
+ {
21
+ type: "file",
22
+ fieldname: "avatar",
23
+ filename: "face.png",
24
+ mimetype: "image/png",
25
+ encoding: "7bit",
26
+ file: Readable.from([Buffer.from("png")])
27
+ }
28
+ ]);
29
+
30
+ const result = await readMultipartFiles(request, {
31
+ fieldNames: ["avatar"],
32
+ required: true,
33
+ label: "Avatar"
34
+ });
35
+
36
+ assert.equal(result.files.length, 1);
37
+ assert.equal(result.files[0].fieldName, "avatar");
38
+ assert.equal(result.files[0].fileName, "face.png");
39
+ assert.equal(result.files[0].mimeType, "image/png");
40
+ assert.equal(result.fields.uploadDimension.value, "256");
41
+ });
42
+
43
+ test("readSingleMultipartFile returns the first file and enforces maxFiles", async () => {
44
+ const request = createRequestStub([
45
+ {
46
+ type: "file",
47
+ fieldname: "avatar",
48
+ filename: "face.png",
49
+ mimetype: "image/png",
50
+ encoding: "7bit",
51
+ file: Readable.from([Buffer.from("png")])
52
+ }
53
+ ]);
54
+
55
+ const file = await readSingleMultipartFile(request, {
56
+ fieldNames: ["avatar"],
57
+ required: true,
58
+ label: "Avatar"
59
+ });
60
+
61
+ assert.equal(file?.fileName, "face.png");
62
+ });
63
+
64
+ test("readMultipartFiles rejects missing required files", async () => {
65
+ const request = createRequestStub([]);
66
+
67
+ await assert.rejects(
68
+ () =>
69
+ readMultipartFiles(request, {
70
+ fieldNames: ["avatar"],
71
+ required: true,
72
+ label: "Avatar"
73
+ }),
74
+ (error) => {
75
+ assert.equal(error?.message, "Validation failed.");
76
+ assert.equal(error?.details?.fieldErrors?.avatar, "Avatar file is required.");
77
+ return true;
78
+ }
79
+ );
80
+ });
@@ -0,0 +1,63 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { registerMultipartSupport } from "../src/server/multipart/registerMultipartSupport.js";
4
+
5
+ function createAppStub({ hasFastify = true, fastify = null } = {}) {
6
+ const resolvedFastify =
7
+ fastify ||
8
+ {
9
+ register: async () => {},
10
+ hasContentTypeParser: () => false
11
+ };
12
+
13
+ return {
14
+ has(token) {
15
+ if (token === "jskit.fastify") {
16
+ return hasFastify;
17
+ }
18
+ return false;
19
+ },
20
+ make(token) {
21
+ if (token === "jskit.fastify") {
22
+ return resolvedFastify;
23
+ }
24
+ return null;
25
+ }
26
+ };
27
+ }
28
+
29
+ test("registerMultipartSupport returns early when Fastify is not available", async () => {
30
+ const app = createAppStub({ hasFastify: false });
31
+ await assert.doesNotReject(async () => registerMultipartSupport(app));
32
+ });
33
+
34
+ test("registerMultipartSupport registers multipart parser only once", async () => {
35
+ let registerCount = 0;
36
+ const fastify = {
37
+ register: async () => {
38
+ registerCount += 1;
39
+ },
40
+ hasContentTypeParser: () => false
41
+ };
42
+ const app = createAppStub({ fastify });
43
+
44
+ await registerMultipartSupport(app);
45
+ await registerMultipartSupport(app);
46
+
47
+ assert.equal(registerCount, 1);
48
+ });
49
+
50
+ test("registerMultipartSupport skips registration when parser already exists", async () => {
51
+ let registerCount = 0;
52
+ const fastify = {
53
+ register: async () => {
54
+ registerCount += 1;
55
+ },
56
+ hasContentTypeParser: (contentType) => String(contentType || "").trim().toLowerCase() === "multipart"
57
+ };
58
+ const app = createAppStub({ fastify });
59
+
60
+ await registerMultipartSupport(app);
61
+
62
+ assert.equal(registerCount, 0);
63
+ });
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Readable } from "node:stream";
4
+ import {
5
+ normalizeUploadPolicy,
6
+ readUploadBuffer,
7
+ validateUploadMimeType
8
+ } from "../src/server/policy/uploadPolicy.js";
9
+
10
+ test("normalizeUploadPolicy applies defaults and normalization", () => {
11
+ const policy = normalizeUploadPolicy(
12
+ {
13
+ allowedMimeTypes: [" IMAGE/PNG ", "image/png", "image/jpeg"],
14
+ maxUploadBytes: "123"
15
+ },
16
+ {
17
+ allowedMimeTypes: ["image/webp"],
18
+ maxUploadBytes: 50
19
+ }
20
+ );
21
+
22
+ assert.deepEqual(policy.allowedMimeTypes, ["image/png", "image/jpeg"]);
23
+ assert.equal(policy.maxUploadBytes, 123);
24
+ });
25
+
26
+ test("validateUploadMimeType rejects unsupported mime types", () => {
27
+ assert.throws(
28
+ () =>
29
+ validateUploadMimeType("application/pdf", {
30
+ allowedMimeTypes: ["image/png"]
31
+ }),
32
+ (error) => {
33
+ assert.equal(error?.message, "Validation failed.");
34
+ assert.equal(error?.details?.fieldErrors?.file, "File must be one of: image/png.");
35
+ return true;
36
+ }
37
+ );
38
+ });
39
+
40
+ test("readUploadBuffer enforces max bytes and empty uploads", async () => {
41
+ await assert.rejects(
42
+ () =>
43
+ readUploadBuffer(Readable.from([Buffer.from("abcdef")]), {
44
+ maxBytes: 3,
45
+ fieldName: "avatar",
46
+ label: "Avatar"
47
+ }),
48
+ (error) => {
49
+ assert.equal(error?.message, "Validation failed.");
50
+ assert.equal(error?.details?.fieldErrors?.avatar, "Avatar file is too large. Maximum allowed size is 0MB.");
51
+ return true;
52
+ }
53
+ );
54
+
55
+ await assert.rejects(
56
+ () =>
57
+ readUploadBuffer(Readable.from([]), {
58
+ fieldName: "avatar",
59
+ label: "Avatar"
60
+ }),
61
+ (error) => {
62
+ assert.equal(error?.message, "Validation failed.");
63
+ assert.equal(error?.details?.fieldErrors?.avatar, "Avatar file is empty.");
64
+ return true;
65
+ }
66
+ );
67
+ });
@@ -0,0 +1,55 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ createUploadStorageService,
5
+ detectCommonMimeTypeFromBuffer,
6
+ normalizeStorageKey
7
+ } from "../src/server/storage/createUploadStorageService.js";
8
+
9
+ function createStorageDouble() {
10
+ const values = new Map();
11
+ return {
12
+ async getItemRaw(key) {
13
+ return values.has(key) ? values.get(key) : null;
14
+ },
15
+ async setItemRaw(key, value) {
16
+ values.set(key, value);
17
+ },
18
+ async removeItem(key) {
19
+ values.delete(key);
20
+ }
21
+ };
22
+ }
23
+
24
+ test("normalizeStorageKey blocks unsafe paths", () => {
25
+ assert.equal(normalizeStorageKey("uploads/test"), "uploads/test");
26
+ assert.equal(normalizeStorageKey("/uploads/test"), "");
27
+ assert.equal(normalizeStorageKey("../uploads/test"), "");
28
+ });
29
+
30
+ test("detectCommonMimeTypeFromBuffer recognizes common image types", () => {
31
+ assert.equal(detectCommonMimeTypeFromBuffer(Buffer.from([0xff, 0xd8, 0xff, 0xdb])), "image/jpeg");
32
+ assert.equal(
33
+ detectCommonMimeTypeFromBuffer(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
34
+ "image/png"
35
+ );
36
+ });
37
+
38
+ test("createUploadStorageService saves, reads, and deletes raw bytes", async () => {
39
+ const storage = createStorageDouble();
40
+ const service = createUploadStorageService({ storage });
41
+ const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
42
+
43
+ const saved = await service.saveFile({
44
+ storageKey: "uploads/images/face.png",
45
+ buffer: payload
46
+ });
47
+ assert.equal(saved.storageKey, "uploads/images/face.png");
48
+
49
+ const loaded = await service.readFile(saved.storageKey);
50
+ assert.equal(loaded?.mimeType, "image/png");
51
+ assert.equal(loaded?.buffer?.toString("hex"), payload.toString("hex"));
52
+
53
+ await service.deleteFile(saved.storageKey);
54
+ assert.equal(await service.readFile(saved.storageKey), null);
55
+ });