import { resolver } from "@rocicorp/resolver";
import { pid } from "node:process";
import { WebSocketServer } from "ws";
import { promiseVoid } from "../../../shared/src/resolved-promises.js";
import "../../../zero-protocol/src/error.js";
import { tokenConfigOptions, verifyToken } from "../auth/jwt.js";
import "../config/zero-config.js";
import { setActiveClientGroupsGetter, recordConnectionAttempted, recordConnectionSuccess } from "../server/anonymous-otel-start.js";
import { ServiceRunner } from "../services/runner.js";
import { DrainCoordinator } from "../services/view-syncer/drain-coordinator.js";
import { installWebSocketReceiver } from "../types/websocket-handoff.js";
import { sendError, Connection } from "./connection.js";
import { createNotifierFrom, subscribeTo } from "./replicator.js";
import { SyncerWsMessageHandler } from "./syncer-ws-message-handler.js";
function getWebSocketServerOptions(config) {
  const options = {
    noServer: true,
    maxPayload: config.websocketMaxPayloadBytes
  };
  if (config.websocketCompression) {
    options.perMessageDeflate = true;
    if (config.websocketCompressionOptions) {
      try {
        const compressionOptions = JSON.parse(
          config.websocketCompressionOptions
        );
        options.perMessageDeflate = compressionOptions;
      } catch (e) {
        throw new Error(
          `Failed to parse ZERO_WEBSOCKET_COMPRESSION_OPTIONS: ${String(e)}. Expected valid JSON.`
        );
      }
    }
  }
  return options;
}
class Syncer {
  id = `syncer-${pid}`;
  #lc;
  #viewSyncers;
  #mutagens;
  #pushers;
  #connections = /* @__PURE__ */ new Map();
  #drainCoordinator = new DrainCoordinator();
  #parent;
  #wss;
  #stopped = resolver();
  #config;
  constructor(lc, config, viewSyncerFactory, mutagenFactory, pusherFactory, parent) {
    this.#config = config;
    const notifier = createNotifierFrom(lc, parent);
    subscribeTo(lc, parent);
    this.#lc = lc;
    this.#viewSyncers = new ServiceRunner(
      lc,
      (id) => viewSyncerFactory(
        id,
        notifier.subscribe(),
        this.#drainCoordinator,
        this.#validateLegacyJWT()
      ),
      (v) => v.keepalive()
    );
    this.#mutagens = new ServiceRunner(lc, mutagenFactory, (m) => m.hasRefs());
    if (pusherFactory) {
      this.#pushers = new ServiceRunner(lc, pusherFactory, (p) => p.hasRefs());
    }
    this.#parent = parent;
    this.#wss = new WebSocketServer(getWebSocketServerOptions(config));
    installWebSocketReceiver(
      lc,
      this.#wss,
      this.#createConnection,
      this.#parent
    );
    setActiveClientGroupsGetter(() => this.#viewSyncers.size);
  }
  #createConnection = async (ws, params) => {
    this.#lc.debug?.(
      "creating connection",
      params.clientGroupID,
      params.clientID
    );
    recordConnectionAttempted();
    const { clientID, clientGroupID, auth, userID } = params;
    const hasProvidedAuth = auth !== void 0 && auth !== "";
    if (hasProvidedAuth) {
      const tokenOptions = tokenConfigOptions(this.#config.auth ?? {});
      const hasPushOrMutate = this.#config?.push?.url !== void 0 || this.#config?.mutate?.url !== void 0;
      const hasQueries = this.#config?.query?.url !== void 0 || this.#config?.getQueries?.url !== void 0;
      const hasExactlyOneTokenOption = tokenOptions.length === 1;
      const hasCustomEndpoints = hasPushOrMutate && hasQueries;
      if (!hasExactlyOneTokenOption && !hasCustomEndpoints) {
        throw new Error(
          "Exactly one of jwk, secret, or jwksUrl must be set in order to verify tokens but actually the following were set: " + JSON.stringify(tokenOptions) + ". You may also set both ZERO_MUTATE_URL and ZERO_QUERY_URL to enable custom mutations and queries without passing token verification options."
        );
      }
    }
    const viewSyncer = this.#viewSyncers.getService(clientGroupID);
    const authResult = await viewSyncer.initAuthSession(userID, auth);
    if (!authResult.ok) {
      sendError(this.#lc, ws, authResult.error);
      ws.close(3e3, authResult.error.message);
      return;
    }
    const existing = this.#connections.get(clientID);
    if (existing) {
      this.#lc.debug?.(
        `client ${clientID} already connected, closing existing connection`
      );
      existing.close(`replaced by ${params.wsID}`);
    }
    const mutagen = this.#mutagens.getService(clientGroupID);
    const pusher = this.#pushers?.getService(clientGroupID);
    mutagen.ref();
    pusher?.ref();
    let connection;
    try {
      connection = new Connection(
        this.#lc,
        params,
        ws,
        new SyncerWsMessageHandler(
          this.#lc,
          params,
          viewSyncer,
          mutagen,
          pusher
        ),
        () => {
          if (this.#connections.get(clientID) === connection) {
            this.#connections.delete(clientID);
          }
          mutagen.unref();
          pusher?.unref();
        }
      );
    } catch (e) {
      mutagen.unref();
      pusher?.unref();
      throw e;
    }
    this.#connections.set(clientID, connection);
    connection.init() && recordConnectionSuccess();
    if (params.initConnectionMsg) {
      this.#lc.debug?.(
        "handling init connection message from sec header",
        params.clientGroupID,
        params.clientID
      );
      await connection.handleInitConnection(
        JSON.stringify(params.initConnectionMsg)
      );
    }
  };
  run() {
    return this.#stopped.promise;
  }
  /**
   * Graceful shutdown involves shutting down view syncers one at a time, pausing
   * for the duration of view syncer's hydration between each one. This paces the
   * disconnects to avoid creating a backlog of hydrations in the receiving server
   * when the clients reconnect.
   */
  async drain() {
    const start = Date.now();
    this.#lc.info?.(`draining ${this.#viewSyncers.size} view-syncers`);
    this.#drainCoordinator.drainNextIn(0);
    while (this.#viewSyncers.size) {
      await this.#drainCoordinator.forceDrainTimeout;
      for (const vs of this.#viewSyncers.getServices()) {
        this.#lc.debug?.(`draining view-syncer ${vs.id} (forced)`);
        void vs.stop();
        break;
      }
    }
    this.#lc.info?.(`finished draining (${Date.now() - start} ms)`);
  }
  stop() {
    this.#wss.close();
    this.#stopped.resolve();
    return promiseVoid;
  }
  /** @deprecated used in JWT validation */
  #validateLegacyJWT() {
    const tokenOptions = tokenConfigOptions(this.#config.auth ?? {});
    if (tokenOptions.length !== 1) {
      return void 0;
    }
    return async (token, { userID }) => {
      const decoded = await verifyToken(this.#config.auth, token, {
        subject: userID,
        ...this.#config.auth?.issuer && { issuer: this.#config.auth.issuer },
        ...this.#config.auth?.audience && {
          audience: this.#config.auth.audience
        }
      });
      return {
        type: "jwt",
        raw: token,
        decoded
      };
    };
  }
}
export {
  Syncer
};
//# sourceMappingURL=syncer.js.map
