mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 16:21:40 +00:00
1242 lines
50 KiB
TypeScript
1242 lines
50 KiB
TypeScript
/**
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import compression from 'compression';
|
|
import cookieParser from 'cookie-parser';
|
|
import express from 'express';
|
|
import type { Application, RequestHandler } from 'express';
|
|
import helmet from 'helmet';
|
|
import uaParser from 'ua-parser-js';
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import { pathToFileURL } from 'node:url';
|
|
import http from 'node:http';
|
|
import { puterClients } from './clients';
|
|
import { puterControllers } from './controllers';
|
|
import { createAuthProbe } from './core/http/middleware/authProbe';
|
|
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
|
|
import { createErrorHandler } from './core/http/middleware/errorHandler';
|
|
import { isHttpError } from './core/http/HttpError';
|
|
import {
|
|
adminOnlyGate,
|
|
allowedAppIdsGate,
|
|
requireAuthGate,
|
|
requireEmailConfirmedGate,
|
|
requireNonAccessTokenGate,
|
|
requireUserActorGate,
|
|
requireVerifiedGate,
|
|
subdomainGate,
|
|
} from './core/http/middleware/gates';
|
|
import { createNotFoundHandler } from './core/http/middleware/notFoundHandler';
|
|
import {
|
|
requireAntiCsrf,
|
|
setAntiCsrfRedis,
|
|
} from './core/http/middleware/antiCsrf';
|
|
import { captchaGate, setCaptchaRedis } from './core/http/middleware/captcha';
|
|
import {
|
|
concurrencyGate,
|
|
configureRateLimit,
|
|
rateLimitGate,
|
|
} from './core/http/middleware/rateLimit';
|
|
import {
|
|
createWwwRedirect,
|
|
createUserSubdomainRedirect,
|
|
createNativeAppStatic,
|
|
} from './core/http/middleware/hostRedirects';
|
|
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
|
|
import { PuterRouter } from './core/http/PuterRouter';
|
|
import { PREFIX_METADATA_KEY, type RouteDescriptor } from './core/http/types';
|
|
import type { AuthService } from './services/auth/AuthService';
|
|
import { puterDrivers } from './drivers';
|
|
import {
|
|
clientsContainers,
|
|
configContainer,
|
|
controllersContainers,
|
|
driversContainers,
|
|
servicesContainers,
|
|
storesContainers,
|
|
} from './exports';
|
|
import { extensionStore } from './extensions';
|
|
import { puterServices } from './services';
|
|
import { puterStores } from './stores';
|
|
import type {
|
|
IConfig,
|
|
LayerInstances,
|
|
WithControllerRegistration,
|
|
WithLifecycle,
|
|
} from './types';
|
|
|
|
export class PuterServer {
|
|
clients!: LayerInstances<typeof puterClients>;
|
|
stores!: LayerInstances<typeof puterStores>;
|
|
services!: LayerInstances<typeof puterServices>;
|
|
controllers!: LayerInstances<typeof puterControllers>;
|
|
drivers!: LayerInstances<typeof puterDrivers>;
|
|
#config: IConfig;
|
|
#app!: ReturnType<typeof express>;
|
|
#server: ReturnType<ReturnType<typeof express>['listen']> | null = null;
|
|
|
|
#ready: Promise<boolean>;
|
|
|
|
constructor(
|
|
config: IConfig,
|
|
clients: typeof puterClients = puterClients,
|
|
stores: typeof puterStores = puterStores,
|
|
services: typeof puterServices = puterServices,
|
|
controllers: typeof puterControllers = puterControllers,
|
|
drivers: typeof puterDrivers = puterDrivers,
|
|
) {
|
|
this.#config = config;
|
|
// Expose config to the extension API (extension.config)
|
|
Object.assign(configContainer, config);
|
|
this.#ready = this.#setupServer(
|
|
clients,
|
|
stores,
|
|
services,
|
|
controllers,
|
|
drivers,
|
|
);
|
|
}
|
|
|
|
async #setupServer(
|
|
clients: typeof puterClients,
|
|
stores: typeof puterStores,
|
|
services: typeof puterServices,
|
|
controllers: typeof puterControllers,
|
|
drivers: typeof puterDrivers,
|
|
) {
|
|
// Load prod extensions from configured directories (dynamic)
|
|
const extensionDirs = this.#config.extensions;
|
|
await this.#importExtensions(extensionDirs);
|
|
|
|
this.clients = {} as typeof this.clients;
|
|
for (const [clientName, ClientClass] of Object.entries(clients)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.clients[clientName] =
|
|
typeof ClientClass === 'object'
|
|
? ClientClass
|
|
: (new (ClientClass as any)(this.#config) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
clientsContainers[clientName] = this.clients[clientName];
|
|
}
|
|
for (const [clientName, ClientClass] of Object.entries(
|
|
extensionStore.clients,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.clients[clientName] =
|
|
typeof ClientClass === 'object'
|
|
? ClientClass
|
|
: (new (ClientClass as any)(this.#config) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
clientsContainers[clientName] = this.clients[clientName];
|
|
}
|
|
|
|
this.stores = {} as typeof this.stores;
|
|
for (const [storeName, StoreClass] of Object.entries(stores)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.stores[storeName] =
|
|
typeof StoreClass === 'object'
|
|
? StoreClass
|
|
: (new (StoreClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
storesContainers[storeName] = this.stores[storeName];
|
|
}
|
|
for (const [storeName, StoreClass] of Object.entries(
|
|
extensionStore.stores,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.stores[storeName] =
|
|
typeof StoreClass === 'object'
|
|
? StoreClass
|
|
: (new (StoreClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
storesContainers[storeName] = this.stores[storeName];
|
|
}
|
|
|
|
this.services = {} as typeof this.services;
|
|
for (const [serviceName, ServiceClass] of Object.entries(services)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.services[serviceName] =
|
|
typeof ServiceClass === 'object'
|
|
? ServiceClass
|
|
: (new (ServiceClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
servicesContainers[serviceName] = this.services[serviceName];
|
|
}
|
|
for (const [serviceName, ServiceClass] of Object.entries(
|
|
extensionStore.services,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.services[serviceName] =
|
|
typeof ServiceClass === 'object'
|
|
? ServiceClass
|
|
: (new (ServiceClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
) as any);
|
|
// @ts-expect-error implicit any casting to avoid overly complex or circular types
|
|
servicesContainers[serviceName] = this.services[serviceName];
|
|
}
|
|
|
|
// Wire the rate-limiter to its configured backend now that clients
|
|
// and stores exist. Memory is the default; `redis` needs a redis
|
|
// client, `kv` needs the system KV store (DynamoDB-backed).
|
|
this.#configureRateLimiter();
|
|
|
|
// Anti-CSRF tokens live in redis so they survive cross-node hops
|
|
// (issue on node A, consume on node B).
|
|
setAntiCsrfRedis(this.clients.redis);
|
|
setCaptchaRedis(this.clients.redis);
|
|
|
|
// init express server here
|
|
this.#app = express();
|
|
// `trust proxy` MUST be set before any middleware reads `req.ip` /
|
|
// `req.ips` / `req.protocol`, since express derives those from XFF
|
|
// only when this flag is set. Default is `false` (no proxy trusted)
|
|
// — deployments behind a reverse proxy chain must set
|
|
// `config.trust_proxy` to the hop count (e.g. `1` for a single
|
|
// Cloudflare/nginx hop). Never `true` in prod: that trusts every hop
|
|
// and makes XFF forgeable.
|
|
this.#app.set('trust proxy', this.#config.trust_proxy ?? false);
|
|
this.#installGlobalMiddleware();
|
|
|
|
// Instantiate drivers BEFORE controllers so controllers can receive
|
|
// a typed `drivers` reference. The `/drivers/*` HTTP surface lives
|
|
// on `DriverController` (a regular controller) which reads from
|
|
// `this.drivers` — no separate registry object here any more.
|
|
this.drivers = {} as typeof this.drivers;
|
|
const allDriverSources = [
|
|
...Object.entries(drivers),
|
|
...Object.entries(extensionStore.drivers),
|
|
];
|
|
for (const [driverKey, DriverClass] of allDriverSources) {
|
|
const instance =
|
|
typeof DriverClass === 'object'
|
|
? DriverClass
|
|
: (new (DriverClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
) as any);
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.drivers[driverKey] = instance;
|
|
driversContainers[driverKey] = instance;
|
|
}
|
|
|
|
this.controllers = {} as typeof this.controllers;
|
|
for (const [controllerName, ControllerClass] of Object.entries(
|
|
controllers,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName] =
|
|
typeof ControllerClass === 'object'
|
|
? ControllerClass
|
|
: (new (ControllerClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
this.drivers,
|
|
) as any);
|
|
this.#registerControllerRoutes(
|
|
controllerName,
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName],
|
|
);
|
|
controllersContainers[controllerName] =
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName];
|
|
}
|
|
for (const [controllerName, ControllerClass] of Object.entries(
|
|
extensionStore.controllers,
|
|
)) {
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName] =
|
|
typeof ControllerClass === 'object'
|
|
? ControllerClass
|
|
: (new (ControllerClass as any)(
|
|
this.#config,
|
|
this.clients,
|
|
this.stores,
|
|
this.services,
|
|
this.drivers,
|
|
) as any);
|
|
this.#registerControllerRoutes(
|
|
controllerName,
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName],
|
|
);
|
|
controllersContainers[controllerName] =
|
|
// @ts-expect-error as any casting to avoid overly complex or circular types
|
|
this.controllers[controllerName];
|
|
}
|
|
|
|
// Extension routes are shaped as `RouteDescriptor`s too, so they
|
|
// flow through the same materializer as controller routes — same
|
|
// option → middleware translation (subdomain, auth, body parsers, …).
|
|
// The extension-layer "prefix" is always empty; extensions compose
|
|
// their own path strings.
|
|
for (const route of extensionStore.routeHandlers) {
|
|
this.#materializeRoute(this.#app, '', route);
|
|
}
|
|
|
|
// Terminal middleware MUST install last — after every route + extension
|
|
// route is registered, so the catch-all 404 only fires for genuinely
|
|
// unmatched requests, and the error handler is reachable from any
|
|
// thrown error in the stack above it.
|
|
this.#installTerminalMiddleware();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Register every rate-limit backend whose dependency is available, so
|
|
* routes / drivers can mix and match per call. `config.rate_limit.backend`
|
|
* selects the *default* applied when a caller doesn't specify a
|
|
* backend; it's no longer an exclusive choice. A typo or missing
|
|
* dependency for the chosen default falls back to memory with a
|
|
* warning so boot doesn't break.
|
|
*/
|
|
#configureRateLimiter() {
|
|
// Default to `redis` — the redis client is always present (falls
|
|
// back to ioredis-mock in dev when no nodes are configured), and
|
|
// sorted-set rate limiting scales across nodes for free. Set
|
|
// `rate_limit.backend` in config to switch to `memory` or `kv`.
|
|
const defaultBackend = this.#config.rate_limit?.backend ?? 'redis';
|
|
// Metering is wired so the concurrency gate can resolve
|
|
// `bySubscription` overrides per actor. Optional — without it,
|
|
// the base `limit` applies uniformly.
|
|
const metering = this.services?.metering as unknown;
|
|
try {
|
|
configureRateLimit({
|
|
default: defaultBackend,
|
|
redis: this.clients.redis,
|
|
kv: this.stores.kv,
|
|
metering,
|
|
});
|
|
} catch (e) {
|
|
console.warn(
|
|
`[rate-limit] default backend '${defaultBackend}' unavailable, falling back to memory:`,
|
|
(e as Error).message,
|
|
);
|
|
configureRateLimit({
|
|
redis: this.clients.redis,
|
|
kv: this.stores.kv,
|
|
metering,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Install always-on middleware on the express app, in the order they
|
|
* must run at request time. Ordering note:
|
|
* - `express.json` must run before `authProbe` so `req.body.auth_token`
|
|
* is readable.
|
|
* - `authProbe` never rejects; it only populates `req.actor` if a valid
|
|
* token is present.
|
|
* - Per-route gate middleware (requireAuth, adminOnly, ...) lands in
|
|
* `#materializeRoute` as those options ship.
|
|
*/
|
|
#installGlobalMiddleware() {
|
|
this.#app.use(cookieParser());
|
|
|
|
this.#app.use(compression());
|
|
|
|
this.#app.use(helmet.noSniff());
|
|
this.#app.use(helmet.hsts());
|
|
this.#app.use(helmet.ieNoOpen());
|
|
this.#app.use(helmet.permittedCrossDomainPolicies());
|
|
this.#app.use(helmet.xssFilter());
|
|
this.#app.disable('x-powered-by');
|
|
|
|
// Cross-Origin-Resource-Policy: always allow cross-origin reads.
|
|
// The stricter COOP+COEP pair (for SharedArrayBuffer) is deferred
|
|
// until the hosting layer lands — it requires UA + context gating.
|
|
this.#app.use((_req, res, next) => {
|
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
next();
|
|
});
|
|
|
|
// -- Query param sanitization --------------------------------
|
|
// Strip non-primitive query values. Express 5's default simple
|
|
// parser mostly avoids these, but when `extended` qs is enabled
|
|
// (or a client tricks the parser) arrays/objects can sneak in.
|
|
this.#app.use((req, _res, next) => {
|
|
if (req.query) {
|
|
const allowed = ['string', 'number', 'boolean'];
|
|
for (const k of Object.keys(req.query)) {
|
|
const v = req.query[k];
|
|
if (v != null && !allowed.includes(typeof v)) {
|
|
delete req.query[k];
|
|
}
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
|
|
// -- UA parsing ----------------------------------------------
|
|
this.#app.use((req, _res, next) => {
|
|
const header = req.headers['user-agent'];
|
|
if (header) {
|
|
req.ua = uaParser(header);
|
|
}
|
|
next();
|
|
});
|
|
|
|
// -- Host header validation ----------------------------------
|
|
this.#installHostValidation();
|
|
|
|
// -- Host redirects (www → root, user subdomain → static hosting)
|
|
// Installed after host validation so we know the host is allowed,
|
|
// and before CORS/body-parsing so we short-circuit on redirects
|
|
// without burning work.
|
|
this.#app.use(createWwwRedirect(this.#config));
|
|
this.#app.use(createUserSubdomainRedirect(this.#config));
|
|
|
|
// -- Native app static serving (editor.*, docs.*, …) ---------
|
|
// No-op when `native_apps_root` is unset.
|
|
this.#app.use(createNativeAppStatic(this.#config));
|
|
|
|
// -- CORS headers --------------------------------------------
|
|
this.#installCors();
|
|
|
|
// -- IP validation -------------------------------------------
|
|
if (this.#config.enable_ip_validation) {
|
|
this.#installIpValidation();
|
|
}
|
|
|
|
// -- OPTIONS preflight ---------------------------------------
|
|
this.#app.options('/*splat', (_req, res) => {
|
|
res.sendStatus(200);
|
|
});
|
|
|
|
// -- Body parsing (JSON + text-as-json shim) -----------------
|
|
const captureRawBody: NonNullable<
|
|
Parameters<typeof express.json>[0]
|
|
>['verify'] = (req, _res, buf) => {
|
|
(req as { rawBody?: Buffer }).rawBody = Buffer.from(buf);
|
|
};
|
|
this.#app.use(express.json({ limit: '50mb', verify: captureRawBody }));
|
|
this.#app.use(
|
|
express.json({
|
|
limit: '50mb',
|
|
type: (req) =>
|
|
req.headers['content-type'] === 'text/plain;actually=json',
|
|
verify: captureRawBody,
|
|
}),
|
|
);
|
|
// Form-encoded bodies (e.g. `/down` from the GUI's iframe-triggered
|
|
// download form). Needs to run before the auth probe so
|
|
// `req.body.auth_token` is populated for urlencoded POSTs the same
|
|
// way it is for JSON POSTs. Small cap — this parser is only here to
|
|
// cover the auth_token / anti_csrf field shape, not file uploads.
|
|
this.#app.use(express.urlencoded({ extended: true, limit: '100kb' }));
|
|
|
|
// -- Auth probe ----------------------------------------------
|
|
const authService = this.services.auth as AuthService | undefined;
|
|
if (authService) {
|
|
this.#app.use(
|
|
createAuthProbe({
|
|
authService,
|
|
cookieName: this.#config.cookie_name,
|
|
kvStore: this.stores.kv,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// -- Per-request ALS context ---------------------------------
|
|
// Runs AFTER auth probe so `req.actor` is already populated when
|
|
// we snapshot it into the context.
|
|
this.#app.use(createRequestContextMiddleware());
|
|
|
|
// -- User-hosted sites (*.puter.site, *.puter.app) -----------
|
|
// Short-circuits hosting-domain hosts before any API/GUI
|
|
// controller route has a chance to match. Needs DI layers for
|
|
// subdomain lookup, private-app gate, and file streaming.
|
|
this.#app.use(
|
|
createPuterSiteMiddleware(this.#config, {
|
|
clients: this.clients,
|
|
stores: this.stores,
|
|
services: this.services,
|
|
}),
|
|
);
|
|
|
|
extensionStore.globalMiddlewares.forEach((mw) => {
|
|
this.#app.use(mw);
|
|
});
|
|
}
|
|
|
|
// -- Host header validation --------------------------------------
|
|
|
|
#installHostValidation() {
|
|
const config = this.#config;
|
|
|
|
// Hostname missing — malformed request from a broken client.
|
|
this.#app.use((req, res, next) => {
|
|
if (req.hostname === undefined) {
|
|
res.status(400).send(
|
|
'Please verify your browser is up-to-date.',
|
|
);
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Build the allowed-domain set from config.
|
|
this.#app.use((req, res, next) => {
|
|
if (config.allow_all_host_values) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (!config.allow_no_host_header && !req.headers.host) {
|
|
res.status(400).send('Missing Host header.');
|
|
return;
|
|
}
|
|
|
|
// /healthcheck is always reachable regardless of host.
|
|
if (req.path === '/healthcheck') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const hostName = (req.headers.host ?? '')
|
|
.split(':')[0]
|
|
.trim()
|
|
.toLowerCase();
|
|
const allowed = this.#getAllowedDomains();
|
|
|
|
if (
|
|
allowed.some((d) => PuterServer.#hostMatchesDomain(hostName, d))
|
|
) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (config.custom_domains_enabled) {
|
|
req.is_custom_domain = true;
|
|
next();
|
|
return;
|
|
}
|
|
|
|
res.status(400).send('Invalid Host header.');
|
|
});
|
|
}
|
|
|
|
#allowedDomainsCache: string[] | null = null;
|
|
|
|
#getAllowedDomains(): string[] {
|
|
if (this.#allowedDomainsCache) return this.#allowedDomainsCache;
|
|
const cfg = this.#config;
|
|
const raw = [
|
|
cfg.domain,
|
|
cfg.static_hosting_domain,
|
|
cfg.static_hosting_domain_alt,
|
|
cfg.private_app_hosting_domain,
|
|
cfg.private_app_hosting_domain_alt,
|
|
];
|
|
const staticDomain = PuterServer.#normalizeDomain(
|
|
cfg.static_hosting_domain,
|
|
);
|
|
if (staticDomain) raw.push(`at.${staticDomain}`);
|
|
if (cfg.allow_nipio_domains) raw.push('nip.io');
|
|
|
|
this.#allowedDomainsCache = raw
|
|
.map(PuterServer.#normalizeDomain)
|
|
.filter((d): d is string => d !== null);
|
|
return this.#allowedDomainsCache;
|
|
}
|
|
|
|
static #normalizeDomain(d: string | undefined | null): string | null {
|
|
if (!d || typeof d !== 'string') return null;
|
|
const trimmed = d.trim().toLowerCase();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
static #hostMatchesDomain(hostname: string, domain: string): boolean {
|
|
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
}
|
|
|
|
// -- CORS headers -------------------------------------------------
|
|
|
|
#installCors() {
|
|
const config = this.#config;
|
|
const allowedMethods =
|
|
'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK';
|
|
const allowedHeaders = [
|
|
'Origin',
|
|
'X-Requested-With',
|
|
'Content-Type',
|
|
'Accept',
|
|
'Authorization',
|
|
'Cache-Control',
|
|
'Pragma',
|
|
'sentry-trace',
|
|
'baggage',
|
|
'Depth',
|
|
'Destination',
|
|
'Overwrite',
|
|
'If',
|
|
'Lock-Token',
|
|
'DAV',
|
|
'stripe-signature',
|
|
].join(', ');
|
|
|
|
this.#app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
const subdomain = req.subdomains?.[req.subdomains.length - 1];
|
|
|
|
// Allow any origin. puter.js is meant to be consumed from
|
|
// arbitrary third-party sites, so reflect the caller's origin
|
|
// (or fall back to `*` for non-browser clients).
|
|
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
|
if (origin) res.vary('Origin');
|
|
|
|
// Sticky cookies require api to allow credentials, but only for the API subdomain, and be careful not to set any other credentials on it
|
|
if (subdomain === 'api' && origin) {
|
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
} else if (subdomain === 'dav') {
|
|
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
|
}
|
|
|
|
res.setHeader('Access-Control-Allow-Methods', allowedMethods);
|
|
res.setHeader('Access-Control-Allow-Headers', allowedHeaders);
|
|
|
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
|
|
// Disable iframes on the main domain
|
|
if (req.hostname === config.domain) {
|
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
}
|
|
|
|
next();
|
|
});
|
|
}
|
|
|
|
// -- IP validation -----------------------------------------------
|
|
|
|
#installIpValidation() {
|
|
this.#app.use(async (req, res, next) => {
|
|
// `req.ip` reflects `trust proxy`: it's the leftmost untrusted
|
|
// address from XFF when behind a configured proxy chain, and the
|
|
// direct socket peer otherwise. Reading XFF directly would let a
|
|
// client forge the value when traffic isn't behind the expected
|
|
// proxy.
|
|
const ip = req.ip;
|
|
const event = { allow: true, ip: ip! };
|
|
// emitAndWait so listeners that do async work (IP-reputation
|
|
// lookups, Redis checks) can complete before we read
|
|
// `event.allow` and decide the gate.
|
|
await this.clients.event.emitAndWait('ip.validate', event, {});
|
|
if (!event.allow) {
|
|
res.status(403).send('Forbidden');
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Install end-of-pipeline middleware. Order matters:
|
|
* 1. The 404 catch-all runs only when no earlier route matched, so it
|
|
* must be installed *after* every controller + extension route.
|
|
* 2. The error handler is the express terminal — it catches everything
|
|
* thrown by routes, gates, and the 404 above. Express 5 auto-forwards
|
|
* thrown errors (sync and async), so handlers can `throw new HttpError(...)`
|
|
* without `next(err)` ceremony.
|
|
*/
|
|
#installTerminalMiddleware() {
|
|
this.#app.use(createNotFoundHandler());
|
|
this.#app.use(
|
|
createErrorHandler({
|
|
onError: (err, req) => {
|
|
// Page on 5xx only — skip 4xx HttpErrors, which are
|
|
// expected client-caused failures. Non-HttpError values
|
|
// are treated as unexpected 500s. De-dupe alarms by
|
|
// route + error signature so a hot loop of the same
|
|
// crash lands as a single alarm with N occurrences
|
|
// instead of N pages.
|
|
//
|
|
// FORCED_ALERT_CODES override the 5xx-only rule: a
|
|
// status < 500 still pages if its legacyCode is in
|
|
// the set. Use this for things we want to know about
|
|
// even though we expose them as 4xx to users (e.g.
|
|
// sustained upstream provider rate limits).
|
|
//
|
|
// SKIP_ALERT_PREFIXES override the 5xx rule the other
|
|
// direction: an error tagged as caused by an upstream
|
|
// provider or a misbehaving client gets exposed to
|
|
// the user but does not page.
|
|
const FORCED_ALERT_CODES = new Set([
|
|
'upstream_rate_limited',
|
|
'upstream_auth_failed',
|
|
]);
|
|
const SKIP_ALERT_PREFIXES = /^(upstream_|client_)/;
|
|
const isHttp = isHttpError(err);
|
|
const status = isHttp ? err.statusCode : 500;
|
|
const legacyCode = isHttp ? (err.legacyCode ?? '') : '';
|
|
const forced = FORCED_ALERT_CODES.has(legacyCode);
|
|
if (!forced) {
|
|
if (status < 500) return;
|
|
if (SKIP_ALERT_PREFIXES.test(legacyCode)) return;
|
|
}
|
|
const signature = isHttp
|
|
? err.legacyCode || err.code || err.message
|
|
: err instanceof Error
|
|
? err.message
|
|
: String(err);
|
|
const routePath =
|
|
(req as unknown as { route?: { path?: string } }).route
|
|
?.path ?? req.path;
|
|
const alarmId = `http_${status}:${req.method}:${routePath}:${signature}`;
|
|
this.clients.alarm.create(
|
|
alarmId,
|
|
`HTTP ${status} on ${req.method} ${req.originalUrl}: ${signature}`,
|
|
{
|
|
error: err instanceof Error ? err : undefined,
|
|
status,
|
|
method: req.method,
|
|
path: req.originalUrl,
|
|
body: req.body,
|
|
route: routePath,
|
|
actor: req.actor,
|
|
},
|
|
);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Walk a controller's declared routes (via `PuterRouter`) and register
|
|
* each one against the underlying express app. Per-route option → middleware
|
|
* translation lives here — when we add auth/subdomain/body-parsing
|
|
* options, they get wired in at this single point without touching any
|
|
* controller call site.
|
|
*/
|
|
#registerControllerRoutes(
|
|
controllerName: string,
|
|
controller: WithControllerRegistration,
|
|
) {
|
|
if (!controller.registerRoutes) {
|
|
throw new Error(
|
|
`Controller ${controllerName} does not have registerRoutes method`,
|
|
);
|
|
}
|
|
|
|
// Controllers annotated with `@Controller('/prefix')` carry the prefix
|
|
// on their prototype; bare (imperative) controllers default to ''.
|
|
const prefix = (controller as unknown as Record<string, unknown>)[
|
|
PREFIX_METADATA_KEY
|
|
] as string | undefined;
|
|
const router = new PuterRouter(prefix ?? '');
|
|
controller.registerRoutes(router);
|
|
|
|
for (const route of router.routes) {
|
|
this.#materializeRoute(this.#app, router.prefix, route);
|
|
}
|
|
}
|
|
|
|
#materializeRoute(
|
|
app: Application,
|
|
routerPrefix: string,
|
|
route: RouteDescriptor,
|
|
) {
|
|
const mwChain: RequestHandler[] = [];
|
|
const opts = route.options;
|
|
|
|
// 1. Subdomain routing. Routes that specify `subdomain` only match
|
|
// that subdomain(s). Routes WITHOUT a `subdomain` option (and that
|
|
// aren't `use` middleware) are restricted to the root origin — this
|
|
// prevents API-subdomain requests from accidentally hitting a root-
|
|
// only route. Explicit `subdomain: '*'` disables the gate entirely.
|
|
//
|
|
// For `use` routes, `next('route')` in a middleware doesn't skip the
|
|
// handler (that's only reliable inside `app.METHOD`/`router.METHOD`
|
|
// chains). We handle subdomain gating by wrapping the handler for
|
|
// `use` routes further down — don't push `subdomainGate` here.
|
|
const isUse = route.method === 'use';
|
|
if (opts.subdomain !== undefined) {
|
|
if (opts.subdomain !== '*' && !isUse) {
|
|
mwChain.push(subdomainGate(opts.subdomain));
|
|
}
|
|
// subdomain: '*' → no gate, match any subdomain
|
|
} else if (!isUse) {
|
|
// No subdomain specified + not a `use()` middleware → root only.
|
|
// Root = no subdomain present (req.subdomains is empty).
|
|
mwChain.push((req, _res, next) => {
|
|
if (req.subdomains && req.subdomains.length > 0) {
|
|
next('route');
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
// 2. Auth gates. Implication graph:
|
|
// adminOnly => requireAuth
|
|
// allowedAppIds => requireAuth
|
|
// requireUserActor => requireAuth
|
|
// Dedupe: only push requireAuthGate once when *any* of these are set.
|
|
const needsAuth = Boolean(
|
|
opts.requireAuth ||
|
|
opts.requireUserActor ||
|
|
opts.adminOnly ||
|
|
opts.allowedAppIds ||
|
|
opts.requireVerified,
|
|
);
|
|
if (needsAuth) {
|
|
mwChain.push(requireAuthGate());
|
|
}
|
|
|
|
// Default-on email confirmation gate. Every authenticated route
|
|
// rejects users pending confirmation unless `allowUnconfirmed`
|
|
// opts out. This prevents unconfirmed accounts from accessing
|
|
// AI, FS, driver, etc. endpoints while still allowing essential
|
|
// flows (logout, confirm-email, whoami, save-account, …).
|
|
if (needsAuth && !opts.allowUnconfirmed) {
|
|
mwChain.push(requireEmailConfirmedGate());
|
|
}
|
|
|
|
// block access tokens by default
|
|
if (needsAuth && !opts.allowAccessToken) {
|
|
mwChain.push(requireNonAccessTokenGate());
|
|
}
|
|
|
|
// `requireVerified` intentionally does NOT imply `requireUserActor`:
|
|
// FS routes (and similar) want the user's email to be confirmed even
|
|
// when an app acts on the user's behalf. `requireVerifiedGate` reads
|
|
// `req.actor?.user?.email_confirmed`, which app-under-user actors
|
|
// carry, so it works for either actor shape.
|
|
//
|
|
// `adminOnly` also does NOT imply `requireUserActor`: admin endpoints
|
|
// should be callable from scripts/automation using an admin's access
|
|
// token, not only from browser sessions. `adminOnlyGate` gates on
|
|
// `actor.user.username`, which is populated for access-token and
|
|
// app-under-user actors alike.
|
|
if (opts.requireUserActor) {
|
|
mwChain.push(requireUserActorGate());
|
|
}
|
|
|
|
if (opts.adminOnly) {
|
|
const extras = Array.isArray(opts.adminOnly) ? opts.adminOnly : [];
|
|
mwChain.push(adminOnlyGate(extras));
|
|
}
|
|
|
|
if (opts.allowedAppIds) {
|
|
mwChain.push(allowedAppIdsGate(opts.allowedAppIds));
|
|
}
|
|
|
|
// 2a. Email verification. Keyed off `strict_email_verification_required`
|
|
// so self-hosted boxes without SMTP don't break every fs route.
|
|
if (opts.requireVerified) {
|
|
mwChain.push(
|
|
requireVerifiedGate(
|
|
Boolean(this.#config.strict_email_verification_required),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2b. Rate limiting. Runs after auth so 'user' key strategy
|
|
// has access to req.actor.
|
|
if (opts.rateLimit) {
|
|
mwChain.push(
|
|
rateLimitGate(opts.rateLimit) as unknown as RequestHandler,
|
|
);
|
|
}
|
|
|
|
// 2b'. Concurrent in-flight limiting. Same auth-ordering reason
|
|
// (user key + bySubscription resolution needs req.actor); installed
|
|
// after rateLimitGate so a rate-rejection short-circuits before
|
|
// we acquire a concurrency slot. Slot is released on res finish/close.
|
|
if (opts.concurrent) {
|
|
mwChain.push(
|
|
concurrencyGate(opts.concurrent) as unknown as RequestHandler,
|
|
);
|
|
}
|
|
|
|
// 2c. Captcha verification. Reads captchaToken + captchaAnswer
|
|
// from req.body — body is already parsed by the global JSON
|
|
// middleware at this point.
|
|
if (opts.captcha) {
|
|
const enabled = Boolean(this.#config.captcha?.enabled);
|
|
mwChain.push(captchaGate(enabled) as unknown as RequestHandler);
|
|
}
|
|
|
|
// 2d. Anti-CSRF token consumption.
|
|
if (opts.antiCsrf) {
|
|
mwChain.push(requireAntiCsrf() as unknown as RequestHandler);
|
|
}
|
|
|
|
// 3. Per-route body parsers. Each is a no-op when the request's
|
|
// content-type doesn't match — multiple can coexist. The global
|
|
// `application/json` parser already ran in `#installGlobalMiddleware`,
|
|
// so by default the only reason to opt into one of these is to handle
|
|
// a non-JSON body shape (raw bytes, plain text, urlencoded form) or
|
|
// to override JSON limits on a hot path.
|
|
// bodyJson is `false | { limit?, type? }`. Truthiness check excludes
|
|
// both `undefined` (no opt) and `false` (explicit opt-out).
|
|
if (opts.bodyJson) {
|
|
mwChain.push(
|
|
express.json({
|
|
limit: opts.bodyJson.limit,
|
|
type: opts.bodyJson.type,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.bodyRaw) {
|
|
const raw = opts.bodyRaw === true ? {} : opts.bodyRaw;
|
|
mwChain.push(
|
|
express.raw({
|
|
limit: raw.limit,
|
|
type: raw.type,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.bodyText) {
|
|
const text = opts.bodyText === true ? {} : opts.bodyText;
|
|
mwChain.push(
|
|
express.text({
|
|
limit: text.limit,
|
|
type: text.type,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (opts.bodyUrlencoded) {
|
|
const ue = opts.bodyUrlencoded === true ? {} : opts.bodyUrlencoded;
|
|
mwChain.push(
|
|
express.urlencoded({
|
|
limit: ue.limit,
|
|
extended: ue.extended ?? true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// 4. Caller-supplied middleware runs after gates + parsers, before the handler.
|
|
if (opts.middleware) mwChain.push(...opts.middleware);
|
|
|
|
const fullPath =
|
|
route.path !== undefined
|
|
? PuterServer.#joinPath(routerPrefix, route.path)
|
|
: undefined;
|
|
|
|
if (route.method === 'use') {
|
|
// Subdomain check for `use` middleware lives INSIDE the handler
|
|
// wrapper — `next('route')` from a stand-alone subdomainGate
|
|
// doesn't reliably skip a `use` handler in Express 5.
|
|
let handler = route.handler;
|
|
if (opts.subdomain !== undefined && opts.subdomain !== '*') {
|
|
const allowList = Array.isArray(opts.subdomain)
|
|
? opts.subdomain
|
|
: [opts.subdomain];
|
|
const original = handler;
|
|
handler = (req, res, next) => {
|
|
const active =
|
|
req.subdomains?.[req.subdomains.length - 1] ?? '';
|
|
if (!allowList.includes(active)) return next();
|
|
return original(req, res, next);
|
|
};
|
|
}
|
|
if (fullPath !== undefined) {
|
|
app.use(fullPath as any, ...mwChain.flat(), handler);
|
|
} else {
|
|
app.use(...mwChain.flat(), handler);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (fullPath === undefined) {
|
|
throw new Error(`Route method '${route.method}' requires a path`);
|
|
}
|
|
|
|
// All express + WebDAV verbs accept the same (path, ...handlers) shape.
|
|
// The `RouteMethod` union is the allowlist of method names we expose.
|
|
const method = app[route.method as keyof Application] as unknown;
|
|
if (typeof method !== 'function') {
|
|
throw new Error(
|
|
`Express app does not support method: ${route.method}`,
|
|
);
|
|
}
|
|
(method as (...args: unknown[]) => unknown).call(
|
|
app,
|
|
fullPath,
|
|
...mwChain,
|
|
route.handler,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Join a controller's prefix with a route path. RegExp / array paths are
|
|
* passed through unprefixed (consistent with express's behavior; decorator
|
|
* paths are assumed to be strings).
|
|
*/
|
|
static #joinPath(
|
|
prefix: string,
|
|
path: NonNullable<RouteDescriptor['path']>,
|
|
): string | RegExp | Array<string | RegExp> {
|
|
if (typeof path !== 'string') return path;
|
|
if (!prefix) return path;
|
|
return `${prefix}/${path}`.replace(/\/+/g, '/');
|
|
}
|
|
|
|
async #importExtensions(extensionDirs: string[]) {
|
|
for (const extDir of extensionDirs) {
|
|
// `withFileTypes: true` gives us `Dirent` objects so we can
|
|
// distinguish files from directories without extra stat calls
|
|
// (and without relying on a dot-in-name heuristic, which breaks
|
|
// for data-bearing sidecar dirs like `pages.assets/`).
|
|
for (const entry of readdirSync(extDir, { withFileTypes: true })) {
|
|
const entryPath = `${extDir}/${entry.name}`;
|
|
|
|
if (entry.isFile()) {
|
|
if (/\.(js|mjs|cjs)$/.test(entry.name)) {
|
|
console.log(`Importing extension file ${entryPath}`);
|
|
await import(pathToFileURL(entryPath).href);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isDirectory()) continue; // symlinks, etc. — skip
|
|
|
|
// Prefer package.json "main"; fall back to index.{js,mjs,cjs}.
|
|
// Dirs that match neither (e.g. data-only sidecars) are
|
|
// silently ignored rather than crashing the boot.
|
|
let mainPath: string | null = null;
|
|
const pkgPath = `${entryPath}/package.json`;
|
|
if (existsSync(pkgPath)) {
|
|
try {
|
|
const pkg = JSON.parse(
|
|
readFileSync(pkgPath, 'utf-8'),
|
|
) as { main?: string };
|
|
if (pkg.main) mainPath = `${entryPath}/${pkg.main}`;
|
|
} catch (e) {
|
|
console.warn(
|
|
`[extensions] invalid package.json at ${pkgPath}:`,
|
|
e,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
if (!mainPath) {
|
|
for (const cand of ['index.js', 'index.mjs', 'index.cjs']) {
|
|
if (existsSync(`${entryPath}/${cand}`)) {
|
|
mainPath = `${entryPath}/${cand}`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!mainPath) continue;
|
|
|
|
console.log(`Importing extension file ${mainPath}`);
|
|
await import(pathToFileURL(mainPath).href);
|
|
}
|
|
}
|
|
}
|
|
|
|
async start(noHttpServer = false) {
|
|
await this.#ready;
|
|
|
|
// Create the http server explicitly (instead of `app.listen()`) so we
|
|
// have the server reference BEFORE listen starts — anything that needs
|
|
// to hook into the raw server (socket.io upgrades, WebSockets, …) runs
|
|
// its `attachHttpServer(server)` here, pre-listen.
|
|
const httpServer = http.createServer(this.#app);
|
|
for (const service of Object.values(this.services) as Array<
|
|
WithLifecycle & {
|
|
attachHttpServer?: (s: http.Server) => void | Promise<void>;
|
|
}
|
|
>) {
|
|
if (typeof service.attachHttpServer === 'function') {
|
|
await service.attachHttpServer(httpServer);
|
|
}
|
|
}
|
|
|
|
if (!noHttpServer) {
|
|
this.#server = httpServer.listen(this.#config.port, async () => {
|
|
const cfg = this.#config;
|
|
const liveUrl =
|
|
cfg.origin ??
|
|
`${cfg.protocol ?? 'http'}://${cfg.domain ?? 'localhost'}:${this.#config.port}`;
|
|
console.log(
|
|
'\n************************************************************',
|
|
);
|
|
console.log(`* Puter is now live at: ${liveUrl}`);
|
|
console.log(
|
|
'************************************************************\n',
|
|
);
|
|
|
|
await this.#fireOnServerStart();
|
|
console.log('PuterServer has fully booted.');
|
|
// Auto-launch the browser on dev boot (matches v1 WebServerService).
|
|
// Opt out via `no_browser_launch: true` in config.
|
|
if (this.#config.env === 'dev' && !cfg.no_browser_launch) {
|
|
try {
|
|
const openModule = await import('open');
|
|
await openModule.default(liveUrl);
|
|
} catch (e) {
|
|
console.log(
|
|
'[server] could not auto-open browser:',
|
|
(e as Error).message,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
this.#server = {
|
|
close: (cb: (error?: Error) => void | undefined) => {
|
|
console.debug('PuterServer mock close called');
|
|
cb?.();
|
|
},
|
|
closeAllConnections: () => {
|
|
console.debug(
|
|
'PuterServer mock closeAllConnections called',
|
|
);
|
|
},
|
|
} as unknown as http.Server;
|
|
// Tests still need onServerStart to fire so stores can
|
|
// bootstrap (e.g. SystemKVStore creates its dynalite table).
|
|
await this.#fireOnServerStart();
|
|
}
|
|
}
|
|
|
|
async #fireOnServerStart() {
|
|
for (const client of Object.values(this.clients) as WithLifecycle[]) {
|
|
if (client.onServerStart) await client.onServerStart();
|
|
}
|
|
for (const store of Object.values(this.stores) as WithLifecycle[]) {
|
|
if (store.onServerStart) await store.onServerStart();
|
|
}
|
|
for (const service of Object.values(this.services) as WithLifecycle[]) {
|
|
if (service.onServerStart) await service.onServerStart();
|
|
}
|
|
for (const controller of Object.values(
|
|
this.controllers,
|
|
) as WithLifecycle[]) {
|
|
if (controller.onServerStart) await controller.onServerStart();
|
|
}
|
|
for (const driver of Object.values(this.drivers) as WithLifecycle[]) {
|
|
if (driver.onServerStart) await driver.onServerStart();
|
|
}
|
|
}
|
|
|
|
async prepareShutdown() {
|
|
if (this.#server) {
|
|
this.#server.close(async () => {
|
|
console.log(
|
|
'PuterServer has stopped accepting new connections',
|
|
);
|
|
for (const client of Object.values(
|
|
this.clients,
|
|
) as WithLifecycle[]) {
|
|
if (client.onServerPrepareShutdown) {
|
|
await client.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const store of Object.values(
|
|
this.stores,
|
|
) as WithLifecycle[]) {
|
|
if (store.onServerPrepareShutdown) {
|
|
await store.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const service of Object.values(
|
|
this.services,
|
|
) as WithLifecycle[]) {
|
|
if (service.onServerPrepareShutdown) {
|
|
await service.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const controller of Object.values(
|
|
this.controllers,
|
|
) as WithLifecycle[]) {
|
|
if (controller.onServerPrepareShutdown) {
|
|
await controller.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
for (const driver of Object.values(
|
|
this.drivers,
|
|
) as WithLifecycle[]) {
|
|
if (driver.onServerPrepareShutdown) {
|
|
await driver.onServerPrepareShutdown();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async shutdown() {
|
|
if (this.#server) {
|
|
console.log('PuterServer is shutting down');
|
|
this.#server.closeAllConnections();
|
|
for (const client of Object.values(
|
|
this.clients,
|
|
) as WithLifecycle[]) {
|
|
if (client.onServerShutdown) {
|
|
await client.onServerShutdown();
|
|
}
|
|
}
|
|
for (const store of Object.values(this.stores) as WithLifecycle[]) {
|
|
if (store.onServerShutdown) {
|
|
await store.onServerShutdown();
|
|
}
|
|
}
|
|
for (const service of Object.values(
|
|
this.services,
|
|
) as WithLifecycle[]) {
|
|
if (service.onServerShutdown) {
|
|
await service.onServerShutdown();
|
|
}
|
|
}
|
|
for (const controller of Object.values(
|
|
this.controllers,
|
|
) as WithLifecycle[]) {
|
|
if (controller.onServerShutdown) {
|
|
await controller.onServerShutdown();
|
|
}
|
|
}
|
|
for (const driver of Object.values(
|
|
this.drivers,
|
|
) as WithLifecycle[]) {
|
|
if (driver.onServerShutdown) {
|
|
await driver.onServerShutdown();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|