Files
outline/shared/editor/commands/insertFiles.ts
T
24f95cadc3 perf: Reduce server memory usage and add container memory best practices (#13042)
* Reduce server memory usage and add container memory best practices

Profiling a full-service boot showed ~300MB RSS per service process, much
of it client-only dependencies pulled into the server module graph, and no
heap limits applied in containers:

- Import date-fns locales individually rather than via the locale index,
  which loaded all ~90 locales into every process
- Load @sentry/react dynamically in insertFiles so the browser Sentry SDK
  stays out of the server-side editor graph
- Replace class-validator isHexColor with the existing validateColorHex
  in the Highlight mark
- Extract icon names into a standalone IconNames module so server-side
  schema validation no longer imports Font Awesome icon packs, with a
  test to keep it in sync with IconLibrary
- Require S3Storage lazily so the AWS SDK and native CRT binding are only
  loaded when S3 file storage is configured
- Default each forked service process to a V8 heap limit derived from the
  cgroup memory constraint, preventing several processes from together
  committing more memory than the container allows
- Cap glibc malloc arenas in the Docker image to reduce resident memory
  fragmentation in multi-threaded processes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

* Require both file storage backends lazily for consistency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

* Load AWS SDK via dynamic imports compatible with test environment

Requiring the storage TypeScript modules lazily broke under Vitest, which
cannot resolve require() of source files and does not apply vi.mock to
bare requires. Move the laziness into S3Storage instead: the module is
imported statically, and the AWS SDK packages are loaded with dynamic
imports on first use, including deferred creation of the S3 client.

Also address review feedback: handle rejection of the dynamic Sentry
import, and warn when the minimum per-process heap limit exceeds the
memory budget of the container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

* Consolidate S3 SDK loading into a single lazy accessor

The commands are loaded together with the client in getS3() rather than
as separate dynamic imports per method. The global S3Client test mock is
now a class, as a mock function is not constructable when reached through
a dynamic import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 18:34:32 -04:00

266 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t } from "i18next";
import { v4 as uuidv4 } from "uuid";
import type { EditorView } from "prosemirror-view";
import { toast } from "sonner";
import FileHelper from "../lib/FileHelper";
import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
export type Options = {
/** Set to true to force images and videos to become file attachments */
isAttachment?: boolean;
/** Set to true to replace any existing image at the users selection */
replaceExisting?: boolean;
/** Callback fired to upload a file */
uploadFile?: (
file: File | string,
options?: {
id?: string;
onProgress?: (fractionComplete: number) => void;
}
) => Promise<string>;
/** Callback fired when the user starts a file upload */
onFileUploadStart?: () => void;
/** Callback fired when the user completes a file upload */
onFileUploadStop?: () => void;
/** Callback fired when file upload progress changes */
onFileUploadProgress?: (id: string, fractionComplete: number) => void;
/** Attributes to overwrite */
attrs?: {
/** Width to use when inserting image */
width?: number;
/** Height to use when inserting image */
height?: number;
/** Alt text / caption to use when inserting image */
alt?: string | null;
/** Layout class for alignment when inserting image */
layoutClass?: string | null;
/** Whether to show a PDF preview embed for attachment nodes */
preview?: boolean;
};
};
const insertFiles = async function (
view: EditorView,
event:
| Event
| React.ChangeEvent<HTMLInputElement>
| React.DragEvent<HTMLDivElement>,
pos: number,
files: File[],
options: Options
) {
const {
uploadFile,
onFileUploadStart,
onFileUploadStop,
onFileUploadProgress,
} = options;
// okay, we have some dropped files and a handler lets stop this
// event going any further up the stack
event.preventDefault();
// let the user know we're starting to process the files
onFileUploadStart?.();
const { schema } = view.state;
// we'll use this to track of how many files have succeeded or failed
let complete = 0;
const filesToUpload = (
await Promise.all(
files.map(async (file) => {
const isImage =
FileHelper.isImage(file.type) &&
!options.isAttachment &&
!!schema.nodes.image;
const isVideo =
FileHelper.isVideo(file.type) &&
!options.isAttachment &&
!!schema.nodes.video;
// a file that cannot be inserted as an image or video falls back to an
// attachment node if the schema in use has none then it cannot be
// represented at all and should be skipped.
if (!isImage && !isVideo && !schema.nodes.attachment) {
return undefined;
}
const getDimensions = isImage
? (f: File) => FileHelper.getImageDimensions(f)
: isVideo
? (f: File) => FileHelper.getVideoDimensions(f)
: undefined;
return {
id: uuidv4(),
dimensions: await getDimensions?.(file),
source: await FileHelper.getImageSourceAttr(file),
isImage,
isVideo,
file,
};
})
)
).filter((upload) => upload !== undefined);
// none of the dropped files can be represented in this schema, nothing to do
if (filesToUpload.length === 0) {
onFileUploadStop?.();
return;
}
// the user might have dropped multiple files at once, we need to loop
for (const upload of filesToUpload) {
const { tr } = view.state;
tr.setMeta(uploadPlaceholderPlugin, {
add: {
pos,
...upload,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
// start uploading the file to the server. Using "then" syntax
// to allow all placeholders to be entered at once with the uploads
// happening in the background in parallel.
uploadFile?.(upload.file, {
id: upload.id,
onProgress: (progress) => onFileUploadProgress?.(upload.id, progress),
})
// then this should be able to get the full URL as well
.then(async (src) => {
if (view.isDestroyed) {
return;
}
if (upload.isImage) {
const newImg = new Image();
newImg.onload = async () => {
const result = findPlaceholder(view.state, upload.id);
if (result === null) {
return;
}
if (view.isDestroyed) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(
from,
to || from,
schema.nodes.image.create({
src,
source: upload.source,
...upload.dimensions,
...options.attrs,
})
)
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
);
};
newImg.onerror = () => {
throw new Error(`Error loading image: ${src}`);
};
newImg.src = src;
} else if (upload.isVideo) {
const result = findPlaceholder(view.state, upload.id);
if (result === null) {
return;
}
const [from, to] = result;
if (view.isDestroyed) {
return;
}
view.dispatch(
view.state.tr
.replaceWith(
from,
to || from,
schema.nodes.video.create({
src,
title: upload.file.name ?? t("Untitled"),
...upload.dimensions,
...options.attrs,
})
)
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
);
} else {
const result = findPlaceholder(view.state, upload.id);
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(
from,
to || from,
schema.nodes.attachment.create({
href: src,
title: upload.file.name ?? t("Untitled"),
size: upload.file.size,
contentType: upload.file.type,
preview: false,
...options.attrs,
})
)
.setMeta(uploadPlaceholderPlugin, { remove: { id: upload.id } })
);
}
})
.catch((error) => {
// Imported dynamically so the Sentry SDK stays out of the module graph
// until an upload actually fails, keeping it off server-side startup.
void import("@sentry/react")
.then((Sentry) => Sentry.captureException(error))
.catch(() => {
// Reporting is best-effort; the error is logged below regardless.
});
// oxlint-disable-next-line no-console
console.error(error);
if (view.isDestroyed) {
return;
}
// cleanup the placeholder if there is a failure
view.dispatch(
view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id: upload.id },
})
);
toast.error(
error.message || t("Sorry, an error occurred uploading the file")
);
})
.finally(() => {
complete++;
// once everything is done, let the user know
if (complete === filesToUpload.length && onFileUploadStop) {
onFileUploadStop();
}
});
}
};
export default insertFiles;