Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 | 131x 131x 147x 1008x 7x 139x 1084x 8x 140x 140x 1x 139x 139x 139x 139x 139x 1x 138x 1x 137x 137x 137x 1x 136x 136x 1x 135x 1x 134x 147x 147x 147x 147x 135x 4x 1x 3x 1x 2x 6359x 41x 2x 39x 39x 21x 18x 3275x 786x 131x 131x 131x 131x 139x 139x 6x 5x 1x 140x 140x 140x 140x 3x 137x 139x 139x 138x 393x 393x 1x 131x 131x 1x 1x 1x 393x 393x 131x 131x 131x 131x 131x 131x 131x 131x 131x 131x 133x 1x 131x 131x 135x 1x 134x 1x 131x 131x 131x 131x 131x 131x 131x 131x 159x 159x 159x 317x 159x 157x 156x 159x 155x 159x 154x 153x 159x 159x 2504x 159x 159x 159x 159x 159x 159x 159x 1x 148x 148x 1x 147x 140x 140x 2x 1x 142x 554x 142x 142x 142x 142x 1x 135x 1x 134x 131x 131x 131x 1x 17x | /**
* Central UnoSim Configuration
*
* Single source of truth for all server-side tunable parameters.
* Values are read from environment variables with sensible defaults.
* Import this module instead of reading process.env directly.
*
* One profile controls the complete runtime topology:
* • "local": server and simulations run on the development host
* • "docker": server runs in Docker and simulations use Docker sandboxes
*/
import { isIP } from "node:net";
import os from "node:os";
import path from "node:path";
import { parseTrustConfig } from "./security/access-control";
import { examplesRefSchema } from "@shared/examples";
import { TEST_RUN_ID_PATTERN } from "@shared/input-limits";
import { normalizeRepositoryInput } from "./services/examples/source-selection";
// ── Mode Types ──────────────────────────────────────────────────────
/** Where the UnoSim server itself runs */
export type ServerMode = "local" | "docker";
export interface RuntimeProfile {
nodeEnv: string;
serverMode: ServerMode;
dockerTestBypassGateway: boolean;
}
const removedRuntimeVariables = [
"UNOSIM_SIMULATION_MODE",
"UNOSIM_TRUST_MODE",
"FORCE_DOCKER",
"UNOSIM_ALLOW_INSECURE_PRODUCTION_LOCAL",
"SANDBOX_POOL_MIN_RUNNERS",
"SANDBOX_POOL_MAX_RUNNERS",
"DOCKER_COMPILE_CONCURRENT",
] as const;
const obsoleteTutorCurriculumVariables = [
"UNOSIM_TUTOR_CURRICULUM_SOURCE",
"UNOSIM_TUTOR_CURRICULUM_COMMIT",
"UNOSIM_TUTOR_CURRICULUM_ALLOWED_HOSTS",
"UNOSIM_TUTOR_CURRICULUM_REFRESH_MS",
"UNOSIM_TUTOR_CURRICULUM_TIMEOUT_MS",
"UNOSIM_TUTOR_CURRICULUM_MAX_MANIFEST_BYTES",
"UNOSIM_TUTOR_CURRICULUM_MAX_TOPIC_BYTES",
"UNOSIM_TUTOR_CURRICULUM_MAX_TOTAL_BYTES",
] as const;
function rejectRemovedRuntimeVariables(env: NodeJS.ProcessEnv): void {
for (const key of removedRuntimeVariables) {
if (env[key] !== undefined) {
throw new Error(`${key} is no longer supported`);
}
}
}
export function rejectObsoleteTutorCurriculumEnv(env: NodeJS.ProcessEnv): void {
for (const key of obsoleteTutorCurriculumVariables) {
if (env[key] !== undefined) {
throw new Error(`${key} is no longer supported; configure the unified Course Content source instead`);
}
}
}
function parseNodeEnvironment(env: NodeJS.ProcessEnv): string {
const nodeEnv = env.NODE_ENV ?? "development";
if (!(["development", "production", "test"] as const).includes(nodeEnv as "development" | "production" | "test")) {
throw new Error(
`Invalid NODE_ENV: expected development, production, or test, received "${nodeEnv}"`,
);
}
return nodeEnv;
}
function parseServerMode(env: NodeJS.ProcessEnv, nodeEnv: string): ServerMode {
const fallbackMode: ServerMode = nodeEnv === "production" ? "docker" : "local";
const rawMode = env.UNOSIM_SERVER_MODE ?? fallbackMode;
Iif (rawMode !== "local" && rawMode !== "docker") {
throw new Error(
`Invalid UNOSIM_SERVER_MODE: expected one of local, docker, received "${rawMode}"`,
);
}
if (nodeEnv === "production" && rawMode !== "docker") {
throw new Error("NODE_ENV=production requires UNOSIM_SERVER_MODE=docker");
}
if (nodeEnv === "development" && rawMode !== "local") {
throw new Error("NODE_ENV=development requires UNOSIM_SERVER_MODE=local");
}
return rawMode;
}
function parseDockerTestBypass(
env: NodeJS.ProcessEnv,
nodeEnv: string,
serverMode: ServerMode,
): boolean {
const rawBypass = env.UNOSIM_DOCKER_TEST_BYPASS_GATEWAY;
if (rawBypass !== undefined && rawBypass !== "1") {
throw new Error("UNOSIM_DOCKER_TEST_BYPASS_GATEWAY must be 1 when enabled");
}
const dockerTestBypassGateway = rawBypass === "1";
if (dockerTestBypassGateway && nodeEnv !== "test") {
throw new Error("UNOSIM_DOCKER_TEST_BYPASS_GATEWAY is allowed only with NODE_ENV=test");
}
if (dockerTestBypassGateway && serverMode !== "docker") {
throw new Error("UNOSIM_DOCKER_TEST_BYPASS_GATEWAY is allowed only in docker mode");
}
return dockerTestBypassGateway;
}
export function parseRuntimeProfile(env: NodeJS.ProcessEnv): RuntimeProfile {
rejectRemovedRuntimeVariables(env);
const nodeEnv = parseNodeEnvironment(env);
const serverMode = parseServerMode(env, nodeEnv);
return {
nodeEnv,
serverMode,
dockerTestBypassGateway: parseDockerTestBypass(env, nodeEnv, serverMode),
};
}
export function parseCapacityTestRunId(
value: string | undefined,
nodeEnv: string,
): string | undefined {
if (value === undefined) return undefined;
if (nodeEnv !== "test") {
throw new Error("CAPACITY_TEST_RUN_ID is allowed only with NODE_ENV=test");
}
if (!TEST_RUN_ID_PATTERN.test(value)) {
throw new Error("CAPACITY_TEST_RUN_ID must be a URL-safe test run identifier");
}
return value;
}
// ── Env-var helpers ─────────────────────────────────────────────────
export function parseEnvInt(key: string, value: string | undefined, fallback: number, options: { min?: number; max?: number } = {}): number {
if (value === undefined || value.trim() === "") return fallback;
if (!/^[+-]?\d+$/.test(value.trim())) {
throw new Error(`Invalid ${key}: expected an integer, received "${value}"`);
}
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || (options.min !== undefined && parsed < options.min) || (options.max !== undefined && parsed > options.max)) {
throw new Error(`Invalid ${key}: value must be between ${options.min ?? "-∞"} and ${options.max ?? "∞"}`);
}
return parsed;
}
function envInt(key: string, fallback: number, options?: { min?: number; max?: number }): number {
return parseEnvInt(key, process.env[key], fallback, options);
}
function envStr(key: string, fallback: string): string {
return process.env[key] ?? fallback;
}
function stripTrailingSlashes(value: string): string {
let result = value;
while (result.endsWith("/")) result = result.slice(0, -1);
return result;
}
const INSTANCE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
export function parseInstanceId(value: string | undefined): string | undefined {
const instanceId = value?.trim();
if (!instanceId) return undefined;
if (!INSTANCE_ID_PATTERN.test(instanceId)) {
throw new Error("UNOSIM_INSTANCE_ID must be 1-63 characters of letters, digits, '.', '_' or '-'");
}
return instanceId;
}
export function parseListenHost(
trustMode: "local" | "gateway",
configuredHost: string | undefined,
allowUnsafeExternalLocalBind = false,
): string {
const defaultHost = trustMode === "local" ? "127.0.0.1" : "0.0.0.0";
const host = configuredHost?.trim();
const listenHost = host || defaultHost;
if (trustMode === "local" && !isLoopbackHost(listenHost) && !allowUnsafeExternalLocalBind) {
throw new Error(
"External binds in local mode are unsafe; use UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND=true only when intentional",
);
}
return listenHost;
}
function isLoopbackHost(host: string): boolean {
const version = isIP(host);
if (version === 6) return host === "::1";
return version === 4 && Number(host.split(".", 1)[0]) === 127;
}
function envBool(key: string, fallback: boolean): boolean {
const v = process.env[key];
if (v === undefined) return fallback;
Eif (v === "1" || v.toLowerCase() === "true") return true;
if (v === "0" || v.toLowerCase() === "false") return false;
throw new Error(`Invalid ${key}: expected true/false, received "${v}"`);
}
function envFloat(key: string, fallback: string): string {
const value = process.env[key];
if (value === undefined || value.trim() === "") return fallback;
const parsed = Number(value);
Iif (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`Invalid ${key}: expected a positive number, received "${value}"`);
}
return value.trim();
}
function envList(key: string, fallback: string[]): string[] {
const v = process.env[key];
Eif (v === undefined) return fallback;
return v
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
// ── Central capacity values ─────────────────────────────────────────
rejectObsoleteTutorCurriculumEnv(process.env);
const runtimeProfile = parseRuntimeProfile(process.env);
const capacityTestRunId = parseCapacityTestRunId(
process.env.CAPACITY_TEST_RUN_ID,
runtimeProfile.nodeEnv,
);
/** Operator-facing capacity values. A runner is a logical execution object;
* only the sandbox-start semaphore controls Docker startup pressure. */
const simulationMaxConcurrent = envInt("SIMULATION_MAX_CONCURRENT", 5, { min: 1, max: 1000 });
const sandboxStartMaxConcurrent = envInt("SANDBOX_START_MAX_CONCURRENT", 8, { min: 1, max: 256 });
const simulationAdmissionMax = envInt("SIMULATION_ADMISSION_MAX", 25, { min: 1, max: 500 });
const simulationQueueTimeoutMs = envInt("SIMULATION_QUEUE_TIMEOUT_MS", 60_000, { min: 1_000, max: 900_000 });
const sandboxStartSlotTimeoutMs = envInt("SANDBOX_START_SLOT_TIMEOUT_MS", 30_000, { min: 1_000, max: 900_000 });
const sandboxMaxLifetimeSeconds = envInt("SANDBOX_MAX_LIFETIME_SECONDS", 7_200, { min: 900, max: 86_400 });
const simulationMaxPausedSeconds = envInt("SIMULATION_MAX_PAUSED_SECONDS", 600, { min: 10, max: 86_400 });
/**
* A paused run must end before the sandbox's own hard lifetime, which cannot
* fire while `docker pause` freezes the container.
*/
export function validatePausedBudget(maxPausedSeconds: number, maxLifetimeSeconds: number): void {
if (maxPausedSeconds >= maxLifetimeSeconds) {
throw new Error("SIMULATION_MAX_PAUSED_SECONDS must be lower than SANDBOX_MAX_LIFETIME_SECONDS");
}
}
validatePausedBudget(simulationMaxPausedSeconds, sandboxMaxLifetimeSeconds);
/** Logical warm-object floor. It never pre-creates Docker containers. */
const logicalWarmRunnerFloor = Math.min(5, simulationMaxConcurrent);
export function validateSimulationCapacity(
minRunners: number,
maxRunners: number,
serverMode: ServerMode,
): void {
if (minRunners > maxRunners) {
throw new Error("Invalid simulation capacity: warm runner floor (" + minRunners + ") must not exceed simulation max (" + maxRunners + ")");
}
if (serverMode === "docker" && maxRunners < 1) {
throw new Error("Docker mode requires SIMULATION_MAX_CONCURRENT to be at least 1");
}
}
validateSimulationCapacity(logicalWarmRunnerFloor, simulationMaxConcurrent, runtimeProfile.serverMode);
const cwd = process.cwd();
const cpuCount = os.cpus().length;
const defaultWorkers = Math.min(8, Math.max(2, Math.floor(cpuCount * 0.5)));
const defaultCompileMaxConcurrent = Math.max(1, cpuCount - 1);
const allowUnsafeExternalLocalBind = envBool("UNOSIM_UNSAFE_ALLOW_EXTERNAL_LOCAL_BIND", false);
const trust = parseTrustConfig(process.env, runtimeProfile, allowUnsafeExternalLocalBind);
const localWebSocketOrigins = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
];
export interface ParsedExamplesConfig {
mode: "builtin" | "repository-ref";
source: string;
ref: string;
repository: string | null;
refreshMs: number;
refreshRetryMs: number;
timeoutMs: number;
maxManifestBytes: number;
maxFileBytes: number;
maxTotalBytes: number;
maxFiles: number;
allowedHosts: string[];
validateRateLimitMaxRequests: number;
overrideRateLimitMaxRequests: number;
globalLoadStartsPerMinute: number;
maxConcurrentLoads: number;
maxLoadQueue: number;
maxFileFetchConcurrency: number;
maxOutboundFetches: number;
maxSources: number;
snapshotCacheMaxEntries: number;
snapshotCacheMaxBytes: number;
/** Optional token for api.github.com only; raises the REST quota from 60 to 5000 requests per hour. */
githubToken?: string;
}
function resolveExamplesSourceConfig(env: NodeJS.ProcessEnv, nodeEnv: string): Pick<ParsedExamplesConfig, "mode" | "source" | "ref" | "repository" | "allowedHosts"> {
const sourceInput = (env.UNOSIM_EXAMPLES_SOURCE ?? "ttbombadil/unosim-examples").trim();
const configuredRef = (env.UNOSIM_EXAMPLES_REF ?? "").trim();
const defaultAllowedHosts = nodeEnv === "production" ? "" : "api.github.com,raw.githubusercontent.com";
const allowedHosts = (env.UNOSIM_EXAMPLES_ALLOWED_HOSTS ?? defaultAllowedHosts).split(",").map((host) => host.trim().toLowerCase()).filter(Boolean);
if (env.UNOSIM_EXAMPLES_CHANNEL !== undefined) throw new Error("UNOSIM_EXAMPLES_CHANNEL is no longer supported; configure UNOSIM_EXAMPLES_REF instead");
if (!sourceInput && configuredRef) throw new Error("UNOSIM_EXAMPLES_SOURCE is required when UNOSIM_EXAMPLES_REF is configured");
const repository = sourceInput ? normalizeRepositoryInput(sourceInput, { allowRawGithub: true }) : null;
if (sourceInput && !repository) throw new Error("UNOSIM_EXAMPLES_SOURCE must be a public GitHub repository slug or repository URL");
const ref = repository ? configuredRef || "main" : "";
if (ref && !examplesRefSchema.safeParse(ref).success) throw new Error("UNOSIM_EXAMPLES_REF has an invalid ref syntax");
if (repository && nodeEnv === "production" && allowedHosts.length === 0) throw new Error("UNOSIM_EXAMPLES_ALLOWED_HOSTS is required for external examples in production");
return { mode: repository ? "repository-ref" : "builtin", source: repository ?? "", ref, repository, allowedHosts };
}
export function parseExamplesConfig(
env: NodeJS.ProcessEnv,
nodeEnv = env.NODE_ENV ?? "development",
): ParsedExamplesConfig {
const source = resolveExamplesSourceConfig(env, nodeEnv);
const int = (key: string, fallback: number, min: number, max: number) =>
parseEnvInt(key, env[key], fallback, { min, max });
const refreshMs = int("UNOSIM_EXAMPLES_REFRESH_MS", 300_000, 1_000, 86_400_000);
const maxTotalBytes = int("UNOSIM_EXAMPLES_MAX_TOTAL_BYTES", 1_048_576, 1, 100 * 1_048_576);
const maxConcurrentLoads = int("UNOSIM_EXAMPLES_MAX_CONCURRENT_LOADS", 4, 1, 16);
const maxFileFetchConcurrency = int("UNOSIM_EXAMPLES_MAX_FILE_FETCH_CONCURRENCY", 8, 1, 16);
const maxOutboundFetches = int("UNOSIM_EXAMPLES_MAX_OUTBOUND_FETCHES", 16, 1, 64);
const snapshotCacheMaxBytes = int("UNOSIM_EXAMPLES_SNAPSHOT_CACHE_MAX_BYTES", 64 * 1_048_576, 1_048_576, 512 * 1_048_576);
if (maxConcurrentLoads > maxOutboundFetches) {
throw new Error("UNOSIM_EXAMPLES_MAX_CONCURRENT_LOADS must not exceed UNOSIM_EXAMPLES_MAX_OUTBOUND_FETCHES");
}
Iif (maxFileFetchConcurrency > maxOutboundFetches) {
throw new Error("UNOSIM_EXAMPLES_MAX_FILE_FETCH_CONCURRENCY must not exceed UNOSIM_EXAMPLES_MAX_OUTBOUND_FETCHES");
}
if (snapshotCacheMaxBytes < maxTotalBytes) {
throw new Error("UNOSIM_EXAMPLES_SNAPSHOT_CACHE_MAX_BYTES must cover UNOSIM_EXAMPLES_MAX_TOTAL_BYTES");
}
return {
...source,
refreshMs,
refreshRetryMs: int("UNOSIM_EXAMPLES_REFRESH_RETRY_MS", 30_000, 1_000, refreshMs),
timeoutMs: int("UNOSIM_EXAMPLES_TIMEOUT_MS", 5_000, 100, 120_000),
maxManifestBytes: int("UNOSIM_EXAMPLES_MAX_MANIFEST_BYTES", 256 * 1024, 1, 10 * 1_048_576),
maxFileBytes: int("UNOSIM_EXAMPLES_MAX_FILE_BYTES", 128 * 1024, 1, 10 * 1_048_576),
maxTotalBytes,
maxFiles: int("UNOSIM_EXAMPLES_MAX_FILES", 100, 1, 10_000),
allowedHosts: source.allowedHosts,
validateRateLimitMaxRequests: int("UNOSIM_EXAMPLES_VALIDATE_RATE_LIMIT_MAX_REQUESTS", 5, 1, 30),
overrideRateLimitMaxRequests: int("UNOSIM_EXAMPLES_OVERRIDE_RATE_LIMIT_MAX_REQUESTS", 60, 10, 600),
globalLoadStartsPerMinute: int("UNOSIM_EXAMPLES_GLOBAL_LOAD_STARTS_PER_MINUTE", 20, 1, 120),
maxConcurrentLoads,
maxLoadQueue: int("UNOSIM_EXAMPLES_MAX_LOAD_QUEUE", 32, 0, 128),
maxFileFetchConcurrency,
maxOutboundFetches,
maxSources: int("UNOSIM_EXAMPLES_MAX_SOURCES", 32, 1, 256),
snapshotCacheMaxEntries: int("UNOSIM_EXAMPLES_SNAPSHOT_CACHE_MAX_ENTRIES", 64, 1, 512),
snapshotCacheMaxBytes,
...parseGitHubToken(env.UNOSIM_GITHUB_TOKEN),
};
}
function parseGitHubToken(value: string | undefined): { githubToken?: string } {
const token = value?.trim();
if (!token) return {};
// Rejects anything that could break out of the Authorization header.
if (!/^[A-Za-z0-9_.-]{1,255}$/.test(token)) throw new Error("UNOSIM_GITHUB_TOKEN has an invalid format");
return { githubToken: token };
}
export interface ParsedTutorSessionConfig {
ttlMs: number;
maxSessions: number;
maxSessionsPerSubject: number;
maxPinnedContentBytes: number;
}
/**
* Bounds for the in-memory Tutor Course Content sessions. A session pins the
* Tutor data of one immutable revision, so the pinned-content budget must hold
* at least one complete Course Content snapshot.
*/
export function parseTutorSessionConfig(env: NodeJS.ProcessEnv, examplesMaxTotalBytes: number): ParsedTutorSessionConfig {
const int = (key: string, fallback: number, min: number, max: number) =>
parseEnvInt(key, env[key], fallback, { min, max });
const maxSessions = int("TUTOR_SESSION_MAX_SESSIONS", 1_000, 10, 10_000);
const maxSessionsPerSubject = int("TUTOR_SESSION_MAX_PER_SUBJECT", 5, 1, 50);
const maxPinnedContentBytes = int("TUTOR_SESSION_MAX_PINNED_CONTENT_BYTES", 64 * 1_048_576, 1_048_576, 512 * 1_048_576);
if (maxSessionsPerSubject > maxSessions) {
throw new Error("TUTOR_SESSION_MAX_PER_SUBJECT must not exceed TUTOR_SESSION_MAX_SESSIONS");
}
if (maxPinnedContentBytes < examplesMaxTotalBytes) {
throw new Error("TUTOR_SESSION_MAX_PINNED_CONTENT_BYTES must cover UNOSIM_EXAMPLES_MAX_TOTAL_BYTES");
}
return {
ttlMs: int("TUTOR_SESSION_TTL_MS", 3_600_000, 60_000, 86_400_000),
maxSessions,
maxSessionsPerSubject,
maxPinnedContentBytes,
};
}
const examplesConfig = parseExamplesConfig(process.env);
const tutorSessionConfig = parseTutorSessionConfig(process.env, examplesConfig.maxTotalBytes);
// ── Config ──────────────────────────────────────────────────────────
export const config = {
/** Runtime environment name, captured once at startup. */
nodeEnv: runtimeProfile.nodeEnv,
/**
* Server mode: "local" (dev) or "docker" (docker-compose).
* Set via UNOSIM_SERVER_MODE env var; falls back to NODE_ENV detection.
*/
serverMode: runtimeProfile.serverMode,
/** Test-only gateway bypass for Docker integration tests. */
dockerTestBypassGateway: runtimeProfile.dockerTestBypassGateway,
/** Test-only identifier for capacity-run API and Docker resource attribution. */
capacityTestRunId,
/** True when running under a test framework */
isTest: process.env.NODE_ENV === "test",
/** HTTP and WebSocket authentication boundary. */
trust,
// ── Server ──────────────────────────────────────────────────────
server: {
/** HTTP and WebSocket listener port. */
port: envInt("PORT", 3000, { min: 1, max: 65535 }),
/** Local mode is loopback-only unless the unsafe opt-in is enabled. */
listenHost: parseListenHost(
trust.mode,
process.env.UNOSIM_LISTEN_HOST,
allowUnsafeExternalLocalBind,
),
/**
* Register destructive endpoints used for test isolation.
* NODE_ENV=test is checked separately at the registration site so this
* flag cannot expose them in production by itself.
*/
enableTestEndpoints: envBool("ENABLE_TEST_ENDPOINTS", false),
/** CSP frame-ancestors: origins allowed to embed UnoSim in an iframe */
allowedFrameAncestors: envList(
"SIMULATOR_ALLOWED_PARENT_ORIGINS",
envList("ALLOW_EMBED_ORIGINS", [
"'self'",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
]),
),
/** Exact browser origins allowed to open the simulation WebSocket. */
allowedWebSocketOrigins: envList(
"UNOSIM_ALLOWED_WS_ORIGINS",
trust.mode === "local" ? localWebSocketOrigins : [],
),
/** Completely bypass rate limiting (for E2E tests) */
disableRateLimit: envBool("DISABLE_RATE_LIMIT", false),
/** API route rate limit window */
/** Ping interval; a WebSocket that misses one pong is terminated and its simulation freed */
webSocketHeartbeatIntervalMs: envInt("WS_HEARTBEAT_INTERVAL_MS", 30_000, { min: 1_000, max: 600_000 }),
/** Sustained and burst inbound messages per WebSocket connection; excess messages are dropped */
webSocketInboundMessagesPerSecond: 500,
webSocketInboundMessageBurst: 1_000,
apiRateLimitWindowMs: 15 * 60 * 1000,
/** API route rate limit in normal operation */
apiRateLimitMax: 300,
/** API route rate limit used when tests disable production throttling */
apiRateLimitTestMax: 10_000,
/** Dedicated compile request rate-limit window */
compileRateLimitWindowMs: envInt("COMPILE_RATE_LIMIT_WINDOW_MS", 60_000, {
min: 1,
max: 86_400_000,
}),
/** Compile requests allowed per trusted identity and window */
compileRateLimitMaxRequests: envInt("COMPILE_RATE_LIMIT_MAX_REQUESTS", 10, {
min: 1,
max: 10_000,
}),
/** Block duration after a trusted identity exceeds the compile limit */
compileRateLimitBlockDurationMs: envInt(
"COMPILE_RATE_LIMIT_BLOCK_DURATION_MS",
10_000,
{ min: 1, max: 86_400_000 },
),
/** Simulation start rate limit window */
simulationRateLimitWindowMs: envInt(
"SIMULATION_START_RATE_LIMIT_WINDOW_MS",
2_000,
{ min: 1, max: 86_400_000 },
),
/** Simulation starts allowed per window */
simulationRateLimitMaxRequests: envInt(
"SIMULATION_START_RATE_LIMIT_MAX_REQUESTS",
1,
{ min: 1, max: 10_000 },
),
/** Simulation start block duration after exceeding the limit */
simulationRateLimitBlockDurationMs: envInt(
"SIMULATION_START_RATE_LIMIT_BLOCK_DURATION_MS",
5_000,
{ min: 1, max: 86_400_000 },
),
/** Running plus queued simulation demands admitted by this process */
simulationAdmissionMax,
/** Maximum time an admitted demand may wait for a logical simulation slot */
simulationQueueTimeoutMs,
/** Cleanup interval for inactive simulation rate-limit entries */
simulationRateLimitCleanupIntervalMs: 5 * 60 * 1000,
/** Inactive simulation rate-limit entries are removed after this duration */
simulationRateLimitInactiveTtlMs: 10 * 60 * 1000,
},
// ── Capacity ───────────────────────────────────────────────────
capacity: {
simulationMaxConcurrent,
sandboxStartMaxConcurrent,
admissionMax: simulationAdmissionMax,
queueTimeoutMs: simulationQueueTimeoutMs,
/** Maximum time an admitted simulation may wait for a sandbox-start slot */
sandboxStartSlotTimeoutMs,
},
// ── Sandbox Pool ────────────────────────────────────────────────
sandbox: {
pool: {
/** Logical runner objects kept ready; this does not create Docker containers. */
minRunners: logicalWarmRunnerFloor,
/** Maximum simultaneously active logical simulation sessions. */
maxRunners: simulationMaxConcurrent,
/** Idle logical runners are removed after this duration */
idleTimeoutMs: envInt("SANDBOX_POOL_IDLE_TIMEOUT_MS", 120_000, { min: 1, max: 86_400_000 }),
/** Queue policy for admitted demands waiting for simulation capacity */
acquireTimeoutMs: simulationQueueTimeoutMs,
/** Max time to wait while resetting a released runner */
resetTimeoutMs: 10_000,
/** Max queued acquire requests before rejecting immediately */
maxQueueSize: 500,
/** Runners stopped in parallel during shutdown (each is one docker rm) */
shutdownConcurrency: 8,
/** Shutdown stops waiting for one runner after this; a final sweep follows */
shutdownStopTimeoutMs: 6_000,
},
// ── Per-Container Resource Limits ───────────────────────────
resources: {
/**
* Docker --memory (and --memory-swap) limit in MB applied to every sandbox
* container. Two very different phases share this budget:
*
* • Compile phase g++/cc1plus needs 150–300 MB per invocation.
* Linux cgroup v2 (GitHub Actions / production) hard-kills the process
* the moment it exceeds the limit → must be ≥ 256 MB.
*
* • Runtime phase The pre-compiled AVR sketch typically uses < 30 MB.
* A tighter limit (e.g. 64 MB) would be safe here, but since compile
* and run happen in the same container, the compile-phase floor wins.
*
* Override with SANDBOX_MEMORY_MB. docker-compose.yml mirrors this value
* explicitly so all environments stay in sync.
*/
memoryMB: envInt("SANDBOX_MEMORY_MB", 256, { min: 64, max: 65_536 }),
/** Docker --cpus flag. 0.25 = 25% of one core. */
cpuLimit: envFloat("SANDBOX_CPU_LIMIT", "0.25"),
/** Max PIDs per container (prevents fork bombs) */
pidsLimit: 50,
/** Kill container after this many seconds */
maxExecutionTimeSec: 60,
/** Kill container if stdout/stderr exceeds this (bytes) */
maxOutputBytes: 100 * 1024 * 1024,
},
/** Docker image used for sandbox containers */
dockerImage: envStr("DOCKER_SANDBOX_IMAGE", "unosim-sandbox:latest"),
/** Docker daemon socket */
dockerHost: envStr("DOCKER_HOST", "unix:///var/run/docker.sock"),
/** Timeout for Docker CLI availability/control probes (not simulation runtime). */
dockerControlTimeoutMs: envInt("DOCKER_CONTROL_TIMEOUT_MS", 2_000, { min: 100, max: 30_000 }),
/**
* Stable identity of this deployment, kept across restarts and redeploys.
* Owns the sandbox containers for the orphan sweep; without it, ownership
* falls back to host and PID. Concurrently running backends need distinct IDs.
*/
instanceId: parseInstanceId(process.env.UNOSIM_INSTANCE_ID),
/**
* Absolute wall-clock lifetime of a sandbox container, enforced inside the
* container so it also ends when the backend dies; far above compile time
* plus the longest simulation timeout. `docker pause` freezes this clock's
* process, so a paused container ends on its pause budget instead.
*/
maxLifetimeSeconds: sandboxMaxLifetimeSeconds,
/**
* Total time one run may spend paused. The backend ends a run that exceeds
* it, which releases its runner, admission and container.
*/
maxPausedSeconds: simulationMaxPausedSeconds,
},
// ── Compilation ─────────────────────────────────────────────────
compilation: {
/** Number of parallel compilation worker threads */
workerCount: envInt("WORKER_COUNT", defaultWorkers, { min: 1, max: 256 }),
/** Max simultaneous source-code compile operations (gatekeeper) */
maxConcurrent: envInt(
"COMPILE_MAX_CONCURRENT",
defaultCompileMaxConcurrent,
{ min: 1, max: 256 },
),
/** arduino-cli timeout for one REST compile (ms) */
timeoutMs: 60_000,
/** Arduino Fully Qualified Board Name */
fqbn: envStr("ARDUINO_FQBN", "arduino:avr:uno"),
/** Arduino CLI core/library cache directory */
cacheDir: envStr(
"ARDUINO_CACHE_DIR",
path.join(cwd, "server/arduino-cache"),
),
/** Build artifact cache directory */
buildCacheDir: envStr("BUILD_CACHE_DIR", path.join(cwd, "storage/cache")),
/** LRU eviction trigger for build cache (bytes) */
buildCacheMaxBytes: envInt("BUILD_CACHE_MAX_BYTES", 2 * 1024 * 1024 * 1024, { min: 1, max: Number.MAX_SAFE_INTEGER }),
/** Max entries kept in the compile result cache */
resultCacheMaxEntries: 100,
/** Time-to-live for compile result cache entries */
resultCacheTtlMs: 5 * 60 * 1000,
/** Subjects whose last compiled code is kept for code-less simulation starts */
lastCompiledCodeMaxSubjects: 1000,
/** Max queued compile requests in the unified gatekeeper */
gatekeeperMaxQueueSize: 500,
},
// ── Examples ─────────────────────────────────────────────────────
examples: {
...examplesConfig,
},
// ── Tutor / LLM ────────────────────────────────────────────────
tutor: {
/** The Tutor always uses the user's personal KI:connect credential. */
provider: "kiconnect" as const,
/** Server-controlled OpenAI-compatible provider base URL. */
baseUrl: stripTrailingSlashes(envStr("UNOSIM_LLM_BASE_URL", "https://chat.kiconnect.nrw/api/v1")),
/** Provider request timeout; no provider call may outlive this window. */
timeoutMs: envInt("UNOSIM_LLM_TIMEOUT_MS", 30_000, { min: 1_000, max: 120_000 }),
/** Dedicated request-scoped tutor rate limit. */
rateLimitWindowMs: envInt("TUTOR_RATE_LIMIT_WINDOW_MS", 60_000, { min: 1_000, max: 86_400_000 }),
rateLimitMaxRequests: envInt("TUTOR_RATE_LIMIT_MAX_REQUESTS", 20, { min: 1, max: 100 }),
rateLimitBlockDurationMs: envInt("TUTOR_RATE_LIMIT_BLOCK_DURATION_MS", 30_000, { min: 1_000, max: 86_400_000 }),
/** Bounded, subject-owned Course Content sessions; see parseTutorSessionConfig. */
sessions: tutorSessionConfig,
},
// ── Scattered Timeouts (centralized) ────────────────────────────
timeouts: {
/** Max time to wait for normal source compilation capacity */
compileGatekeeperAcquireMs: 30_000,
/** Unified gatekeeper distributed-lock TTL */
gatekeeperLockTTLMs: 60_000,
/** Interval for the gatekeeper to scan for expired locks */
gatekeeperLockCheckIntervalMs: 5_000,
/** Default timeout for generic process execution */
processExecutionDefaultMs: 20_000,
/** Default registry collection wait-mode duration */
registryWaitModeDefaultMs: 1_500,
/** Registry wait-mode duration applied after sketch start */
registryWaitModeAfterStartMs: 5_000,
/** Default tick interval for stream batchers (pin/serial) */
batcherTickIntervalMs: 50,
},
// ── Client Polling (served via GET /api/config) ─────────────────
client: {
/** /api/health ping interval */
healthPollIntervalMs: 15_000,
/** /api/status fetch interval */
statusPollIntervalMs: 60_000,
/** Suppress error toasts during startup */
startupGraceMs: 5_000,
/** Abort health/status fetch after this */
fetchTimeoutMs: 2_000,
},
};
/** Subset of config safe to expose to the browser via GET /api/config */
export function getClientConfig() {
return {
...config.client,
serverMode: config.serverMode,
tutor: {
provider: config.tutor.provider,
},
};
}
export type UnoSimConfig = typeof config;
/** Read the compile limit for components that support runtime test overrides. */
export function getCompileMaxConcurrent(): number {
return parseEnvInt("COMPILE_MAX_CONCURRENT", process.env.COMPILE_MAX_CONCURRENT, config.compilation.maxConcurrent, { min: 1, max: 256 });
}
|