diff --git a/flake.nix b/flake.nix index e32d28bea5..388fa573ba 100644 --- a/flake.nix +++ b/flake.nix @@ -3,36 +3,10 @@ outputs = { self, nixpkgs }: { - nixosConfigurations.container = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = - [ - ( import ./sharkey-service.nix ) - ({ pkgs, ... }: { - boot.isContainer = true; + nixosModules.sharkey-service = args : import ./sharkey-service.nix ( args // { sharkey = self.packages."x86_64-linux".sharkey; pkgs = nixpkgs.legacyPackages."x86_64-linux"; } ); - # Let 'nixos-version --json' know about the Git revision - # of this flake. - system.configurationRevision = nixpkgs.lib.mkIf (self ? rev) self.rev; - - # Network configuration. - networking.useDHCP = false; - networking.firewall.allowedTCPPorts = [ 3000 ]; - - system.stateVersion = "24.04"; - - services.sharkey = { - enable = true; - package = (pkgs.callPackage ./sharkey.nix {}); - settings = { - url = "https://sharkey.localhost"; - }; - redis.createLocally = true; - database.createLocally = true; - }; - }) - ]; - }; + packages."x86_64-linux".sharkey = nixpkgs.legacyPackages."x86_64-linux".callPackage ./sharkey.nix { }; + packages."x86_64-linux".default = self.packages."x86_64-linux".sharkey; }; } diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index f102cb42e1..07b92a7342 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -161,9 +161,19 @@ export default class Connection { case 'disconnect': this.onChannelDisconnectRequested(body); break; case 'channel': this.onChannelMessageRequested(body); break; case 'ch': this.onChannelMessageRequested(body); break; // alias + case 'hb': this.onHeartbeat(body); break; } } + @bindThis + private onHeartbeat(data: JsonValue | undefined) { + if (!isJsonObject(data)) { + console.error('Received invalid heartbeat payload: ', data); + return; + } + this.sendMessageToWs('hb', data); + } + @bindThis private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) { this.sendMessageToWs(data.type, data.body); diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 395d7d9ad1..3dd1ca1e8b 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -3,29 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { createApp, defineAsyncComponent, markRaw } from 'vue'; -import { common } from './common.js'; +import {createApp, defineAsyncComponent, markRaw} from 'vue'; +import {common} from './common.js'; import type * as Misskey from 'misskey-js'; -import { ui } from '@@/js/config.js'; -import { i18n } from '@/i18n.js'; -import { alert, confirm, popup, post, toast } from '@/os.js'; -import { useStream } from '@/stream.js'; +import {ui} from '@@/js/config.js'; +import {i18n} from '@/i18n.js'; +import {alert, popup, post, toast} from '@/os.js'; +import {useStream} from '@/stream.js'; import * as sound from '@/scripts/sound.js'; -import { $i, signout, updateAccount } from '@/account.js'; -import { instance } from '@/instance.js'; -import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { reactionPicker } from '@/scripts/reaction-picker.js'; -import { miLocalStorage } from '@/local-storage.js'; -import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; -import { initializeSw } from '@/scripts/initialize-sw.js'; -import { deckStore } from '@/ui/deck/deck-store.js'; -import { emojiPicker } from '@/scripts/emoji-picker.js'; -import { mainRouter } from '@/router/main.js'; -import { setFavIconDot } from '@/scripts/favicon-dot.js'; -import { type Keymap, makeHotkey } from '@/scripts/hotkey.js'; -import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js'; +import {$i, signout, updateAccount} from '@/account.js'; +import {instance} from '@/instance.js'; +import {ColdDeviceStorage, defaultStore} from '@/store.js'; +import {reactionPicker} from '@/scripts/reaction-picker.js'; +import {miLocalStorage} from '@/local-storage.js'; +import {claimAchievement, claimedAchievements} from '@/scripts/achievements.js'; +import {initializeSw} from '@/scripts/initialize-sw.js'; +import {deckStore} from '@/ui/deck/deck-store.js'; +import {emojiPicker} from '@/scripts/emoji-picker.js'; +import {mainRouter} from '@/router/main.js'; +import {setFavIconDot} from '@/scripts/favicon-dot.js'; +import {type Keymap, makeHotkey} from '@/scripts/hotkey.js'; +import {addCustomEmoji, removeCustomEmojis, updateCustomEmojis} from '@/custom-emojis.js'; +import MkDialog from "@/components/MkDialog.vue"; export async function mainBoot() { + const { isClientUpdated } = await common(() => createApp( new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : @@ -45,20 +47,45 @@ export async function mainBoot() { const stream = useStream(); - let reloadDialogShowing = false; + // A reference to the function to close the "reconnecting" dialog; null if the dialog is not open. + let reloadDialogDisposeFn : Function | null = null; + + // When the stream is disconnected, show a dialog to reload the page. stream.on('_disconnected_', async () => { + // TODO: Where's that setting? if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { - if (reloadDialogShowing) return; - reloadDialogShowing = true; - const { canceled } = await confirm({ - type: 'warning', - title: i18n.ts.disconnectedFromServer, - text: i18n.ts.reloadConfirm, + + // If the dialog is already open, do nothing. + if (reloadDialogDisposeFn) return; + + // Show a popup dialog with a warning message and a "reload" button. + const {dispose} = popup(MkDialog, { + ...({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }), + showCancelButton: true, // Show a "cancel" button; this dismisses the dialog without reloading the page. + }, { + // If the user clicks one of the buttons, and it's the "positive" button (in this case, the "reload" button), + done: result => { + if (!result) location.reload(); + }, + // If the dialog is closed, dispose of the dialog. + closed: () => dispose(), }); - reloadDialogShowing = false; - if (!canceled) { - location.reload(); - } + + // Save the function to close the dialog so that it can be called later. + reloadDialogDisposeFn = dispose; + } + }); + + // When the stream is (re)connected, close the "reconnecting" dialog if it's open. + stream.on('_connected_', () => { + if (reloadDialogDisposeFn) { + reloadDialogDisposeFn(); + reloadDialogDisposeFn = null; + toast("Reconnected to server."); } }); diff --git a/packages/misskey-js/src/streaming.ts b/packages/misskey-js/src/streaming.ts index ffb46c77f6..65706312a6 100644 --- a/packages/misskey-js/src/streaming.ts +++ b/packages/misskey-js/src/streaming.ts @@ -50,6 +50,8 @@ export default class Stream extends EventEmitter implements IStrea private nonSharedConnections: NonSharedConnection[] = []; private idCounter = 0; + /// A timestamp of the last-received message. + private lastMessageTime = Date.now(); constructor(origin: string, user: { token: string; } | null, options?: { WebSocket?: WebSocket; }) { @@ -136,6 +138,9 @@ export default class Stream extends EventEmitter implements IStrea * Callback of when open connection */ private onOpen(): void { + + this.lastMessageTime = Date.now(); + const isReconnect = this.state === 'reconnecting'; this.state = 'connected'; @@ -162,6 +167,9 @@ export default class Stream extends EventEmitter implements IStrea * Callback of when received a message from connection */ private onMessage(message: { data: string; }): void { + + this.lastMessageTime = Date.now(); + const { type, body } = JSON.parse(message.data); if (type === 'channel') { @@ -211,7 +219,8 @@ export default class Stream extends EventEmitter implements IStrea } public heartbeat(): void { - this.stream.send('h'); + // Send a heartbeat message, with the current time so the server can echo it. + this.send('hb', { time: Date.now() }); } /** diff --git a/run_with_db.sh b/run_with_db.sh new file mode 100755 index 0000000000..389afc7cb5 --- /dev/null +++ b/run_with_db.sh @@ -0,0 +1,100 @@ +# Set up a postgresql database and a redis server for the integration tests. + +# Set up a PostgreSQL database for the integration tests. +# +# Preconditions: +# - PostgreSQL must be installed on the system. +# - The `initdb`, `pg_ctl`, and `psql` commands must be available in the PATH. +# +# Postconditions: +# - A temporary PostgreSQL data directory will be created and initialized. +# - PostgreSQL will be started using a Unix socket. +# - A PostgreSQL user and database named "sharkey" will be created. +create_tmp_psql() { + export PGDATA=$(mktemp -d) + + # Initialize the PostgreSQL data directory + initdb -D $PGDATA -U postgres + + # Start PostgreSQL using Unix sockets + pg_ctl -D $PGDATA -l $PGDATA/logfile start -o "-k $PGDATA --listen-addresses=''" + until pg_isready -h $PGDATA; do sleep 1; done + echo "PostgreSQL started with Unix socket at $PGDATA" + + # Create a "sharkey" user and database + psql -h $PGDATA -c "CREATE USER sharkey" -U postgres + psql -h $PGDATA -c "CREATE DATABASE sharkey OWNER sharkey" -U postgres +} + +# Function to find a random unused port +# It generates a random port number between 2000 and 65000 +# and checks if it is in use. If it is in use, it recursively +# calls itself until an unused port is found. +function random_unused_port { + local port=$(shuf -i 2000-65000 -n 1) + netstat -lat | grep $port > /dev/null + if [[ $? == 1 ]] ; then + echo $port + else + random_unused_port + fi +} + +# Function to create a Redis server on a random unused port +# It exports the port number to the REDIS_PORT environment variable +# and starts the Redis server with log level set to warning. +# It waits until the Redis server is ready to accept connections. +create_redis_on_random_port() { + export REDIS_PORT=$(random_unused_port) + redis-server --port $REDIS_PORT --loglevel warning & + until redis-cli -p $REDIS_PORT ping; do sleep 1; done + echo "Redis started on port $REDIS_PORT" +} + +pick_sharkey_port() { + export SHARKEY_PORT=$(random_unused_port) +} + +# Function to copy the example configuration file and update it with the PostgreSQL and Redis settings. +# Arguments: +# $1: The destination file path (optional). Defaults to `.config/default.yml`. +copy_and_update_config() { + local dest_file=.config/default.yml + cp .config/example.yml $dest_file + yq -i -Y ".db.host = \"$PGDATA\"" $dest_file + yq -i -Y ".redis.port = $REDIS_PORT" $dest_file + yq -i -Y ".port = $SHARKEY_PORT" $dest_file +} + +# Function to stop the PostgreSQL server; $PGDATA must be set +stop_psql() { + pg_ctl -D "$PGDATA" stop +} + +# Function to stop the Redis server +stop_redis() { + redis-cli -p "$REDIS_PORT" shutdown +} + +# Function to stop both PostgreSQL and Redis servers +stop_databases() { + stop_psql + stop_redis +} + +# Function to start a fresh instance of the Sharkey application +# It sets up a temporary PostgreSQL database and a Redis server on a random port, +# updates the configuration file, and runs the application. +start_fresh_sharkey() { + create_tmp_psql + create_redis_on_random_port + pick_sharkey_port + trap stop_databases EXIT + + copy_and_update_config + pnpm run migrateandstart +} + +start_fresh_sharkey + +firefox http://localhost:$SHARKEY_PORT --marionette --new-instance diff --git a/sharkey-service.nix b/sharkey-service.nix index e6e004c8d1..64fa85fb6a 100644 --- a/sharkey-service.nix +++ b/sharkey-service.nix @@ -2,6 +2,7 @@ config, pkgs, lib, + sharkey, ... }: @@ -186,9 +187,11 @@ in { options = { + + + services.sharkey = { enable = lib.mkEnableOption "sharkey"; - package = lib.mkPackageOption pkgs "sharkey" { }; inherit settings; database = { createLocally = lib.mkOption { @@ -340,7 +343,7 @@ in ${pkgs.replace-secret}/bin/replace-secret '@MEILISEARCH_KEY@' "${cfg.meilisearch.keyFile}" /run/sharkey/default.yml ''); serviceConfig = { - ExecStart = "${cfg.package}/bin/sharkey migrateandstart"; + ExecStart = "${sharkey}/bin/sharkey migrateandstart"; RuntimeDirectory = "sharkey"; RuntimeDirectoryMode = "700"; StateDirectory = "sharkey"; diff --git a/sharkey.nix b/sharkey.nix index aca87ca223..6f8280c442 100644 --- a/sharkey.nix +++ b/sharkey.nix @@ -36,7 +36,7 @@ stdenv.mkDerivation (finalAttrs: { # https://nixos.org/manual/nixpkgs/unstable/#javascript-pnpm pnpmDeps = pnpm.fetchDeps { inherit (finalAttrs) pname version src; - hash = "sha256-PpXmNBO4pWj8AG0we4DpPhzfx/18rwDZHi86+esFghM="; + hash = "sha256-ymHPzoU5/0RL1Z0v5MVDunFCsFU1c6uzKK5wusabZ+E="; }; buildPhase = ''