mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d2bf2b8c5a
commit
24f95cadc3
@@ -14,6 +14,11 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Limit glibc malloc arenas, which default to 8 per CPU. Each arena can hold
|
||||
# onto 64MB of virtual memory and freed allocations, which inflates resident
|
||||
# memory in multi-threaded Node.js processes for no performance benefit here.
|
||||
ENV MALLOC_ARENA_MAX=2
|
||||
|
||||
# Create a non-root user compatible with Debian and BusyBox based images
|
||||
RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
|
||||
+16
-1
@@ -7,6 +7,7 @@ import "./logging/tracer"; // must come before importing any instrumented module
|
||||
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import os from "node:os";
|
||||
import type { Context } from "koa";
|
||||
import Koa from "koa";
|
||||
import helmet from "koa-helmet";
|
||||
@@ -21,7 +22,11 @@ import services from "./services";
|
||||
import { getArg } from "./utils/args";
|
||||
import { getSSLOptions } from "./utils/ssl";
|
||||
import { defaultRateLimiter } from "@server/middlewares/rateLimiter";
|
||||
import { printEnv, checkPendingMigrations } from "./utils/startup";
|
||||
import {
|
||||
printEnv,
|
||||
checkPendingMigrations,
|
||||
configureChildHeapLimit,
|
||||
} from "./utils/startup";
|
||||
import { checkUpdates } from "./utils/updates";
|
||||
import onerror from "./onerror";
|
||||
import ShutdownHelper, { ShutdownOrder } from "./utils/ShutdownHelper";
|
||||
@@ -262,6 +267,16 @@ const isWebProcess =
|
||||
const isWorkerProcess =
|
||||
env.SERVICES.length === 1 && env.SERVICES.includes("worker");
|
||||
|
||||
// Mirrors the `count` passed to throng below, where undefined falls back to
|
||||
// throng's default of one process per CPU.
|
||||
const processCount = isWorkerProcess
|
||||
? 1
|
||||
: isWebProcess
|
||||
? (webProcessCount ?? os.cpus().length)
|
||||
: os.cpus().length;
|
||||
|
||||
configureChildHeapLimit(processCount);
|
||||
|
||||
void throng({
|
||||
master,
|
||||
worker: start,
|
||||
|
||||
@@ -23,7 +23,8 @@ describe("S3Storage", () => {
|
||||
}
|
||||
);
|
||||
const storage = new S3Storage();
|
||||
vi.spyOn(Reflect.get(storage, "client"), "send").mockRejectedValue(error);
|
||||
const { client } = await Reflect.get(storage, "getS3").call(storage);
|
||||
vi.spyOn(client, "send").mockRejectedValue(error);
|
||||
const errorSpy = vi.spyOn(Logger, "error");
|
||||
const infoSpy = vi.spyOn(Logger, "info");
|
||||
|
||||
@@ -40,7 +41,8 @@ describe("S3Storage", () => {
|
||||
$metadata: { httpStatusCode: 500 },
|
||||
});
|
||||
const storage = new S3Storage();
|
||||
vi.spyOn(Reflect.get(storage, "client"), "send").mockRejectedValue(error);
|
||||
const { client } = await Reflect.get(storage, "getS3").call(storage);
|
||||
vi.spyOn(client, "send").mockRejectedValue(error);
|
||||
const errorSpy = vi.spyOn(Logger, "error");
|
||||
|
||||
const stream = await storage.getFileStream("some/key");
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import path from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { ObjectCannedACL } from "@aws-sdk/client-s3";
|
||||
import {
|
||||
S3Client,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
import type * as AwsS3 from "@aws-sdk/client-s3";
|
||||
import type { ObjectCannedACL, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { PresignedPostOptions } from "@aws-sdk/s3-presigned-post";
|
||||
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { getSignedUrl as getCloudFrontSignedUrl } from "@aws-sdk/cloudfront-signer";
|
||||
import fs from "fs-extra";
|
||||
import invariant from "invariant";
|
||||
import { compact } from "es-toolkit/compat";
|
||||
@@ -24,23 +13,11 @@ import Logger from "@server/logging/Logger";
|
||||
import BaseStorage from "./BaseStorage";
|
||||
import type { AppContext } from "@server/types";
|
||||
|
||||
// The AWS SDK packages are imported dynamically inside methods rather than at
|
||||
// module top-level so they are only loaded into memory once S3 storage is
|
||||
// used — this module itself is imported regardless of the configured storage
|
||||
// backend. Only the packages' type imports are free of runtime cost.
|
||||
export default class S3Storage extends BaseStorage {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Loaded here rather than at module top-level so the native CRT binding
|
||||
// only loads when S3 storage is actually used, keeping it off startup.
|
||||
// https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt
|
||||
require("@aws-sdk/signature-v4-crt");
|
||||
|
||||
this.client = new S3Client({
|
||||
bucketEndpoint: env.AWS_S3_ACCELERATE_URL ? true : false,
|
||||
forcePathStyle: env.AWS_S3_FORCE_PATH_STYLE,
|
||||
region: env.AWS_REGION,
|
||||
endpoint: this.getEndpoint(),
|
||||
});
|
||||
}
|
||||
|
||||
public async getPresignedPost(
|
||||
_ctx: AppContext,
|
||||
key: string,
|
||||
@@ -66,7 +43,9 @@ export default class S3Storage extends BaseStorage {
|
||||
Expires: 3600,
|
||||
};
|
||||
|
||||
return createPresignedPost(this.client, params);
|
||||
const { createPresignedPost } = await import("@aws-sdk/s3-presigned-post");
|
||||
const { client } = await this.getS3();
|
||||
return createPresignedPost(client, params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +67,8 @@ export default class S3Storage extends BaseStorage {
|
||||
const contentDisposition = this.getContentDisposition(contentType);
|
||||
const cacheControl = "max-age=31557600";
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
const { sdk, client } = await this.getS3();
|
||||
const command = new sdk.PutObjectCommand({
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
@@ -98,7 +78,8 @@ export default class S3Storage extends BaseStorage {
|
||||
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
|
||||
});
|
||||
|
||||
let url = await getSignedUrl(this.client, command, {
|
||||
const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");
|
||||
let url = await getSignedUrl(client, command, {
|
||||
expiresIn: 3600,
|
||||
});
|
||||
|
||||
@@ -174,8 +155,10 @@ export default class S3Storage extends BaseStorage {
|
||||
key: string;
|
||||
acl?: string;
|
||||
}) => {
|
||||
const { Upload } = await import("@aws-sdk/lib-storage");
|
||||
const { client } = await this.getS3();
|
||||
const upload = new Upload({
|
||||
client: this.client,
|
||||
client,
|
||||
params: {
|
||||
...(env.AWS_S3_ACL && { ACL: env.AWS_S3_ACL as ObjectCannedACL }),
|
||||
Bucket: this.getBucket(),
|
||||
@@ -194,8 +177,9 @@ export default class S3Storage extends BaseStorage {
|
||||
};
|
||||
|
||||
public async deleteFile(key: string) {
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
const { sdk, client } = await this.getS3();
|
||||
await client.send(
|
||||
new sdk.DeleteObjectCommand({
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
@@ -219,6 +203,9 @@ export default class S3Storage extends BaseStorage {
|
||||
const cfUrl = this.getCloudFrontUrlForKey(key);
|
||||
|
||||
try {
|
||||
const { getSignedUrl: getCloudFrontSignedUrl } = await import(
|
||||
"@aws-sdk/cloudfront-signer"
|
||||
);
|
||||
return getCloudFrontSignedUrl({
|
||||
url: cfUrl,
|
||||
keyPairId: env.AWS_CLOUDFRONT_KEY_PAIR_ID,
|
||||
@@ -273,10 +260,11 @@ export default class S3Storage extends BaseStorage {
|
||||
});
|
||||
}
|
||||
|
||||
public getFileExists(key: string): Promise<boolean> {
|
||||
return this.client
|
||||
public async getFileExists(key: string): Promise<boolean> {
|
||||
const { sdk, client } = await this.getS3();
|
||||
return client
|
||||
.send(
|
||||
new HeadObjectCommand({
|
||||
new sdk.HeadObjectCommand({
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
})
|
||||
@@ -286,28 +274,30 @@ export default class S3Storage extends BaseStorage {
|
||||
}
|
||||
|
||||
public moveFile = async (fromKey: string, toKey: string) => {
|
||||
await this.client.send(
|
||||
new CopyObjectCommand({
|
||||
const { sdk, client } = await this.getS3();
|
||||
await client.send(
|
||||
new sdk.CopyObjectCommand({
|
||||
Bucket: this.getBucket(),
|
||||
CopySource: `${env.AWS_S3_UPLOAD_BUCKET_NAME}/${fromKey}`,
|
||||
Key: toKey,
|
||||
})
|
||||
);
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
await client.send(
|
||||
new sdk.DeleteObjectCommand({
|
||||
Bucket: this.getBucket(),
|
||||
Key: fromKey,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
public getFileStream(
|
||||
public async getFileStream(
|
||||
key: string,
|
||||
range?: { start: number; end: number }
|
||||
): Promise<NodeJS.ReadableStream | null> {
|
||||
return this.client
|
||||
const { sdk, client } = await this.getS3();
|
||||
return client
|
||||
.send(
|
||||
new GetObjectCommand({
|
||||
new sdk.GetObjectCommand({
|
||||
Bucket: this.getBucket(),
|
||||
Key: key,
|
||||
Range: range ? `bytes=${range.start}-${range.end}` : undefined,
|
||||
@@ -364,7 +354,31 @@ export default class S3Storage extends BaseStorage {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private client: S3Client;
|
||||
private s3Promise?: Promise<{ sdk: typeof AwsS3; client: S3Client }>;
|
||||
|
||||
/**
|
||||
* Returns the S3 SDK module and client, loading both on first use. Loading
|
||||
* is deferred so the AWS SDK and its native CRT binding are not loaded at
|
||||
* startup.
|
||||
*/
|
||||
private getS3(): Promise<{ sdk: typeof AwsS3; client: S3Client }> {
|
||||
this.s3Promise ??= (async () => {
|
||||
// Must be loaded before the client is constructed so SigV4a request
|
||||
// signing is available.
|
||||
// https://github.com/aws/aws-sdk-js-v3#functionality-requiring-aws-common-runtime-crt
|
||||
await import("@aws-sdk/signature-v4-crt");
|
||||
|
||||
const sdk = await import("@aws-sdk/client-s3");
|
||||
const client = new sdk.S3Client({
|
||||
bucketEndpoint: env.AWS_S3_ACCELERATE_URL ? true : false,
|
||||
forcePathStyle: env.AWS_S3_FORCE_PATH_STYLE,
|
||||
region: env.AWS_REGION,
|
||||
endpoint: this.getEndpoint(),
|
||||
});
|
||||
return { sdk, client };
|
||||
})();
|
||||
return this.s3Promise;
|
||||
}
|
||||
|
||||
private getCloudFrontUrlForKey(key: string): string {
|
||||
if (!env.AWS_CLOUDFRONT_URL) {
|
||||
@@ -404,8 +418,10 @@ export default class S3Storage extends BaseStorage {
|
||||
// Ensure expiration does not exceed AWS S3 Signature V4 limit of 7 days
|
||||
const clampedExpiresIn = Math.min(expiresIn, S3Storage.maxSignedUrlExpires);
|
||||
|
||||
const command = new GetObjectCommand(params);
|
||||
const url = await getSignedUrl(this.client, command, {
|
||||
const { sdk, client } = await this.getS3();
|
||||
const { getSignedUrl } = await import("@aws-sdk/s3-request-presigner");
|
||||
const command = new sdk.GetObjectCommand(params);
|
||||
const url = await getSignedUrl(client, command, {
|
||||
expiresIn: clampedExpiresIn,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import env from "@server/env";
|
||||
import type BaseStorage from "./BaseStorage";
|
||||
import LocalStorage from "./LocalStorage";
|
||||
import S3Storage from "./S3Storage";
|
||||
|
||||
const storage =
|
||||
// Only the configured backend is instantiated. S3Storage requires the AWS SDK
|
||||
// lazily, so the SDK and its native CRT binding are never loaded into memory
|
||||
// when local file storage is in use.
|
||||
const storage: BaseStorage =
|
||||
env.FILE_STORAGE === "local" ? new LocalStorage() : new S3Storage();
|
||||
|
||||
export default storage;
|
||||
|
||||
@@ -13,13 +13,18 @@ afterAll(() => server.close());
|
||||
// This needs to be done before any modules that use EventEmitter are loaded
|
||||
EventEmitter.defaultMaxListeners = 100;
|
||||
|
||||
// Mock AWS SDK S3 client and related commands
|
||||
// Mock AWS SDK S3 client and related commands. The client must be a real
|
||||
// class as it is instantiated with `new` via a dynamic import, which does not
|
||||
// apply the spy-constructor interop that static imports receive.
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
S3Client: vi.fn(() => ({
|
||||
send: vi.fn(),
|
||||
})),
|
||||
S3Client: class MockS3Client {
|
||||
send = vi.fn();
|
||||
},
|
||||
DeleteObjectCommand: vi.fn(),
|
||||
GetObjectCommand: vi.fn(),
|
||||
HeadObjectCommand: vi.fn(),
|
||||
CopyObjectCommand: vi.fn(),
|
||||
PutObjectCommand: vi.fn(),
|
||||
ObjectCannedACL: {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import cluster from "node:cluster";
|
||||
import os from "node:os";
|
||||
import { styleText } from "node:util";
|
||||
import { isEmpty } from "es-toolkit/compat";
|
||||
import { toError, errToString } from "@shared/utils/error";
|
||||
@@ -10,6 +12,65 @@ import { getArg } from "./args";
|
||||
import { MutexLock } from "./MutexLock";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
/**
|
||||
* Applies a default V8 heap limit to forked service processes when the process
|
||||
* is running under a memory constraint (e.g. a container limit) and no limit
|
||||
* was configured explicitly. Without this, V8 sizes its heap from the host's
|
||||
* total memory, so several forked processes can together commit far more
|
||||
* memory than the container allows and are eventually OOM-killed.
|
||||
*
|
||||
* The limit can be overridden by setting `--max-old-space-size` in
|
||||
* `NODE_OPTIONS` or on the command line.
|
||||
*
|
||||
* @param processCount the number of service processes that will be forked.
|
||||
*/
|
||||
export function configureChildHeapLimit(processCount: number) {
|
||||
if (cluster.isWorker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeOptions = process.env.NODE_OPTIONS ?? "";
|
||||
const isConfigured =
|
||||
nodeOptions.includes("--max-old-space-size") ||
|
||||
process.execArgv.some((arg) => arg.startsWith("--max-old-space-size"));
|
||||
if (isConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
// constrainedMemory() reports 0/undefined when unknown, and an effectively
|
||||
// unlimited sentinel (2^64) when no cgroup limit is set — only apply a
|
||||
// default when a real constraint below the host's total memory exists.
|
||||
const constrainedMemory = process.constrainedMemory();
|
||||
if (!constrainedMemory || constrainedMemory >= os.totalmem()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Budget ~80% of the constraint for V8 heaps across all forked processes,
|
||||
// leaving headroom for the master process and non-heap memory such as
|
||||
// compiled code, buffers, and native allocations.
|
||||
const totalMB = constrainedMemory / 1024 / 1024;
|
||||
const budgetMB = Math.floor((totalMB * 0.8) / Math.max(1, processCount));
|
||||
|
||||
// Below 256MB per process the server cannot operate reliably, so the floor
|
||||
// is applied even though the combined heaps may then exceed the budget.
|
||||
const heapMB = Math.max(256, budgetMB);
|
||||
if (heapMB > budgetMB) {
|
||||
Logger.warn(
|
||||
`The available memory of ${Math.round(totalMB)}MB is low for ${processCount} service process(es); reduce process count or increase available memory`
|
||||
);
|
||||
}
|
||||
|
||||
// Forked processes parse NODE_OPTIONS on startup, so this applies to every
|
||||
// service process without affecting the already-running master.
|
||||
process.env.NODE_OPTIONS =
|
||||
`${nodeOptions} --max-old-space-size=${heapMB}`.trim();
|
||||
|
||||
Logger.info(
|
||||
"lifecycle",
|
||||
`Memory constraint of ${Math.round(totalMB)}MB detected, defaulting each service process to a ${heapMB}MB heap limit`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for pending database migrations on startup and runs them, unless the
|
||||
* --no-migrate flag was passed in which case the process exits with an error.
|
||||
|
||||
+2
-6
@@ -1,6 +1,6 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { z } from "zod";
|
||||
import { IconLibrary } from "@shared/utils/IconLibrary";
|
||||
import { iconNames } from "@shared/utils/IconNames";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
|
||||
/**
|
||||
@@ -35,11 +35,7 @@ export const zodIdType = () =>
|
||||
* @returns a zod schema for icons.
|
||||
*/
|
||||
export const zodIconType = () =>
|
||||
z.union([
|
||||
z.string().regex(emojiRegex()),
|
||||
zodEnumFromObjectKeys(IconLibrary.mapping),
|
||||
z.uuid(),
|
||||
]);
|
||||
z.union([z.string().regex(emojiRegex()), z.enum(iconNames), z.uuid()]);
|
||||
|
||||
/**
|
||||
* Returns a zod schema that validates an emoji value, either an emoji
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { t } from "i18next";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
@@ -226,7 +225,13 @@ const insertFiles = async function (
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
Sentry.captureException(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);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { isHexColor } from "class-validator";
|
||||
import { parseToRgb, rgba } from "polished";
|
||||
import type { MarkSpec, MarkType } from "prosemirror-model";
|
||||
import { toggleMark } from "../commands/toggleMark";
|
||||
import { markInputRuleForPattern } from "../lib/markInputRule";
|
||||
import markRule from "../rules/mark";
|
||||
import Mark from "./Mark";
|
||||
import { presetColors, hexToRgba } from "@shared/utils/color";
|
||||
import { presetColors, hexToRgba, validateColorHex } from "@shared/utils/color";
|
||||
|
||||
export default class Highlight extends Mark {
|
||||
/** The default opacity of the highlight */
|
||||
@@ -72,7 +71,7 @@ export default class Highlight extends Mark {
|
||||
const color = dom.getAttribute("data-color") || "";
|
||||
|
||||
return {
|
||||
color: isHexColor(color) ? color : null,
|
||||
color: validateColorHex(color) ? color : null,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IconLibrary } from "./IconLibrary";
|
||||
import { iconNames } from "./IconNames";
|
||||
|
||||
describe("IconNames", () => {
|
||||
it("stays in sync with IconLibrary.mapping", () => {
|
||||
expect(Object.keys(IconLibrary.mapping)).toEqual([...iconNames]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* The names of all icons available to end users in the app, in the same order
|
||||
* as `IconLibrary.mapping`. Kept as a standalone module so that code which
|
||||
* only needs to validate icon names (e.g. server-side schemas) can avoid
|
||||
* importing the icon components and their heavy dependency tree.
|
||||
*
|
||||
* A unit test in `IconLibrary.test.ts` ensures this list stays in sync with
|
||||
* the mapping.
|
||||
*/
|
||||
export const iconNames = [
|
||||
"academicCap",
|
||||
"bicycle",
|
||||
"beaker",
|
||||
"buildingBlocks",
|
||||
"bookmark",
|
||||
"browser",
|
||||
"collection",
|
||||
"coins",
|
||||
"camera",
|
||||
"carrot",
|
||||
"clock",
|
||||
"cloud",
|
||||
"code",
|
||||
"database",
|
||||
"done",
|
||||
"email",
|
||||
"eye",
|
||||
"feedback",
|
||||
"flame",
|
||||
"graph",
|
||||
"globe",
|
||||
"hashtag",
|
||||
"info",
|
||||
"icecream",
|
||||
"image",
|
||||
"internet",
|
||||
"leaf",
|
||||
"library",
|
||||
"lightbulb",
|
||||
"lightning",
|
||||
"letter",
|
||||
"math",
|
||||
"moon",
|
||||
"notepad",
|
||||
"padlock",
|
||||
"palette",
|
||||
"pencil",
|
||||
"plane",
|
||||
"promote",
|
||||
"ramen",
|
||||
"question",
|
||||
"server",
|
||||
"sun",
|
||||
"shapes",
|
||||
"sport",
|
||||
"smiley",
|
||||
"target",
|
||||
"team",
|
||||
"terminal",
|
||||
"thumbsup",
|
||||
"truck",
|
||||
"tools",
|
||||
"vehicle",
|
||||
"warning",
|
||||
"bag-shopping",
|
||||
"book",
|
||||
"brush",
|
||||
"cake-candles",
|
||||
"cat",
|
||||
"clapperboard",
|
||||
"compact-disc",
|
||||
"cookie-bite",
|
||||
"crow",
|
||||
"crown",
|
||||
"cube",
|
||||
"dna",
|
||||
"dog",
|
||||
"dollar-sign",
|
||||
"display",
|
||||
"droplet",
|
||||
"face-dizzy",
|
||||
"face-grin-stars",
|
||||
"face-laugh",
|
||||
"face-meh",
|
||||
"face-smile-beam",
|
||||
"face-smile-wink",
|
||||
"face-surprise",
|
||||
"feather",
|
||||
"fish",
|
||||
"folder-closed",
|
||||
"flask-vial",
|
||||
"gamepad",
|
||||
"gauge",
|
||||
"gem",
|
||||
"gift",
|
||||
"hammer",
|
||||
"hands-clapping",
|
||||
"heart",
|
||||
"industry",
|
||||
"kit-medical",
|
||||
"laptop",
|
||||
"laptop-code",
|
||||
"magnet",
|
||||
"map",
|
||||
"microchip",
|
||||
"mountain-sun",
|
||||
"mug-hot",
|
||||
"network-wired",
|
||||
"newspaper",
|
||||
"paint-roller",
|
||||
"passport",
|
||||
"paw",
|
||||
"pen-ruler",
|
||||
"peso-sign",
|
||||
"phone-volume",
|
||||
"pizza-slice",
|
||||
"prescription",
|
||||
"puzzle-piece",
|
||||
"rainbow",
|
||||
"record-vinyl",
|
||||
"road",
|
||||
"robot",
|
||||
"rocket",
|
||||
"sailboat",
|
||||
"scissors",
|
||||
"seedling",
|
||||
"shield",
|
||||
"shirt",
|
||||
"shop",
|
||||
"snowflake",
|
||||
"socks",
|
||||
"solar-panel",
|
||||
"spa",
|
||||
"star-and-crescent",
|
||||
"star-of-life",
|
||||
"sterling-sign",
|
||||
"swatchbook",
|
||||
"tent",
|
||||
"tooth",
|
||||
"tower-cell",
|
||||
"tractor",
|
||||
"train",
|
||||
"tree",
|
||||
"trophy",
|
||||
"umbrella",
|
||||
"umbrella-beach",
|
||||
"universal-access",
|
||||
"user-graduate",
|
||||
"utensils",
|
||||
"vault",
|
||||
"wand-sparkles",
|
||||
"web-awesome",
|
||||
"wheelchair-move",
|
||||
"worm",
|
||||
"yen-sign",
|
||||
"apple",
|
||||
"windows",
|
||||
"android",
|
||||
"square-js",
|
||||
"python",
|
||||
"x-twitter",
|
||||
"bluesky",
|
||||
"github",
|
||||
"gitlab",
|
||||
"discord",
|
||||
"docker",
|
||||
"codepen",
|
||||
"dropbox",
|
||||
"paypal",
|
||||
"shopify",
|
||||
"swift",
|
||||
"slack",
|
||||
"circle",
|
||||
"square",
|
||||
"pentagon",
|
||||
"hexagon",
|
||||
"diamond",
|
||||
"spiral",
|
||||
] as const;
|
||||
|
||||
/** The name of an icon from the icon library. */
|
||||
export type IconName = (typeof iconNames)[number];
|
||||
+27
-26
@@ -16,32 +16,33 @@ import {
|
||||
isValid,
|
||||
parse,
|
||||
} from "date-fns";
|
||||
import {
|
||||
ca,
|
||||
cs,
|
||||
de,
|
||||
enGB,
|
||||
enUS,
|
||||
es,
|
||||
faIR,
|
||||
fr,
|
||||
he,
|
||||
hu,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
nb,
|
||||
nl,
|
||||
ptBR,
|
||||
pt,
|
||||
pl,
|
||||
sv,
|
||||
tr,
|
||||
vi,
|
||||
uk,
|
||||
zhCN,
|
||||
zhTW,
|
||||
} from "date-fns/locale";
|
||||
// Locales are imported from their individual modules rather than the
|
||||
// "date-fns/locale" index, which would load every locale date-fns ships
|
||||
// (~90) into memory rather than only those supported by the app.
|
||||
import { ca } from "date-fns/locale/ca";
|
||||
import { cs } from "date-fns/locale/cs";
|
||||
import { de } from "date-fns/locale/de";
|
||||
import { enGB } from "date-fns/locale/en-GB";
|
||||
import { enUS } from "date-fns/locale/en-US";
|
||||
import { es } from "date-fns/locale/es";
|
||||
import { faIR } from "date-fns/locale/fa-IR";
|
||||
import { fr } from "date-fns/locale/fr";
|
||||
import { he } from "date-fns/locale/he";
|
||||
import { hu } from "date-fns/locale/hu";
|
||||
import { it } from "date-fns/locale/it";
|
||||
import { ja } from "date-fns/locale/ja";
|
||||
import { ko } from "date-fns/locale/ko";
|
||||
import { nb } from "date-fns/locale/nb";
|
||||
import { nl } from "date-fns/locale/nl";
|
||||
import { ptBR } from "date-fns/locale/pt-BR";
|
||||
import { pt } from "date-fns/locale/pt";
|
||||
import { pl } from "date-fns/locale/pl";
|
||||
import { sv } from "date-fns/locale/sv";
|
||||
import { tr } from "date-fns/locale/tr";
|
||||
import { vi } from "date-fns/locale/vi";
|
||||
import { uk } from "date-fns/locale/uk";
|
||||
import { zhCN } from "date-fns/locale/zh-CN";
|
||||
import { zhTW } from "date-fns/locale/zh-TW";
|
||||
import type { DateFilter } from "../types";
|
||||
import { isBrowser } from "./browser";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isUUID } from "validator";
|
||||
import { IconType } from "../types";
|
||||
import { IconLibrary } from "./IconLibrary";
|
||||
import { iconNames } from "./IconNames";
|
||||
|
||||
const outlineIconNames = new Set(Object.keys(IconLibrary.mapping));
|
||||
const outlineIconNames = new Set<string>(iconNames);
|
||||
|
||||
export const determineIconType = (
|
||||
icon?: string | null
|
||||
|
||||
Reference in New Issue
Block a user