snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
10
apps/meteor/client/lib/sdk/ddpProtocol.ts
Normal file
10
apps/meteor/client/lib/sdk/ddpProtocol.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Single point of access to the DDP wire codec. Today it forwards to Meteor's
|
||||
// `ddp-common` package; the eventual replacement will be a standalone EJSON
|
||||
// helper. Consumers MUST import parseDDP / stringifyDDP from here so the codec
|
||||
// stays swappable.
|
||||
|
||||
import { DDPCommon } from 'meteor/ddp-common';
|
||||
|
||||
export const { parseDDP, stringifyDDP } = DDPCommon;
|
||||
|
||||
export type DDPMessage = Parameters<typeof stringifyDDP>[0];
|
||||
332
apps/meteor/client/lib/sdk/ddpSdk.ts
Normal file
332
apps/meteor/client/lib/sdk/ddpSdk.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { DDPSDK } from '@rocket.chat/ddp-client';
|
||||
import EJSON from 'ejson';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { createMeteorBackedSdk, createMeteorBackedStorage } from './meteorBackedSdk';
|
||||
import { isSdkTransportEnabled } from './sdkTransportEnabled';
|
||||
import { getRootUrl } from '../meteorRuntimeConfig';
|
||||
import { STORAGE_KEYS, getStoredItem, removeStoredItem } from './storage';
|
||||
import { userIdStore } from '../user';
|
||||
|
||||
const sdkTransportEnabled = isSdkTransportEnabled();
|
||||
|
||||
const stripTrailingSlash = (value: string): string => (value.endsWith('/') ? value.slice(0, -1) : value);
|
||||
|
||||
const computeDdpUrl = (): string => {
|
||||
const rootUrl = getRootUrl();
|
||||
const source = rootUrl && rootUrl !== '/' ? rootUrl : window.location.origin;
|
||||
return stripTrailingSlash(source.replace(/^http/, 'ws'));
|
||||
};
|
||||
|
||||
let instance: DDPSDK | undefined;
|
||||
let connectPromise: Promise<unknown> | undefined;
|
||||
|
||||
const applyEjsonEncoding = (sdk: DDPSDK): void => {
|
||||
const { ddp } = sdk.client as unknown as { ddp: { encode: unknown; decode: unknown } };
|
||||
if (!ddp) return;
|
||||
ddp.encode = EJSON.stringify;
|
||||
ddp.decode = EJSON.parse;
|
||||
};
|
||||
|
||||
const startConnect = (sdk: DDPSDK): Promise<unknown> => {
|
||||
if (connectPromise) return connectPromise;
|
||||
connectPromise = sdk.connection.connect().catch((err) => {
|
||||
console.warn('[ddpSdk] connect failed', err);
|
||||
// Allow a retry on the next call.
|
||||
connectPromise = undefined;
|
||||
});
|
||||
return connectPromise;
|
||||
};
|
||||
|
||||
const waitForConnected = (sdk: DDPSDK): Promise<void> => {
|
||||
if (sdk.connection.status === 'connected') return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const stop = sdk.connection.on('connected', () => {
|
||||
stop();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const getDdpSdk = (): DDPSDK => {
|
||||
if (!instance) {
|
||||
if (sdkTransportEnabled) {
|
||||
instance = DDPSDK.create(computeDdpUrl());
|
||||
// TODO: This is a temporary fix to ensure Accounts/Meteor and Update Session On Window Close work together.
|
||||
try {
|
||||
instance.storage = createMeteorBackedStorage();
|
||||
} catch (error) {
|
||||
// DDPSDK.create may return a sealed/frozen instance under strict mode; failing
|
||||
// to attach the storage hook must not abort SDK bootstrap.
|
||||
console.warn('[ddpSdk] failed to attach storage hook to SDK instance', error);
|
||||
}
|
||||
applyEjsonEncoding(instance);
|
||||
void startConnect(instance);
|
||||
} else {
|
||||
// Meteor-backed pass-through. Same DDPSDK shape, but every call
|
||||
// delegates to Meteor.connection / Meteor.callAsync / Meteor.userId
|
||||
// — no second WebSocket opened, no auth lifecycle, no Presence
|
||||
// session duplicated server-side. Lets ServerProvider, presence.ts,
|
||||
// SDKClient, etc. continue calling getDdpSdk() unconditionally
|
||||
// without per-call-site flag gates.
|
||||
instance = createMeteorBackedSdk();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const readStoredLoginToken = (): string | null => getStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
|
||||
let inflightLogin: Promise<void> | undefined;
|
||||
|
||||
export const ensureConnectedAndAuthenticated = async (): Promise<void> => {
|
||||
const sdk = getDdpSdk();
|
||||
|
||||
// IMPORTANT: must wait for the DDP `connected` handshake before issuing
|
||||
// any wait-method (login uses wait:true). DDPDispatcher serializes wait
|
||||
// blocks at the queue head, so a login dispatched while connecting
|
||||
// queues ahead of the connect frame ws.onopen later emits — the connect
|
||||
// frame ends up wedged in a non-wait block behind the wait block and
|
||||
// never flushes, leaving the socket open but DDP-unhandshaked.
|
||||
if (
|
||||
sdk.connection.status === 'idle' ||
|
||||
sdk.connection.status === 'closed' ||
|
||||
sdk.connection.status === 'disconnected' ||
|
||||
sdk.connection.status === 'failed'
|
||||
) {
|
||||
void startConnect(sdk);
|
||||
}
|
||||
await waitForConnected(sdk);
|
||||
|
||||
const token = readStoredLoginToken();
|
||||
if (!token || sdk.account.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inflightLogin) {
|
||||
await inflightLogin;
|
||||
return;
|
||||
}
|
||||
|
||||
// Give Meteor's own login flow (resume routed through stubMeteorStream
|
||||
// + adoptAccountFromMeteorLoginResult) time to populate sdk.account
|
||||
// before we issue our own loginWithToken. If adopt fires first, we can
|
||||
// short-circuit and avoid sending a second login frame on the SDK
|
||||
// socket — which would otherwise create a duplicate Presence
|
||||
// connection (processConnectionStatus prefers ONLINE over AWAY in the
|
||||
// aggregate, breaking the auto-away flow). 500ms covers a single
|
||||
// server roundtrip in CI; if the stub-routed login hasn't completed by
|
||||
// then, fall back to issuing our own loginWithToken below.
|
||||
for (let i = 0; i < 20 && !sdk.account.uid; i++) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
if (sdk.account.uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
inflightLogin = (async () => {
|
||||
try {
|
||||
await sdk.account.loginWithToken(token);
|
||||
} finally {
|
||||
inflightLogin = undefined;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await inflightLogin;
|
||||
} catch (error) {
|
||||
if (isAuthError(error) && readStoredLoginToken() === token) {
|
||||
// Server rejected the stored token. Without this branch the stored
|
||||
// token stays in localStorage forever and the router keeps the user
|
||||
// wedged on /home with no main UI and no login form: ddpOverREST
|
||||
// routes Meteor's resume login through DDPSDK / REST (not Meteor's
|
||||
// own WS), and on rejection the resume invoker errors but the
|
||||
// account state isn't cleared automatically. The token-stable
|
||||
// guard (readStoredLoginToken() === token) avoids kicking the user
|
||||
// out when localStorage was updated mid-flight by a parallel flow
|
||||
// (fresh registration, Meteor's own resume) — the 401 is then on a
|
||||
// stale token a newer credential already replaced. Drop the local
|
||||
// credentials manually instead of calling Meteor.logout(): the
|
||||
// latter dispatches a `logout` method which itself races against
|
||||
// parallel re-auth flows in CI's parallel-shard environment and
|
||||
// kicked otherwise-healthy tests out.
|
||||
clearStoredCredentials();
|
||||
return;
|
||||
}
|
||||
console.warn('[ddpSdk] loginWithToken failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the local session credentials without dispatching Meteor's `logout`
|
||||
* method. Nulling the connection userId propagates through the
|
||||
* Accounts.connection.userId() Tracker.autorun (see overrides/userAndUsers.ts)
|
||||
* into the userIdStore, so `useUserId()` becomes undefined and the router falls
|
||||
* through to LoginPage. We avoid `Meteor.logout()` on purpose: it dispatches a
|
||||
* `logout` method that races parallel re-auth flows (fresh registration,
|
||||
* Meteor's own resume) and has kicked otherwise-healthy sessions/tests out.
|
||||
*/
|
||||
export const clearStoredCredentials = (): void => {
|
||||
removeStoredItem(STORAGE_KEYS.USER_ID);
|
||||
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN);
|
||||
removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN_EXPIRES);
|
||||
Meteor.connection.setUserId(null);
|
||||
};
|
||||
|
||||
export const isAuthError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const e = error as { error?: unknown; reason?: unknown; status?: unknown; statusCode?: unknown };
|
||||
return (
|
||||
e.error === 401 ||
|
||||
e.error === 403 ||
|
||||
// REST-shaped failures (e.g. sdk.rest.get('/v1/me'), userData stream
|
||||
// `nosub`) surface the HTTP status instead of a DDP `error` code.
|
||||
e.status === 401 ||
|
||||
e.status === 403 ||
|
||||
e.statusCode === 401 ||
|
||||
e.statusCode === 403 ||
|
||||
e.reason === 'User not found' ||
|
||||
e.reason === 'Login token expired' ||
|
||||
e.reason === 'You are not allowed to use this token'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* When Meteor.applyAsync('login', ...) is routed through ddpOverREST it lands on
|
||||
* DDPSDK as `client.callAsync('login', ...)`. The result authenticates the
|
||||
* underlying DDP socket — server-side the session is now logged in — but
|
||||
* `sdk.account` is bypassed entirely (only `sdk.account.loginWithToken` populates
|
||||
* `account.uid` / `account.user`). Without this sync, our userIdStore subscriber
|
||||
* sees uid set, calls ensureConnectedAndAuthenticated, finds `account.uid` empty,
|
||||
* and fires a SECOND login on the same socket. The server happily honours both,
|
||||
* issuing two different login tokens; whichever arrives second wins on the
|
||||
* server but on the client we end up with `account.user.token !== Meteor.loginToken`,
|
||||
* which surfaces later as auth-mismatched subscription errors and React crashes
|
||||
* mid-flow.
|
||||
*
|
||||
* Call this from ddpOverREST after a successful 'login' method result so DDPSDK's
|
||||
* `account` reflects the same credentials Meteor stored, and ensureConnectedAndAuthenticated
|
||||
* short-circuits its own loginWithToken path.
|
||||
*/
|
||||
export const adoptAccountFromMeteorLoginResult = (result: unknown): void => {
|
||||
if (!result || typeof result !== 'object') return;
|
||||
const r = result as { id?: unknown; token?: unknown; tokenExpires?: unknown };
|
||||
if (typeof r.id !== 'string' || typeof r.token !== 'string') return;
|
||||
const tokenExpiresRaw = r.tokenExpires;
|
||||
let tokenExpires: Date | undefined;
|
||||
if (tokenExpiresRaw instanceof Date) {
|
||||
tokenExpires = tokenExpiresRaw;
|
||||
} else if (typeof tokenExpiresRaw === 'object' && tokenExpiresRaw !== null && '$date' in tokenExpiresRaw) {
|
||||
const d = (tokenExpiresRaw as { $date: number | string }).$date;
|
||||
tokenExpires = new Date(typeof d === 'string' ? parseInt(d, 10) : d);
|
||||
}
|
||||
const sdk = getDdpSdk();
|
||||
sdk.account.user = { ...sdk.account.user, token: r.token, tokenExpires, id: r.id } as typeof sdk.account.user;
|
||||
sdk.account.uid = r.id;
|
||||
};
|
||||
|
||||
const teardownAuthenticatedConnection = (): void => {
|
||||
if (!instance) return;
|
||||
try {
|
||||
instance.connection.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
instance.account.uid = undefined;
|
||||
instance.account.user = undefined;
|
||||
connectPromise = undefined;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Window {
|
||||
__rocketChatSdk?: DDPSDK;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && isSdkTransportEnabled()) {
|
||||
console.info(
|
||||
'%c[Rocket.Chat] SDK-over-DDP transport enabled (experimental)',
|
||||
'color:#fff;background:#f5455c;padding:2px 6px;border-radius:3px;font-weight:bold',
|
||||
);
|
||||
const sdk = getDdpSdk();
|
||||
window.__rocketChatSdk = sdk;
|
||||
|
||||
// DDPSDK auto-fires loginWithToken on every `connected` event using the
|
||||
// in-memory account.user.token (DDPSDK.create line 115-122). When the
|
||||
// server force-logs the user out (resetUserE2EKey →
|
||||
// Users.unsetLoginTokens → meteor.service force_logout listener closes
|
||||
// the user's WebSocket sessions), the SDK reconnects and immediately
|
||||
// retries the now-dead token. DDPSDK calls this with `void` so the
|
||||
// rejection is swallowed; account.user stays populated, Meteor.userId()
|
||||
// stays set, and the navbar continues to render Home with stale creds.
|
||||
//
|
||||
// Wrap account.loginWithToken so we can observe rejections from the
|
||||
// auto-retry. To avoid breaking the SAML/password login flows where a
|
||||
// fresh login is concurrently in flight, only act when:
|
||||
// - the error is auth-shaped (`isAuthError`) AND
|
||||
// - the token in localStorage still matches the one we tried with
|
||||
// (nothing rotated it mid-flight) AND
|
||||
// - the SDK account didn't get refreshed by a successful adopt while
|
||||
// we were awaiting (sdk.account.uid still maps to this token's user)
|
||||
// Wrap account.loginWithToken so the SDK's auto-relogin rejection (called
|
||||
// with `void` in DDPSDK.create) doesn't surface as an unhandled rejection
|
||||
// (window.onunhandledrejection → pageError). The actual recovery from a
|
||||
// failed auto-relogin is now driven by Meteor's `DDP.onReconnect`
|
||||
// callback (registered by `callLoginMethod`), which fires after
|
||||
// stubMeteorStream re-emits `reset` on each SDK 'connected' event. That
|
||||
// callback retries login with the latest stored token and calls
|
||||
// `makeClientLoggedOut` on failure — no need to duplicate that logic.
|
||||
const account = sdk.account as unknown as { loginWithToken: (token: string) => Promise<unknown> };
|
||||
const originalLogin = account.loginWithToken.bind(sdk.account);
|
||||
account.loginWithToken = async (token: string) => {
|
||||
try {
|
||||
return await originalLogin(token);
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
// Meteor's onReconnect path will retry through stubMeteorStream
|
||||
// with the current localStorage token; nothing for us to do here
|
||||
// beyond not letting the rejection escape.
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Boot-time auth is now driven by Meteor's login resume routed through
|
||||
// stubMeteorStream, which calls adoptAccountFromMeteorLoginResult on
|
||||
// success. Calling ensureConnectedAndAuthenticated here as well would
|
||||
// fire a *second* loginWithToken on the SDK socket before the Meteor
|
||||
// resume completes — server-side that ends up as TWO Accounts.onLogin
|
||||
// fires → TWO Presence.newConnection inserts in usersSessions, with
|
||||
// duplicate entries that confuse processConnectionStatus (one stays
|
||||
// online while the other goes away, aggregating to online — auto-away
|
||||
// never propagates).
|
||||
|
||||
userIdStore.subscribe((uid) => {
|
||||
if (uid) {
|
||||
// Subsequent userId transitions (logout → login) still need to
|
||||
// re-establish auth on the SDK socket; adopt only kicks in for
|
||||
// login frames going through the stub, not for the post-logout
|
||||
// re-auth that doesn't necessarily go through Meteor.
|
||||
void ensureConnectedAndAuthenticated();
|
||||
} else {
|
||||
teardownAuthenticatedConnection();
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge Meteor's URL-routing-based account events into the SDK so
|
||||
// sdk.account.onEmailVerificationLink / onPageLoadLogin fire in flag-ON
|
||||
// mode too. The SDK has no native source for these — they come from
|
||||
// Meteor's hash-route parser (verification link) and Meteor's first-login
|
||||
// resolution (page load login). Register one bridge per event; AccountImpl's
|
||||
// emitter fans out to whatever consumers attached via onEmailVerificationLink
|
||||
// / onPageLoadLogin.
|
||||
Accounts.onEmailVerificationLink((token: string) => {
|
||||
sdk.account.emit('emailVerificationLink', token);
|
||||
});
|
||||
Accounts.onPageLoadLogin((loginAttempt: unknown) => {
|
||||
sdk.account.emit('pageLoadLogin', loginAttempt);
|
||||
});
|
||||
}
|
||||
28
apps/meteor/client/lib/sdk/meteorBackedSdk.spec.ts
Normal file
28
apps/meteor/client/lib/sdk/meteorBackedSdk.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
|
||||
import { createMeteorBackedSdk } from './meteorBackedSdk';
|
||||
|
||||
describe('createMeteorBackedSdk account lifecycle hooks', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reports an asynchronously rejected lifecycle hook', async () => {
|
||||
let lifecycleHook: (() => void) | undefined;
|
||||
jest.mocked(Accounts.onLogin).mockImplementation((handler) => {
|
||||
lifecycleHook = handler as () => void;
|
||||
return { stop: jest.fn() };
|
||||
});
|
||||
|
||||
const error = new Error('lifecycle hook failed');
|
||||
const rejection = Promise.reject(error);
|
||||
void rejection.catch(() => undefined);
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
createMeteorBackedSdk().account.onLogin(() => rejection);
|
||||
lifecycleHook?.();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(consoleError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
366
apps/meteor/client/lib/sdk/meteorBackedSdk.ts
Normal file
366
apps/meteor/client/lib/sdk/meteorBackedSdk.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import type { DDPSDK } from '@rocket.chat/ddp-client';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
|
||||
import { parseDDP } from './ddpProtocol';
|
||||
import { setStorageBackend } from './storage';
|
||||
|
||||
// Wrap an accounts-base onLogin/onLogout hook so it cannot block or abort the
|
||||
// (now awaited & sequential, since accounts-base 3.3) hook chain: run it,
|
||||
// discard any returned promise so the chain never waits on it, and swallow
|
||||
// synchronous throws so later hooks still run.
|
||||
const isolateLifecycleHook =
|
||||
(fn: (...args: unknown[]) => unknown) =>
|
||||
(...args: unknown[]): void => {
|
||||
try {
|
||||
const result = fn(...args);
|
||||
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
|
||||
void Promise.resolve(result).catch((error) => console.error(error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Meteor-backed pass-through DDPSDK used when the SDK transport is OFF.
|
||||
*
|
||||
* Returned by `getDdpSdk()` when `isSdkTransportEnabled()` is false. Satisfies
|
||||
* the subset of the `DDPSDK` interface that the codebase actually consumes —
|
||||
* delegating each call to `Meteor.connection`/`Meteor.callAsync`/`Meteor.userId`
|
||||
* etc. — so consumers don't need their own `if (isSdkTransportEnabled())`
|
||||
* branches. All operations are no-ops or fall back to Meteor; no second
|
||||
* WebSocket is opened, no auth lifecycle is run, no Presence session is
|
||||
* duplicated server-side.
|
||||
*/
|
||||
const noopUnsubscribe = (): void => undefined;
|
||||
|
||||
const safeMeteorStatus = (): { status: string; connected: boolean; retryCount?: number; retryTime?: number } | undefined => {
|
||||
if (typeof Meteor.status !== 'function') return undefined;
|
||||
try {
|
||||
return Meteor.status();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const onMeteorStatusChange = (cb: () => void): (() => void) => {
|
||||
// Drive notifications off Meteor.status's Tracker reactivity. Listening to
|
||||
// `_stream` events directly misses the `connecting`↔`waiting` transitions
|
||||
// and the per-second `retryTime` ticks that Meteor mutates internally
|
||||
// without restarting the socket, leaving ConnectionStatusBar stuck on a
|
||||
// stale status while reconnection retries run.
|
||||
if (typeof Meteor.status !== 'function' || typeof Tracker?.autorun !== 'function') {
|
||||
// Test / SSR environment with a stubbed Meteor — nothing to subscribe to.
|
||||
return noopUnsubscribe;
|
||||
}
|
||||
let firstRun = true;
|
||||
const computation = Tracker.autorun(() => {
|
||||
try {
|
||||
Meteor.status();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (firstRun) {
|
||||
firstRun = false;
|
||||
return;
|
||||
}
|
||||
cb();
|
||||
});
|
||||
return () => computation.stop();
|
||||
};
|
||||
|
||||
const meteorStatusToSdkStatus = (): string => {
|
||||
const s = safeMeteorStatus();
|
||||
if (!s) return 'disconnected';
|
||||
if (s.connected) return 'connected';
|
||||
switch (s.status) {
|
||||
case 'connecting':
|
||||
return 'connecting';
|
||||
case 'failed':
|
||||
return 'failed';
|
||||
case 'waiting':
|
||||
return 'disconnected';
|
||||
case 'offline':
|
||||
return 'idle';
|
||||
default:
|
||||
return 'disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
const createMeteorBackedClient = () => {
|
||||
const subscribe = (name: string, ...args: Parameters<typeof Meteor.connection.subscribe>) => {
|
||||
const sub = Meteor.connection.subscribe(name, ...args);
|
||||
// Approximate DDPSDK's Subscription shape with Meteor's handle. The
|
||||
// codebase only reads `stop`/`ready`/`isReady`/`id` from it.
|
||||
return Object.assign(sub, {
|
||||
id: '',
|
||||
isReady: false,
|
||||
ready: () => Promise.resolve(),
|
||||
onChange: () => undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const callAsync = (method: string, ...args: unknown[]): Promise<unknown> & { id: string } => {
|
||||
const promise = Meteor.callAsync(method, ...args) as Promise<unknown> & { id: string };
|
||||
// `id` is required on DDPSDK's CallAsync return type; Meteor doesn't
|
||||
// surface it, but no caller in this codebase reads it from a Meteor-
|
||||
// routed call.
|
||||
Object.defineProperty(promise, 'id', { value: '', enumerable: false });
|
||||
return promise;
|
||||
};
|
||||
|
||||
const onCollection = (id: string, callback: (data: unknown) => void): (() => void) => {
|
||||
const handler = (rawMsg: string): void => {
|
||||
let msg: unknown;
|
||||
try {
|
||||
msg = parseDDP(rawMsg);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof msg !== 'object' || msg === null) return;
|
||||
if ((msg as { collection?: unknown }).collection !== id) return;
|
||||
callback(msg);
|
||||
};
|
||||
const stream = Meteor.connection._stream!;
|
||||
stream.on('message', handler);
|
||||
// Meteor's stream `on` doesn't expose an off; the listener is harmless
|
||||
// and lives for the page lifetime. Caller's stop is a no-op.
|
||||
return noopUnsubscribe;
|
||||
};
|
||||
|
||||
// `ddp.onMessage` is reached by code that wants raw DDP frames off the SDK
|
||||
// socket. In Meteor-backed mode there is no separate SDK socket; the same
|
||||
// frames are already delivered through `onCollection` above (which listens
|
||||
// on `Meteor.connection._stream`). Registering a second listener here
|
||||
// would double-deliver. The Meteor-backed `onCollection` is also the
|
||||
// temporary bridge used by `sdk.onAnyStreamEvent` until SDK transport
|
||||
// rollout completes and the Meteor fallback can be removed.
|
||||
const ddp = {
|
||||
onMessage: (_cb: (payload: unknown) => void): (() => void) => noopUnsubscribe,
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
callAsync,
|
||||
call: (method: string, ...params: unknown[]) => {
|
||||
void callAsync(method, ...params);
|
||||
return '';
|
||||
},
|
||||
callWithOptions: (method: string, _opts: unknown, ...params: unknown[]) => {
|
||||
void callAsync(method, ...params);
|
||||
return '';
|
||||
},
|
||||
callAsyncWithOptions: (method: string, _opts: unknown, ...params: unknown[]) => callAsync(method, ...params),
|
||||
unsubscribe: () => Promise.resolve(),
|
||||
connect: () => Promise.resolve(),
|
||||
onCollection,
|
||||
subscriptions: new Map(),
|
||||
ddp,
|
||||
// Emitter shape Account inherits from
|
||||
on: () => () => undefined,
|
||||
off: () => undefined,
|
||||
once: () => () => undefined,
|
||||
emit: () => undefined,
|
||||
events: () => [],
|
||||
has: () => false,
|
||||
} as unknown as DDPSDK['client'];
|
||||
};
|
||||
|
||||
const createMeteorBackedConnection = () => {
|
||||
const emitter = new Emitter<{ connected: void; disconnected: void; connection: void }>();
|
||||
let bridgeStarted = false;
|
||||
let stopBridge: (() => void) | undefined;
|
||||
const startBridge = () => {
|
||||
if (bridgeStarted) return;
|
||||
bridgeStarted = true;
|
||||
let lastConnected = safeMeteorStatus()?.connected ?? false;
|
||||
stopBridge = onMeteorStatusChange(() => {
|
||||
const nowConnected = safeMeteorStatus()?.connected ?? false;
|
||||
if (nowConnected !== lastConnected) {
|
||||
lastConnected = nowConnected;
|
||||
emitter.emit(nowConnected ? 'connected' : 'disconnected');
|
||||
}
|
||||
emitter.emit('connection');
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
get status(): string {
|
||||
return meteorStatusToSdkStatus();
|
||||
},
|
||||
on: (event: 'connected' | 'disconnected' | 'connection', cb: () => void): (() => void) => {
|
||||
// Lazy-start the Meteor.status() autorun on the first subscriber so
|
||||
// modules importing `getDdpSdk` in non-browser test envs don't crash
|
||||
// on the absent Meteor.status reactive var.
|
||||
startBridge();
|
||||
emitter.on(event, cb);
|
||||
return () => emitter.off(event, cb);
|
||||
},
|
||||
off: () => undefined,
|
||||
connect: () => Promise.resolve(),
|
||||
close: () => {
|
||||
if (typeof Meteor.disconnect === 'function') Meteor.disconnect();
|
||||
stopBridge?.();
|
||||
},
|
||||
} as unknown as DDPSDK['connection'];
|
||||
};
|
||||
|
||||
const createMeteorBackedAccount = () => {
|
||||
return {
|
||||
get uid(): string | undefined {
|
||||
if (typeof Meteor.userId !== 'function') return undefined;
|
||||
try {
|
||||
return Meteor.userId() ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
set uid(_v: string | undefined) {
|
||||
// no-op — Meteor owns the userId
|
||||
},
|
||||
get user() {
|
||||
if (typeof Meteor.user !== 'function') return undefined;
|
||||
let u;
|
||||
try {
|
||||
u = Meteor.user();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!u) return undefined;
|
||||
return u as unknown as { id: string; username?: string; token?: string; tokenExpires?: Date };
|
||||
},
|
||||
set user(_v) {
|
||||
// no-op — Meteor owns the user
|
||||
},
|
||||
loginWithPassword: (username: string, password: string) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
(Meteor as unknown as { loginWithPassword: (u: string, p: string, cb: (err?: unknown) => void) => void }).loginWithPassword(
|
||||
username,
|
||||
password,
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
),
|
||||
),
|
||||
loginWithToken: (token: string) =>
|
||||
new Promise<{ id: string; token: string; tokenExpires: Date }>((resolve, reject) =>
|
||||
(Meteor as unknown as { loginWithToken: (t: string, cb: (err?: unknown, res?: unknown) => void) => void }).loginWithToken(
|
||||
token,
|
||||
(err, res) => (err ? reject(err) : resolve(res as { id: string; token: string; tokenExpires: Date })),
|
||||
),
|
||||
),
|
||||
logout: () =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
(Meteor as unknown as { logout: (cb: (err?: unknown) => void) => void }).logout((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
// Lifecycle handlers delegate to Meteor's accounts-base. The unsubscribe
|
||||
// shape (returning a () => void) matches the SDK contract; Meteor's
|
||||
// onLogin/onLogout hand back a `{ stop }` handle, while
|
||||
// onEmailVerificationLink/onPageLoadLogin don't expose an unsubscribe at
|
||||
// all — call sites for those are singletons registered at module load,
|
||||
// so a no-op unsubscribe is acceptable.
|
||||
// accounts-base 3.3 (Meteor 3.5) runs onLogin/onLogout hooks sequentially
|
||||
// with `await` (3.2 fired them synchronously, fire-and-forget). A hook that
|
||||
// returns a promise which only settles after login/logout completes now
|
||||
// blocks the whole chain, and a hook that throws aborts every hook after it
|
||||
// — so unrelated cleanup (e.g. E2EE key removal) silently stops running.
|
||||
// Isolate each hook: run its synchronous part immediately, don't propagate
|
||||
// its promise back to the awaited chain, and swallow throws.
|
||||
onLogin: (fn: () => void): (() => void) => {
|
||||
const handle = Accounts.onLogin(isolateLifecycleHook(fn));
|
||||
return () => handle.stop();
|
||||
},
|
||||
onLogout: (fn: () => void): (() => void) => {
|
||||
// @types/meteor declares onLogout's return as void, but at runtime it
|
||||
// returns the same `{ stop }` handle as onLogin (Meteor source:
|
||||
// packages/accounts-base/accounts_common.js).
|
||||
const handle = (Accounts.onLogout as unknown as (fn: () => void) => { stop: () => void })(isolateLifecycleHook(fn));
|
||||
return () => handle.stop();
|
||||
},
|
||||
onEmailVerificationLink: (fn: (token: string) => void): (() => void) => {
|
||||
Accounts.onEmailVerificationLink(fn);
|
||||
return () => undefined;
|
||||
},
|
||||
onPageLoadLogin: (fn: (loginAttempt: unknown) => void): (() => void) => {
|
||||
Accounts.onPageLoadLogin(fn);
|
||||
return () => undefined;
|
||||
},
|
||||
// Emitter shape Account inherits from
|
||||
on: () => () => undefined,
|
||||
off: () => undefined,
|
||||
once: () => () => undefined,
|
||||
emit: () => undefined,
|
||||
events: () => [],
|
||||
has: () => false,
|
||||
} as unknown as DDPSDK['account'];
|
||||
};
|
||||
|
||||
export const createMeteorBackedStorage = () => {
|
||||
let appliedBackend: 'local' | 'session' | undefined;
|
||||
|
||||
return {
|
||||
changeStorageBackend: () => {
|
||||
const backend: 'local' | 'session' = window[FORGET_SESSION_SETTING_ID] ? 'session' : 'local';
|
||||
|
||||
if (appliedBackend === backend) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setStorageBackend(backend)) {
|
||||
return;
|
||||
}
|
||||
|
||||
(Meteor._localStorage as unknown as Storage) = backend === 'session' ? window.sessionStorage : window.localStorage;
|
||||
|
||||
try {
|
||||
Accounts.config({ clientStorage: backend });
|
||||
} catch (error) {
|
||||
// Accounts.config throws when invoked twice with a conflicting value. Meteor's
|
||||
// own boot may have already set clientStorage; the _localStorage reassign above
|
||||
// is what actually switches the backend Meteor reads from, so swallow.
|
||||
console.warn('[storage] Accounts.config(clientStorage) refused at runtime', error);
|
||||
}
|
||||
|
||||
appliedBackend = backend;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const FORGET_SESSION_SETTING_ID = 'Accounts_ForgetUserSessionOnWindowClose';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface Window {
|
||||
[FORGET_SESSION_SETTING_ID]?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@rocket.chat/ddp-client' {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
interface DDPSDK {
|
||||
storage: {
|
||||
changeStorageBackend: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createMeteorBackedSdk = (): DDPSDK => {
|
||||
const connection = createMeteorBackedConnection();
|
||||
const client = createMeteorBackedClient();
|
||||
const account = createMeteorBackedAccount();
|
||||
const storage = createMeteorBackedStorage();
|
||||
|
||||
return {
|
||||
storage,
|
||||
connection,
|
||||
client,
|
||||
account,
|
||||
timeoutControl: undefined,
|
||||
rest: undefined,
|
||||
call: (method: string, ...params: unknown[]) => client.callAsync(method, ...params),
|
||||
stream: () => {
|
||||
throw new Error('SDK transport is disabled — use sdk.publish/sdk.stream from SDKClient.ts instead');
|
||||
},
|
||||
} as unknown as DDPSDK;
|
||||
};
|
||||
28
apps/meteor/client/lib/sdk/sdkTransportEnabled.ts
Normal file
28
apps/meteor/client/lib/sdk/sdkTransportEnabled.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const KEY = 'sdk_transport';
|
||||
const META_NAME = 'rc-sdk-transport-enabled';
|
||||
|
||||
/**
|
||||
* Runtime flag that gates the SDK-over-DDP transport migration. Three sources,
|
||||
* checked in order:
|
||||
* 1. URL parameter `?sdk_transport=on|off` — per-tab override (highest).
|
||||
* 2. `rc-config-sdk_transport` in localStorage — persisted per-user opt-in.
|
||||
* 3. `<meta name="rc-sdk-transport-enabled" content="on|off">` injected by
|
||||
* the server from the `SDK_DDP_Transport_Enabled` admin setting — global
|
||||
* opt-in / kill-switch.
|
||||
*
|
||||
* Default is `false` (legacy Meteor DDP transport) when none of the three
|
||||
* resolves, so the migration ships dormant for staged rollout.
|
||||
*/
|
||||
export const isSdkTransportEnabled = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get(KEY);
|
||||
if (fromUrl === 'on') return true;
|
||||
if (fromUrl === 'off') return false;
|
||||
if (window.localStorage.getItem(`rc-config-${KEY}`) === 'on') return true;
|
||||
const meta = window.document?.querySelector(`meta[name="${META_NAME}"]`);
|
||||
return meta?.getAttribute('content') === 'on';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
149
apps/meteor/client/lib/sdk/storage.ts
Normal file
149
apps/meteor/client/lib/sdk/storage.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Single point of access to the client-side persistent storage that
|
||||
// Rocket.Chat shares with Meteor's accounts-base. Reads and writes use
|
||||
// window.localStorage under the hood; the keys mirror the names Meteor
|
||||
// originally wrote so sessions persist across the Meteor → SDK migration.
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
USER_ID: 'Meteor.userId',
|
||||
LOGIN_TOKEN: 'Meteor.loginToken',
|
||||
LOGIN_TOKEN_EXPIRES: 'Meteor.loginTokenExpires',
|
||||
E2EE_PUBLIC_KEY: 'public_key',
|
||||
E2EE_PRIVATE_KEY: 'private_key',
|
||||
E2EE_RANDOM_PASSWORD: 'e2e.randomPassword',
|
||||
} as const;
|
||||
|
||||
export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
|
||||
|
||||
type StorageBackend = 'local' | 'session';
|
||||
|
||||
const getStorageForBackend = (backend: StorageBackend): Storage | undefined => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return backend === 'session' ? window.sessionStorage : window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorage = (): Storage | undefined => {
|
||||
return getStorageForBackend(storageBackend);
|
||||
};
|
||||
|
||||
/**
|
||||
* A same-tab write to `localStorage` announces itself to nobody: the DOM `storage` event is only ever delivered
|
||||
* to the *other* tabs. Anything that renders from a stored value — the session-resume gate in
|
||||
* `AuthenticationCheck` above all — therefore has to be told, or it reads a stale value and keeps it until some
|
||||
* unrelated state change happens to render it again. Every mutation below goes through `notify`, and
|
||||
* `subscribeStoredItem` is what `useSyncExternalStore` consumers hook into.
|
||||
*
|
||||
* The `storage` event is the other half of that, and covers exactly the writes `notify` cannot see: a logout in
|
||||
* another tab takes the token out from under a window that is still resuming, and that window has no other way
|
||||
* to hear about it. `ensureConnectedAndAuthenticated` reads the token only once it has a connection, so a token
|
||||
* that left in the meantime makes it return without a user, without clearing anything, and without a resume to
|
||||
* fail — leaving a gate that never re-renders sitting on a token that is no longer there.
|
||||
*/
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const notify = (): void => {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attached with the first subscriber and dropped with the last, so imperative readers pay nothing for it. The
|
||||
* event is not filtered by key or by storage area: `notify` is keyless anyway, and a subscriber that reads a
|
||||
* key nobody touched re-reads an unchanged snapshot, which React discards.
|
||||
*/
|
||||
const handleStorageEvent = (): void => {
|
||||
notify();
|
||||
};
|
||||
|
||||
export const subscribeStoredItem = (listener: () => void): (() => void) => {
|
||||
if (listeners.size === 0 && typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', handleStorageEvent);
|
||||
}
|
||||
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
|
||||
if (listeners.size === 0 && typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', handleStorageEvent);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getStoredItem = (key: StorageKey): string | null => getStorage()?.getItem(key) ?? null;
|
||||
|
||||
export const setStoredItem = (key: StorageKey, value: string): void => {
|
||||
getStorage()?.setItem(key, value);
|
||||
notify();
|
||||
};
|
||||
|
||||
export const removeStoredItem = (key: StorageKey): void => {
|
||||
getStorage()?.removeItem(key);
|
||||
notify();
|
||||
};
|
||||
|
||||
let storageBackend: StorageBackend = 'local';
|
||||
|
||||
export const setStorageBackend = (backend: StorageBackend): boolean => {
|
||||
if (backend === storageBackend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!moveLoginKeys(backend)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
storageBackend = backend;
|
||||
notify();
|
||||
return true;
|
||||
};
|
||||
|
||||
const moveLoginKeys = (backend: StorageBackend): boolean => {
|
||||
const keys = [
|
||||
STORAGE_KEYS.USER_ID,
|
||||
STORAGE_KEYS.LOGIN_TOKEN,
|
||||
STORAGE_KEYS.LOGIN_TOKEN_EXPIRES,
|
||||
STORAGE_KEYS.E2EE_PUBLIC_KEY,
|
||||
STORAGE_KEYS.E2EE_PRIVATE_KEY,
|
||||
STORAGE_KEYS.E2EE_RANDOM_PASSWORD,
|
||||
];
|
||||
|
||||
const sourceStorage = getStorageForBackend(backend === 'session' ? 'local' : 'session');
|
||||
const targetStorage = getStorageForBackend(backend);
|
||||
|
||||
if (!sourceStorage || !targetStorage) {
|
||||
console.warn('Unable to switch storage backend because source or target storage is unavailable');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
let value: string | null;
|
||||
try {
|
||||
value = sourceStorage.getItem(key);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
targetStorage.setItem(key, value);
|
||||
sourceStorage.removeItem(key);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
sourceStorage.clear();
|
||||
|
||||
return true;
|
||||
};
|
||||
Reference in New Issue
Block a user