diff --git a/.github/actions/meteor-build/action.yml b/.github/actions/meteor-build/action.yml index 9466a172cebf7..888f2bcb1aa94 100644 --- a/.github/actions/meteor-build/action.yml +++ b/.github/actions/meteor-build/action.yml @@ -59,7 +59,7 @@ runs: deno-version: ${{ inputs.deno-version }} cache-modules: true install: true - type: 'production' + type: 'development' NPM_TOKEN: ${{ inputs.NPM_TOKEN }} # - name: Free disk space @@ -165,6 +165,12 @@ runs: yarn build:ci + # Build Vite frontend and package it with the standard Meteor artifact. + ( + cd apps/meteor + ROOT_URL=http://localhost:3000/ VITE_TEST_MODE=true npx vite build --outDir /tmp/dist/vite + ) + declare -a meter_modules_to_remove=( "meteor/babel-compiler/node_modules/@meteorjs/swc-core/.swc/node_modules/@swc/core-darwin-arm64" # Removes 35M "meteor/babel-compiler/node_modules/@meteorjs/swc-core/.swc/node_modules/@swc/core-linux-x64-musl" # Removes 58M @@ -201,7 +207,7 @@ runs: if: steps.cache-build.outputs.cache-hit != 'true' run: | cd /tmp/dist - tar czf /tmp/Rocket.Chat.tar.gz bundle + tar czf /tmp/Rocket.Chat.tar.gz bundle vite - name: Store build uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci-test-e2e.yml b/.github/workflows/ci-test-e2e.yml index 4838332eff2b7..0830f94cc014c 100644 --- a/.github/workflows/ci-test-e2e.yml +++ b/.github/workflows/ci-test-e2e.yml @@ -77,6 +77,7 @@ jobs: COVERAGE_DIR: '/tmp/coverage/${{ startsWith(inputs.type, ''api'') && ''api'' || inputs.type }}' COVERAGE_FILE_NAME: '${{ inputs.type }}-${{ matrix.shard }}.json' COVERAGE_REPORTER: ${{ inputs.coverage == matrix.mongodb-version && 'json' || '' }} + FRONTEND_DELIVERY_MODE: meteor strategy: fail-fast: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6250eb56e37f7..18b35a8e70464 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -497,6 +497,7 @@ jobs: test-api: name: 🔨 Test API (CE) needs: [checks, build-gh-docker-publish, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.release-versions.result == 'success' }} uses: ./.github/workflows/ci-test-e2e.yml with: @@ -513,6 +514,7 @@ jobs: test-api-livechat: name: 🔨 Test API Livechat (CE) needs: [checks, build-gh-docker-publish, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.release-versions.result == 'success' }} uses: ./.github/workflows/ci-test-e2e.yml with: @@ -529,6 +531,7 @@ jobs: test-ui: name: 🔨 Test UI (CE) needs: [checks, build-gh-docker-publish, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.release-versions.result == 'success' }} uses: ./.github/workflows/ci-test-e2e.yml with: @@ -554,6 +557,7 @@ jobs: test-api-ee: name: 🔨 Test API (EE) needs: [checks, build-gh-docker-publish, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.release-versions.result == 'success' }} uses: ./.github/workflows/ci-test-e2e.yml with: @@ -574,6 +578,7 @@ jobs: test-api-livechat-ee: name: 🔨 Test API Livechat (EE) needs: [checks, build-gh-docker-publish, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.release-versions.result == 'success' }} uses: ./.github/workflows/ci-test-e2e.yml with: @@ -594,6 +599,7 @@ jobs: test-ui-ee: name: 🔨 Test UI (EE) needs: [checks, build-gh-docker-publish, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.release-versions.result == 'success' }} uses: ./.github/workflows/ci-test-e2e.yml with: @@ -622,6 +628,7 @@ jobs: test-federation-matrix: name: 🔨 Test Federation Matrix needs: [checks, build-gh-docker-publish, packages-build, release-versions] + if: ${{ always() && needs.build-gh-docker-publish.result == 'success' && needs.packages-build.result == 'success' && needs.release-versions.result == 'success' }} runs-on: ubuntu-24.04 steps: diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore index 6411fe002c516..e9a54923c91ff 100644 --- a/apps/meteor/.gitignore +++ b/apps/meteor/.gitignore @@ -80,6 +80,7 @@ tests/end-to-end/temporary_staged_test /tests/e2e/.playwright coverage .nyc_output +.nyc_cache /data tests/e2e/test-failures/ out.txt @@ -88,3 +89,5 @@ dist matrix-federation-config/* .eslintcache tsconfig.typecheck.tsbuildinfo +.vite-inspect +.build \ No newline at end of file diff --git a/apps/meteor/.nycrc.json b/apps/meteor/.nycrc.json new file mode 100644 index 0000000000000..de177485348aa --- /dev/null +++ b/apps/meteor/.nycrc.json @@ -0,0 +1,26 @@ +{ + "report-dir": "./coverage", + "temp-dir": "./.nyc_output", + "reporter": ["html", "lcov", "text", "text-summary"], + "extension": [".ts", ".tsx", ".js", ".jsx"], + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.js", + "**/*.test.js", + "**/*.stories.tsx", + "**/*.stories.ts", + "tests/**", + "node_modules/**", + "**/*.d.ts", + "**/mocks/**", + "**/fixtures/**", + "**/__mocks__/**" + ], + "all": false, + "check-coverage": false, + "sourceMap": true, + "instrument": false, + "cache": true, + "cacheDir": "./.nyc_cache" +} diff --git a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts index b5dc34b6314a7..04e0a5b57acf2 100644 --- a/apps/meteor/app/livechat/client/lib/stream/queueManager.ts +++ b/apps/meteor/app/livechat/client/lib/stream/queueManager.ts @@ -1,4 +1,5 @@ import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelAgent, Serialized } from '@rocket.chat/core-typings'; +import { Tracker } from 'meteor/tracker'; import { useLivechatInquiryStore } from '../../../../../client/hooks/useLivechatInquiryStore'; import { queryClient } from '../../../../../client/lib/queryClient'; diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index ed282ce3c75aa..189e5cb8239cd 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -1,6 +1,7 @@ import type { IMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Accounts } from 'meteor/accounts-base'; +import { Tracker } from 'meteor/tracker'; import type { RefObject } from 'react'; import { limitQuoteChain } from './limitQuoteChain'; diff --git a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts index 7cf01ba3370c9..b499201756768 100644 --- a/apps/meteor/client/lib/2fa/overrideLoginMethod.ts +++ b/apps/meteor/client/lib/2fa/overrideLoginMethod.ts @@ -1,3 +1,6 @@ +import { Accounts } from 'meteor/accounts-base'; +import type { Meteor } from 'meteor/meteor'; + import { isTotpInvalidError, isTotpMaxAttemptsError, isTotpRequiredError } from './utils'; type LoginError = globalThis.Error | Meteor.Error | Meteor.TypedError; diff --git a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts index fa88249550726..865e95a0f9e38 100644 --- a/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts +++ b/apps/meteor/client/lib/e2ee/rocketchat.e2e.ts @@ -1,6 +1,3 @@ -import QueryString from 'querystring'; -import URL from 'url'; - import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, Serialized, IE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; @@ -721,13 +718,13 @@ class E2E extends Emitter { return; } - const urlObj = URL.parse(url); + const urlObj = new URL(url); // if the URL doesn't have query params (doesn't reference message) skip - if (!urlObj.query) { + if (!urlObj?.searchParams) { return; } - const { msg: msgId } = QueryString.parse(urlObj.query); + const { msg: msgId } = Object.fromEntries(urlObj.searchParams.entries()); if (!msgId || Array.isArray(msgId)) { return; diff --git a/apps/meteor/client/lib/rooms/roomCoordinator.tsx b/apps/meteor/client/lib/rooms/roomCoordinator.tsx index 54b0cf2145f5a..430a9738192f9 100644 --- a/apps/meteor/client/lib/rooms/roomCoordinator.tsx +++ b/apps/meteor/client/lib/rooms/roomCoordinator.tsx @@ -1,6 +1,7 @@ import type { IRoom, RoomType, IUser, AtLeast, ValueOf, ISubscription } from '@rocket.chat/core-typings'; import type { RouteName } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; +import type { ReactElement } from 'react'; import { hasPermission } from '../../../app/authorization/client'; import type { @@ -15,9 +16,15 @@ import type { import { RoomCoordinator } from '../../../lib/rooms/coordinator'; import { router } from '../../providers/RouterProvider'; import { Subscriptions } from '../../stores'; -import RoomRoute from '../../views/room/RoomRoute'; -import MainLayout from '../../views/root/MainLayout'; -import { appLayout } from '../appLayout'; + +// Route element factory - set by startup code to avoid circular imports +let createRoomRouteElement: + | ((props: { name: string; extractOpenRoomParams: Required['extractOpenRoomParams'] }) => ReactElement) + | null = null; + +export const setRoomRouteElementFactory = (factory: typeof createRoomRouteElement): void => { + createRoomRouteElement = factory; +}; class RoomCoordinatorClient extends RoomCoordinator { public add(roomConfig: IRoomTypeClientConfig, directives: Partial): void { @@ -178,15 +185,16 @@ class RoomCoordinatorClient extends RoomCoordinator { route: { name, path }, } = roomConfig; const { extractOpenRoomParams } = directives; + + if (!createRoomRouteElement) { + throw new Error('Room route element factory not set. Call setRoomRouteElementFactory before registering room types.'); + } + router.defineRoutes([ { path, id: name, - element: appLayout.wrap( - - - , - ), + element: createRoomRouteElement({ name, extractOpenRoomParams }), }, ]); } diff --git a/apps/meteor/client/lib/rooms/roomRouteFactory.tsx b/apps/meteor/client/lib/rooms/roomRouteFactory.tsx new file mode 100644 index 0000000000000..452a3225bc5ec --- /dev/null +++ b/apps/meteor/client/lib/rooms/roomRouteFactory.tsx @@ -0,0 +1,13 @@ +import { setRoomRouteElementFactory } from './roomCoordinator'; +import RoomRoute from '../../views/room/RoomRoute'; +import MainLayout from '../../views/root/MainLayout'; +import { appLayout } from '../appLayout'; + +// Set up the room route element factory before any room types are registered +setRoomRouteElementFactory(({ name, extractOpenRoomParams }) => + appLayout.wrap( + + + , + ), +); diff --git a/apps/meteor/client/lib/rooms/roomTypes/index.ts b/apps/meteor/client/lib/rooms/roomTypes/index.ts index bdb2d43aac19a..2ea500bc8affb 100644 --- a/apps/meteor/client/lib/rooms/roomTypes/index.ts +++ b/apps/meteor/client/lib/rooms/roomTypes/index.ts @@ -1,3 +1,6 @@ +// Initialize the route factory before registering room types +import '../roomRouteFactory'; + import './conversation'; import './direct'; import './favorite'; diff --git a/apps/meteor/client/noop.ts b/apps/meteor/client/noop.ts new file mode 100644 index 0000000000000..61c00543350ff --- /dev/null +++ b/apps/meteor/client/noop.ts @@ -0,0 +1,3 @@ +console.error('The frontend is disabled in this Meteor build.'); + +export {}; \ No newline at end of file diff --git a/apps/meteor/client/views/room/Header/ParentRoom/ParentDiscussion/ParentDiscussion.tsx b/apps/meteor/client/views/room/Header/ParentRoom/ParentDiscussion/ParentDiscussion.tsx index 09375e137d1de..bca7577786ce5 100644 --- a/apps/meteor/client/views/room/Header/ParentRoom/ParentDiscussion/ParentDiscussion.tsx +++ b/apps/meteor/client/views/room/Header/ParentRoom/ParentDiscussion/ParentDiscussion.tsx @@ -1,7 +1,7 @@ import type { IRoom } from '@rocket.chat/core-typings'; +import { useRoomRoute } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; -import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; import ParentRoomButton from '../ParentRoomButton'; type ParentDiscussionProps = { @@ -11,8 +11,12 @@ type ParentDiscussionProps = { const ParentDiscussion = ({ loading = false, room }: ParentDiscussionProps) => { const { t } = useTranslation(); - const roomName = roomCoordinator.getRoomName(room.t, room); - const handleRedirect = (): void => roomCoordinator.openRouteLink(room.t, { rid: room._id, ...room }); + const goToRoom = useRoomRoute(); + const roomName = room.fname || room.name || ''; + + const handleRedirect = (): void => { + goToRoom({ rid: room._id, t: room.t, name: room.name }); + }; return ; }; diff --git a/apps/meteor/client/views/room/Header/ParentRoom/ParentTeam.tsx b/apps/meteor/client/views/room/Header/ParentRoom/ParentTeam.tsx index eee946aeefdae..0ccc2fd22c381 100644 --- a/apps/meteor/client/views/room/Header/ParentRoom/ParentTeam.tsx +++ b/apps/meteor/client/views/room/Header/ParentRoom/ParentTeam.tsx @@ -1,11 +1,10 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { TeamType } from '@rocket.chat/core-typings'; -import { useUserId } from '@rocket.chat/ui-contexts'; +import { useGoToRoom, useUserId } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; import ParentRoomButton from './ParentRoomButton'; import { useTeamInfoQuery } from '../../../../hooks/useTeamInfoQuery'; -import { goToRoomById } from '../../../../lib/utils/goToRoomById'; import { useUserTeamsQuery } from '../../hooks/useUserTeamsQuery'; type APIErrorResult = { success: boolean; error: string }; @@ -19,6 +18,7 @@ const ParentTeam = ({ room }: ParentTeamProps) => { const { teamId } = room; const userId = useUserId(); + const goToRoom = useGoToRoom(); if (!teamId) { throw new Error('invalid rid'); @@ -46,7 +46,7 @@ const ParentTeam = ({ room }: ParentTeamProps) => { return; } - goToRoomById(rid); + goToRoom(rid); }; if (teamInfoError || !shouldDisplayTeam) { diff --git a/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx b/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx index 015cc59169368..2c8afdaa6dba0 100644 --- a/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx +++ b/apps/meteor/client/views/room/body/RoomForeword/RoomForewordUsernameList.tsx @@ -1,19 +1,21 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Margins } from '@rocket.chat/fuselage'; +import { useRouter } from '@rocket.chat/ui-contexts'; import RoomForewordUsernameListItem from './RoomForewordUsernameListItem'; -import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator'; type RoomForewordUsernameListProps = { usernames: Array> }; const RoomForewordUsernameList = ({ usernames }: RoomForewordUsernameListProps) => { + const router = useRouter(); + return ( {usernames.map((username) => ( ))} diff --git a/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts b/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts index e7f9f277cb4ac..b30a78e3f9994 100644 --- a/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts +++ b/apps/meteor/client/views/room/body/hooks/useUnreadMessages.ts @@ -1,5 +1,6 @@ import type { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { useRouter } from '@rocket.chat/ui-contexts'; +import { Tracker } from 'meteor/tracker'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; diff --git a/apps/meteor/client/views/root/IndexRoute.tsx b/apps/meteor/client/views/root/IndexRoute.tsx index efd6a75b88598..90773af044916 100644 --- a/apps/meteor/client/views/root/IndexRoute.tsx +++ b/apps/meteor/client/views/root/IndexRoute.tsx @@ -1,5 +1,6 @@ import type { RouteName } from '@rocket.chat/ui-contexts'; import { useRouter, useUser, useUserId } from '@rocket.chat/ui-contexts'; +import { Tracker } from 'meteor/tracker'; import { useEffect } from 'react'; import PageLoading from './PageLoading'; diff --git a/apps/meteor/client/views/root/hooks/useSettingsOnLoadSiteUrl.ts b/apps/meteor/client/views/root/hooks/useSettingsOnLoadSiteUrl.ts index 8b35ed7ad0e38..f1a185ed8ed28 100644 --- a/apps/meteor/client/views/root/hooks/useSettingsOnLoadSiteUrl.ts +++ b/apps/meteor/client/views/root/hooks/useSettingsOnLoadSiteUrl.ts @@ -9,6 +9,9 @@ export const useSettingsOnLoadSiteUrl = () => { if (value == null || value.trim() === '') { return; } + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + return; + } (window as any).__meteor_runtime_config__.ROOT_URL = value; }, [siteUrl]); }; diff --git a/apps/meteor/definition/externals/global.d.ts b/apps/meteor/definition/externals/global.d.ts index d9c45be89d90f..32e9eabaf2ce6 100644 --- a/apps/meteor/definition/externals/global.d.ts +++ b/apps/meteor/definition/externals/global.d.ts @@ -10,6 +10,17 @@ declare global { const __meteor_runtime_config__: { ROOT_URL_PATH_PREFIX: string; ROOT_URL: string; + PUBLIC_SETTINGS?: Record; + accountsConfigCalled?: boolean; + meteorEnv: { + TEST_METADATA?: string; + NODE_ENV?: string; + }; + ACCOUNTS_CONNECTION_URL?: string; + isModern?: boolean; + gitCommitHash?: string; + meteorRelease?: string; + debug?: boolean; }; interface Window { diff --git a/apps/meteor/definition/externals/meteor/mongo.d.ts b/apps/meteor/definition/externals/meteor/mongo.d.ts index 443c8de2a8798..c751d3c973c33 100644 --- a/apps/meteor/definition/externals/meteor/mongo.d.ts +++ b/apps/meteor/definition/externals/meteor/mongo.d.ts @@ -37,6 +37,7 @@ declare module 'meteor/mongo' { connection?: object | null; idGeneration?: string; transform?: ((doc: T) => T) | null; + _preventAutopublish?: boolean; }, ): Collection; } diff --git a/apps/meteor/index.html b/apps/meteor/index.html new file mode 100644 index 0000000000000..d42482af6bc24 --- /dev/null +++ b/apps/meteor/index.html @@ -0,0 +1,16 @@ + + + + Rocket.Chat + + + + + + + + +
+ + + diff --git a/apps/meteor/lib/getMessageUrlRegex.ts b/apps/meteor/lib/getMessageUrlRegex.ts index 78e3993ef0a4b..f08653b3d59a5 100644 --- a/apps/meteor/lib/getMessageUrlRegex.ts +++ b/apps/meteor/lib/getMessageUrlRegex.ts @@ -1,2 +1,2 @@ -export const getMessageUrlRegex = (): RegExp => +export const getMessageUrlRegex: () => RegExp = (): RegExp => /([A-Za-z]{3,9}):\/\/([-;:&=\+\$,\w]+@{1})?([-A-Za-z0-9\.]+)+:?(\d+)?((\/[-\+=!:~%\/\.@\,\w]*)?\??([-\+=&!:;%@\/\.\,\w]+)?(?:#([^\s\)]+))?)?/g; diff --git a/apps/meteor/lib/utils/stringUtils.ts b/apps/meteor/lib/utils/stringUtils.ts index bd457c2337f01..828e74404002b 100644 --- a/apps/meteor/lib/utils/stringUtils.ts +++ b/apps/meteor/lib/utils/stringUtils.ts @@ -1,5 +1,7 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; -import { sanitize } from 'dompurify'; +import DOMPurify from 'dompurify'; + +const {sanitize} = DOMPurify; export function truncate(str: string, length: number): string { return str.length > length ? `${str.slice(0, length - 3)}...` : str; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 815932d3d7af3..9b162d8ed4887 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -320,9 +320,9 @@ "@rocket.chat/livechat": "workspace:^", "@rocket.chat/mock-providers": "workspace:^", "@rocket.chat/tsconfig": "workspace:*", - "@storybook/addon-a11y": "^8.6.17", - "@storybook/addon-essentials": "^8.6.17", - "@storybook/addon-interactions": "^8.6.17", + "@storybook/addon-a11y": "^8.6.15", + "@storybook/addon-essentials": "^8.6.15", + "@storybook/addon-interactions": "^8.6.15", "@storybook/addon-styling-webpack": "^1.0.1", "@storybook/addon-webpack5-compiler-swc": "~3.0.0", "@storybook/react": "^8.6.17", @@ -399,6 +399,7 @@ "@types/underscore": "^1.13.0", "@types/xml-crypto": "~1.4.6", "@types/xml-encryption": "~1.2.4", + "@vitejs/plugin-react": "~5.1.4", "autoprefixer": "^9.8.8", "babel-loader": "~10.0.0", "babel-plugin-array-includes": "^2.0.3", @@ -447,6 +448,8 @@ "ts-node": "^10.9.2", "tsx": "~4.20.6", "typescript": "~5.9.3", + "vite": "^8.0.0-beta.16", + "vite-plugin-istanbul": "^7.2.1", "webpack": "~5.99.9" }, "volta": { @@ -457,7 +460,7 @@ }, "meteor": { "mainModule": { - "client": "client/main.ts", + "client": "client/noop.ts", "server": "server/main.ts" } }, diff --git a/apps/meteor/server/routes/index.ts b/apps/meteor/server/routes/index.ts index 4abc6c6f044a8..d0523ca6a378a 100644 --- a/apps/meteor/server/routes/index.ts +++ b/apps/meteor/server/routes/index.ts @@ -4,3 +4,4 @@ import './i18n'; import './timesync'; import './fileDecrypt'; import './userDataDownload'; +import './vite'; diff --git a/apps/meteor/server/routes/vite.ts b/apps/meteor/server/routes/vite.ts new file mode 100644 index 0000000000000..caf47697f8bea --- /dev/null +++ b/apps/meteor/server/routes/vite.ts @@ -0,0 +1,284 @@ +import { createReadStream } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import type { ServerResponse } from 'node:http'; +import path from 'node:path'; + +import type { IncomingMessage } from 'connect'; +import { WebApp } from 'meteor/webapp'; + +import { getWebAppHash } from '../configuration/configureBoilerplate'; +import { SystemLogger } from '../lib/logger/system'; + +const frontendDeliveryMode = process.env.FRONTEND_DELIVERY_MODE ?? 'separate'; +const ENABLED = frontendDeliveryMode === 'meteor'; + +SystemLogger.info( + `Vite static route is ${ENABLED ? 'enabled' : 'disabled'} (FRONTEND_DELIVERY_MODE=${frontendDeliveryMode}, VITE_DIST_PATH=${process.env.VITE_DIST_PATH ?? 'unset'})`, +); + +if (ENABLED) { + const viteDistPath = await resolveViteDistPath(); + + if (!viteDistPath) { + SystemLogger.warn('FRONTEND_DELIVERY_MODE is meteor, but no Vite dist directory was found. Skipping Vite static handler.'); + } else { + SystemLogger.info(`Serving Vite frontend from Meteor: ${viteDistPath}`); + WebApp.connectHandlers.use(async (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD') { + next(); + return; + } + + const pathname = getPathname(req); + if (!pathname || isBackendRoute(pathname)) { + next(); + return; + } + + const requestedFile = await resolvePublicFile(viteDistPath, pathname); + if (requestedFile) { + if (path.basename(requestedFile) === 'index.html') { + await streamSpaIndexHtml(requestedFile, req, req.method, res); + return; + } + + await streamFile(requestedFile, req.method, res); + return; + } + + if (looksLikeAsset(pathname)) { + next(); + return; + } + + const fallbackPath = path.join(viteDistPath, 'index.html'); + if (!(await fileExists(fallbackPath))) { + next(); + return; + } + + await streamSpaIndexHtml(fallbackPath, req, req.method, res); + }); + } +} + +function getPathname(req: IncomingMessage): string | undefined { + try { + const host = req.headers.host ?? 'localhost'; + return new URL(req.url ?? '/', `http://${host}`).pathname; + } catch { + return undefined; + } +} + +function isBackendRoute(pathname: string): boolean { + const backendPrefixes = [ + '/api', + '/sockjs', + '/websocket', + '/_oauth', + '/_saml', + '/_timesync', + '/file-upload', + '/ufs', + '/avatar', + '/emoji-custom', + '/custom-sounds', + '/images', + '/assets', + '/i18n', + '/livechat', + '/health', + '/livez', + '/readyz', + '/data-export', + '/file-decrypt', + '/meteor_runtime_config.js', + ]; + + return backendPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + +function looksLikeAsset(pathname: string): boolean { + return path.extname(pathname).length > 0; +} + +async function resolveViteDistPath(): Promise { + const envPath = process.env.VITE_DIST_PATH; + + const paths = [ + envPath, + path.resolve(process.cwd(), 'vite'), + path.resolve(process.cwd(), '../vite'), + path.resolve(process.cwd(), 'dist'), + path.resolve(process.cwd(), '../dist'), + path.resolve(process.cwd(), '../../../../../dist'), + ]; + + console.debug('Checking for Vite dist directory in the following locations:', paths); + + const candidates = paths.filter((candidate): candidate is string => Boolean(candidate)); + + const checks = await Promise.all( + candidates.map(async (candidate) => { + const absoluteCandidate = path.resolve(candidate); + const hasDirectory = await directoryExists(absoluteCandidate); + if (!hasDirectory) { + return undefined; + } + + const indexPath = path.join(absoluteCandidate, 'index.html'); + if (await fileExists(indexPath)) { + return absoluteCandidate; + } + + return undefined; + }), + ); + + return checks.find((candidate): candidate is string => Boolean(candidate)); +} + +async function resolvePublicFile(baseDir: string, pathname: string): Promise { + const normalizedPath = pathname === '/' ? '/index.html' : pathname; + const decodedPath = safeDecodeURIComponent(normalizedPath); + if (!decodedPath) { + return undefined; + } + + const resolvedPath = path.resolve(baseDir, `.${decodedPath}`); + if (!isInsideBaseDir(baseDir, resolvedPath)) { + return undefined; + } + + if (await fileExists(resolvedPath)) { + return resolvedPath; + } + + return undefined; +} + +function isInsideBaseDir(baseDir: string, targetPath: string): boolean { + const relative = path.relative(baseDir, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function safeDecodeURIComponent(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +async function streamFile(filePath: string, method: string | undefined, res: ServerResponse): Promise { + const fileStats = await stat(filePath); + const ext = path.extname(filePath); + + res.setHeader('Content-Type', contentTypeByExtension[ext] ?? 'application/octet-stream'); + res.setHeader('Content-Length', String(fileStats.size)); + res.setHeader('Cache-Control', getCacheControl(filePath)); + + if (method === 'HEAD') { + res.writeHead(200); + res.end(); + return; + } + + await new Promise((resolve, reject) => { + const stream = createReadStream(filePath); + stream.on('error', reject); + stream.on('end', resolve); + stream.pipe(res); + }); +} + +async function streamSpaIndexHtml(indexPath: string, req: IncomingMessage, method: string | undefined, res: ServerResponse): Promise { + const rawHtml = await readFile(indexPath, 'utf8'); + const runtimeConfigPath = getRuntimeConfigScriptPath(req); + const html = replaceInlineMeteorRuntimeConfig(rawHtml, runtimeConfigPath); + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Content-Length', Buffer.byteLength(html, 'utf8')); + + if (method === 'HEAD') { + res.writeHead(200); + res.end(); + return; + } + + res.writeHead(200); + res.end(html); +} + +function replaceInlineMeteorRuntimeConfig(html: string, runtimeConfigPath: string): string { + const inlineRuntimePattern = + /]*>\s*const\s+config\s*=\s*[\s\S]*?globalThis\.__meteor_runtime_config__\s*=\s*config;?\s*<\/script>/m; + + if (!inlineRuntimePattern.test(html)) { + return html; + } + + return html.replace(inlineRuntimePattern, ``); +} + +function getRuntimeConfigScriptPath(req: IncomingMessage): string { + const { categorizeRequest } = WebApp as typeof WebApp & { + categorizeRequest?: (request: IncomingMessage) => { arch?: string }; + }; + const { arch = 'web.browser' } = categorizeRequest?.(req) ?? {}; + const hash = getWebAppHash(arch) || getWebAppHash('web.browser'); + + if (!hash) { + return '/meteor_runtime_config.js'; + } + + return `/meteor_runtime_config.js?hash=${encodeURIComponent(hash)}`; +} + +function getCacheControl(filePath: string): string { + if (filePath.endsWith('index.html')) { + return 'no-cache'; + } + + const fileName = path.basename(filePath); + if (/\.[A-Za-z0-9_-]{8,}\./.test(fileName)) { + return 'public, max-age=31536000, immutable'; + } + + return 'public, max-age=300'; +} + +async function fileExists(filePath: string): Promise { + try { + const fileStat = await stat(filePath); + return fileStat.isFile(); + } catch { + return false; + } +} + +async function directoryExists(dirPath: string): Promise { + try { + const dirStat = await stat(dirPath); + return dirStat.isDirectory(); + } catch { + return false; + } +} + +const contentTypeByExtension: Record = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; diff --git a/apps/meteor/src/index.ts b/apps/meteor/src/index.ts new file mode 100644 index 0000000000000..11adfe162e813 --- /dev/null +++ b/apps/meteor/src/index.ts @@ -0,0 +1,3 @@ +import './setup.ts'; + +await import('../client/main.ts'); diff --git a/apps/meteor/src/meteor/accounts-base.ts b/apps/meteor/src/meteor/accounts-base.ts new file mode 100644 index 0000000000000..e2409b7d14854 --- /dev/null +++ b/apps/meteor/src/meteor/accounts-base.ts @@ -0,0 +1,736 @@ +import { Hook } from './callback-hook.ts'; +import { DDP, type Connection } from './ddp-client.ts'; +import { MeteorError } from './meteor.ts'; +import { Collection } from './mongo.ts'; +import { Random } from './random.ts'; +import { ReactiveVar } from './reactive-var.ts'; +import { Tracker } from './tracker.ts'; +import { isKey } from './utils/isKey.ts'; +import { keys } from './utils/keys.ts'; + +const VALID_CONFIG_KEYS = [ + 'sendVerificationEmail', + 'forbidClientAccountCreation', + 'restrictCreationByEmailDomain', + 'loginExpiration', + 'loginExpirationInDays', + 'oauthSecretKey', + 'passwordResetTokenExpirationInDays', + 'passwordResetTokenExpiration', + 'passwordEnrollTokenExpirationInDays', + 'passwordEnrollTokenExpiration', + 'ambiguousErrorMessages', + 'bcryptRounds', + 'argon2Enabled', + 'argon2Type', + 'argon2TimeCost', + 'argon2MemoryCost', + 'argon2Parallelism', + 'defaultFieldSelector', + 'collection', + 'loginTokenExpirationHours', + 'tokenSequenceLength', + 'clientStorage', + 'ddpUrl', + 'connection', +] as const; + +type AccountsClientOptions = { + sendVerificationEmail?: boolean; + forbidClientAccountCreation?: boolean; + restrictCreationByEmailDomain?: boolean; + loginExpiration?: number; + loginExpirationInDays?: number; + oauthSecretKey?: string; + passwordResetTokenExpirationInDays?: number; + passwordResetTokenExpiration?: number; + passwordEnrollTokenExpirationInDays?: number; + passwordEnrollTokenExpiration?: number; + ambiguousErrorMessages?: boolean; + bcryptRounds?: number; + argon2Enabled?: boolean; + argon2Type?: number; + argon2TimeCost?: number; + argon2MemoryCost?: number; + argon2Parallelism?: number; + defaultFieldSelector?: Record; + collection?: Collection | string; + loginTokenExpirationHours?: number; + tokenSequenceLength?: number; + clientStorage?: 'local' | 'session'; + ddpUrl?: string; + connection?: Connection; +}; +const DEFAULT_LOGIN_EXPIRATION_DAYS = 90; +const DEFAULT_PASSWORD_RESET_TOKEN_EXPIRATION_DAYS = 3; +const DEFAULT_PASSWORD_ENROLL_TOKEN_EXPIRATION_DAYS = 30; +const MIN_TOKEN_LIFETIME_CAP_SECS = 3600; // one hour +const LOGIN_UNEXPIRING_TOKEN_DAYS = 365 * 100; + +export class LoginCancelledError extends Error { + numericError = 0x8acdc2f; + + override name = 'Accounts.LoginCancelledError'; +} + +const URL_PARTS = [ + { key: 'reset-password', regex: /^#\/reset-password\/(.*)$/, property: '_resetPasswordToken' }, + { key: 'verify-email', regex: /^#\/verify-email\/(.*)$/, property: '_verifyEmailToken' }, + { key: 'enroll-account', regex: /^#\/enroll-account\/(.*)$/, property: '_enrollAccountToken' }, +] as const; + +type AttemptInfo = { type: string; allowed: boolean; error: any; methodName: string; methodArguments: [{ resume: string | null }] }; + +export class AccountsClient { + public _options: AccountsClientOptions; + + public connection: Connection = DDP.connection; + + public users: any; + + public _onLoginHook = new Hook< + [{ type: 'resume' | 'normal'; allowed?: boolean; error?: any; methodName?: string; methodArguments?: any[] }] + >({ + bindEnvironment: false, + debugPrintExceptions: 'onLogin callback', + }); + + public _onLoginFailureHook = new Hook<[{ error: any }]>({ + bindEnvironment: false, + debugPrintExceptions: 'onLoginFailure callback', + }); + + public _onLogoutHook = new Hook<[]>({ + bindEnvironment: false, + debugPrintExceptions: 'onLogout callback', + }); + + public DEFAULT_LOGIN_EXPIRATION_DAYS = DEFAULT_LOGIN_EXPIRATION_DAYS; + + public LOGIN_UNEXPIRING_TOKEN_DAYS = LOGIN_UNEXPIRING_TOKEN_DAYS; + + public LoginCancelledError = LoginCancelledError; + + public _loggingIn = new ReactiveVar(false); + + public _loggingOut = new ReactiveVar(false); + + public _loginServicesHandle: any; + + public _pageLoadLoginCallbacks: Array<(...args: any[]) => any> = []; + + public _pageLoadLoginAttemptInfo: AttemptInfo | null = null; + + public savedHash: string; + + public storageLocation: Storage; + + public _loginFuncs: Record any>; + + public _loginCallbacksCalled: boolean; + + public _autoLoginEnabled = false; + + public _lastLoginTokenWhenPolled: string | null = null; + + public LOGIN_TOKEN_KEY = 'Meteor.loginToken'; + + public LOGIN_TOKEN_EXPIRES_KEY = 'Meteor.loginTokenExpires'; + + public USER_ID_KEY = 'Meteor.userId'; + + public _pollIntervalTimer: any; + + public _accountsCallbacks: Partial any>> = {}; + + public _reconnectStopper: any; + + public _resetPasswordToken: string | undefined; + + public _verifyEmailToken: string | undefined; + + public _enrollAccountToken: string | undefined; + + constructor(options: AccountsClientOptions = {}) { + for (const key of keys(options)) { + if (!VALID_CONFIG_KEYS.includes(key)) { + console.error(`Accounts.config: Invalid key: ${key}`); + } + } + + this._options = options || {}; + this.connection = this._initConnection(options || {}); + this.users = this._initializeCollection(options || {}); + + this._loginServicesHandle = this.connection.subscribe('meteor.loginServiceConfiguration'); + + this.savedHash = window.location.hash; + this._autoLoginEnabled = true; + this._attemptToMatchHash(); + + this.storageLocation = localStorage; + this._initLocalStorage(); + this._loginFuncs = {}; + this._loginCallbacksCalled = false; + } + + _initializeCollection(options: AccountsClientOptions) { + if (options.collection && typeof options.collection !== 'string' && !(options.collection instanceof Collection)) { + throw new MeteorError('Collection parameter can be only of type string or "Mongo.Collection"'); + } + + let collectionName = 'users'; + if (typeof options.collection === 'string') { + collectionName = options.collection; + } + + return options.collection instanceof Collection + ? options.collection + : new Collection(collectionName, { + _preventAutopublish: true, + connection: this.connection, + }); + } + + _addDefaultFieldSelector(options: any = {}) { + if (!this._options.defaultFieldSelector) { + return options; + } + if (!options.fields) + return { + ...options, + fields: this._options.defaultFieldSelector, + }; + const keys = Object.keys(options.fields); + if (!keys.length) { + return options; + } + if (options.fields[keys[0]]) { + return options; + } + const keys2 = Object.keys(this._options.defaultFieldSelector); + return this._options.defaultFieldSelector[keys2[0]] + ? options + : { + ...options, + fields: { + ...options.fields, + ...this._options.defaultFieldSelector, + }, + }; + } + + user(options?: any) { + const userId = this.userId(); + const findOne = (...args: any[]) => this.users.findOne(...args); + return userId ? findOne(userId, this._addDefaultFieldSelector(options)) : null; + } + + async userAsync(options?: any) { + const userId = this.userId(); + return userId ? this.users.findOneAsync(userId, this._addDefaultFieldSelector(options)) : null; + } + + onLogin(func: (...args: any[]) => any) { + const ret = this._onLoginHook.register(func); + this._startupCallback(ret.callback); + return ret; + } + + onLoginFailure(func: (...args: any[]) => any) { + return this._onLoginFailureHook.register(func); + } + + onLogout(func: (...args: any[]) => any) { + return this._onLogoutHook.register(func); + } + + _initConnection(options: AccountsClientOptions) { + if (options.connection) { + this.connection = options.connection; + } + + if (options.ddpUrl) { + this.connection = DDP.connect(options.ddpUrl); + } + + return this.connection; + } + + _getTokenLifetimeMs(): number { + const loginExpirationInDays = + this._options.loginExpirationInDays === null ? LOGIN_UNEXPIRING_TOKEN_DAYS : this._options.loginExpirationInDays; + return this._options.loginExpiration || (loginExpirationInDays || DEFAULT_LOGIN_EXPIRATION_DAYS) * 86400000; + } + + _getPasswordResetTokenLifetimeMs() { + return ( + this._options.passwordResetTokenExpiration || + (this._options.passwordResetTokenExpirationInDays || DEFAULT_PASSWORD_RESET_TOKEN_EXPIRATION_DAYS) * 86400000 + ); + } + + _getPasswordEnrollTokenLifetimeMs() { + return ( + this._options.passwordEnrollTokenExpiration || + (this._options.passwordEnrollTokenExpirationInDays || DEFAULT_PASSWORD_ENROLL_TOKEN_EXPIRATION_DAYS) * 86400000 + ); + } + + _tokenExpiration(when: any) { + return new Date(new Date(when).getTime() + this._getTokenLifetimeMs()); + } + + _tokenExpiresSoon(when: any) { + let minLifetimeMs = 0.1 * this._getTokenLifetimeMs(); + const minLifetimeCapMs = MIN_TOKEN_LIFETIME_CAP_SECS * 1000; + if (minLifetimeMs > minLifetimeCapMs) { + minLifetimeMs = minLifetimeCapMs; + } + return new Date().getTime() > new Date(when).getTime() - minLifetimeMs; + } + + initStorageLocation(options?: any) { + this.storageLocation = options && options.clientStorage === 'session' ? sessionStorage : localStorage; + } + + config(options: AccountsClientOptions) { + if (!__meteor_runtime_config__.accountsConfigCalled) { + console.debug('Accounts.config was called on the client but not on the server; some configuration options may not take effect.'); + } + + if (isKey(options, 'oauthSecretKey')) { + throw new Error('The oauthSecretKey option may only be specified on the server'); + } + for (const key of keys(options)) { + if (!VALID_CONFIG_KEYS.includes(key)) { + console.error(`Accounts.config: Invalid key: ${key}`); + } + } + + if (options.collection && options.collection !== this.users._name && options.collection !== this.users) { + this.users = this._initializeCollection(options); + } + this.initStorageLocation(options); + } + + userId() { + return this.connection.userId(); + } + + _setLoggingIn(x: boolean) { + this._loggingIn.set(x); + } + + loggingIn() { + return this._loggingIn.get(); + } + + loggingOut() { + return this._loggingOut.get(); + } + + registerClientLoginFunction(funcName: string, func: (...args: any[]) => any) { + if (this._loginFuncs[funcName]) { + throw new Error(`${funcName} has been defined already`); + } + this._loginFuncs[funcName] = func; + } + + callLoginFunction(funcName: string, ...funcArgs: any[]) { + if (!this._loginFuncs[funcName]) { + throw new Error(`${funcName} was not defined`); + } + return this._loginFuncs[funcName].apply(this, funcArgs); + } + + applyLoginFunction(funcName: string, funcArgs: any[]) { + if (!this._loginFuncs[funcName]) { + throw new Error(`${funcName} was not defined`); + } + return this._loginFuncs[funcName].apply(this, funcArgs); + } + + logout(callback?: (error?: any) => void) { + this._loggingOut.set(true); + + this.connection + .applyAsync('logout', [], { + wait: true, + }) + .then((_result: any) => { + this._loggingOut.set(false); + this._loginCallbacksCalled = false; + this.makeClientLoggedOut(); + callback?.(); + }) + .catch((e: any) => { + this._loggingOut.set(false); + callback?.(e); + }); + } + + logoutAllClients(callback?: (error?: any) => void) { + this._loggingOut.set(true); + + this.connection + .applyAsync('logoutAllClients', [], { + wait: true, + }) + .then((_result: any) => { + this._loggingOut.set(false); + this._loginCallbacksCalled = false; + this.makeClientLoggedOut(); + callback?.(); + }) + .catch((e: any) => { + this._loggingOut.set(false); + callback?.(e); + }); + } + + logoutOtherClients(callback?: (error?: any) => void) { + this.connection.apply('getNewToken', [], { wait: true }, (err: any, result: any) => { + const userId = this.userId(); + if (!err && userId) { + this._storeLoginToken(userId, result.token, result.tokenExpires); + } + }); + + this.connection.apply('removeOtherTokens', [], { wait: true }, (err: any) => callback?.(err)); + } + + callLoginMethod(options: any) { + options = { + methodName: 'login', + methodArguments: [{}], + _suppressLoggingIn: false, + ...options, + }; + + ['validateResult', 'userCallback'].forEach((f) => { + if (!options[f]) options[f] = () => null; + }); + + let called = false; + const loginCallbacks = ({ error, loginDetails }: { error?: any; loginDetails?: any }) => { + if (!called) { + called = true; + if (!error) { + this._onLoginHook.forEach((callback) => { + callback(loginDetails); + return true; + }); + this._loginCallbacksCalled = true; + } else { + this._loginCallbacksCalled = false; + this._onLoginFailureHook.forEach((callback) => { + callback({ error }); + return true; + }); + } + options.userCallback(error, loginDetails); + } + }; + + let reconnected = false; + + const onResultReceived = (err: any, result: any) => { + if (err || !result || !result.token) { + // error handling + } else { + if (this._reconnectStopper) { + this._reconnectStopper.stop(); + } + + this._reconnectStopper = DDP.onReconnect((conn) => { + if (conn !== this.connection) { + return; + } + reconnected = true; + const storedToken = this._storedLoginToken(); + if (storedToken) { + result = { + token: storedToken, + tokenExpires: this._storedLoginTokenExpires(), + }; + } + if (!result.tokenExpires) result.tokenExpires = this._tokenExpiration(new Date()); + if (this._tokenExpiresSoon(result.tokenExpires)) { + this.makeClientLoggedOut(); + } else { + this.callLoginMethod({ + methodArguments: [{ resume: result.token }], + _suppressLoggingIn: true, + userCallback: (error: any, loginDetails: any) => { + const storedTokenNow = this._storedLoginToken(); + if (error) { + if (storedTokenNow && storedTokenNow === result.token) { + this.makeClientLoggedOut(); + } + } + loginCallbacks({ error, loginDetails }); + }, + }); + } + }); + } + }; + + const loggedInAndDataReadyCallback = (error: any, result: any) => { + if (reconnected) return; + + if (error || !result) { + error = error || new Error(`No result from call to ${options.methodName}`); + loginCallbacks({ error }); + this._setLoggingIn(false); + return; + } + try { + options.validateResult(result); + } catch (e) { + loginCallbacks({ error: e }); + this._setLoggingIn(false); + return; + } + + this.makeClientLoggedIn(result.id, result.token, result.tokenExpires); + + void Tracker.autorun(async (computation) => { + const user = await Tracker.withComputation(computation, () => this.userAsync()); + + if (user) { + loginCallbacks({ loginDetails: result }); + this._setLoggingIn(false); + computation.stop(); + } + }); + }; + + if (!options._suppressLoggingIn) { + this._setLoggingIn(true); + } + void this.connection.applyAsync( + options.methodName, + options.methodArguments, + { wait: true, onResultReceived }, + loggedInAndDataReadyCallback, + ); + } + + makeClientLoggedOut() { + if (this.connection._userId) { + this._onLogoutHook.forEach((callback) => { + callback(); + return true; + }); + } + this._unstoreLoginToken(); + this.connection.setUserId(null); + this._reconnectStopper?.stop(); + } + + makeClientLoggedIn(userId: any, token: any, tokenExpires: any) { + this._storeLoginToken(userId, token, tokenExpires); + this.connection.setUserId(userId); + } + + loginServicesConfigured() { + return this._loginServicesHandle.ready(); + } + + onPageLoadLogin(f: (...args: any[]) => any) { + if (this._pageLoadLoginAttemptInfo) { + f(this._pageLoadLoginAttemptInfo); + } else { + this._pageLoadLoginCallbacks.push(f); + } + } + + _pageLoadLogin(attemptInfo: AttemptInfo) { + if (this._pageLoadLoginAttemptInfo) { + console.debug('Ignoring unexpected duplicate page load login attempt info'); + return; + } + + this._pageLoadLoginCallbacks.forEach((callback: (...args: any[]) => any) => callback(attemptInfo)); + this._pageLoadLoginCallbacks = []; + this._pageLoadLoginAttemptInfo = attemptInfo; + } + + _startupCallback(callback: (...args: any[]) => any) { + if (this._loginCallbacksCalled) { + setTimeout(() => callback({ type: 'resume' }), 0); + } + } + + loginWithToken(token: any, callback: (error?: any) => void) { + this.callLoginMethod({ + methodArguments: [ + { + resume: token, + }, + ], + userCallback: callback, + }); + } + + _enableAutoLogin() { + this._autoLoginEnabled = true; + this._pollStoredLoginToken(); + } + + _isolateLoginTokenForTest() { + this.LOGIN_TOKEN_KEY += Random.id(); + this.USER_ID_KEY += Random.id(); + } + + _storeLoginToken(userId: string, token: string, tokenExpires: any) { + this.storageLocation.setItem(this.USER_ID_KEY, userId); + this.storageLocation.setItem(this.LOGIN_TOKEN_KEY, token); + if (!tokenExpires) tokenExpires = this._tokenExpiration(new Date()); + this.storageLocation.setItem(this.LOGIN_TOKEN_EXPIRES_KEY, tokenExpires); + + this._lastLoginTokenWhenPolled = token; + } + + _unstoreLoginToken() { + this.storageLocation.removeItem(this.USER_ID_KEY); + this.storageLocation.removeItem(this.LOGIN_TOKEN_KEY); + this.storageLocation.removeItem(this.LOGIN_TOKEN_EXPIRES_KEY); + this._lastLoginTokenWhenPolled = null; + } + + _storedLoginToken() { + return this.storageLocation.getItem(this.LOGIN_TOKEN_KEY); + } + + _storedLoginTokenExpires() { + return this.storageLocation.getItem(this.LOGIN_TOKEN_EXPIRES_KEY); + } + + _storedUserId() { + return this.storageLocation.getItem(this.USER_ID_KEY); + } + + _unstoreLoginTokenIfExpiresSoon() { + const tokenExpires = this._storedLoginTokenExpires(); + if (tokenExpires && this._tokenExpiresSoon(new Date(tokenExpires))) { + this._unstoreLoginToken(); + } + } + + _initLocalStorage() { + const rootUrlPathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX; + if (rootUrlPathPrefix || this.connection !== DDP.connection) { + let namespace = `:${this.connection._stream.rawUrl}`; + if (rootUrlPathPrefix) { + namespace += `:${rootUrlPathPrefix}`; + } + this.LOGIN_TOKEN_KEY += namespace; + this.LOGIN_TOKEN_EXPIRES_KEY += namespace; + this.USER_ID_KEY += namespace; + } + + let token: string | null = null; + if (this._autoLoginEnabled) { + this._unstoreLoginTokenIfExpiresSoon(); + token = this._storedLoginToken(); + if (token) { + const userId = this._storedUserId(); + userId && this.connection.setUserId(userId); + this.loginWithToken(token, (err) => { + if (err) { + console.debug(`Error logging in with token: ${err}`); + this.makeClientLoggedOut(); + } + + this._pageLoadLogin({ + type: 'resume', + allowed: !err, + error: err, + methodName: 'login', + methodArguments: [{ resume: token }], + }); + }); + } + } + + this._lastLoginTokenWhenPolled = token; + + if (this._pollIntervalTimer) { + clearInterval(this._pollIntervalTimer); + } + + this._pollIntervalTimer = setInterval(() => { + this._pollStoredLoginToken(); + }, 3000); + } + + _pollStoredLoginToken() { + if (!this._autoLoginEnabled) { + return; + } + + const currentLoginToken = this._storedLoginToken(); + + if (this._lastLoginTokenWhenPolled !== currentLoginToken) { + if (currentLoginToken) { + this.loginWithToken(currentLoginToken, (err) => { + if (err) { + this.makeClientLoggedOut(); + } + }); + } else { + this.logout(); + } + } + + this._lastLoginTokenWhenPolled = currentLoginToken; + } + + _attemptToMatchHash() { + for (const urlPart of URL_PARTS) { + const match = this.savedHash.match(urlPart.regex); + if (!match) continue; + + const token = match[1]; + this[urlPart.property] = token; + window.location.hash = ''; + + this._autoLoginEnabled = false; + + if (this._accountsCallbacks[urlPart.key]) { + this._accountsCallbacks[urlPart.key]?.(token, () => this._enableAutoLogin()); + } + + return; + } + } + + onResetPasswordLink(callback: (...args: any[]) => any) { + if (this._accountsCallbacks['reset-password']) { + console.debug('Accounts.onResetPasswordLink was called more than once. Only one callback added will be executed.'); + } + + this._accountsCallbacks['reset-password'] = callback; + } + + onEmailVerificationLink(callback: (...args: any[]) => any) { + if (this._accountsCallbacks['verify-email']) { + console.debug('Accounts.onEmailVerificationLink was called more than once. Only one callback added will be executed.'); + } + + this._accountsCallbacks['verify-email'] = callback; + } + + onEnrollmentLink(callback: (...args: any[]) => any) { + if (this._accountsCallbacks['enroll-account']) { + console.debug('Accounts.onEnrollmentLink was called more than once. Only one callback added will be executed.'); + } + + this._accountsCallbacks['enroll-account'] = callback; + } +} + +export const Accounts = new AccountsClient(); diff --git a/apps/meteor/src/meteor/accounts-oauth.ts b/apps/meteor/src/meteor/accounts-oauth.ts new file mode 100644 index 0000000000000..f612e34c43779 --- /dev/null +++ b/apps/meteor/src/meteor/accounts-oauth.ts @@ -0,0 +1,33 @@ +class ServiceSet extends Set { + includes(service: string): boolean { + return this.has(service); + } + + override add(service: string): this { + if (this.has(service)) { + throw new Error(`Duplicate service: ${service}`); + } + + return super.add(service); + } + + override delete(service: string): boolean { + if (!this.has(service)) { + throw new Error(`Service not found: ${service}`); + } + + return super.delete(service); + } +} + +const services = new ServiceSet(); + +export const registerService = (name: T) => { + services.add(name); +}; + +export const unregisterService = (name: T) => { + services.delete(name); +}; + +export const serviceNames = () => services; diff --git a/apps/meteor/src/meteor/accounts-password.ts b/apps/meteor/src/meteor/accounts-password.ts new file mode 100644 index 0000000000000..5bd7aecca86f0 --- /dev/null +++ b/apps/meteor/src/meteor/accounts-password.ts @@ -0,0 +1,229 @@ +import { Accounts } from './accounts-base.ts'; +import { MeteorError } from './meteor.ts'; +import { SHA256 } from './sha.ts'; + +type MeteorCallback = (error?: Error, result?: T) => void; + +type UserSelectorObject = { + username?: string; + email?: string; + id?: string; +}; + +type UserSelector = string | UserSelectorObject; + +type PasswordDigest = { + digest: string; + algorithm: string; +}; + +type InternalLoginOptions = { + selector: UserSelector; + password: string; + code?: string | undefined; // 2FA code + callback?: MeteorCallback | undefined; +}; + +type CreateUserOptions = { + username?: string; + email?: string; + password: string | PasswordDigest; + profile?: Record; + [key: string]: any; +}; + +type ForgotPasswordOptions = { + email: string; +}; + +const reportError = (error: Error, callback?: MeteorCallback): void => { + if (callback) { + callback(error); + } else { + throw error; + } +}; + +const internalLoginWithPassword = ({ selector, password, code, callback }: InternalLoginOptions): UserSelector => { + let normalizedSelector: UserSelectorObject; + + if (typeof selector === 'string') { + if (!selector.includes('@')) { + normalizedSelector = { username: selector }; + } else { + normalizedSelector = { email: selector }; + } + } else { + normalizedSelector = selector; + } + + Accounts.callLoginMethod({ + methodArguments: [ + { + user: normalizedSelector, + password: _hashPassword(password), + code, + }, + ], + userCallback: (error: Error | undefined, result?: any) => { + if (error) { + reportError(error, callback); + } else if (callback) { + callback(undefined, result); + } + }, + }); + + return selector; +}; + +export const _hashPassword = (password: string): PasswordDigest => ({ + digest: SHA256(password), + algorithm: 'sha-256', +}); + +export const loginWithPassword = (selector: UserSelector, password: string, callback?: MeteorCallback): UserSelector => { + return internalLoginWithPassword({ selector, password, callback }); +}; + +export const loginWithPasswordAsync = (selector: UserSelector, password: string): Promise => { + return new Promise((resolve, reject) => { + internalLoginWithPassword({ + selector, + password, + callback: (err, res) => (err ? reject(err) : resolve(res)), + }); + }); +}; + +export const loginWithPasswordAnd2faCode = ( + selector: UserSelector, + password: string, + code: string, + callback?: MeteorCallback, +): UserSelector => { + if (!code || typeof code !== 'string') { + throw new MeteorError(400, 'Token is required to use loginWithPasswordAnd2faCode and must be a string'); + } + return internalLoginWithPassword({ selector, password, code, callback }); +}; + +export const loginWithPasswordAnd2faCodeAsync = (selector: UserSelector, password: string, code: string): Promise => { + return new Promise((resolve, reject) => { + loginWithPasswordAnd2faCode(selector, password, code, (err, res) => (err ? reject(err) : resolve(res))); + }); +}; + +export const createUser = (options: CreateUserOptions, callback?: MeteorCallback): void => { + const safeOptions = { ...options }; + + if (typeof safeOptions.password !== 'string') { + throw new Error('options.password must be a string'); + } + + if (!safeOptions.password) { + return reportError(new MeteorError(400, 'Password may not be empty'), callback); + } + safeOptions.password = _hashPassword(safeOptions.password); + + Accounts.callLoginMethod({ + methodName: 'createUser', + methodArguments: [safeOptions], + userCallback: callback, + }); +}; + +export const createUserAsync = (options: CreateUserOptions): Promise => { + return new Promise((resolve, reject) => + createUser(options, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }), + ); +}; + +export const changePassword = (oldPassword: string | null, newPassword: string, callback?: MeteorCallback): void => { + if (!Accounts.user()) { + return reportError(new Error('Must be logged in to change password.'), callback); + } + + if (typeof newPassword !== 'string' || !newPassword) { + return reportError(new MeteorError(400, 'Password must be a non-empty string'), callback); + } + + Accounts.connection.apply( + 'changePassword', + [oldPassword ? _hashPassword(oldPassword) : null, _hashPassword(newPassword)], + undefined, + (error, result) => { + if (error || !result) { + reportError(error || new Error('No result from changePassword.'), callback); + } else if (callback) { + callback(); + } + }, + ); +}; + +export const changePasswordAsync = (oldPassword: string | null, newPassword: string): Promise => { + return new Promise((resolve, reject) => { + changePassword(oldPassword, newPassword, (err) => (err ? reject(err) : resolve())); + }); +}; + +export const forgotPassword = (options: ForgotPasswordOptions, callback: MeteorCallback): void => { + if (!options.email) { + return reportError(new MeteorError(400, 'Must pass options.email'), callback); + } + + Accounts.connection.call('forgotPassword', options, callback); +}; + +export const forgotPasswordAsync = (options: ForgotPasswordOptions): Promise => { + return new Promise((resolve, reject) => { + forgotPassword(options, (err, res) => (err ? reject(err) : resolve(res))); + }); +}; + +export const resetPassword = (token: string, newPassword: string, callback?: MeteorCallback): void => { + if (typeof token !== 'string') { + return reportError(new MeteorError(400, 'Token must be a string'), callback); + } + + if (typeof newPassword !== 'string' || !newPassword) { + return reportError(new MeteorError(400, 'Password must be a non-empty string'), callback); + } + + Accounts.callLoginMethod({ + methodName: 'resetPassword', + methodArguments: [token, _hashPassword(newPassword)], + userCallback: callback, + }); +}; + +export const resetPasswordAsync = (token: string, newPassword: string): Promise => { + return new Promise((resolve, reject) => { + resetPassword(token, newPassword, (err, res) => (err ? reject(err) : resolve(res))); + }); +}; + +export const verifyEmail = (token: string, callback?: MeteorCallback): void => { + if (!token) { + return reportError(new MeteorError(400, 'Need to pass token'), callback); + } + + Accounts.callLoginMethod({ + methodName: 'verifyEmail', + methodArguments: [token], + userCallback: callback, + }); +}; + +export const verifyEmailAsync = (token: string): Promise => { + return new Promise((resolve, reject) => { + verifyEmail(token, (err, res) => (err ? reject(err) : resolve(res))); + }); +}; diff --git a/apps/meteor/src/meteor/allow-deny.ts b/apps/meteor/src/meteor/allow-deny.ts new file mode 100644 index 0000000000000..ee30fa5e119fa --- /dev/null +++ b/apps/meteor/src/meteor/allow-deny.ts @@ -0,0 +1,383 @@ +import { check, Match } from './check.ts'; +import { EJSON } from './ejson.ts'; +import { MeteorError } from './meteor.ts'; +import { _selectorIsIdPerhapsAsObject } from './minimongo.ts'; +import { isKey } from './utils/isKey.ts'; + +type MongoDoc = Record; +type ValidatorFn = (userId: string | null, doc: MongoDoc, fields?: string[], modifier?: MongoDoc) => boolean | Promise; + +type ValidatorSet = { + allow: ValidatorFn[]; + deny: ValidatorFn[]; +}; + +type CollectionValidators = { + insert: ValidatorSet; + update: ValidatorSet; + remove: ValidatorSet; + fetch: string[]; + fetchAllFields: boolean; +}; + +type AllowDenyOptions = { + insert?: ValidatorFn; + update?: ValidatorFn; + remove?: ValidatorFn; + fetch?: string[]; + transform?: ((doc: MongoDoc) => unknown) | undefined; + [key: string]: unknown; +}; + +type MethodContext = { + userId: string | null; + isSimulation: boolean; + connection: any; +}; + +const ALLOWED_UPDATE_OPERATIONS = new Set([ + '$inc', + '$set', + '$unset', + '$addToSet', + '$pop', + '$pullAll', + '$pull', + '$pushAll', + '$push', + '$bit', +]); + +const asyncSome = async (array: T[], predicate: (item: T) => boolean | Promise): Promise => { + for (const item of array) { + // eslint-disable-next-line no-await-in-loop + if (await predicate(item)) return true; + } + return false; +}; + +const asyncEvery = async (array: T[], predicate: (item: T) => boolean | Promise): Promise => { + for (const item of array) { + // eslint-disable-next-line no-await-in-loop + if (!(await predicate(item))) return false; + } + return true; +}; + +const transformDoc = (validator: { transform?: ((doc: MongoDoc) => unknown) | null }, doc: MongoDoc): unknown => { + if (validator.transform) return validator.transform(doc); + return doc; +}; + +const docToValidate = ( + validator: { transform?: ((doc: MongoDoc) => unknown) | null }, + doc: MongoDoc, + generatedId: string | null, +): unknown => { + let ret = doc; + if (validator.transform) { + ret = EJSON.clone(doc); + if (generatedId !== null) { + ret._id = generatedId; + } + ret = validator.transform(ret) as MongoDoc; + } + return ret; +}; + +const validateUpdateMutator = (mutator: MongoDoc): string[] => { + const keys = Object.keys(mutator); + if (keys.length === 0) { + throw new MeteorError( + 403, + "Access denied. In a restricted collection you can only update documents, not replace them. Use a Mongo update operator, such as '$set'.", + ); + } + + const modifiedFields: Record = {}; + + for (const op of keys) { + if (op.charAt(0) !== '$') { + throw new MeteorError( + 403, + "Access denied. In a restricted collection you can only update documents, not replace them. Use a Mongo update operator, such as '$set'.", + ); + } + if (!ALLOWED_UPDATE_OPERATIONS.has(op)) { + throw new MeteorError(403, `Access denied. Operator ${op} not allowed in a restricted collection.`); + } + + const params = mutator[op] as Record; + for (const field of Object.keys(params)) { + const rootField = field.indexOf('.') !== -1 ? field.substring(0, field.indexOf('.')) : field; + modifiedFields[rootField] = true; + } + } + + return Object.keys(modifiedFields); +}; + +export class RestrictedCollectionMixin { + public _name?: string; + + public _connection?: any; + + public _collection: any; + + public _prefix = ''; + + public _validators: CollectionValidators = { + insert: { allow: [], deny: [] }, + update: { allow: [], deny: [] }, + remove: { allow: [], deny: [] }, + fetch: [], + fetchAllFields: false, + }; + + public _restricted = false; + + public _insecure?: boolean | undefined; + + public _transform?: (doc: MongoDoc) => unknown; + + public _makeNewID(): string { + throw new Error('Mixin requirement: _makeNewID not implemented'); + } + + public allow(options: AllowDenyOptions): void { + this._addValidator('allow', options); + } + + public deny(options: AllowDenyOptions): void { + this._addValidator('deny', options); + } + + public _isInsecure(): boolean { + return !!this._insecure; + } + + public _updateFetch(fields?: string[]): void { + if (!this._validators.fetchAllFields) { + if (fields) { + const union = new Set(this._validators.fetch); + fields.forEach((f) => union.add(f)); + this._validators.fetch = Array.from(union); + } else { + this._validators.fetchAllFields = true; + this._validators.fetch = []; + } + } + } + + public _defineMutationMethods(options: { useExisting?: boolean } = {}): void { + this._restricted = false; + this._insecure = undefined; + this._validators = { + insert: { allow: [], deny: [] }, + update: { allow: [], deny: [] }, + remove: { allow: [], deny: [] }, + fetch: [], + fetchAllFields: false, + }; + + if (!this._name) return; // anonymous collection + + this._prefix = `/${this._name}/`; + if (this._connection) { + const methods: Record any> = {}; + const methodNames = ['insertAsync', 'updateAsync', 'removeAsync', 'insert', 'update', 'remove']; + + for (const method of methodNames) { + const fullMethodName = this._prefix + method; + + if (options.useExisting) { + const handlerProp = '_methodHandlers'; + if (this._connection[handlerProp] && typeof this._connection[handlerProp][fullMethodName] === 'function') { + continue; + } + } + + methods[fullMethodName] = this._createMutationMethod(method); + } + + this._connection.methods(methods); + } + } + + protected async _validatedInsertAsync(userId: string | null, doc: MongoDoc, generatedId: string | null): Promise { + if ( + await asyncSome(this._validators.insert.deny, (validator) => + validator(userId, docToValidate(validator as any, doc, generatedId) as MongoDoc), + ) + ) { + throw new MeteorError(403, 'Access denied'); + } + if ( + await asyncEvery( + this._validators.insert.allow, + (validator) => !validator(userId, docToValidate(validator as any, doc, generatedId) as MongoDoc), + ) + ) { + throw new MeteorError(403, 'Access denied'); + } + + if (generatedId !== null) doc._id = generatedId; + return this._collection.insertAsync(doc); + } + + protected async _validatedUpdateAsync(userId: string | null, selector: unknown, mutator: MongoDoc, options: any): Promise { + check(mutator, Object); + const safeOptions = Object.assign(Object.create(null), options); + + if (!_selectorIsIdPerhapsAsObject(selector)) { + throw new Error('validated update should be of a single ID'); + } + if (safeOptions.upsert) { + throw new MeteorError(403, 'Access denied. Upserts not allowed in a restricted collection.'); + } + + const fields = validateUpdateMutator(mutator); + const findOptions = this._getFindOptions(); + + const doc = await this._collection.findOneAsync(selector, findOptions); + if (!doc) return 0; + if ( + await asyncSome(this._validators.update.deny, (validator) => + validator(userId, transformDoc(validator as any, doc) as MongoDoc, fields, mutator), + ) + ) { + throw new MeteorError(403, 'Access denied'); + } + if ( + await asyncEvery( + this._validators.update.allow, + (validator) => !validator(userId, transformDoc(validator as any, doc) as MongoDoc, fields, mutator), + ) + ) { + throw new MeteorError(403, 'Access denied'); + } + + safeOptions._forbidReplace = true; + return this._collection.updateAsync(selector, mutator, safeOptions); + } + + protected async _validatedRemoveAsync(userId: string | null, selector: unknown): Promise { + const findOptions = this._getFindOptions(); + const doc = await this._collection.findOneAsync(selector, findOptions); + if (!doc) return 0; + if (await asyncSome(this._validators.remove.deny, (validator) => validator(userId, transformDoc(validator as any, doc) as MongoDoc))) { + throw new MeteorError(403, 'Access denied'); + } + if ( + await asyncEvery(this._validators.remove.allow, (validator) => !validator(userId, transformDoc(validator as any, doc) as MongoDoc)) + ) { + throw new MeteorError(403, 'Access denied'); + } + + return this._collection.removeAsync(selector); + } + + private _getFindOptions() { + const findOptions: Record = { transform: null }; + if (!this._validators.fetchAllFields) { + findOptions.fields = {}; + this._validators.fetch.forEach((fieldName) => { + findOptions.fields[fieldName] = 1; + }); + } + return findOptions; + } + + private _addValidator(allowOrDeny: 'allow' | 'deny', options: AllowDenyOptions) { + const validKeys = new Set(['insert', 'update', 'remove', 'fetch', 'transform', 'insertAsync', 'updateAsync', 'removeAsync']); + + for (const key of Object.keys(options)) { + if (!validKeys.has(key)) throw new Error(`${allowOrDeny}: Invalid key: ${key}`); + if (key.includes('Async')) { + const syncKey = key.replace('Async', ''); + console.warn(`${allowOrDeny}: The "${key}" key is deprecated. Use "${syncKey}" instead.`); + } + } + + this._restricted = true; + + if (options.update || options.remove || options.updateAsync || options.removeAsync || options.fetch) { + if (options.fetch && !Array.isArray(options.fetch)) { + throw new Error(`${allowOrDeny}: Value for \`fetch\` must be an array`); + } + this._updateFetch(options.fetch); + } + } + + private _createMutationMethod(methodName: string) { + const _executeMutation = this._executeMutation.bind(this); + return function (this: MethodContext, ...args: unknown[]) { + check(args, [Match.Any]); + const argArray = Array.from(args); + try { + return _executeMutation(this, methodName, argArray); + } catch (e: any) { + if (e.name === 'MongoError' || e.name === 'BulkWriteError' || e.name === 'MongoBulkWriteError' || e.name === 'MinimongoError') { + throw new MeteorError(409, e.toString()); + } + throw e; + } + }; + } + + private async _executeMutation(methodContext: MethodContext, methodName: string, args: any[]): Promise { + const isInsert = methodName.includes('insert'); + const [firstArg] = args; + let generatedId: string | null = null; + if (isInsert && !isKey(firstArg, '_id')) { + generatedId = this._makeNewID(); + } + if (methodContext.isSimulation) { + if (generatedId !== null && typeof firstArg === 'object' && firstArg !== null) { + firstArg._id = generatedId; + } + return this._collection[methodName](...args); + } + + const syncMethodName = methodName.replace('Async', ''); + const validatedMethodName = + `_validated${syncMethodName.charAt(0).toUpperCase()}${syncMethodName.slice(1)}Async` as keyof RestrictedCollectionMixin; + if (this._restricted) { + if (this._validators[syncMethodName as 'insert' | 'update' | 'remove'].allow.length === 0) { + throw new MeteorError(403, `Access denied. No allow validators set on restricted collection for method '${methodName}'.`); + } + + const methodArgs = [methodContext.userId, ...args]; + if (isInsert) methodArgs.push(generatedId); + + return this[validatedMethodName](...methodArgs); + } + if (this._isInsecure()) { + if (generatedId !== null && typeof firstArg === 'object' && firstArg !== null) { + (firstArg as MongoDoc)._id = generatedId; + } + const syncMethodsMapper = { + insert: 'insertAsync', + update: 'updateAsync', + remove: 'removeAsync', + } as const; + const targetMethod = syncMethodsMapper[methodName as keyof typeof syncMethodsMapper] || methodName; + return this._collection[targetMethod](...args); + } + throw new MeteorError(403, 'Access denied'); + } +} +const CollectionPrototype: Record = {}; +const propertyNames = Object.getOwnPropertyNames(RestrictedCollectionMixin.prototype); + +for (const name of propertyNames) { + if (name === 'constructor') continue; + const descriptor = Object.getOwnPropertyDescriptor(RestrictedCollectionMixin.prototype, name); + if (descriptor) { + Object.defineProperty(CollectionPrototype, name, { ...descriptor, enumerable: true }); + } +} + +export const AllowDeny = { + CollectionPrototype, +}; diff --git a/apps/meteor/src/meteor/base64.ts b/apps/meteor/src/meteor/base64.ts new file mode 100644 index 0000000000000..bf569ce0522c1 --- /dev/null +++ b/apps/meteor/src/meteor/base64.ts @@ -0,0 +1 @@ +export { Base64 } from '@rocket.chat/base64'; diff --git a/apps/meteor/src/meteor/callback-hook.ts b/apps/meteor/src/meteor/callback-hook.ts new file mode 100644 index 0000000000000..a50535f3723e9 --- /dev/null +++ b/apps/meteor/src/meteor/callback-hook.ts @@ -0,0 +1,74 @@ +interface IHookOptions { + bindEnvironment?: boolean; + wrapAsync?: boolean; + exceptionHandler?: ((exception: unknown) => void) | string; + debugPrintExceptions?: string; +} + +type Callback = (...args: TArgs) => TResult; + +export class Hook = Callback> { + nextCallbackId = 0; + + callbacks = new Map(); + + bindEnvironment = true; + + wrapAsync = true; + + exceptionHandler: ((exception: unknown) => void) | string | undefined; + + constructor(options: IHookOptions = {}) { + const { bindEnvironment = true, wrapAsync = true, exceptionHandler, debugPrintExceptions } = options; + + this.bindEnvironment = bindEnvironment; + this.wrapAsync = wrapAsync; + + if (exceptionHandler) { + this.exceptionHandler = exceptionHandler; + } else if (debugPrintExceptions) { + if (typeof debugPrintExceptions !== 'string') { + throw new Error('Hook option debugPrintExceptions should be a string'); + } + this.exceptionHandler = debugPrintExceptions; + } + } + + register(callback: TCallback): { callback: TCallback; stop: () => void } { + const id = this.nextCallbackId++; + this.callbacks.set(id, callback); + + return { + callback, + stop: () => { + this.callbacks.delete(id); + }, + }; + } + + clear() { + this.nextCallbackId = 0; + this.callbacks.clear(); + } + + forEach(iterator: (callback: TCallback) => boolean | void | undefined) { + for (const callback of this.callbacks.values()) { + if (!iterator(callback)) { + break; + } + } + } + + async forEachAsync(iterator: (callback: TCallback) => Promise): Promise { + for (const callback of this.callbacks.values()) { + // eslint-disable-next-line no-await-in-loop + if (!(await iterator(callback))) { + break; + } + } + } + + each(iterator: (callback: TCallback) => boolean | void | undefined) { + return this.forEach(iterator); + } +} diff --git a/apps/meteor/src/meteor/check.ts b/apps/meteor/src/meteor/check.ts new file mode 100644 index 0000000000000..b5d535131a9ef --- /dev/null +++ b/apps/meteor/src/meteor/check.ts @@ -0,0 +1,511 @@ +import { EJSON } from './ejson.ts'; +import { Meteor } from './meteor.ts'; +import { hasOwn } from './utils/hasOwn.ts'; +import { isFunction } from './utils/isFunction.ts'; +import { isObject } from './utils/isObject.ts'; + +type ValidationError = { + message: string; + path: string; +}; +type ValidationResult = null | false | ValidationError | ValidationError[]; + +type Pattern = + | StringConstructor + | NumberConstructor + | BooleanConstructor + | FunctionConstructor + | DateConstructor + | string + | number + | boolean + | null + | undefined + | Pattern[] + | { [key: string]: Pattern } + | Matcher + | (new (...args: any[]) => any); // For custom class constructors + +type Matcher = { + validate(value: unknown, validateFn: typeof testSubtree): ValidationResult; +}; + +const class2type: Record = {}; +const { toString } = class2type; +const fnToString = hasOwn.toString; +const ObjectFunctionString = fnToString.call(Object); +const getProto = Object.getPrototypeOf; + +const isPlainObject = (obj: unknown): obj is Record => { + if (!obj || toString.call(obj) !== '[object Object]') { + return false; + } + + const proto = getProto(obj); + if (!proto) { + return true; + } + + const Ctor = hasOwn(proto, 'constructor') && proto.constructor; + return typeof Ctor === 'function' && fnToString.call(Ctor) === ObjectFunctionString; +}; + +class ArgumentChecker { + public args: unknown[]; + + public description: string; + + constructor(args: unknown[], description: string) { + this.args = [...args]; + this.args.reverse(); + this.description = description; + } + + checking(value: unknown): void { + if (this._checkingOneValue(value)) { + return; + } + + if (Array.isArray(value) || isArguments(value)) { + Array.prototype.forEach.call(value, this._checkingOneValue.bind(this)); + } + } + + _checkingOneValue(value: unknown): boolean { + for (let i = 0; i < this.args.length; ++i) { + if (value === this.args[i] || (Number.isNaN(value) && Number.isNaN(this.args[i] as number))) { + this.args.splice(i, 1); + return true; + } + } + return false; + } + + throwUnlessAllArgumentsHaveBeenChecked(): void { + if (this.args.length > 0) throw new Error(`Did not check() all arguments during ${this.description}`); + } +} + +const currentArgumentChecker = new Meteor.EnvironmentVariable(); + +const formatError = (result: ValidationError) => { + const err = new Match.Error(result.message) as MatchError; + if (result.path) { + err.message += ` in field ${result.path}`; + err.path = result.path; + } + return err; +}; + +const stringForErrorMessage = (value: unknown, options: { onlyShowType?: boolean } = {}): string => { + if (value === null) return 'null'; + if (options.onlyShowType) return typeof value; + if (typeof value !== 'object') return EJSON.stringify(value); + + try { + JSON.stringify(value); + } catch (stringifyError: unknown) { + if (stringifyError instanceof TypeError) { + return typeof value; + } + } + return EJSON.stringify(value); +}; + +class Optional implements Matcher { + constructor(public pattern: Pattern) {} + + validate(value: unknown, validateFn: typeof testSubtree): ValidationResult { + if (value === undefined) return false; + return validateFn(value, this.pattern); + } +} + +class Maybe implements Matcher { + constructor(public pattern: Pattern) {} + + validate(value: unknown, validateFn: typeof testSubtree): ValidationResult { + if (value === undefined || value === null) return false; + return validateFn(value, this.pattern); + } +} + +class OneOf implements Matcher { + public choices: Pattern[]; + + constructor(choices: Pattern[]) { + if (!choices || choices.length === 0) { + throw new Error('Must provide at least one choice to Match.OneOf'); + } + this.choices = choices; + } + + validate(value: unknown, validateFn: typeof testSubtree): ValidationResult { + for (const choice of this.choices) { + if (!validateFn(value, choice)) { + return false; + } + } + return { + message: 'Failed Match.OneOf, Match.Maybe or Match.Optional validation', + path: '', + }; + } +} + +class Where implements Matcher { + constructor(public condition: (value: unknown) => boolean | ValidationError) {} + + validate(value: unknown): ValidationResult { + let result; + try { + result = this.condition(value); + } catch (err: unknown) { + if (err instanceof Match.Error) { + return { message: err.message, path: (err as MatchError).path || '' }; + } + throw err; + } + + if (result) return false; + return { message: 'Failed Match.Where validation', path: '' }; + } +} + +class ObjectIncluding implements Matcher { + constructor(public pattern: Record) {} + + validate(value: unknown, validateFn: typeof testSubtree): ValidationResult { + return validateObject(value, this.pattern, true, validateFn); + } +} + +class ObjectWithValues implements Matcher { + constructor(public pattern: Pattern) {} + + validate(value: unknown, validateFn: typeof testSubtree): ValidationResult { + return validateObjectWithValues(value, this.pattern, validateFn); + } +} + +const typeofChecks = [ + [String, 'string'], + [Number, 'number'], + [Boolean, 'boolean'], + [Function, 'function'], + [undefined, 'undefined'], +] as const; + +const checkPrimitive = (value: unknown, pattern: unknown): ValidationResult => { + for (const [typeConstructor, typeName] of typeofChecks) { + if (pattern === typeConstructor) { + const valueType = typeof value; + if (valueType === typeName) return false; + return { + message: `Expected ${typeName}, got ${stringForErrorMessage(value, { onlyShowType: true })}`, + path: '', + }; + } + } + return null; // Not a primitive check +}; + +const checkLiteral = (value: unknown, pattern: unknown): ValidationResult => { + if (pattern === null) { + return value === null ? false : { message: `Expected null, got ${stringForErrorMessage(value)}`, path: '' }; + } + + if (typeof pattern === 'string' || typeof pattern === 'number' || typeof pattern === 'boolean') { + return value === pattern ? false : { message: `Expected ${pattern}, got ${stringForErrorMessage(value)}`, path: '' }; + } + + return null; // Not a literal check +}; + +const validateArray = ( + value: unknown, + pattern: Pattern[], + collectErrors: boolean, + errors: ValidationError[], + path: string, +): ValidationResult => { + if (pattern.length !== 1) { + return { + message: `Bad pattern: arrays must have one type element ${stringForErrorMessage(pattern)}`, + path: '', + }; + } + + if (!Array.isArray(value) && !isArguments(value)) { + return { message: `Expected array, got ${stringForErrorMessage(value)}`, path: '' }; + } + const arrayValue = value as unknown[]; + + for (let i = 0; i < arrayValue.length; i++) { + const arrPath = `${path}[${i}]`; + const result = testSubtree(arrayValue[i], pattern[0], collectErrors, errors, arrPath); + + if (result) { + const errorResult = result as ValidationError; + errorResult.path = _prependPath(collectErrors ? arrPath : i, errorResult.path); + + if (!collectErrors) return result; + if (typeof arrayValue[i] !== 'object' || errorResult.message) errors.push(errorResult); + } + } + + if (!collectErrors) return false; + return errors.length === 0 ? false : errors; +}; + +const validateObjectWithValues = (value: unknown, valuePattern: Pattern, validateFn: typeof testSubtree): ValidationResult => { + if (typeof value !== 'object' || value === null) { + return { message: `Expected object, got ${value === null ? 'null' : typeof value}`, path: '' }; + } + if (!isPlainObject(value)) { + return { message: 'Expected plain object', path: '' }; + } + for (const key in value) { + if (hasOwn(value, key)) { + const result = validateFn((value as Record)[key], valuePattern); + if (result) return result; + } + } + return false; +}; + +const validateObject = ( + value: unknown, + pattern: Record, + unknownKeysAllowed: boolean, + validateFn: typeof testSubtree, + collectErrors = false, + errors: ValidationError[] = [], + path = '', +): ValidationResult => { + if (typeof value !== 'object' || value === null) { + return { message: `Expected object, got ${value === null ? 'null' : typeof value}`, path: '' }; + } + if (!isPlainObject(value)) { + return { message: 'Expected plain object', path: '' }; + } + + const requiredPatterns: Record = Object.create(null); + const optionalPatterns: Record = Object.create(null); + for (const key of Object.keys(pattern)) { + const subPattern = pattern[key]; + if (subPattern instanceof Optional || subPattern instanceof Maybe) { + optionalPatterns[key] = subPattern.pattern; + } else { + requiredPatterns[key] = subPattern; + } + } + + const objectValue = value as Record; + + for (const key in objectValue) { + if (!hasOwn(objectValue, key)) continue; + + const subValue = objectValue[key]; + const objPath = path ? `${path}.${key}` : key; + let result: ValidationResult = false; + + if (hasOwn(requiredPatterns, key)) { + result = validateFn(subValue, requiredPatterns[key], collectErrors, errors, objPath); + delete requiredPatterns[key]; + } else if (hasOwn(optionalPatterns, key)) { + result = validateFn(subValue, optionalPatterns[key], collectErrors, errors, objPath); + } else if (!unknownKeysAllowed) { + result = { message: 'Unknown key', path: key }; + if (collectErrors) errors.push(result as ValidationError); + else return result; + } + + if (result) { + const res = result as ValidationError; + res.path = _prependPath(collectErrors ? objPath : key, res.path); + if (!collectErrors) return res; + if (typeof subValue !== 'object' || res.message) errors.push(res); + } + } + const missingKeys = Object.keys(requiredPatterns); + if (missingKeys.length) { + const createMissingError = (key: string) => ({ + message: `Missing key '${key}'`, + path: collectErrors ? path : '', + }); + + if (!collectErrors) return createMissingError(missingKeys[0]); + + for (const key of missingKeys) { + errors.push(createMissingError(key)); + } + } + + if (!collectErrors) return false; + return errors.length === 0 ? false : errors; +}; + +const testSubtree = ( + value: unknown, + pattern: Pattern, + collectErrors = false, + errors: ValidationError[] = [], + path = '', +): ValidationResult => { + if (pattern === Match.Any) return false; + const primitiveResult = checkPrimitive(value, pattern); + if (primitiveResult !== null) return primitiveResult; + const literalResult = checkLiteral(value, pattern); + if (literalResult !== null) return literalResult; + if (pattern === Match.Integer) { + if (typeof value === 'number' && (value | 0) === value) return false; + return { message: `Expected Integer, got ${stringForErrorMessage(value)}`, path: '' }; + } + if (pattern === Object) { + return validateObject(value, {}, true, testSubtree, collectErrors, errors, path); + } + if (Array.isArray(pattern)) { + return validateArray(value, pattern, collectErrors, errors, path); + } + if (typeof pattern === 'object' && pattern !== null && 'validate' in pattern && isFunction((pattern as Matcher).validate)) { + return (pattern as Matcher).validate(value, testSubtree); + } + if (pattern instanceof Function) { + if (value instanceof pattern) return false; + return { message: `Expected ${pattern.name || 'particular constructor'}`, path: '' }; + } + if (typeof pattern === 'object' && pattern !== null) { + return validateObject(value, pattern as Record, false, testSubtree, collectErrors, errors, path); + } + + return { message: 'Bad pattern: unknown pattern type', path: '' }; +}; + +export function check(value: unknown, pattern: Pattern, options: { throwAllErrors?: boolean } = { throwAllErrors: false }): void { + const argChecker = currentArgumentChecker.getOrNullIfOutsideFiber(); + if (argChecker) { + argChecker.checking(value); + } + + const result = testSubtree(value, pattern, options.throwAllErrors); + + if (result) { + if (Array.isArray(result) && options.throwAllErrors) { + throw result.map((r) => formatError(r)); + } + throw formatError(result as ValidationError); + } +} + +class MatchError extends Error { + path?: string; + + sanitizedError: Error; + + constructor(msg: string) { + super(`Match error: ${msg}`); + this.path = ''; + this.sanitizedError = new Meteor.Error(400, 'Match failed'); + } +} + +export const Match = { + Optional(pattern: Pattern) { + return new Optional(pattern); + }, + Maybe(pattern: Pattern) { + return new Maybe(pattern); + }, + OneOf(...args: Pattern[]) { + return new OneOf(args); + }, + Any: ['__any__'] as unknown as Pattern, + Where(condition: (value: unknown) => boolean | ValidationError) { + return new Where(condition); + }, + ObjectIncluding(pattern: Record) { + return new ObjectIncluding(pattern); + }, + ObjectWithValues(pattern: Pattern) { + return new ObjectWithValues(pattern); + }, + Integer: ['__integer__'] as unknown as Pattern, + Error: MatchError, + test(value: unknown, pattern: Pattern): boolean { + return !testSubtree(value, pattern); + }, + + _failIfArgumentsAreNotAllChecked(f: (...args: unknown[]) => unknown, context: unknown, args: unknown[], description: string): unknown { + const argChecker = new ArgumentChecker(args, description); + const result = currentArgumentChecker.withValue(argChecker, () => f.apply(context, args)); + argChecker.throwUnlessAllArgumentsHaveBeenChecked(); + return result; + }, +}; + +const _jsKeywords = new Set([ + 'do', + 'if', + 'in', + 'for', + 'let', + 'new', + 'try', + 'var', + 'case', + 'else', + 'enum', + 'eval', + 'false', + 'null', + 'this', + 'true', + 'void', + 'with', + 'break', + 'catch', + 'class', + 'const', + 'super', + 'throw', + 'while', + 'yield', + 'delete', + 'export', + 'import', + 'public', + 'return', + 'static', + 'switch', + 'typeof', + 'default', + 'extends', + 'finally', + 'package', + 'private', + 'continue', + 'debugger', + 'function', + 'arguments', + 'interface', + 'protected', + 'implements', + 'instanceof', +]); + +const _prependPath = (key: string | number, base: string): string => { + let keyStr = key.toString(); + if (typeof key === 'number' || keyStr.match(/^[0-9]+$/)) { + keyStr = `[${key}]`; + } else if (!keyStr.match(/^[a-z_$][0-9a-z_$.[\]]*$/i) || _jsKeywords.has(keyStr)) { + keyStr = JSON.stringify([keyStr]); + } + + if (base && base[0] !== '[') { + return `${keyStr}.${base}`; + } + return keyStr + base; +}; + +const isArguments = (value: unknown): value is IArguments => isObject(value) && hasOwn(value, 'callee') && isFunction(value.callee); diff --git a/apps/meteor/src/meteor/ddp-client.ts b/apps/meteor/src/meteor/ddp-client.ts new file mode 100644 index 0000000000000..7d219fa80f87a --- /dev/null +++ b/apps/meteor/src/meteor/ddp-client.ts @@ -0,0 +1,2132 @@ +import { Hook } from './callback-hook.ts'; +import { DDPCommon, type MethodInvocation, RandomStream } from './ddp-common.ts'; +import { DiffSequence } from './diff-sequence.ts'; +import { EJSON, type EJSONable } from './ejson.ts'; +import { IdMap } from './id-map.ts'; +import { Meteor } from './meteor.ts'; +import type { LocalCollection } from './minimongo.ts'; +import { ObjectID } from './mongo-id.ts'; +import { Random } from './random.ts'; +import { Reload } from './reload.ts'; +import { Retry } from './retry.ts'; +import { ClientStream, type ClientStreamOptions } from './socket-stream-client.ts'; +import { Tracker } from './tracker.ts'; +import { hasOwn } from './utils/hasOwn.ts'; +import { isEmpty } from './utils/isEmpty.ts'; +import { isFunction, type UnknownFunction } from './utils/isFunction.ts'; +import { isKey } from './utils/isKey.ts'; +import { keys } from './utils/keys.ts'; +import { last } from './utils/last.ts'; +import { noop } from './utils/noop.ts'; + +class MongoIDMap extends IdMap { + constructor() { + super(ObjectID.stringify, ObjectID.parse); + } +} + +export class ConnectionStreamHandlers { + _connection: Connection; + + constructor(connection: Connection) { + this._connection = connection; + } + + async onMessage(rawMsg: string) { + let msg; + try { + msg = DDPCommon.parseDDP(rawMsg); + } catch (e) { + console.debug('Exception while parsing DDP', e); + return; + } + + // Any message counts as receiving a pong, as it demonstrates that + // the server is still alive. + if (this._connection._heartbeat) { + this._connection._heartbeat.messageReceived(); + } + + if (msg === null || !msg.msg) { + return; + } + + // Important: This was missing from previous version + // We need to set the current version before routing the message + if (msg.msg === 'connected') { + this._connection._version = this._connection._versionSuggestion; + } + + await this._routeMessage(msg); + } + + async _routeMessage(msg: any) { + switch (msg.msg) { + case 'connected': + await this._connection._livedata_connected(msg); + this._connection.options.onConnected(); + break; + + case 'failed': + await this._handleFailedMessage(msg); + break; + + case 'ping': + if (this._connection.options.respondToPings) { + this._connection._send({ msg: 'pong', id: msg.id }); + } + break; + + case 'pong': + // noop, as we assume everything's a pong + break; + + case 'added': + case 'changed': + case 'removed': + case 'ready': + case 'updated': + await this._connection._livedata_data(msg); + break; + + case 'nosub': + await this._connection._livedata_nosub(msg); + break; + + case 'result': + await this._connection._livedata_result(msg); + break; + + case 'error': + this._connection._livedata_error(msg); + break; + + default: + console.debug('discarding unknown livedata message type', msg); + } + } + + _handleFailedMessage(msg: any) { + if (this._connection._supportedDDPVersions.indexOf(msg.version) >= 0) { + this._connection._versionSuggestion = msg.version; + this._connection._stream.reconnect({ _force: true }); + } else { + const description = `DDP version negotiation failed; server requested version ${msg.version}`; + this._connection._stream.disconnect({ _permanent: true, _error: description }); + this._connection.options.onDDPVersionNegotiationFailure(description); + } + } + + onReset() { + // Reset is called even on the first connection, so this is + // the only place we send this message. + const msg = this._buildConnectMessage(); + this._connection._send(msg); + + // Mark non-retry calls as failed and handle outstanding methods + this._handleOutstandingMethodsOnReset(); + + // Now, to minimize setup latency, go ahead and blast out all of + // our pending methods ands subscriptions before we've even taken + // the necessary RTT to know if we successfully reconnected. + this._connection._callOnReconnectAndSendAppropriateOutstandingMethods(); + this._resendSubscriptions(); + } + + _buildConnectMessage() { + return { + msg: 'connect', + version: this._connection._versionSuggestion || this._connection._supportedDDPVersions[0], + support: this._connection._supportedDDPVersions, + session: this._connection._lastSessionId, + } as const; + } + + _handleOutstandingMethodsOnReset() { + const blocks = this._connection._outstandingMethodBlocks; + if (blocks.length === 0) return; + + const currentMethodBlock = blocks[0].methods; + blocks[0].methods = currentMethodBlock.filter((methodInvoker: any) => { + // Methods with 'noRetry' option set are not allowed to re-send after + // recovering dropped connection. + if (methodInvoker.sentMessage && methodInvoker.noRetry) { + methodInvoker.receiveResult( + new Meteor.Error( + 'invocation-failed', + 'Method invocation might have failed due to dropped connection. ' + + 'Failing because `noRetry` option was passed to Meteor.apply.', + ), + ); + } + + // Only keep a method if it wasn't sent or it's allowed to retry. + return !(methodInvoker.sentMessage && methodInvoker.noRetry); + }); + + // Clear empty blocks + if (blocks.length > 0 && blocks[0].methods.length === 0) { + blocks.shift(); + } + + // Reset all method invokers as unsent + Object.values(this._connection._methodInvokers).forEach((invoker: any) => { + invoker.sentMessage = false; + }); + } + + _resendSubscriptions() { + Object.entries(this._connection._subscriptions).forEach(([id, sub]: [string, any]) => { + this._connection._sendQueued({ + msg: 'sub', + id, + name: sub.name, + params: sub.params, + }); + }); + } +} + +export class MessageProcessors { + _connection: Connection; + + constructor(connection: Connection) { + this._connection = connection; + } + + async _livedata_connected(msg: any) { + const self = this._connection; + + if (self._version !== 'pre1' && self._heartbeatInterval !== 0) { + self._heartbeat = new DDPCommon.Heartbeat({ + heartbeatInterval: self._heartbeatInterval, + heartbeatTimeout: self._heartbeatTimeout, + onTimeout() { + self._lostConnection(new ConnectionError('DDP heartbeat timed out')); + }, + sendPing() { + self._send({ msg: 'ping' }); + }, + }); + self._heartbeat.start(); + } + + // If this is a reconnect, we'll have to reset all stores. + if (self._lastSessionId) self._resetStores = true; + + let reconnectedToPreviousSession; + if (typeof msg.session === 'string') { + reconnectedToPreviousSession = self._lastSessionId === msg.session; + self._lastSessionId = msg.session; + } + + if (reconnectedToPreviousSession) { + // Successful reconnection -- pick up where we left off. + return; + } + + // Server doesn't have our data anymore. Re-sync a new session. + + // Forget about messages we were buffering for unknown collections. They'll + // be resent if still relevant. + self._updatesForUnknownStores = Object.create(null); + + if (self._resetStores) { + // Forget about the effects of stubs. We'll be resetting all collections + // anyway. + self._documentsWrittenByStub = Object.create(null); + self._serverDocuments = Object.create(null); + } + + // Clear _afterUpdateCallbacks. + self._afterUpdateCallbacks = []; + + // Mark all named subscriptions which are ready as needing to be revived. + self._subsBeingRevived = Object.create(null); + Object.entries(self._subscriptions).forEach(([id, sub]: [string, any]) => { + if (sub.ready) { + self._subsBeingRevived[id] = true; + } + }); + + // Arrange for "half-finished" methods to have their callbacks run, and + // track methods that were sent on this connection so that we don't + // quiesce until they are all done. + // + // Start by clearing _methodsBlockingQuiescence: methods sent before + // reconnect don't matter, and any "wait" methods sent on the new connection + // that we drop here will be restored by the loop below. + self._methodsBlockingQuiescence = Object.create(null); + if (self._resetStores) { + const invokers = self._methodInvokers; + keys(invokers).forEach((id) => { + const invoker = invokers[id]; + if (invoker.gotResult()) { + // This method already got its result, but it didn't call its callback + // because its data didn't become visible. We did not resend the + // method RPC. We'll call its callback when we get a full quiesce, + // since that's as close as we'll get to "data must be visible". + self._afterUpdateCallbacks.push(() => invoker.dataVisible()); + } else if (invoker.sentMessage) { + // This method has been sent on this connection (maybe as a resend + // from the last connection, maybe from onReconnect, maybe just very + // quickly before processing the connected message). + // + // We don't need to do anything special to ensure its callbacks get + // called, but we'll count it as a method which is preventing + // reconnect quiescence. (eg, it might be a login method that was run + // from onReconnect, and we don't want to see flicker by seeing a + // logged-out state.) + self._methodsBlockingQuiescence[invoker.methodId] = true; + } + }); + } + + self._messagesBufferedUntilQuiescence = []; + + // If we're not waiting on any methods or subs, we can reset the stores and + // call the callbacks immediately. + if (!self._waitingForQuiescence()) { + if (self._resetStores) { + const promises = Object.values(self._stores).map((store: any) => store.beginUpdate(0, true).then(() => store.endUpdate())); + await Promise.all(promises); + self._resetStores = false; + } + self._runAfterUpdateCallbacks(); + } + } + + async _livedata_data(msg: any) { + const self = this._connection; + + if (self._waitingForQuiescence()) { + self._messagesBufferedUntilQuiescence.push(msg); + + if (msg.msg === 'nosub') { + delete self._subsBeingRevived[msg.id]; + } + + if (msg.subs) { + msg.subs.forEach((subId: string) => { + delete self._subsBeingRevived[subId]; + }); + } + + if (msg.methods) { + msg.methods.forEach((methodId: string) => { + delete self._methodsBlockingQuiescence[methodId]; + }); + } + + if (self._waitingForQuiescence()) { + return; + } + + // No methods or subs are blocking quiescence! + // We'll now process and all of our buffered messages, reset all stores, + // and apply them all at once. + const bufferedMessages = self._messagesBufferedUntilQuiescence; + const promises = Object.values(bufferedMessages).map((bufferedMessage) => + self._processOneDataMessage(bufferedMessage, self._bufferedWrites), + ); + await Promise.all(promises); + self._messagesBufferedUntilQuiescence = []; + } else { + await this._processOneDataMessage(msg, self._bufferedWrites); + } + + // Immediately flush writes when: + // 1. Buffering is disabled. Or; + // 2. any non-(added/changed/removed) message arrives. + const standardWrite = msg.msg === 'added' || msg.msg === 'changed' || msg.msg === 'removed'; + + if (self._bufferedWritesInterval === 0 || !standardWrite) { + await self._flushBufferedWrites(); + return; + } + + if (self._bufferedWritesFlushAt === null) { + self._bufferedWritesFlushAt = new Date().valueOf() + self._bufferedWritesMaxAge; + } else if (self._bufferedWritesFlushAt < new Date().valueOf()) { + await self._flushBufferedWrites(); + return; + } + + if (self._bufferedWritesFlushHandle) { + clearTimeout(self._bufferedWritesFlushHandle); + } + self._bufferedWritesFlushHandle = setTimeout(() => { + self._liveDataWritesPromise = self._flushBufferedWrites(); + if (Meteor._isPromise(self._liveDataWritesPromise)) { + self._liveDataWritesPromise.finally(() => { + self._liveDataWritesPromise = undefined; + }); + } + }, self._bufferedWritesInterval); + } + + async _processOneDataMessage(msg: any, updates: any) { + const messageType = msg.msg; + + switch (messageType) { + case 'added': + await this._connection._process_added(msg, updates); + break; + case 'changed': + this._connection._process_changed(msg, updates); + break; + case 'removed': + this._connection._process_removed(msg, updates); + break; + case 'ready': + this._connection._process_ready(msg, updates); + break; + case 'updated': + this._connection._process_updated(msg, updates); + break; + case 'nosub': + // ignore this + break; + default: + console.debug('discarding unknown livedata data message type', msg); + } + } + + async _livedata_result(msg: any) { + const self = this._connection; + + // Lets make sure there are no buffered writes before returning result. + if (!isEmpty(self._bufferedWrites)) { + await self._flushBufferedWrites(); + } + + // find the outstanding request + // should be O(1) in nearly all realistic use cases + if (isEmpty(self._outstandingMethodBlocks)) { + console.debug('Received method result but no methods outstanding'); + return; + } + const currentMethodBlock = self._outstandingMethodBlocks[0].methods; + let i = -1; + const m = currentMethodBlock.find((method: any, idx: number) => { + const found = method.methodId === msg.id; + if (found) i = idx; + return found; + }); + if (!m) { + console.debug("Can't match method response to original method call", msg); + return; + } + + // Remove from current method block. This may leave the block empty, but we + // don't move on to the next block until the callback has been delivered, in + // _outstandingMethodFinished. + if (i !== -1) { + currentMethodBlock.splice(i, 1); + } + + if (isKey(msg, 'error')) { + m.receiveResult(new Meteor.Error(msg.error.error, msg.error.reason, msg.error.details)); + } else { + // msg.result may be undefined if the method didn't return a value + m.receiveResult(undefined, msg.result); + } + } + + async _livedata_nosub(msg: any) { + const self = this._connection; + + // First pass it through _livedata_data, which only uses it to help get + // towards quiescence. + await this._livedata_data(msg); + + // Do the rest of our processing immediately, with no + // buffering-until-quiescence. + + // we weren't subbed anyway, or we initiated the unsub. + if (!hasOwn(self._subscriptions, msg.id)) { + return; + } + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + const { errorCallback } = self._subscriptions[msg.id]; + const { stopCallback } = self._subscriptions[msg.id]; + + self._subscriptions[msg.id].remove(); + + const meteorErrorFromMsg = (msgArg?: { error?: { error: string | number; reason?: string; details?: string } }) => { + return msgArg?.error && new Meteor.Error(msgArg.error.error, msgArg.error.reason, msgArg.error.details); + }; + + // XXX COMPAT WITH 1.0.3.1 #errorCallback + if (errorCallback && msg.error) { + errorCallback(meteorErrorFromMsg(msg)); + } + + if (stopCallback) { + stopCallback(meteorErrorFromMsg(msg)); + } + } + + _livedata_error(msg: any) { + console.debug('Received error from server: ', msg.reason); + if (msg.offendingMessage) console.debug('For: ', msg.offendingMessage); + } + + // Document change message processors will be defined in a separate class +} + +export class DocumentProcessors { + _connection: any; + + constructor(connection: any) { + this._connection = connection; + } + + async _process_added(msg: any, updates: any) { + const self = this._connection; + const id = ObjectID.parse(msg.id); + const serverDoc = self._getServerDoc(msg.collection, id); + + if (serverDoc) { + // Some outstanding stub wrote here. + const isExisting = serverDoc.document !== undefined; + + serverDoc.document = msg.fields || Object.create(null); + serverDoc.document._id = id; + + if (self._resetStores) { + // During reconnect the server is sending adds for existing ids. + // Always push an update so that document stays in the store after + // reset. Use current version of the document for this update, so + // that stub-written values are preserved. + const currentDoc = await self._stores[msg.collection].getDoc(msg.id); + if (currentDoc !== undefined) msg.fields = currentDoc; + + self._pushUpdate(updates, msg.collection, msg); + } else if (isExisting) { + throw new Error(`Server sent add for existing id: ${msg.id}`); + } + } else { + self._pushUpdate(updates, msg.collection, msg); + } + } + + _process_changed(msg: any, updates: any) { + const self = this._connection; + const serverDoc = self._getServerDoc(msg.collection, ObjectID.parse(msg.id)); + + if (serverDoc) { + if (serverDoc.document === undefined) { + throw new Error(`Server sent changed for nonexisting id: ${msg.id}`); + } + DiffSequence.applyChanges(serverDoc.document, msg.fields); + } else { + self._pushUpdate(updates, msg.collection, msg); + } + } + + _process_removed(msg: any, updates: any) { + const self = this._connection; + const serverDoc = self._getServerDoc(msg.collection, ObjectID.parse(msg.id)); + + if (serverDoc) { + // Some outstanding stub wrote here. + if (serverDoc.document === undefined) { + throw new Error(`Server sent removed for nonexisting id:${msg.id}`); + } + serverDoc.document = undefined; + } else { + self._pushUpdate(updates, msg.collection, { + msg: 'removed', + collection: msg.collection, + id: msg.id, + }); + } + } + + _process_ready(msg: any, _updates: any) { + const self = this._connection; + + // Process "sub ready" messages. "sub ready" messages don't take effect + // until all current server documents have been flushed to the local + // database. We can use a write fence to implement this. + msg.subs.forEach((subId: string) => { + self._runWhenAllServerDocsAreFlushed(() => { + const subRecord = self._subscriptions[subId]; + // Did we already unsubscribe? + if (!subRecord) return; + // Did we already receive a ready message? (Oops!) + if (subRecord.ready) return; + subRecord.ready = true; + subRecord.readyCallback?.(); + subRecord.readyDeps.changed(); + }); + }); + } + + _process_updated(msg: any, updates: any) { + const self = this._connection; + // Process "method done" messages. + msg.methods.forEach((methodId: string) => { + const docs = self._documentsWrittenByStub[methodId] || {}; + Object.values(docs).forEach((written: any) => { + const serverDoc = self._getServerDoc(written.collection, written.id); + if (!serverDoc) { + throw new Error(`Lost serverDoc for ${JSON.stringify(written)}`); + } + if (!serverDoc.writtenByStubs[methodId]) { + throw new Error(`Doc ${JSON.stringify(written)} not written by method ${methodId}`); + } + delete serverDoc.writtenByStubs[methodId]; + if (isEmpty(serverDoc.writtenByStubs)) { + // All methods whose stubs wrote this method have completed! We can + // now copy the saved document to the database (reverting the stub's + // change if the server did not write to this object, or applying the + // server's writes if it did). + + // This is a fake ddp 'replace' message. It's just for talking + // between livedata connections and minimongo. (We have to stringify + // the ID because it's supposed to look like a wire message.) + self._pushUpdate(updates, written.collection, { + msg: 'replace', + id: ObjectID.stringify(written.id), + replace: serverDoc.document, + }); + // Call all flush callbacks. + serverDoc.flushCallbacks.forEach((c: any) => { + c(); + }); + + // Delete this completed serverDocument. Don't bother to GC empty + // IdMaps inside self._serverDocuments, since there probably aren't + // many collections and they'll be written repeatedly. + self._serverDocuments[written.collection].remove(written.id); + } + }); + delete self._documentsWrittenByStub[methodId]; + + // We want to call the data-written callback, but we can't do so until all + // currently buffered messages are flushed. + const callbackInvoker = self._methodInvokers[methodId]; + if (!callbackInvoker) { + throw new Error(`No callback invoker for method ${methodId}`); + } + + self._runWhenAllServerDocsAreFlushed((...args: any[]) => callbackInvoker.dataVisible(...args)); + }); + } + + _pushUpdate(updates: any, collection: string, msg: any) { + if (!isKey(updates, collection)) { + updates[collection] = []; + } + updates[collection].push(msg); + } + + _getServerDoc(collection: string, id: string) { + const self = this._connection; + if (!hasOwn(self._serverDocuments, collection)) { + return null; + } + const serverDocsForCollection = self._serverDocuments[collection]; + return serverDocsForCollection.get(id) || null; + } +} + +// A MethodInvoker manages sending a method to the server and calling the user's +// callbacks. On construction, it registers itself in the connection's +// _methodInvokers map; it removes itself once the method is fully finished and +// the callback is invoked. This occurs when it has both received a result, +// and the data written by it is fully visible. +export class MethodInvoker { + methodId: string; + + sentMessage: boolean; + + _callback: UnknownFunction | undefined; + + _connection: any; + + _message: any; + + _onResultReceived: UnknownFunction; + + _wait: boolean; + + noRetry: boolean; + + _methodResult: any; + + _dataVisible: boolean; + + constructor(options: any) { + // Public (within this file) fields. + this.methodId = options.methodId; + this.sentMessage = false; + + this._callback = options.callback; + this._connection = options.connection; + this._message = options.message; + this._onResultReceived = options.onResultReceived || noop; + this._wait = options.wait; + this.noRetry = options.noRetry; + this._methodResult = null; + this._dataVisible = false; + + // Register with the connection. + this._connection._methodInvokers[this.methodId] = this; + } + + // Sends the method message to the server. May be called additional times if + // we lose the connection and reconnect before receiving a result. + sendMessage() { + // This function is called before sending a method (including resending on + // reconnect). We should only (re)send methods where we don't already have a + // result! + if (this.gotResult()) throw new Error('sendingMethod is called on method with result'); + + // If we're re-sending it, it doesn't matter if data was written the first + // time. + this._dataVisible = false; + this.sentMessage = true; + + // If this is a wait method, make all data messages be buffered until it is + // done. + if (this._wait) this._connection._methodsBlockingQuiescence[this.methodId] = true; + + // Actually send the message. + this._connection._send(this._message); + } + + // Invoke the callback, if we have both a result and know that all data has + // been written to the local cache. + _maybeInvokeCallback() { + if (this._methodResult && this._dataVisible) { + // Call the callback. (This won't throw: the callback was wrapped with + // bindEnvironment.) + this._callback?.(this._methodResult[0], this._methodResult[1]); + + // Forget about this method. + delete this._connection._methodInvokers[this.methodId]; + + // Let the connection know that this method is finished, so it can try to + // move on to the next block of methods. + this._connection._outstandingMethodFinished(); + } + } + + // Call with the result of the method from the server. Only may be called + // once; once it is called, you should not call sendMessage again. + // If the user provided an onResultReceived callback, call it immediately. + // Then invoke the main callback if data is also visible. + receiveResult(err: any, result: any) { + if (this.gotResult()) throw new Error('Methods should only receive results once'); + this._methodResult = [err, result]; + this._onResultReceived(err, result); + this._maybeInvokeCallback(); + } + + // Call this when all data written by the method is visible. This means that + // the method has returns its "data is done" message *AND* all server + // documents that are buffered at that time have been written to the local + // cache. Invokes the main callback if the result has been received. + dataVisible() { + this._dataVisible = true; + this._maybeInvokeCallback(); + } + + // True if receiveResult has been called. + gotResult() { + return !!this._methodResult; + } +} + +type StubOptions = + | { + hasStub: false; + alreadyInSimulation?: boolean | undefined; + randomSeed: { + value: string | null; + }; + isFromCallAsync?: boolean | undefined; + exception?: any; + stubReturnValue?: unknown; + } + | { + hasStub: true; + stubInvocation: () => any; + invocation: MethodInvocation; + stubReturnValue?: unknown; + exception?: any; + alreadyInSimulation?: boolean | undefined; + randomSeed: { + value: string | null; + }; + isFromCallAsync?: boolean | undefined; + }; + +type ConnectionOptions = ClientStreamOptions & { + onConnected: VoidFunction; + reloadWithOutstanding?: boolean; + headers?: Record; + _sockjsOptions?: Record; + onDDPVersionNegotiationFailure: (description: string) => void; + supportedDDPVersions?: string[]; + connectTimeoutMs?: number; + retry?: boolean; + respondToPings?: boolean; + bufferedWritesInterval: number; + bufferedWritesMaxAge: number; +}; + +// @param url {String|Object} URL to Meteor app, +// or an object as a test hook (see code) +// Options: +// reloadWithOutstanding: is it OK to reload if there are outstanding methods? +// headers: extra headers to send on the websockets connection, for +// server-to-server DDP only +// _sockjsOptions: Specifies options to pass through to the sockjs client +// onDDPNegotiationVersionFailure: callback when version negotiation fails. +// +// XXX There should be a way to destroy a DDP connection, causing all +// outstanding method calls to fail. +// +// XXX Our current way of handling failure and reconnection is great +// for an app (where we want to tolerate being disconnected as an +// expect state, and keep trying forever to reconnect) but cumbersome +// for something like a command line tool that wants to make a +// connection, call a method, and print an error if connection +// fails. We should have better usability in the latter case (while +// still transparently reconnecting if it's just a transient failure +// or the server migrating us). +export class Connection { + options: ConnectionOptions; + + onReconnect: VoidFunction | null; + + _stream: ClientStream; + + _lastSessionId: string | null; + + _versionSuggestion: string | null; + + _version: string | null; + + _stores: Record; + + _methodHandlers: Record; + + _nextMethodId: number; + + _supportedDDPVersions: string[]; + + _heartbeatInterval: number; + + _heartbeatTimeout: number; + + _methodInvokers: Record; + + _outstandingMethodBlocks: any[]; + + _documentsWrittenByStub: Record; + + _serverDocuments: Record; + + _afterUpdateCallbacks: VoidFunction[]; + + _messagesBufferedUntilQuiescence: any[]; + + _methodsBlockingQuiescence: Record; + + _subsBeingRevived: Record; + + _resetStores: boolean; + + _updatesForUnknownStores: Record; + + _retryMigrate: VoidFunction | null; + + _bufferedWrites: Record; + + _bufferedWritesFlushAt: number | null; + + _bufferedWritesFlushHandle: any; + + _bufferedWritesInterval: number; + + _bufferedWritesMaxAge: number; + + _subscriptions: Record; + + _userId: string | null; + + _userIdDeps: any; + + _streamHandlers: ConnectionStreamHandlers; + + _heartbeat: any; + + _messageProcessors: MessageProcessors; + + _livedata_connected: UnknownFunction; + + _livedata_data: UnknownFunction; + + _livedata_nosub: UnknownFunction; + + _livedata_result: UnknownFunction; + + _livedata_error: UnknownFunction; + + _documentProcessors: DocumentProcessors; + + _process_added: UnknownFunction; + + _process_changed: UnknownFunction; + + _process_removed: UnknownFunction; + + _process_ready: UnknownFunction; + + _process_updated: UnknownFunction; + + _pushUpdate: (updates: any, collection: string, msg: any) => void; + + _getServerDoc: (collection: string, id: string) => any; + + _liveDataWritesPromise: Promise | undefined; + + _mongo_livedata_collections?: Map; + + constructor(url: string | any, options: Partial) { + this.options = { + onConnected: noop, + onDDPVersionNegotiationFailure(description) { + console.debug(description); + }, + heartbeatInterval: 17500, + heartbeatTimeout: 15000, + // npmFayeOptions: Object.create(null), + // These options are only for testing. + reloadWithOutstanding: false, + supportedDDPVersions: DDPCommon.SUPPORTED_DDP_VERSIONS, + retry: true, + respondToPings: true, + // When updates are coming within this ms interval, batch them together. + bufferedWritesInterval: 5, + // Flush buffers immediately if writes are happening continuously for more than this many ms. + bufferedWritesMaxAge: 500, + + ...options, + }; + + // If set, called when we reconnect, queuing method calls _before_ the + // existing outstanding ones. + // NOTE: This feature has been preserved for backwards compatibility. The + // preferred method of setting a callback on reconnect is to use + // DDP.onReconnect. + this.onReconnect = null; + + this._stream = new ClientStream(url, { + ConnectionError, + ...options, + }); + + this._lastSessionId = null; + this._versionSuggestion = null; // The last proposed DDP version. + this._version = null; // The DDP version agreed on by client and server. + this._stores = Object.create(null); // name -> object with methods + this._methodHandlers = Object.create(null); // name -> func + this._nextMethodId = 1; + this._supportedDDPVersions = this.options.supportedDDPVersions ?? []; + + this._heartbeatInterval = this.options.heartbeatInterval; + this._heartbeatTimeout = this.options.heartbeatTimeout; + + // Tracks methods which the user has tried to call but which have not yet + // called their user callback (ie, they are waiting on their result or for all + // of their writes to be written to the local cache). Map from method ID to + // MethodInvoker object. + this._methodInvokers = Object.create(null); + + // Tracks methods which the user has called but whose result messages have not + // arrived yet. + // + // _outstandingMethodBlocks is an array of blocks of methods. Each block + // represents a set of methods that can run at the same time. The first block + // represents the methods which are currently in flight; subsequent blocks + // must wait for previous blocks to be fully finished before they can be sent + // to the server. + // + // Each block is an object with the following fields: + // - methods: a list of MethodInvoker objects + // - wait: a boolean; if true, this block had a single method invoked with + // the "wait" option + // + // There will never be adjacent blocks with wait=false, because the only thing + // that makes methods need to be serialized is a wait method. + // + // Methods are removed from the first block when their "result" is + // received. The entire first block is only removed when all of the in-flight + // methods have received their results (so the "methods" list is empty) *AND* + // all of the data written by those methods are visible in the local cache. So + // it is possible for the first block's methods list to be empty, if we are + // still waiting for some objects to quiesce. + // + // Example: + // _outstandingMethodBlocks = [ + // {wait: false, methods: []}, + // {wait: true, methods: []}, + // {wait: false, methods: [, + // ]}] + // This means that there were some methods which were sent to the server and + // which have returned their results, but some of the data written by + // the methods may not be visible in the local cache. Once all that data is + // visible, we will send a 'login' method. Once the login method has returned + // and all the data is visible (including re-running subs if userId changes), + // we will send the 'foo' and 'bar' methods in parallel. + this._outstandingMethodBlocks = []; + + // method ID -> array of objects with keys 'collection' and 'id', listing + // documents written by a given method's stub. keys are associated with + // methods whose stub wrote at least one document, and whose data-done message + // has not yet been received. + this._documentsWrittenByStub = {}; + // collection -> IdMap of "server document" object. A "server document" has: + // - "document": the version of the document according the + // server (ie, the snapshot before a stub wrote it, amended by any changes + // received from the server) + // It is undefined if we think the document does not exist + // - "writtenByStubs": a set of method IDs whose stubs wrote to the document + // whose "data done" messages have not yet been processed + this._serverDocuments = {}; + + // Array of callbacks to be called after the next update of the local + // cache. Used for: + // - Calling methodInvoker.dataVisible and sub ready callbacks after + // the relevant data is flushed. + // - Invoking the callbacks of "half-finished" methods after reconnect + // quiescence. Specifically, methods whose result was received over the old + // connection (so we don't re-send it) but whose data had not been made + // visible. + this._afterUpdateCallbacks = []; + + // In two contexts, we buffer all incoming data messages and then process them + // all at once in a single update: + // - During reconnect, we buffer all data messages until all subs that had + // been ready before reconnect are ready again, and all methods that are + // active have returned their "data done message"; then + // - During the execution of a "wait" method, we buffer all data messages + // until the wait method gets its "data done" message. (If the wait method + // occurs during reconnect, it doesn't get any special handling.) + // all data messages are processed in one update. + // + // The following fields are used for this "quiescence" process. + + // This buffers the messages that aren't being processed yet. + this._messagesBufferedUntilQuiescence = []; + // Map from method ID -> true. Methods are removed from this when their + // "data done" message is received, and we will not quiesce until it is + // empty. + this._methodsBlockingQuiescence = {}; + // map from sub ID -> true for subs that were ready (ie, called the sub + // ready callback) before reconnect but haven't become ready again yet + this._subsBeingRevived = {}; // map from sub._id -> true + // if true, the next data update should reset all stores. (set during + // reconnect.) + this._resetStores = false; + + // name -> array of updates for (yet to be created) collections + this._updatesForUnknownStores = {}; + // if we're blocking a migration, the retry func + this._retryMigrate = null; + // Collection name -> array of messages. + this._bufferedWrites = {}; + // When current buffer of updates must be flushed at, in ms timestamp. + this._bufferedWritesFlushAt = null; + // Timeout handle for the next processing of all pending writes + this._bufferedWritesFlushHandle = null; + + this._bufferedWritesInterval = this.options.bufferedWritesInterval; + this._bufferedWritesMaxAge = this.options.bufferedWritesMaxAge; + + // metadata for subscriptions. Map from sub ID to object with keys: + // - id + // - name + // - params + // - inactive (if true, will be cleaned up if not reused in re-run) + // - ready (has the 'ready' message been received?) + // - readyCallback (an optional callback to call when ready) + // - errorCallback (an optional callback to call if the sub terminates with + // an error, XXX COMPAT WITH 1.0.3.1) + // - stopCallback (an optional callback to call when the sub terminates + // for any reason, with an error argument if an error triggered the stop) + this._subscriptions = {}; + + // Reactive userId. + this._userId = null; + this._userIdDeps = new Tracker.Dependency(); + + // Block auto-reload while we're waiting for method responses. + if (!options.reloadWithOutstanding) { + Reload._onMigrate((retry: any) => { + if (!this._readyToMigrate()) { + this._retryMigrate = retry; + return [false]; + } + return [true]; + }); + } + + this._streamHandlers = new ConnectionStreamHandlers(this); + + const onDisconnect = () => { + if (this._heartbeat) { + this._heartbeat.stop(); + this._heartbeat = null; + } + }; + + this._stream.on('message', (msg) => this._streamHandlers.onMessage(msg)); + this._stream.on('reset', () => this._streamHandlers.onReset()); + this._stream.on('disconnect', onDisconnect); + + this._messageProcessors = new MessageProcessors(this); + + // Expose message processor methods to maintain backward compatibility + this._livedata_connected = (msg: any) => this._messageProcessors._livedata_connected(msg); + this._livedata_data = (msg: any) => this._messageProcessors._livedata_data(msg); + this._livedata_nosub = (msg: any) => this._messageProcessors._livedata_nosub(msg); + this._livedata_result = (msg: any) => this._messageProcessors._livedata_result(msg); + this._livedata_error = (msg: any) => this._messageProcessors._livedata_error(msg); + + this._documentProcessors = new DocumentProcessors(this); + + // Expose document processor methods to maintain backward compatibility + this._process_added = (msg: any, updates: any) => this._documentProcessors._process_added(msg, updates); + this._process_changed = (msg: any, updates: any) => this._documentProcessors._process_changed(msg, updates); + this._process_removed = (msg: any, updates: any) => this._documentProcessors._process_removed(msg, updates); + this._process_ready = (msg: any, updates: any) => this._documentProcessors._process_ready(msg, updates); + this._process_updated = (msg: any, updates: any) => this._documentProcessors._process_updated(msg, updates); + + // Also expose utility methods used by other parts of the system + this._pushUpdate = (updates: any, collection: string, msg: any) => this._documentProcessors._pushUpdate(updates, collection, msg); + this._getServerDoc = (collection: string, id: string) => this._documentProcessors._getServerDoc(collection, id); + } + + // 'name' is the name of the data on the wire that should go in the + // store. 'wrappedStore' should be an object with methods beginUpdate, update, + // endUpdate, saveOriginals, retrieveOriginals. see Collection for an example. + createStoreMethods(name: string, wrappedStore: any) { + // const self = this; + + if (name in this._stores) return false; + + // Wrap the input object in an object which makes any store method not + // implemented by 'store' into a no-op. + const store: any = Object.create(null); + const keysOfStore = ['update', 'beginUpdate', 'endUpdate', 'saveOriginals', 'retrieveOriginals', 'getDoc', '_getCollection']; + keysOfStore.forEach((method) => { + store[method] = (...args: any[]) => { + if (wrappedStore[method]) { + return wrappedStore[method](...args); + } + }; + }); + this._stores[name] = store; + // Add _name prop to store + store._name = name; + return store; + } + + registerStoreClient(name: string, wrappedStore: any) { + // const self = this; + + const store = this.createStoreMethods(name, wrappedStore); + + const queued = this._updatesForUnknownStores[name]; + if (Array.isArray(queued)) { + store.beginUpdate(queued.length, false); + queued.forEach((msg: any) => { + store.update(msg); + }); + store.endUpdate(); + delete this._updatesForUnknownStores[name]; + } + + return true; + } + + async registerStoreServer(name: string, wrappedStore: any) { + // const self = this; + + const store = this.createStoreMethods(name, wrappedStore); + + const queued = this._updatesForUnknownStores[name]; + if (Array.isArray(queued)) { + await store.beginUpdate(queued.length, false); + await Promise.all(queued.map((msg) => store.update(msg))); + await store.endUpdate(); + delete this._updatesForUnknownStores[name]; + } + + return true; + } + + subscribe(name: string, ...params: any[]) { + let callbacks: any = Object.create(null); + if (params.length) { + const lastParam = params[params.length - 1]; + if (typeof lastParam === 'function') { + callbacks.onReady = params.pop(); + } else if ( + lastParam && + [ + lastParam.onReady, + // XXX COMPAT WITH 1.0.3.1 onError used to exist, but now we use + // onStop with an error callback instead. + lastParam.onError, + lastParam.onStop, + ].some((f: any) => typeof f === 'function') + ) { + callbacks = params.pop(); + } + } + + // Is there an existing sub with the same name and param, run in an + // invalidated Computation? This will happen if we are rerunning an + // existing computation. + // + // For example, consider a rerun of: + // + // Tracker.autorun(function () { + // Meteor.subscribe("foo", Session.get("foo")); + // Meteor.subscribe("bar", Session.get("bar")); + // }); + // + // If "foo" has changed but "bar" has not, we will match the "bar" + // subcribe to an existing inactive subscription in order to not + // unsub and resub the subscription unnecessarily. + // + // We only look for one such sub; if there are N apparently-identical subs + // being invalidated, we will require N matching subscribe calls to keep + // them all active. + const existing = Object.values(this._subscriptions).find( + (sub) => sub.inactive && sub.name === name && EJSON.equals(sub.params, params), + ); + + let id: string; + if (existing) { + id = existing.id; + existing.inactive = false; // reactivate + + if (callbacks.onReady) { + // If the sub is not already ready, replace any ready callback with the + // one provided now. (It's not really clear what users would expect for + // an onReady callback inside an autorun; the semantics we provide is + // that at the time the sub first becomes ready, we call the last + // onReady callback provided, if any.) + // If the sub is already ready, run the ready callback right away. + // It seems that users would expect an onReady callback inside an + // autorun to trigger once the sub first becomes ready and also + // when re-subs happens. + if (existing.ready) { + callbacks.onReady(); + } else { + existing.readyCallback = callbacks.onReady; + } + } + + // XXX COMPAT WITH 1.0.3.1 we used to have onError but now we call + // onStop with an optional error argument + if (callbacks.onError) { + // Replace existing callback if any, so that errors aren't + // double-reported. + existing.errorCallback = callbacks.onError; + } + + if (callbacks.onStop) { + existing.stopCallback = callbacks.onStop; + } + } else { + // New sub! Generate an id, save it locally, and send message. + id = Random.id(); + this._subscriptions[id] = { + id, + name, + params: EJSON.clone(params), + inactive: false, + ready: false, + readyDeps: new Tracker.Dependency(), + readyCallback: callbacks.onReady, + // XXX COMPAT WITH 1.0.3.1 #errorCallback + errorCallback: callbacks.onError, + stopCallback: callbacks.onStop, + connection: this, + remove() { + delete this.connection._subscriptions[this.id]; + this.ready && this.readyDeps.changed(); + }, + stop() { + this.connection._sendQueued({ msg: 'unsub', id }); + this.remove(); + + if (callbacks.onStop) { + callbacks.onStop(); + } + }, + }; + this._send({ msg: 'sub', id, name, params }); + } + + // return a handle to the application. + const handle = { + stop: () => { + if (!isKey(this._subscriptions, id)) { + return; + } + this._subscriptions[id].stop(); + }, + ready: () => { + // return false if we've unsubscribed. + if (!hasOwn(this._subscriptions, id)) { + return false; + } + const record = this._subscriptions[id]; + record.readyDeps.depend(); + return record.ready; + }, + subscriptionId: id, + }; + + if (Tracker.active) { + // We're in a reactive computation, so we'd like to unsubscribe when the + // computation is invalidated... but not if the rerun just re-subscribes + // to the same subscription! When a rerun happens, we use onInvalidate + // as a change to mark the subscription "inactive" so that it can + // be reused from the rerun. If it isn't reused, it's killed from + // an afterFlush. + Tracker.onInvalidate((_c) => { + if (hasOwn(this._subscriptions, id)) { + this._subscriptions[id].inactive = true; + } + + Tracker.afterFlush(() => { + if (hasOwn(this._subscriptions, id) && this._subscriptions[id].inactive) { + handle.stop(); + } + }); + }); + } + + return handle; + } + + isAsyncCall() { + return DDP._CurrentMethodInvocation._isCallAsyncMethodRunning(); + } + + methods(methods: Record) { + Object.entries(methods).forEach(([name, func]) => { + if (typeof func !== 'function') { + throw new Error(`Method '${name}' must be a function`); + } + if (this._methodHandlers[name]) { + throw new Error(`A method named '${name}' is already defined`); + } + this._methodHandlers[name] = func; + }); + } + + _getIsSimulation({ isFromCallAsync, alreadyInSimulation }: any) { + if (!isFromCallAsync) { + return alreadyInSimulation; + } + return alreadyInSimulation && DDP._CurrentMethodInvocation._isCallAsyncMethodRunning(); + } + + call(name: string, ...args: [...EJSONable[], (...args: any[]) => any]): any { + // if it's a function, the last argument is the result callback, + // not a parameter to the remote method. + const lastArg = args[args.length - 1]; + if (isFunction(lastArg)) { + return this.apply(name, args.slice(0, -1), undefined, lastArg); + } + + return this.apply(name, args, undefined); + } + + callAsync(name: string, ...args: EJSONable[]): Promise { + if (args.length && typeof args[args.length - 1] === 'function') { + throw new Error("Meteor.callAsync() does not accept a callback. You should 'await' the result, or use .then()."); + } + + return this.applyAsync(name, args, { returnServerResultPromise: true }); + } + + apply( + name: string, + args: any[], + options?: { + wait?: boolean; + onResultReceived?: (...args: any[]) => void; + noRetry?: boolean; + throwStubExceptions?: boolean; + returnStubValue?: boolean; + }, + callback?: (...args: any[]) => void, + ) { + const stubOptions = this._stubCall(name, EJSON.clone(args), options); + + if (stubOptions.hasStub) { + if ( + !this._getIsSimulation({ + alreadyInSimulation: stubOptions.alreadyInSimulation, + isFromCallAsync: stubOptions.isFromCallAsync, + }) + ) { + this._saveOriginals(); + } + try { + stubOptions.stubReturnValue = DDP._CurrentMethodInvocation.withValue(stubOptions.invocation, stubOptions.stubInvocation); + if (Meteor._isPromise(stubOptions.stubReturnValue)) { + console.debug( + `Method ${name}: Calling a method that has an async method stub with call/apply can lead to unexpected behaviors. Use callAsync/applyAsync instead.`, + ); + } + } catch (e) { + stubOptions.exception = e; + } + } + return this._apply(name, stubOptions, args, options, callback); + } + + applyAsync(name: string, args: any[], options: any, callback?: ((...args: any[]) => void) | undefined) { + const stubPromise = this._applyAsyncStubInvocation(name, args, options); + + const promise = this._applyAsync({ + name, + args, + options, + callback, + stubPromise, + }); + // only return the stubReturnValue + promise.stubPromise = stubPromise.then((o: any) => { + if (o.exception) { + throw o.exception; + } + return o.stubReturnValue; + }); + // this avoids attribute recursion + promise.serverPromise = new Promise((resolve, reject) => promise.then(resolve).catch(reject)); + return promise; + } + + async _applyAsyncStubInvocation(name: string, args: any[], options: any) { + const stubOptions = this._stubCall(name, EJSON.clone(args), options); + if (stubOptions.hasStub) { + if ( + !this._getIsSimulation({ + alreadyInSimulation: stubOptions.alreadyInSimulation, + isFromCallAsync: stubOptions.isFromCallAsync, + }) + ) { + this._saveOriginals(); + } + try { + const currentContext = DDP._CurrentMethodInvocation._setNewContextAndGetCurrent(stubOptions.invocation); + try { + stubOptions.stubReturnValue = await stubOptions.stubInvocation(); + } catch (e) { + stubOptions.exception = e; + } finally { + DDP._CurrentMethodInvocation._set(currentContext); + } + } catch (e) { + stubOptions.exception = e; + } + } + return stubOptions; + } + + _applyAsync({ + name, + args, + options, + callback, + stubPromise, + }: { + name: string; + args: any[]; + options: any; + callback?: ((...args: any[]) => any) | null | undefined; + stubPromise: Promise; + }): Promise & { stubPromise?: Promise; serverPromise?: Promise } { + return stubPromise.then((stubOptions: StubOptions) => { + return this._apply(name, stubOptions, args, options, callback); + }); + } + + _apply(name: string, stubCallValue: StubOptions, args: any[], options: any, callback?: ((...args: any[]) => any) | null | undefined) { + if (!callback && typeof options === 'function') { + callback = options; + options = Object.create(null); + } + options = options || Object.create(null); + + if (callback) { + // XXX would it be better form to do the binding in stream.on, + // or caller, instead of here? + // XXX improve error message (and how we report it) + callback = Meteor.bindEnvironment(callback, `delivering result of invoking '${name}'`); + } + const { hasStub, exception, stubReturnValue, alreadyInSimulation, randomSeed } = stubCallValue; + + // Keep our args safe from mutation (eg if we don't send the message for a + // while because of a wait method). + args = EJSON.clone(args); + // If we're in a simulation, stop and return the result we have, + // rather than going on to do an RPC. If there was no stub, + // we'll end up returning undefined. + if ( + this._getIsSimulation({ + alreadyInSimulation, + isFromCallAsync: stubCallValue.isFromCallAsync, + }) + ) { + let result; + + if (callback) { + callback(exception, stubReturnValue); + } else { + if (exception) throw exception; + result = stubReturnValue; + } + + return options._returnMethodInvoker ? { result } : result; + } + + // We only create the methodId here because we don't actually need one if + // we're already in a simulation + const methodId = `${this._nextMethodId++}`; + if (hasStub) { + this._retrieveAndStoreOriginals(methodId); + } + + // Generate the DDP message for the method call. Note that on the client, + // it is important that the stub have finished before we send the RPC, so + // that we know we have a complete list of which local documents the stub + // wrote. + const message: Record = { + msg: 'method', + id: methodId, + method: name, + params: args, + }; + + // If an exception occurred in a stub, and we're ignoring it + // because we're doing an RPC and want to use what the server + // returns instead, log it so the developer knows + // (unless they explicitly ask to see the error). + // + // Tests can set the '_expectedByTest' flag on an exception so it won't + // go to log. + if (exception) { + if (options.throwStubExceptions) { + throw exception; + } else if (!exception._expectedByTest) { + console.debug(`Exception while simulating the effect of invoking '${name}'`, exception); + } + } + + // At this point we're definitely doing an RPC, and we're going to + // return the value of the RPC to the caller. + + // If the caller didn't give a callback, decide what to do. + let promise; + if (!callback) { + if (!options.returnServerResultPromise && (!options.isFromCallAsync || options.returnStubValue)) { + callback = (err: any) => { + err && console.debug(`Error invoking Method '${name}'`, err); + }; + } else { + promise = new Promise((resolve: any, reject) => { + callback = (...allArgs: any[]) => { + const args = Array.from(allArgs); + const err = args.shift(); + if (err) { + reject(err); + return; + } + resolve(...args); + }; + }); + } + } + + // Send the randomSeed only if we used it + if (randomSeed.value !== null) { + message.randomSeed = randomSeed.value; + } + + const methodInvoker = new MethodInvoker({ + methodId, + callback, + connection: this, + onResultReceived: options.onResultReceived, + wait: !!options.wait, + message, + noRetry: !!options.noRetry, + }); + + let result; + + if (promise) { + result = options.returnStubValue ? promise.then(() => stubReturnValue) : promise; + } else { + result = options.returnStubValue ? stubReturnValue : undefined; + } + + if (options._returnMethodInvoker) { + return { + methodInvoker, + result, + }; + } + + this._addOutstandingMethod(methodInvoker, options); + return result; + } + + _stubCall(name: string, args: any[], options?: any): StubOptions { + // Run the stub, if we have one. The stub is supposed to make some + // temporary writes to the database to give the user a smooth experience + // until the actual result of executing the method comes back from the + // server (whereupon the temporary writes to the database will be reversed + // during the beginUpdate/endUpdate process.) + // + // Normally, we ignore the return value of the stub (even if it is an + // exception), in favor of the real return value from the server. The + // exception is if the *caller* is a stub. In that case, we're not going + // to do a RPC, so we use the return value of the stub as our return + // value. + // const self = this; + const enclosing = DDP._CurrentMethodInvocation.get(); + const stub = this._methodHandlers[name]; + const alreadyInSimulation = enclosing?.isSimulation; + const isFromCallAsync = enclosing?._isFromCallAsync; + const randomSeed: { value: string | null } = { value: null }; + + const defaultReturn = { + alreadyInSimulation, + randomSeed, + isFromCallAsync, + }; + if (!stub) { + return { ...defaultReturn, hasStub: false }; + } + + // Lazily generate a randomSeed, only if it is requested by the stub. + // The random streams only have utility if they're used on both the client + // and the server; if the client doesn't generate any 'random' values + // then we don't expect the server to generate any either. + // Less commonly, the server may perform different actions from the client, + // and may in fact generate values where the client did not, but we don't + // have any client-side values to match, so even here we may as well just + // use a random seed on the server. In that case, we don't pass the + // randomSeed to save bandwidth, and we don't even generate it to save a + // bit of CPU and to avoid consuming entropy. + + const randomSeedGenerator = () => { + if (randomSeed.value === null) { + randomSeed.value = DDPCommon.makeRpcSeed(enclosing, name); + } + return randomSeed.value; + }; + + const setUserId = (userId: string | null) => { + this.setUserId(userId); + }; + + const invocation = new DDPCommon.MethodInvocation({ + name, + isSimulation: true, + userId: this.userId(), + isFromCallAsync: options?.isFromCallAsync, + setUserId, + randomSeed: randomSeedGenerator, + }); + + // Note that unlike in the corresponding server code, we never audit + // that stubs check() their arguments. + const stubInvocation = () => { + return stub.apply(invocation, EJSON.clone(args)); + }; + return { ...defaultReturn, hasStub: true, stubInvocation, invocation }; + } + + // Before calling a method stub, prepare all stores to track changes and allow + // _retrieveAndStoreOriginals to get the original versions of changed + // documents. + _saveOriginals() { + if (!this._waitingForQuiescence()) { + void this._flushBufferedWrites(); + } + + Object.values(this._stores).forEach((store) => { + store.saveOriginals(); + }); + } + + // Retrieves the original versions of all documents modified by the stub for + // method 'methodId' from all stores and saves them to _serverDocuments (keyed + // by document) and _documentsWrittenByStub (keyed by method ID). + _retrieveAndStoreOriginals(methodId: string) { + if (this._documentsWrittenByStub[methodId]) throw new Error('Duplicate methodId in _retrieveAndStoreOriginals'); + + const docsWritten: any[] = []; + + Object.entries(this._stores).forEach(([collection, store]: [string, any]) => { + const originals = store.retrieveOriginals(); + // not all stores define retrieveOriginals + if (!originals) return; + originals.forEach((doc: any, id: string) => { + docsWritten.push({ collection, id }); + if (!isKey(this._serverDocuments, collection)) { + this._serverDocuments[collection] = new MongoIDMap(); + } + const serverDoc = this._serverDocuments[collection].setDefault(id, Object.create(null)); + if (serverDoc.writtenByStubs) { + // We're not the first stub to write this doc. Just add our method ID + // to the record. + serverDoc.writtenByStubs[methodId] = true; + } else { + // First stub! Save the original value and our method ID. + serverDoc.document = doc; + serverDoc.flushCallbacks = []; + serverDoc.writtenByStubs = Object.create(null); + serverDoc.writtenByStubs[methodId] = true; + } + }); + }); + if (!isEmpty(docsWritten)) { + this._documentsWrittenByStub[methodId] = docsWritten; + } + } + + // This is very much a private function we use to make the tests + // take up fewer server resources after they complete. + _unsubscribeAll() { + Object.values(this._subscriptions).forEach((sub: any) => { + // Avoid killing the autoupdate subscription so that developers + // still get hot code pushes when writing tests. + // + // XXX it's a hack to encode knowledge about autoupdate here, + // but it doesn't seem worth it yet to have a special API for + // subscriptions to preserve after unit tests. + if (sub.name !== 'meteor_autoupdate_clientVersions') { + sub.stop(); + } + }); + } + + // Sends the DDP stringification of the given message object + _send(obj: any, _queue = false) { + this._stream.send(DDPCommon.stringifyDDP(obj)); + } + + // Always queues the call before sending the message + // Used, for example, on subscription.[id].stop() to make sure a "sub" message is always called before an "unsub" message + // https://github.com/meteor/meteor/issues/13212 + // + // This is part of the actual fix for the rest check: + // https://github.com/meteor/meteor/pull/13236 + _sendQueued(obj: any) { + this._send(obj, true); + } + + // We detected via DDP-level heartbeats that we've lost the + // connection. Unlike `disconnect` or `close`, a lost connection + // will be automatically retried. + _lostConnection(maybeError?: unknown) { + this._stream._lostConnection(maybeError); + } + + status() { + return this._stream.status(); + } + + reconnect(...args: any[]) { + return this._stream.reconnect(...args); + } + + disconnect(...args: any[]) { + return this._stream.disconnect(...args); + } + + close() { + return this._stream.disconnect({ _permanent: true }); + } + + userId() { + if (this._userIdDeps) this._userIdDeps.depend(); + return this._userId; + } + + setUserId(userId: string | null) { + // Avoid invalidating dependents if setUserId is called with current value. + if (this._userId === userId) return; + this._userId = userId; + if (this._userIdDeps) this._userIdDeps.changed(); + } + + // Returns true if we are in a state after reconnect of waiting for subs to be + // revived or early methods to finish their data, or we are waiting for a + // "wait" method to finish. + _waitingForQuiescence() { + return !isEmpty(this._subsBeingRevived) || !isEmpty(this._methodsBlockingQuiescence); + } + + // Returns true if any method whose message has been sent to the server has + // not yet invoked its user callback. + _anyMethodsAreOutstanding() { + const invokers = this._methodInvokers; + return Object.values(invokers).some((invoker: any) => !!invoker.sentMessage); + } + + async _processOneDataMessage(msg: any, updates: any) { + const messageType = msg.msg; + + // msg is one of ['added', 'changed', 'removed', 'ready', 'updated'] + if (messageType === 'added') { + await this._process_added(msg, updates); + } else if (messageType === 'changed') { + this._process_changed(msg, updates); + } else if (messageType === 'removed') { + this._process_removed(msg, updates); + } else if (messageType === 'ready') { + this._process_ready(msg, updates); + } else if (messageType === 'updated') { + this._process_updated(msg, updates); + } else if (messageType === 'nosub') { + // ignore this + } else { + console.debug('discarding unknown livedata data message type', msg); + } + } + + _prepareBuffersToFlush() { + if (this._bufferedWritesFlushHandle) { + clearTimeout(this._bufferedWritesFlushHandle); + this._bufferedWritesFlushHandle = null; + } + + this._bufferedWritesFlushAt = null; + // We need to clear the buffer before passing it to + // performWrites. As there's no guarantee that it + // will exit cleanly. + const writes = this._bufferedWrites; + this._bufferedWrites = Object.create(null); + return writes; + } + + _performWritesClient(updates: Record) { + // const self = this; + + if (this._resetStores || !isEmpty(updates)) { + // Synchronous store updates for client + Object.values(this._stores).forEach((store) => { + store.beginUpdate(updates[store._name]?.length || 0, this._resetStores); + }); + + this._resetStores = false; + + Object.entries(updates).forEach(([storeName, messages]) => { + const store = this._stores[storeName]; + if (store) { + messages.forEach((msg) => store.update(msg)); + } else { + this._updatesForUnknownStores[storeName] = this._updatesForUnknownStores[storeName] || []; + this._updatesForUnknownStores[storeName].push(...messages); + } + }); + + Object.values(this._stores).forEach((store) => store.endUpdate()); + } + + this._runAfterUpdateCallbacks(); + } + + async _flushBufferedWrites() { + const writes = this._prepareBuffersToFlush(); + return this._performWritesClient(writes); + } + + // Call any callbacks deferred with _runWhenAllServerDocsAreFlushed whose + // relevant docs have been flushed, as well as dataVisible callbacks at + // reconnect-quiescence time. + _runAfterUpdateCallbacks() { + const callbacks = this._afterUpdateCallbacks; + this._afterUpdateCallbacks = []; + callbacks.forEach((c) => { + c(); + }); + } + + // Ensures that "f" will be called after all documents currently in + // _serverDocuments have been written to the local cache. f will not be called + // if the connection is lost before then! + _runWhenAllServerDocsAreFlushed(f: VoidFunction) { + const runFAfterUpdates = () => { + this._afterUpdateCallbacks.push(f); + }; + let unflushedServerDocCount = 0; + const onServerDocFlush = () => { + --unflushedServerDocCount; + if (unflushedServerDocCount === 0) { + // This was the last doc to flush! Arrange to run f after the updates + // have been applied. + runFAfterUpdates(); + } + }; + + Object.values(this._serverDocuments).forEach((serverDocuments) => { + serverDocuments.forEach((serverDoc: any) => { + const writtenByStubForAMethodWithSentMessage = keys(serverDoc.writtenByStubs).some((methodId) => { + const invoker = this._methodInvokers[methodId]; + return invoker?.sentMessage; + }); + + if (writtenByStubForAMethodWithSentMessage) { + ++unflushedServerDocCount; + serverDoc.flushCallbacks.push(onServerDocFlush); + } + }); + }); + if (unflushedServerDocCount === 0) { + // There aren't any buffered docs --- we can call f as soon as the current + // round of updates is applied! + runFAfterUpdates(); + } + } + + _addOutstandingMethod(methodInvoker: any, options: any) { + if (options?.wait) { + // It's a wait method! Wait methods go in their own block. + this._outstandingMethodBlocks.push({ + wait: true, + methods: [methodInvoker], + }); + } else { + // Not a wait method. Start a new block if the previous block was a wait + // block, and add it to the last block of methods. + if (isEmpty(this._outstandingMethodBlocks) || last(this._outstandingMethodBlocks).wait) { + this._outstandingMethodBlocks.push({ + wait: false, + methods: [], + }); + } + + last(this._outstandingMethodBlocks).methods.push(methodInvoker); + } + + // If we added it to the first block, send it out now. + if (this._outstandingMethodBlocks.length === 1) { + methodInvoker.sendMessage(); + } + } + + // Called by MethodInvoker after a method's callback is invoked. If this was + // the last outstanding method in the current block, runs the next block. If + // there are no more methods, consider accepting a hot code push. + _outstandingMethodFinished() { + if (this._anyMethodsAreOutstanding()) return; + + // No methods are outstanding. This should mean that the first block of + // methods is empty. (Or it might not exist, if this was a method that + // half-finished before disconnect/reconnect.) + if (!isEmpty(this._outstandingMethodBlocks)) { + const firstBlock = this._outstandingMethodBlocks.shift(); + if (!isEmpty(firstBlock.methods)) throw new Error(`No methods outstanding but nonempty block: ${JSON.stringify(firstBlock)}`); + + // Send the outstanding methods now in the first block. + if (!isEmpty(this._outstandingMethodBlocks)) this._sendOutstandingMethods(); + } + + // Maybe accept a hot code push. + this._maybeMigrate(); + } + + // Sends messages for all the methods in the first block in + // _outstandingMethodBlocks. + _sendOutstandingMethods() { + if (isEmpty(this._outstandingMethodBlocks)) { + return; + } + + this._outstandingMethodBlocks[0].methods.forEach((m: any) => { + m.sendMessage(); + }); + } + + _sendOutstandingMethodBlocksMessages(oldOutstandingMethodBlocks: { wait: boolean; methods: any[] }[]) { + if (isEmpty(oldOutstandingMethodBlocks)) return; + + // We have at least one block worth of old outstanding methods to try + // again. First: did onReconnect actually send anything? If not, we just + // restore all outstanding methods and run the first block. + if (isEmpty(this._outstandingMethodBlocks)) { + this._outstandingMethodBlocks = oldOutstandingMethodBlocks; + this._sendOutstandingMethods(); + return; + } + + // OK, there are blocks on both sides. Special case: merge the last block of + // the reconnect methods with the first block of the original methods, if + // neither of them are "wait" blocks. + if (!last(this._outstandingMethodBlocks).wait && !oldOutstandingMethodBlocks[0].wait) { + oldOutstandingMethodBlocks[0].methods.forEach((m) => { + last(this._outstandingMethodBlocks).methods.push(m); + + // If this "last block" is also the first block, send the message. + if (this._outstandingMethodBlocks.length === 1) { + m.sendMessage(); + } + }); + + oldOutstandingMethodBlocks.shift(); + } + + // Now add the rest of the original blocks on. + this._outstandingMethodBlocks.push(...oldOutstandingMethodBlocks); + } + + _callOnReconnectAndSendAppropriateOutstandingMethods() { + const oldOutstandingMethodBlocks = this._outstandingMethodBlocks; + this._outstandingMethodBlocks = []; + + this.onReconnect?.(); + DDP._reconnectHook.forEach((callback) => { + callback(this); + return true; + }); + + this._sendOutstandingMethodBlocksMessages(oldOutstandingMethodBlocks); + } + + // We can accept a hot code push if there are no methods in flight. + _readyToMigrate() { + return isEmpty(this._methodInvokers); + } + + // If we were blocking a migration, see if it's now possible to continue. + // Call whenever the set of outstanding/blocked methods shrinks. + _maybeMigrate() { + if (this._retryMigrate && this._readyToMigrate()) { + this._retryMigrate(); + this._retryMigrate = null; + } + } +} + +// This array allows the `_allSubscriptionsReady` method below, which +// is used by the `spiderable` package, to keep track of whether all +// data is ready. +const allConnections: Map = new Map(); +const _reconnectHook = new Hook<[connection: Connection]>({ bindEnvironment: false }); +// This is private but it's used in a few places. accounts-base uses +// it to get the current user. Meteor.setTimeout and friends clear +// it. We can probably find a better way to factor this. +const _CurrentMethodInvocation = new Meteor.EnvironmentVariable<{ + isSimulation?: boolean; + _isFromCallAsync?: boolean; + randomStream?: RandomStream; + randomSeed?: any; +}>(); +// const _CurrentPublicationInvocation = new Meteor.EnvironmentVariable(); + +// XXX: Keep DDP._CurrentInvocation for backwards-compatibility. +// DDP._CurrentInvocation = DDP._CurrentMethodInvocation; + +// const _CurrentCallAsyncInvocation = new Meteor.EnvironmentVariable(); + +// This is passed into a weird `makeErrorType` function that expects its thing +// to be a constructor +// function connectionErrorConstructor(this: any, message: string) { +// this.message = message; +// } + +// const ConnectionError = Meteor.makeErrorType('DDP.ConnectionError', connectionErrorConstructor); +class ConnectionError extends Error { + constructor(message: string) { + super(message); + this.name = 'DDP.ConnectionError'; + } +} + +// const ForcedReconnectError = Meteor.makeErrorType('DDP.ForcedReconnectError'); +class ForcedReconnectError extends Error { + constructor(message: string) { + super(message); + this.name = 'DDP.ForcedReconnectError'; + } +} + +// Returns the named sequence of pseudo-random values. +// The scope will be DDP._CurrentMethodInvocation.get(), so the stream will produce +// consistent values for method calls on the client and server. +const randomStream = (name: string) => { + const scope = DDP._CurrentMethodInvocation.get(); + return RandomStream.get(scope, name); +}; + +const connect = (url: string, options: Partial = {}) => { + const connection = allConnections.get(url); + if (connection) { + return connection; + } + const ret = new Connection(url, options); + allConnections.set(url, ret); // hack. see below. + return ret; +}; + +const onReconnect = (callback: (connection: Connection) => void) => _reconnectHook.register(callback); + +const runtimeConfig = typeof __meteor_runtime_config__ !== 'undefined' ? __meteor_runtime_config__ : Object.create(null); +const ddpUrl = runtimeConfig.DDP_DEFAULT_CONNECTION_URL || '/'; +export const connection = connect(ddpUrl, { onDDPVersionNegotiationFailure }); + +export const DDP = { + _reconnectHook, + _CurrentMethodInvocation, + ConnectionError, + ForcedReconnectError, + randomStream, + connect, + onReconnect, + connection, +}; + +const retry = new Retry(); + +function onDDPVersionNegotiationFailure(description: string) { + console.debug(description); + + const migrationData = Reload._migrationData('livedata') || Object.create(null); + let failures = migrationData.DDPVersionNegotiationFailures || 0; + + ++failures; + Reload._onMigrate('livedata', () => [true, { DDPVersionNegotiationFailures: failures }]); + + retry.retryLater(failures, () => { + Reload._reload({ immediateMigration: true }); + }); +} + +Meteor.connection = connection; + +['subscribe', 'methods', 'isAsyncCall', 'call', 'callAsync', 'apply', 'applyAsync', 'status', 'reconnect', 'disconnect'].forEach((name) => { + (Meteor as any)[name] = (Meteor.connection as any)[name].bind(Meteor.connection); +}); diff --git a/apps/meteor/src/meteor/ddp-common.ts b/apps/meteor/src/meteor/ddp-common.ts new file mode 100644 index 0000000000000..becf6b11725e0 --- /dev/null +++ b/apps/meteor/src/meteor/ddp-common.ts @@ -0,0 +1,293 @@ +import { EJSON } from './ejson.ts'; +import { Meteor } from './meteor.ts'; +import { Random } from './random.ts'; +import { hasOwn } from './utils/hasOwn.ts'; +import { isEmpty } from './utils/isEmpty.ts'; +import { isKey } from './utils/isKey.ts'; +import { noop } from './utils/noop.ts'; + +class Heartbeat { + heartbeatInterval: number; + + heartbeatTimeout: number; + + _sendPing: (...args: unknown[]) => void; + + _onTimeout: (...args: unknown[]) => void; + + _seenPacket = false; + + _heartbeatIntervalHandle: ReturnType | null = null; + + _heartbeatTimeoutHandle: ReturnType | null = null; + + constructor(options: { + heartbeatInterval: number; + heartbeatTimeout: number; + sendPing: (...args: unknown[]) => void; + onTimeout: (...args: unknown[]) => void; + }) { + this.heartbeatInterval = options.heartbeatInterval; + this.heartbeatTimeout = options.heartbeatTimeout; + this._sendPing = options.sendPing; + this._onTimeout = options.onTimeout; + } + + stop() { + this._clearHeartbeatIntervalTimer(); + this._clearHeartbeatTimeoutTimer(); + } + + start() { + this.stop(); + this._startHeartbeatIntervalTimer(); + } + + _startHeartbeatIntervalTimer() { + this._heartbeatIntervalHandle = setInterval(() => this._heartbeatIntervalFired(), this.heartbeatInterval); + } + + _startHeartbeatTimeoutTimer() { + this._heartbeatTimeoutHandle = setTimeout(() => this._heartbeatTimeoutFired(), this.heartbeatTimeout); + } + + _clearHeartbeatIntervalTimer() { + if (this._heartbeatIntervalHandle) { + Meteor.clearInterval(this._heartbeatIntervalHandle); + this._heartbeatIntervalHandle = null; + } + } + + _clearHeartbeatTimeoutTimer() { + if (this._heartbeatTimeoutHandle) { + Meteor.clearTimeout(this._heartbeatTimeoutHandle); + this._heartbeatTimeoutHandle = null; + } + } + + _heartbeatIntervalFired() { + if (!this._seenPacket && !this._heartbeatTimeoutHandle) { + this._sendPing(); + this._startHeartbeatTimeoutTimer(); + } + + this._seenPacket = false; + } + + _heartbeatTimeoutFired() { + this._heartbeatTimeoutHandle = null; + this._onTimeout(); + } + + messageReceived() { + this._seenPacket = true; + + if (this._heartbeatTimeoutHandle) { + this._clearHeartbeatTimeoutTimer(); + } + } +} + +const SUPPORTED_DDP_VERSIONS = ['1', 'pre2', 'pre1']; + +function parseDDP(stringMessage: string) { + let msg: Record; + try { + msg = JSON.parse(stringMessage); + } catch (e) { + console.debug('Discarding message with invalid JSON', stringMessage); + + return null; + } + + if (msg === null || typeof msg !== 'object') { + console.debug('Discarding non-object DDP message', stringMessage); + + return null; + } + + if (hasOwn(msg, 'cleared')) { + if (!isKey(msg, 'fields')) { + msg.fields = {}; + } + + msg.cleared.forEach((clearKey: string) => { + msg.fields[clearKey] = undefined; + }); + + delete msg.cleared; + } + + ['fields', 'params', 'result'].forEach((field) => { + if (hasOwn(msg, field)) { + msg[field] = EJSON._adjustTypesFromJSONValue(msg[field]); + } + }); + + return msg; +} + +function stringifyDDP(msg: any) { + const copy = EJSON.clone(msg); + + if (hasOwn(msg, 'fields')) { + const cleared: string[] = []; + + Object.keys(msg.fields).forEach((key) => { + const value = msg.fields[key]; + + if (typeof value === 'undefined') { + cleared.push(key); + delete copy.fields[key]; + } + }); + + if (!isEmpty(cleared)) { + copy.cleared = cleared; + } + + if (isEmpty(copy.fields)) { + delete copy.fields; + } + } + + ['fields', 'params', 'result'].forEach((field) => { + if (hasOwn(copy, field)) { + copy[field] = EJSON._adjustTypesToJSONValue(copy[field]); + } + }); + + if (msg.id && typeof msg.id !== 'string') { + throw new Error('Message id is not a string'); + } + + return JSON.stringify(copy); +} + +type MethodInvocationOptions = { + name: string; + isSimulation: boolean; + unblock?: (...args: unknown[]) => void; + isFromCallAsync?: boolean; + userId: string | null; + setUserId?: (id: string | null) => void; + connection?: any; + randomSeed: string | (() => string); + fence?: any; +}; +export class MethodInvocation { + name: string; + + isSimulation: boolean; + + _unblock: (...args: unknown[]) => void; + + _calledUnblock: boolean; + + _isFromCallAsync: boolean; + + userId: string | null; + + _setUserId: (id: string | null) => void; + + connection: any; + + randomSeed: string | (() => string); + + randomStream: any; + + fence: any; + + constructor(options: MethodInvocationOptions) { + this.name = options.name; + this.isSimulation = options.isSimulation; + this._unblock = options.unblock || noop; + this._calledUnblock = false; + this._isFromCallAsync = !!options.isFromCallAsync; + this.userId = options.userId; + this._setUserId = options.setUserId || noop; + this.connection = options.connection; + this.randomSeed = options.randomSeed; + this.randomStream = null; + this.fence = options.fence; + } + + unblock() { + this._calledUnblock = true; + this._unblock(); + } + + async setUserId(userId: string | null) { + if (this._calledUnblock) { + throw new Error("Can't call setUserId in a method after calling unblock"); + } + + this.userId = userId; + await this._setUserId(userId); + } +} + +function randomToken() { + return Random.hexString(20); +} + +export class RandomStream { + seed: (string | (() => string))[]; + + sequences: any; + + constructor(options: { seed?: string }) { + this.seed = [options.seed || randomToken()]; + this.sequences = Object.create(null); + } + + _sequence(name: any) { + let sequence = this.sequences[name] || null; + + if (sequence === null) { + const sequenceSeed = this.seed.concat(name).map((s) => (typeof s === 'function' ? s() : s)); + + sequence = Random.createWithSeeds.apply(null, sequenceSeed); + this.sequences[name] = sequence; + } + + return sequence; + } + + static get(scope?: { randomStream?: RandomStream; randomSeed?: any } | undefined, name?: string): (typeof Random)['insecure'] { + if (!name) { + name = 'default'; + } + + if (!scope) { + return Random.insecure; + } + + let { randomStream } = scope; + + if (!randomStream) { + randomStream = new RandomStream({ + seed: scope.randomSeed, + }); + scope.randomStream = randomStream; + } + + return randomStream._sequence(name); + } +} + +function makeRpcSeed(enclosing: any, methodName: string) { + const stream = RandomStream.get(enclosing, `/rpc/${methodName}`); + + return stream.hexString(20); +} + +export const DDPCommon = { + Heartbeat, + SUPPORTED_DDP_VERSIONS, + parseDDP, + stringifyDDP, + MethodInvocation, + RandomStream, + makeRpcSeed, +}; diff --git a/apps/meteor/src/meteor/diff-sequence-core.ts b/apps/meteor/src/meteor/diff-sequence-core.ts new file mode 100644 index 0000000000000..14c72e08a6c23 --- /dev/null +++ b/apps/meteor/src/meteor/diff-sequence-core.ts @@ -0,0 +1,252 @@ +import { EJSON } from './ejson.ts'; +import { isEmptyObject } from './utils/isEmptyObject.ts'; + +export type DocWithId = { + _id: string; + [key: string]: unknown; +}; + +export type DiffCallbacks = { + both?: (key: K, leftValue: V, rightValue: V) => void; + leftOnly?: (key: K, value: V) => void; + rightOnly?: (key: K, value: V) => void; +}; + +export type UnorderedObserver = { + added?: (id: string, fields: Partial) => void; + changed?: (id: string, fields: Partial) => void; + removed?: (id: string) => void; + movedBefore?: never; +}; + +export type OrderedObserver = { + added?: (id: string, fields: Partial) => void; + addedBefore?: (id: string, fields: Partial, before: string | null) => void; + changed?: (id: string, fields: Partial) => void; + movedBefore?: (id: string, before: string | null) => void; + removed?: (id: string) => void; +}; + +export const diffObjects = >( + left: TDoc, + right: TDoc, + callbacks: DiffCallbacks, +) => { + for (const key of Object.keys(left) as Array) { + const leftValue = left[key]; + + if (Object.hasOwn(right, key as string)) { + callbacks.both?.(key, leftValue, right[key]); + } else { + callbacks.leftOnly?.(key, leftValue); + } + } + + if (callbacks.rightOnly) { + for (const key of Object.keys(right) as Array) { + if (!Object.hasOwn(left, key as string)) { + callbacks.rightOnly(key, right[key]); + } + } + } +}; + +export const diffMaps = (left: Map, right: Map, callbacks: DiffCallbacks) => { + for (const [key, leftValue] of left) { + const rightValue = right.get(key); + + if (rightValue !== undefined) { + callbacks.both?.(key, leftValue, rightValue); + } else { + callbacks.leftOnly?.(key, leftValue); + } + } + + if (callbacks.rightOnly) { + for (const [key, rightValue] of right) { + if (!left.has(key)) { + callbacks.rightOnly(key, rightValue); + } + } + } +}; + +export const makeChangedFields = >(newDoc: TDoc, oldDoc: TDoc): Partial => { + const fields: Partial = {}; + + diffObjects(oldDoc, newDoc, { + leftOnly: (key) => { + fields[key] = undefined; + }, + rightOnly: (key, value) => { + fields[key] = value; + }, + both: (key, leftValue, rightValue) => { + if (!EJSON.equals(leftValue, rightValue)) { + fields[key] = rightValue; + } + }, + }); + + return fields; +}; + +export const diffQueryUnorderedChanges = ( + oldResults: Map, + newResults: Map, + observer: UnorderedObserver, + { projectionFn = EJSON.clone }: { projectionFn?: (doc: T) => Partial } = {}, +) => { + if ('movedBefore' in observer && observer.movedBefore) { + throw new Error('_diffQueryUnordered called with a movedBefore observer!'); + } + + for (const [id, newDoc] of newResults) { + const oldDoc = oldResults.get(id); + + if (oldDoc) { + if (observer.changed && !EJSON.equals(oldDoc, newDoc)) { + const changedFields = makeChangedFields(projectionFn(newDoc), projectionFn(oldDoc)); + + if (!isEmptyObject(changedFields)) observer.changed(id, changedFields); + } + } else if (observer.added) { + const fields = projectionFn(newDoc); + delete fields._id; + observer.added(id, fields); + } + } + + if (observer.removed) { + for (const id of oldResults.keys()) { + if (!newResults.has(id)) observer.removed(id); + } + } +}; + +export const diffQueryOrderedChanges = ( + oldResults: T[], + newResults: T[], + observer: OrderedObserver, + { projectionFn = EJSON.clone }: { projectionFn?: (doc: T) => Partial } = {}, +) => { + const newPresenceOfId = new Set(); + for (const doc of newResults) { + if (newPresenceOfId.has(doc._id)) console.debug('Duplicate _id in newResults'); + newPresenceOfId.add(doc._id); + } + + const oldIndexOfId = new Map(); + oldResults.forEach((doc, i) => { + if (oldIndexOfId.has(doc._id)) console.debug('Duplicate _id in oldResults'); + oldIndexOfId.set(doc._id, i); + }); + + const unmoved: number[] = []; + let maxSeqLen = 0; + const N = newResults.length; + const seqEnds = new Array(N); + const ptrs = new Array(N); + + for (let i = 0; i < N; i++) { + const currentOldIdx = oldIndexOfId.get(newResults[i]._id); + if (currentOldIdx !== undefined) { + let j = maxSeqLen; + + while (j > 0) { + const prevOldIdx = oldIndexOfId.get(newResults[seqEnds[j - 1]]._id); + if (prevOldIdx !== undefined && prevOldIdx < currentOldIdx) { + break; + } + j--; + } + + ptrs[i] = j === 0 ? -1 : seqEnds[j - 1]; + seqEnds[j] = i; + + if (j + 1 > maxSeqLen) { + maxSeqLen = j + 1; + } + } + } + + let idx = maxSeqLen === 0 ? -1 : seqEnds[maxSeqLen - 1]; + while (idx >= 0) { + unmoved.push(idx); + idx = ptrs[idx]; + } + + unmoved.reverse(); + unmoved.push(newResults.length); + + if (observer.removed) { + for (const doc of oldResults) { + if (!newPresenceOfId.has(doc._id)) observer.removed(doc._id); + } + } + + let startOfGroup = 0; + + for (const endOfGroup of unmoved) { + const groupId = newResults[endOfGroup]?._id ?? null; + + for (let i = startOfGroup; i < endOfGroup; i++) { + const newDoc = newResults[i]; + + const oldIndex = oldIndexOfId.get(newDoc._id); + + if (oldIndex === undefined) { + const fields = projectionFn(newDoc); + delete fields._id; + + if (observer.addedBefore) observer.addedBefore(newDoc._id, fields, groupId); + else observer.added?.(newDoc._id, fields); + } else { + const oldDoc = oldResults[oldIndex]; + const fields = makeChangedFields(projectionFn(newDoc), projectionFn(oldDoc)); + + if (!isEmptyObject(fields)) observer.changed?.(newDoc._id, fields); + observer.movedBefore?.(newDoc._id, groupId); + } + } + + if (groupId) { + const newDoc = newResults[endOfGroup]; + const oldIndex = oldIndexOfId.get(newDoc._id); + + if (oldIndex !== undefined) { + const oldDoc = oldResults[oldIndex]; + const fields = makeChangedFields(projectionFn(newDoc), projectionFn(oldDoc)); + + if (!isEmptyObject(fields)) observer.changed?.(newDoc._id, fields); + } + } + + startOfGroup = endOfGroup + 1; + } +}; + +type DiffQueryArgs = + | [ordered: true, oldResults: T[], newResults: T[], observer: OrderedObserver, options?: { projectionFn?: (doc: T) => Partial }] + | [ + ordered: false | undefined, + oldResults: Map, + newResults: Map, + observer: UnorderedObserver, + options?: { projectionFn?: (doc: T) => Partial }, + ]; + +export const diffQueryChanges = (...[ordered, oldResults, newResults, observer, options]: DiffQueryArgs) => + ordered + ? diffQueryOrderedChanges(oldResults, newResults, observer, options) + : diffQueryUnorderedChanges(oldResults, newResults, observer, options); + +export const applyChanges = >(doc: T, changeFields: Partial) => { + for (const [key, value] of Object.entries(changeFields)) { + if (value === undefined) { + delete doc[key]; + } else { + doc[key as keyof T] = value as T[keyof T]; + } + } +}; diff --git a/apps/meteor/src/meteor/diff-sequence.ts b/apps/meteor/src/meteor/diff-sequence.ts new file mode 100644 index 0000000000000..2eabbd590fd3f --- /dev/null +++ b/apps/meteor/src/meteor/diff-sequence.ts @@ -0,0 +1 @@ +export * as DiffSequence from './diff-sequence-core.ts'; \ No newline at end of file diff --git a/apps/meteor/src/meteor/ejson.ts b/apps/meteor/src/meteor/ejson.ts new file mode 100644 index 0000000000000..aaba946b13450 --- /dev/null +++ b/apps/meteor/src/meteor/ejson.ts @@ -0,0 +1,695 @@ +import { Base64 } from './base64.ts'; +import { hasOwn } from './utils/hasOwn.ts'; + +type EJSONOptions = { + canonical?: boolean; + indent?: boolean | number | string; + keyOrderSensitive?: boolean; +}; + +interface IEJSONConverter { + matchJSONValue(obj: any): boolean; + matchObject(obj: any): boolean; + toJSONValue(obj: any): any; + fromJSONValue(obj: any): any; +} + +type JSONable = { + [key: string]: number | string | boolean | object | number[] | string[] | object[] | undefined | null; +}; + +export type EJSONableCustomType = { + clone?(): EJSONableCustomType; + equals?(other: object): boolean; + toJSONValue(): JSONable; + typeName(): string; +}; + +export type EJSONableProperty = + | number + | string + | boolean + | object + | number[] + | string[] + | object[] + | Date + | Uint8Array + | EJSONableCustomType + | undefined + | null; + +export type EJSONable = { + [key: string]: EJSONableProperty; +}; + +class CustomTypesMap extends Map any> { + override get(name: string): (jsonValue: any) => any { + const factory = super.get(name); + + if (!factory) { + throw new Error(`Custom EJSON type ${name} is not defined`); + } + + return factory; + } +} + +const customTypes = new CustomTypesMap(); + +const isFunction = (fn: unknown): fn is (...args: unknown[]) => unknown => typeof fn === 'function'; +const isObject = (fn: any): fn is Record => typeof fn === 'object' && fn !== null; + +const keysOf = (obj: any) => Object.keys(obj); +const lengthOf = (obj: any) => Object.keys(obj).length; + +const convertMapToObject = (map: Map) => + Array.from(map).reduce( + (acc, [key, value]) => { + acc[key] = value; + return acc; + }, + {} as Record, + ); + +const isArguments = (obj: any) => obj != null && hasOwn(obj, 'callee'); +const isInfOrNaN = (obj: any) => Number.isNaN(obj) || obj === Infinity || obj === -Infinity; + +const checkError = { + maxStack: (msgError: string) => new RegExp('Maximum call stack size exceeded', 'g').test(msgError), +}; + +const handleError = any>(fn: T) => + function (this: any, ...args: Parameters): ReturnType { + try { + return fn.apply(this, args); + } catch (error: any) { + const isMaxStack = checkError.maxStack(error.message); + + if (isMaxStack) { + throw new Error('Converting circular structure to JSON'); + } + + throw error; + } + }; + +function quote(string: string) { + return JSON.stringify(string); +} + +const str = (key: string, holder: any, singleIndent: string | false, outerIndent: string, canonical: boolean): string => { + const value = holder[key]; + + switch (typeof value) { + case 'string': + return quote(value); + + case 'number': + return isFinite(value) ? String(value) : 'null'; + + case 'boolean': + return String(value); + + case 'object': { + if (!value) { + return 'null'; + } + + const innerIndent = outerIndent + singleIndent; + const partial: string[] = []; + let v: string | undefined; + + if (Array.isArray(value) || hasOwn(value, 'callee')) { + const { length } = value; + + for (let i = 0; i < length; i += 1) { + partial[i] = str(String(i), value, singleIndent, innerIndent, canonical) || 'null'; + } + + if (partial.length === 0) { + v = '[]'; + } else if (innerIndent) { + v = `[\n${innerIndent}${partial.join(`,\n${innerIndent}`)}\n${outerIndent}]`; + } else { + v = `[${partial.join(',')}]`; + } + + return v; + } + + let keys = Object.keys(value); + + if (canonical) { + keys = keys.sort(); + } + + keys.forEach((k) => { + v = str(k, value, singleIndent, innerIndent, canonical); + + if (v) { + partial.push(quote(k) + (innerIndent ? ': ' : ':') + v); + } + }); + + if (partial.length === 0) { + v = '{}'; + } else if (innerIndent) { + v = `{\n${innerIndent}${partial.join(`,\n${innerIndent}`)}\n${outerIndent}}`; + } else { + v = `{${partial.join(',')}}`; + } + + return v; + } + default: + return 'null'; + } +}; + +const canonicalStringify = (value: any, options: EJSONOptions): string => { + const allOptions = { indent: '', canonical: false, ...options }; + + if (allOptions.indent === true) { + allOptions.indent = ' '; + } else if (typeof allOptions.indent === 'number') { + let newIndent = ''; + for (let i = 0; i < allOptions.indent; i++) { + newIndent += ' '; + } + allOptions.indent = newIndent; + } + + return str('', { '': value }, allOptions.indent, '', allOptions.canonical); +}; +function toJSONValue(item: any): any { + const changed = toJSONValueHelper(item); + + if (changed !== undefined) { + return changed; + } + + let newItem = item; + + if (isObject(item)) { + newItem = EJSON.clone(item); + adjustTypesToJSONValue(newItem); + } + + return newItem; +} + +function fromJSONValue(item: any): any { + let changed = fromJSONValueHelper(item); + + if (changed === item && isObject(item)) { + changed = EJSON.clone(item); + adjustTypesFromJSONValue(changed); + } + + return changed; +} + +function _isCustomType(obj: any): boolean { + return obj && isFunction(obj.toJSONValue) && isFunction(obj.typeName) && customTypes.has(obj.typeName()); +} + +const builtinConverters: IEJSONConverter[] = [ + { + matchJSONValue(obj) { + return hasOwn(obj, '$date') && lengthOf(obj) === 1; + }, + + matchObject(obj) { + return obj instanceof Date; + }, + + toJSONValue(obj) { + return { $date: obj.getTime() }; + }, + + fromJSONValue(obj) { + return new Date(obj.$date); + }, + }, + + { + matchJSONValue(obj) { + return hasOwn(obj, '$regexp') && hasOwn(obj, '$flags') && lengthOf(obj) === 2; + }, + + matchObject(obj) { + return obj instanceof RegExp; + }, + + toJSONValue(regexp) { + return { $regexp: regexp.source, $flags: regexp.flags }; + }, + + fromJSONValue(obj) { + return new RegExp( + obj.$regexp, + obj.$flags + .slice(0, 50) + .replace(/[^gimuy]/g, '') + .replace(/(.)(?=.*\1)/g, ''), + ); + }, + }, + + { + matchJSONValue(obj) { + return hasOwn(obj, '$InfNaN') && lengthOf(obj) === 1; + }, + matchObject: isInfOrNaN, + toJSONValue(obj) { + let sign; + + if (Number.isNaN(obj)) { + sign = 0; + } else if (obj === Infinity) { + sign = 1; + } else { + sign = -1; + } + + return { $InfNaN: sign }; + }, + + fromJSONValue(obj) { + return obj.$InfNaN / 0; + }, + }, + + { + matchJSONValue(obj) { + return hasOwn(obj, '$binary') && lengthOf(obj) === 1; + }, + + matchObject(obj) { + return (typeof Uint8Array !== 'undefined' && obj instanceof Uint8Array) || (obj && hasOwn(obj, '$Uint8ArrayPolyfill')); + }, + + toJSONValue(obj) { + return { $binary: Base64.encode(obj) }; + }, + + fromJSONValue(obj) { + return Base64.decode(obj.$binary); + }, + }, + + { + matchJSONValue(obj) { + return hasOwn(obj, '$escape') && lengthOf(obj) === 1; + }, + + matchObject(obj) { + let match = false; + + if (obj) { + const keyCount = lengthOf(obj); + + if (keyCount === 1 || keyCount === 2) { + match = builtinConverters.some((converter) => converter.matchJSONValue(obj)); + } + } + + return match; + }, + + toJSONValue(obj) { + const newObj: Record = {}; + + keysOf(obj).forEach((key) => { + newObj[key] = toJSONValue(obj[key]); + }); + + return { $escape: newObj }; + }, + + fromJSONValue(obj) { + const newObj: Record = {}; + + keysOf(obj.$escape).forEach((key) => { + newObj[key] = fromJSONValue(obj.$escape[key]); + }); + + return newObj; + }, + }, + + { + matchJSONValue(obj) { + return hasOwn(obj, '$type') && hasOwn(obj, '$value') && lengthOf(obj) === 2; + }, + + matchObject(obj) { + return _isCustomType(obj); + }, + + toJSONValue(obj) { + const jsonValue = obj.toJSONValue(); + + return { $type: obj.typeName(), $value: jsonValue }; + }, + + fromJSONValue(obj: { $type: string; $value: any }) { + const typeName = obj.$type; + + if (!customTypes.has(typeName)) { + throw new Error(`Custom EJSON type ${typeName} is not defined`); + } + + const converter = customTypes.get(typeName); + return converter(obj.$value); + }, + }, +]; + +const _getTypes = (isOriginal = false) => { + return isOriginal ? customTypes : convertMapToObject(customTypes); +}; + +const _getConverters = () => builtinConverters; + +const toJSONValueHelper = (item: any) => { + for (let i = 0; i < builtinConverters.length; i++) { + const converter = builtinConverters[i]; + + if (converter.matchObject(item)) { + return converter.toJSONValue(item); + } + } + + return undefined; +}; + +const adjustTypesToJSONValue = (obj: unknown): any => { + if (obj === null) { + return null; + } + + const maybeChanged = toJSONValueHelper(obj); + + if (maybeChanged !== undefined) { + return maybeChanged; + } + + if (!isObject(obj)) { + return obj; + } + + keysOf(obj).forEach((key) => { + const value = obj[key]; + + if (!isObject(value) && value !== undefined && !isInfOrNaN(value)) { + return; + } + + const changed = toJSONValueHelper(value); + + if (changed) { + obj[key] = changed; + + return; + } + + adjustTypesToJSONValue(value); + }); + + return obj; +}; + +const fromJSONValueHelper = (value: any) => { + if (isObject(value) && value !== null) { + const keys = keysOf(value); + + if (keys.length <= 2 && keys.every((k) => typeof k === 'string' && k.substr(0, 1) === '$')) { + for (let i = 0; i < builtinConverters.length; i++) { + const converter = builtinConverters[i]; + + if (converter.matchJSONValue(value)) { + return converter.fromJSONValue(value); + } + } + } + } + + return value; +}; + +const adjustTypesFromJSONValue = (obj: any): any => { + if (obj === null) { + return null; + } + + const maybeChanged = fromJSONValueHelper(obj); + + if (maybeChanged !== obj) { + return maybeChanged; + } + + if (!isObject(obj)) { + return obj; + } + + keysOf(obj).forEach((key) => { + const value = obj[key]; + + if (isObject(value)) { + const changed = fromJSONValueHelper(value); + + if (value !== changed) { + obj[key] = changed; + + return; + } + + adjustTypesFromJSONValue(value); + } + }); + + return obj; +}; + +const stringify = handleError((item: any, options?: EJSONOptions): string => { + let serialized: string; + const json = toJSONValue(item); + + if (options && (options.canonical || options.indent)) { + serialized = canonicalStringify(json, options); + } else { + serialized = JSON.stringify(json); + } + + return serialized; +}); + +const parse = (item: string) => { + if (typeof item !== 'string') { + throw new Error('EJSON.parse argument should be a string'); + } + + return fromJSONValue(JSON.parse(item)); +}; + +const isBinary = (obj: unknown): obj is Uint8Array => { + return obj instanceof Uint8Array; +}; + +const equals = (a: any, b: any, options?: { keyOrderSensitive?: boolean }): boolean => { + let i: number; + const keyOrderSensitive = !!options?.keyOrderSensitive; + + if (a === b) { + return true; + } + + if (Number.isNaN(a) && Number.isNaN(b)) { + return true; + } + + if (!a || !b) { + return false; + } + + if (!isObject(a) || !isObject(b)) { + return false; + } + + if (a instanceof Date && b instanceof Date) { + return a.valueOf() === b.valueOf(); + } + + if (isBinary(a) && isBinary(b)) { + if ((a as any).length !== (b as any).length) { + return false; + } + + for (i = 0; i < (a as any).length; i++) { + if ((a as any)[i] !== (b as any)[i]) { + return false; + } + } + + return true; + } + + if (isFunction((a as any).equals)) { + return (a as any).equals(b, options); + } + + if (isFunction((b as any).equals)) { + return (b as any).equals(a, options); + } + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + + if (aIsArray !== bIsArray) { + return false; + } + + if (aIsArray && bIsArray) { + if (a.length !== b.length) { + return false; + } + + for (i = 0; i < a.length; i++) { + if (!equals(a[i], b[i], options)) { + return false; + } + } + + return true; + } + + if (_isCustomType(a) || _isCustomType(b)) { + if (!_isCustomType(a) || !_isCustomType(b)) { + return false; + } + return equals(toJSONValue(a), toJSONValue(b)); + } + + let ret; + const aKeys = keysOf(a); + const bKeys = keysOf(b); + + if (keyOrderSensitive) { + i = 0; + + ret = aKeys.every((key) => { + if (i >= bKeys.length) { + return false; + } + + if (key !== bKeys[i]) { + return false; + } + + if (!equals((a as any)[key], (b as any)[bKeys[i]], options)) { + return false; + } + + i++; + + return true; + }); + } else { + i = 0; + + ret = aKeys.every((key) => { + if (!hasOwn(b, key)) { + return false; + } + + if (!equals((a as any)[key], (b as any)[key], options)) { + return false; + } + + i++; + + return true; + }); + } + + return ret && i === bKeys.length; +}; + +const clone = (v: any): any => { + let ret: any; + + if (!isObject(v)) { + return v; + } + + if (v === null) { + return null; + } + + if (v instanceof Date) { + return new Date(v.getTime()); + } + + if (v instanceof RegExp) { + return v; + } + + if (isBinary(v)) { + ret = (Base64 as any).newBinary((v as any).length); + + for (let i = 0; i < (v as any).length; i++) { + ret[i] = (v as any)[i]; + } + + return ret; + } + + if (Array.isArray(v)) { + return v.map(clone); + } + + if (isArguments(v)) { + return Array.from(v as any).map(clone); + } + + if (isFunction(v.clone)) { + return v.clone(); + } + + if (_isCustomType(v)) { + return fromJSONValue(clone(toJSONValue(v))); + } + + ret = {}; + + keysOf(v).forEach((key) => { + ret[key] = clone(v[key]); + }); + + return ret; +}; + +export const EJSON = { + addType: (name: string, factory: (jsonValue: any) => any) => { + if (customTypes.has(name)) { + throw new Error(`Type ${name} already present`); + } + + customTypes.set(name, factory); + }, + _getTypes, + _getConverters, + _isCustomType, + _adjustTypesToJSONValue: adjustTypesToJSONValue, + _adjustTypesFromJSONValue: adjustTypesFromJSONValue, + toJSONValue, + fromJSONValue, + stringify, + parse, + isBinary, + equals, + clone, + newBinary: Base64.newBinary, +}; diff --git a/apps/meteor/src/meteor/facebook-oauth.ts b/apps/meteor/src/meteor/facebook-oauth.ts new file mode 100644 index 0000000000000..d875f8e045d18 --- /dev/null +++ b/apps/meteor/src/meteor/facebook-oauth.ts @@ -0,0 +1,64 @@ +import { Meteor } from './meteor.ts'; +import { OAuth } from './oauth.ts'; +import { Random } from './random.ts'; +import { ServiceConfiguration } from './service-configuration.ts'; +import { hasOwn } from './utils/hasOwn.ts'; +import { isObject } from './utils/isObject.ts'; + +type FacebookOptions = { + requestPermissions?: string[]; + params?: any; + absoluteUrlOptions?: any; + redirectUrl?: string; + auth_type?: string; + loginStyle?: string; +}; + +type CredentialRequestCompleteCallback = (token?: string | Error) => void; + +export const Facebook = { + requestCredential( + options?: FacebookOptions | CredentialRequestCompleteCallback, + credentialRequestCompleteCallback?: CredentialRequestCompleteCallback, + ) { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + const config = ServiceConfiguration.configurations.findOne({ service: 'facebook' }); + + if (!config || !isObject(config) || !hasOwn(config, 'appId')) { + if (credentialRequestCompleteCallback) { + credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError()); + } + return; + } + + const opts = (options as FacebookOptions) || {}; + const credentialToken = Random.secret(); + const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone/i.test(navigator.userAgent); + const display = mobile ? 'touch' : 'popup'; + const scope = opts.requestPermissions ? opts.requestPermissions.join(',') : 'email'; + + const loginStyle = OAuth._loginStyle('facebook', config, opts); + const API_VERSION = Meteor.settings?.public?.packages?.['facebook-oauth']?.apiVersion || '17.0'; + + const redirectUri = OAuth._redirectUri('facebook', config, opts.params, opts.absoluteUrlOptions); + const stateParam = OAuth._stateParam(loginStyle, credentialToken, opts.redirectUrl); + + let loginUrl = `https://www.facebook.com/v${API_VERSION}/dialog/oauth?client_id=${config.appId}&redirect_uri=${redirectUri}&display=${display}&scope=${scope}&state=${stateParam}`; + + if (opts.auth_type) { + loginUrl += `&auth_type=${encodeURIComponent(opts.auth_type)}`; + } + + OAuth.launchLogin({ + loginService: 'facebook', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); + }, +}; diff --git a/apps/meteor/src/meteor/geojson-utils-core.ts b/apps/meteor/src/meteor/geojson-utils-core.ts new file mode 100644 index 0000000000000..3d991319f2391 --- /dev/null +++ b/apps/meteor/src/meteor/geojson-utils-core.ts @@ -0,0 +1,269 @@ +export type Position = [longitude: number, latitude: number]; + +export type Shape = { + type: TType; + coordinates: TCoordinates; +}; + +export type Point = Shape<'Point', Position>; +export type LineString = Shape<'LineString', Position[]>; +export type Polygon = Shape<'Polygon', Position[][]>; +export type Geometry = Point | LineString | Polygon; + +export type BoundingBox = [[minLng: number, minLat: number], [maxLng: number, maxLat: number]]; + +const EARTH_RADIUS_KM = 6371; + +export const numberToRadius = (deg: number): number => (deg * Math.PI) / 180; +export const numberToDegree = (rad: number): number => (rad * 180) / Math.PI; + +const getSegmentIntersection = (p1: Position, p2: Position, p3: Position, p4: Position): Position | null => { + const [[x1, y1], [x2, y2], [x3, y3], [x4, y4]] = [p1, p2, p3, p4]; + const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1); + + if (denom === 0) return null; // Parallel or collinear + + const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom; + const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom; + + if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) { + return [x1 + ua * (x2 - x1), y1 + ua * (y2 - y1)]; + } + return null; +}; + +export const lineStringsIntersect = (l1: LineString, l2: LineString): Point[] | false => { + const intersects: Point[] = []; + const c1 = l1.coordinates; + const c2 = l2.coordinates; + + for (let i = 0; i < c1.length - 1; i++) { + for (let j = 0; j < c2.length - 1; j++) { + const intersection = getSegmentIntersection(c1[i], c1[i + 1], c2[j], c2[j + 1]); + if (intersection) { + intersects.push({ type: 'Point', coordinates: intersection }); + } + } + } + return intersects.length > 0 ? intersects : false; +}; + +export const boundingBoxAroundPolyCoords = (coords: Position[][]): BoundingBox => { + const outerRing = coords[0]; + if (!outerRing?.length) throw new Error('Polygon has no coordinates'); + + return outerRing.reduce( + ([[minLng, minLat], [maxLng, maxLat]], [lng, lat]) => [ + [Math.min(minLng, lng), Math.min(minLat, lat)], + [Math.max(maxLng, lng), Math.max(maxLat, lat)], + ], + [ + [Infinity, Infinity], + [-Infinity, -Infinity], + ], + ); +}; + +export const pointInBoundingBox = (point: Point, [[minLng, minLat], [maxLng, maxLat]]: BoundingBox): boolean => { + const [lng, lat] = point.coordinates; + return lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat; +}; + +const isPointInRing = ([px, py]: Position, ring: Position[]): boolean => { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + + const intersect = yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; +}; + +export const pointInPolygon = (p: Point, poly: Polygon): boolean => { + if (!pointInBoundingBox(p, boundingBoxAroundPolyCoords(poly.coordinates))) return false; + return poly.coordinates.some((ring) => isPointInRing(p.coordinates, ring)); +}; + +export const drawCircle = (radiusInMeters: number, centerPoint: Point, steps = 15): Polygon => { + const [centerLng, centerLat] = centerPoint.coordinates; + const dist = radiusInMeters / 1000 / EARTH_RADIUS_KM; + const radCenterLat = numberToRadius(centerLat); + const radCenterLng = numberToRadius(centerLng); + + const polyCoordinates: Position[] = Array.from({ length: steps }, (_, i) => { + const brng = (2 * Math.PI * i) / steps; + const lat = Math.asin(Math.sin(radCenterLat) * Math.cos(dist) + Math.cos(radCenterLat) * Math.sin(dist) * Math.cos(brng)); + const lng = + radCenterLng + + Math.atan2(Math.sin(brng) * Math.sin(dist) * Math.cos(radCenterLat), Math.cos(dist) - Math.sin(radCenterLat) * Math.sin(lat)); + + return [numberToDegree(lng), numberToDegree(lat)]; + }); + + polyCoordinates.push(polyCoordinates[0]); // Close the circle + + return { type: 'Polygon', coordinates: [polyCoordinates] }; +}; + +export const rectangleCentroid = (rectangle: Polygon): Point => { + const [[xmin, ymin], , [xmax, ymax]] = rectangle.coordinates[0]; + return { + type: 'Point', + coordinates: [xmin + (xmax - xmin) / 2, ymin + (ymax - ymin) / 2], + }; +}; + +export const pointDistance = (pt1: Point, pt2: Point): number => { + const [lon1, lat1] = pt1.coordinates; + const [lon2, lat2] = pt2.coordinates; + + const dLat = numberToRadius(lat2 - lat1); + const dLon = numberToRadius(lon2 - lon1); + + const a = + Math.pow(Math.sin(dLat / 2), 2) + Math.cos(numberToRadius(lat1)) * Math.cos(numberToRadius(lat2)) * Math.pow(Math.sin(dLon / 2), 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return EARTH_RADIUS_KM * c * 1000; +}; + +export const geometryWithinRadius = (geometry: Geometry, center: Point, radius: number): boolean => { + let coords: Position[]; + if (geometry.type === 'Point') { + coords = [geometry.coordinates]; + } else if (geometry.type === 'Polygon') { + coords = geometry.coordinates[0]; + } else { + coords = geometry.coordinates; + } + + return coords.every((coord) => pointDistance({ type: 'Point', coordinates: coord }, center) <= radius); +}; + +const getPolygonCartesianData = (ring: Position[]) => { + let areaSize = 0; + let x = 0; + let y = 0; + + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + + const f = xi * yj - xj * yi; + areaSize += f; + x += (xi + xj) * f; + y += (yi + yj) * f; + } + + return { area: areaSize / 2, f: areaSize * 3, x, y }; +}; + +export const area = (polygon: Polygon): number => getPolygonCartesianData(polygon.coordinates[0]).area; + +export const centroid = (polygon: Polygon): Point => { + const { f, x, y } = getPolygonCartesianData(polygon.coordinates[0]); + return { type: 'Point', coordinates: [x / f, y / f] }; +}; + +export const simplify = (sourcePoints: Point[], kinkMeters = 20): Point[] => { + if (sourcePoints.length < 3) return sourcePoints; + + const source = sourcePoints.map((p) => ({ lng: p.coordinates[0], lat: p.coordinates[1] })); + const nSource = source.length; + + let bandSqr = (kinkMeters * 360.0) / (2.0 * Math.PI * 6378137.0); + bandSqr *= bandSqr; + + const index: number[] = []; + const sigStart: number[] = [0]; + const sigEnd: number[] = [nSource - 1]; + let nStack = 1; + const F = (Math.PI / 180.0) * 0.5; + + while (nStack > 0) { + const start = sigStart[--nStack]; + const end = sigEnd[nStack]; + + if (end - start > 1) { + const s = source[start]; + const e = source[end]; + + let x12 = e.lng - s.lng; + const y12 = e.lat - s.lat; + + if (Math.abs(x12) > 180.0) x12 = 360.0 - Math.abs(x12); + x12 *= Math.cos(F * (e.lat + s.lat)); + + const d12 = x12 * x12 + y12 * y12; + let maxDevSqr = -1.0; + let sig = start; + + for (let i = start + 1; i < end; i++) { + const cur = source[i]; + + let x13 = cur.lng - s.lng; + const y13 = cur.lat - s.lat; + + if (Math.abs(x13) > 180.0) x13 = 360.0 - Math.abs(x13); + x13 *= Math.cos(F * (cur.lat + s.lat)); + const d13 = x13 * x13 + y13 * y13; + + let x23 = cur.lng - e.lng; + const y23 = cur.lat - e.lat; + + if (Math.abs(x23) > 180.0) x23 = 360.0 - Math.abs(x23); + x23 *= Math.cos(F * (cur.lat + e.lat)); + const d23 = x23 * x23 + y23 * y23; + + let devSqr: number; + if (d13 >= d12 + d23) { + devSqr = d23; + } else if (d23 >= d12 + d13) { + devSqr = d13; + } else { + devSqr = Math.pow(x13 * y12 - y13 * x12, 2) / d12; + } + + if (devSqr > maxDevSqr) { + sig = i; + maxDevSqr = devSqr; + } + } + + if (maxDevSqr < bandSqr) { + index.push(start); + } else { + sigStart[nStack] = sig; + sigEnd[nStack++] = end; + sigStart[nStack] = start; + sigEnd[nStack++] = sig; + } + } else { + index.push(start); + } + } + + index.push(nSource - 1); + return index.sort((a, b) => a - b).map((i) => sourcePoints[i]); +}; + +export const destinationPoint = (pt: Point, brng: number, dist: number): Point => { + const distRad = dist / EARTH_RADIUS_KM; + const brngRad = numberToRadius(brng); + + const lon1 = numberToRadius(pt.coordinates[0]); + const lat1 = numberToRadius(pt.coordinates[1]); + + const lat2 = Math.asin(Math.sin(lat1) * Math.cos(distRad) + Math.cos(lat1) * Math.sin(distRad) * Math.cos(brngRad)); + let lon2 = lon1 + Math.atan2(Math.sin(brngRad) * Math.sin(distRad) * Math.cos(lat1), Math.cos(distRad) - Math.sin(lat1) * Math.sin(lat2)); + + lon2 = ((lon2 + 3 * Math.PI) % (2 * Math.PI)) - Math.PI; + + return { + type: 'Point', + coordinates: [numberToDegree(lon2), numberToDegree(lat2)], + }; +}; diff --git a/apps/meteor/src/meteor/geojson-utils.ts b/apps/meteor/src/meteor/geojson-utils.ts new file mode 100644 index 0000000000000..b8a908cd1e39e --- /dev/null +++ b/apps/meteor/src/meteor/geojson-utils.ts @@ -0,0 +1 @@ +export * as GeoJSON from './geojson-utils-core.ts'; diff --git a/apps/meteor/src/meteor/google-oauth.ts b/apps/meteor/src/meteor/google-oauth.ts new file mode 100644 index 0000000000000..df65e7f355033 --- /dev/null +++ b/apps/meteor/src/meteor/google-oauth.ts @@ -0,0 +1,100 @@ +import { OAuth } from './oauth.ts'; +import { Random } from './random.ts'; +import { ServiceConfiguration } from './service-configuration.ts'; + +type GoogleOptions = { + requestPermissions?: string[]; + loginUrlParameters?: Record; + requestOfflineToken?: boolean; + forceApprovalPrompt?: boolean; + prompt?: string; + loginHint?: string; + loginStyle?: 'popup' | 'redirect'; + redirectUrl?: string; + [key: string]: any; +}; + +type CredentialRequestCompleteCallback = (error?: Error | unknown) => void; + +const ILLEGAL_PARAMETERS: Record = { + response_type: true, + client_id: true, + scope: true, + redirect_uri: true, + state: true, +}; + +export const Google = { + requestCredential( + options?: GoogleOptions | CredentialRequestCompleteCallback, + credentialRequestCompleteCallback?: CredentialRequestCompleteCallback, + ) { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } else if (!options) { + options = {}; + } + + const opts = options as GoogleOptions; + + const config = ServiceConfiguration.configurations.findOne({ service: 'google' }) as GoogleOptions | undefined; + + if (!config) { + if (credentialRequestCompleteCallback) { + credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError()); + } + return; + } + + const credentialToken = Random.secret(); + const scopeSet = new Set(opts.requestPermissions || ['profile']); + scopeSet.add('email'); + const scopes = Array.from(scopeSet); + const loginUrlParameters: Record = { + ...(config.loginUrlParameters || {}), + ...(opts.loginUrlParameters || {}), + }; + Object.keys(loginUrlParameters).forEach((key) => { + if (ILLEGAL_PARAMETERS[key]) { + throw new Error(`Google.requestCredential: Invalid loginUrlParameter: ${key}`); + } + }); + if (opts.requestOfflineToken != null) { + loginUrlParameters.access_type = opts.requestOfflineToken ? 'offline' : 'online'; + } + + if (opts.prompt != null) { + loginUrlParameters.prompt = opts.prompt; + } else if (opts.forceApprovalPrompt) { + loginUrlParameters.prompt = 'consent'; + } + + if (opts.loginHint) { + loginUrlParameters.login_hint = opts.loginHint; + } + + const loginStyle = OAuth._loginStyle('google', config, opts); + Object.assign(loginUrlParameters, { + response_type: 'code', + client_id: config.clientId, + scope: scopes.join(' '), + redirect_uri: OAuth._redirectUri('google', config), + state: OAuth._stateParam(loginStyle, credentialToken, opts.redirectUrl), + }); + const queryString = Object.keys(loginUrlParameters) + .map((param) => `${encodeURIComponent(param)}=${encodeURIComponent(loginUrlParameters[param])}`) + .join('&'); + + const loginUrl = `https://accounts.google.com/o/oauth2/auth?${queryString}`; + + OAuth.launchLogin({ + loginService: 'google', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { height: 600 }, + }); + }, +}; diff --git a/apps/meteor/src/meteor/id-map.ts b/apps/meteor/src/meteor/id-map.ts new file mode 100644 index 0000000000000..b040210f17f7d --- /dev/null +++ b/apps/meteor/src/meteor/id-map.ts @@ -0,0 +1,81 @@ +import { EJSON } from './ejson.ts'; + +export class IdMap { + _map = new Map(); + + _idStringify: (id: TId) => string; + + _idParse: (id: string) => TId; + + constructor(idStringify: (id: TId) => string = JSON.stringify, idParse: (id: string) => TId = JSON.parse) { + this._idStringify = idStringify; + this._idParse = idParse; + } + + get(id: TId) { + const key = this._idStringify(id); + return this._map.get(key); + } + + set(id: TId, value: TValue) { + const key = this._idStringify(id); + this._map.set(key, value); + } + + remove(id: TId) { + const key = this._idStringify(id); + this._map.delete(key); + } + + has(id: TId) { + const key = this._idStringify(id); + return this._map.has(key); + } + + empty() { + return this._map.size === 0; + } + + clear() { + this._map.clear(); + } + + forEach(iterator: (value: TValue, id: TId) => unknown) { + for (const [key, value] of this._map) { + const breakIfFalse = iterator.call(null, value, this._idParse(key)); + if (breakIfFalse === false) { + return; + } + } + } + + async forEachAsync(iterator: (this: null, value: TValue, id: TId) => unknown) { + for (const [key, value] of this._map) { + // eslint-disable-next-line no-await-in-loop + if ((await iterator.call(null, value, this._idParse(key))) === false) { + return; + } + } + } + + size() { + return this._map.size; + } + + setDefault(id: TId, def: TValue) { + const key = this._idStringify(id); + if (this._map.has(key)) { + return this._map.get(key); + } + this._map.set(key, def); + return def; + } + + clone() { + const clone = new IdMap(this._idStringify, this._idParse); + this._map.forEach((value, key) => { + clone._map.set(key, EJSON.clone(value)); + }); + return clone; + } +} diff --git a/apps/meteor/src/meteor/meteor-developer-oauth.ts b/apps/meteor/src/meteor/meteor-developer-oauth.ts new file mode 100644 index 0000000000000..11b201bea847e --- /dev/null +++ b/apps/meteor/src/meteor/meteor-developer-oauth.ts @@ -0,0 +1,76 @@ +import { OAuth } from './oauth.ts'; +import { Random } from './random.ts'; +import { ServiceConfiguration } from './service-configuration.ts'; + +type MeteorDeveloperOptions = { + developerAccountsServer?: string; + redirectUrl?: string; + details?: string; + userEmail?: string; + loginHint?: string; + loginStyle?: 'popup' | 'redirect'; + [key: string]: any; +}; + +type CredentialRequestCompleteCallback = (error?: Error | unknown) => void; + +export const MeteorDeveloperAccounts = { + _server: 'https://www.meteor.com', + + _config(options: MeteorDeveloperOptions) { + if (options.developerAccountsServer) { + this._server = options.developerAccountsServer; + } + }, + + requestCredential( + options?: MeteorDeveloperOptions | CredentialRequestCompleteCallback, + credentialRequestCompleteCallback?: CredentialRequestCompleteCallback, + ) { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + const config = ServiceConfiguration.configurations.findOne({ service: 'meteor-developer' }) as MeteorDeveloperOptions | undefined; + + if (!config) { + if (credentialRequestCompleteCallback) { + credentialRequestCompleteCallback(new ServiceConfiguration.ConfigError()); + } + return; + } + + const opts = (options as MeteorDeveloperOptions) || {}; + const credentialToken = Random.secret(); + const loginStyle = OAuth._loginStyle('meteor-developer', config, opts); + let { loginHint } = opts; + if (opts.userEmail && !loginHint) { + loginHint = opts.userEmail; + } + let loginUrl = + `${MeteorDeveloperAccounts._server}/oauth2/authorize` + + `?state=${OAuth._stateParam(loginStyle, credentialToken, opts.redirectUrl)}` + + `&response_type=code` + + `&client_id=${config.clientId}`; + + if (opts.details) { + loginUrl += `&details=${opts.details}`; + } + + if (loginHint) { + loginUrl += `&user_email=${encodeURIComponent(loginHint)}`; + } + + loginUrl += `&redirect_uri=${OAuth._redirectUri('meteor-developer', config)}`; + + OAuth.launchLogin({ + loginService: 'meteor-developer', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + popupOptions: { width: 497, height: 749 }, + }); + }, +}; diff --git a/apps/meteor/src/meteor/meteor.ts b/apps/meteor/src/meteor/meteor.ts new file mode 100644 index 0000000000000..e8f91250ab6c3 --- /dev/null +++ b/apps/meteor/src/meteor/meteor.ts @@ -0,0 +1,636 @@ +import type { Connection } from './ddp-client.ts'; +import { noop } from './utils/noop.ts'; + +type Callback = (...args: any[]) => void; + +type PackagesSettings = Partial<{ + ['facebook-oauth']: Partial<{ + apiVersion: string; + }>; + reload: Partial<{ + debug: boolean; + }>; + oauth: Partial<{ + setRedirectUrlWhenLoginStyleIsPopup: boolean; + }>; + accounts: Partial<{ + loginExpirationInDays: number; + clientStorage: 'local' | 'session'; + }>; +}>; + +type PublicSettings = Partial<{ + packages: PackagesSettings; +}>; + +type MeteorRuntimeConfig = { + meteorRelease?: string; + NODE_ENV?: string; + PUBLIC_SETTINGS?: PublicSettings; + ROOT_URL?: string; + ROOT_URL_PATH_PREFIX?: string; + ACCOUNTS_CONNECTION_URL?: string; + gitCommitHash?: string; + isModern?: boolean; + debug?: boolean; + noDeprecation?: boolean | string; + meteorEnv: { + NODE_ENV?: string; + [key: string]: unknown; + }; + accountsConfigCalled?: boolean; +}; + +declare global { + // eslint-disable-next-line @typescript-eslint/naming-convention + const __meteor_runtime_config__: MeteorRuntimeConfig; + const meteorEnv: MeteorRuntimeConfig['meteorEnv']; +} +const globalScope = globalThis; +const config: MeteorRuntimeConfig = + typeof __meteor_runtime_config__ === 'object' + ? __meteor_runtime_config__ + : { + meteorEnv: {}, + }; +const { meteorEnv } = config; + +export class MeteorError extends Error { + public error: string | number; + + public reason?: string | undefined; + + public details?: string | undefined; + + public isClientSafe = true; + + public errorType = 'Meteor.Error'; + + constructor(error: string | number, reason?: string | undefined, details?: string | undefined) { + super(); + this.name = 'Meteor.Error'; + this.error = error; + this.reason = reason; + this.details = details; + if (this.reason) { + this.message = `${this.reason} [${this.error}]`; + } else { + this.message = `[${this.error}]`; + } + } + + public clone(): MeteorError { + return new MeteorError(this.error, this.reason, this.details); + } +} + +let nextSlot = 0; +let currentValues: unknown[] = []; +let callAsyncMethodRunning = false; + +class EnvironmentVariable { + private readonly slot: number; + + constructor() { + this.slot = nextSlot++; + } + + public get(): T | undefined { + return currentValues[this.slot] as T; + } + + public getOrNullIfOutsideFiber(): T | undefined { + return this.get(); + } + + public withValue(value: T, func: () => R): R { + const saved = currentValues[this.slot]; + try { + currentValues[this.slot] = value; + return func(); + } finally { + currentValues[this.slot] = saved; + } + } + + public _set(value: T): void { + currentValues[this.slot] = value; + } + + public _setNewContextAndGetCurrent(value: T): T { + const saved = currentValues[this.slot]; + this._set(value); + return saved as T; + } + + public _isCallAsyncMethodRunning(): boolean { + return callAsyncMethodRunning; + } + + public _setCallAsyncMethodRunning(value: boolean): void { + callAsyncMethodRunning = value; + } + + public static getCurrentValues(): unknown[] { + return currentValues; + } +} + +class FakeDoubleEndedQueue { + private queue: unknown[] = []; + + push(task: unknown): void { + this.queue.push(task); + } + + shift(): unknown { + return this.queue.shift(); + } + + isEmpty(): boolean { + return this.queue.length === 0; + } +} + +export class SynchronousQueue { + private _tasks: Array<() => void> = []; + + private _running = false; + + private _runTimeout: number | null = null; + + public runTask(task: () => void): void { + if (!this.safeToRunTask()) { + throw new Error('Could not synchronously run a task from a running task'); + } + + this._tasks.push(task); + const tasks = this._tasks; + this._tasks = []; + this._running = true; + + if (this._runTimeout) { + clearTimeout(this._runTimeout); + this._runTimeout = null; + } + + try { + while (tasks.length > 0) { + const t = tasks.shift(); + try { + t?.(); + } catch (e) { + if (tasks.length === 0) throw e; + console.debug('Exception in queued task', e); + } + } + } finally { + this._running = false; + } + } + + public queueTask(task: () => void): void { + this._tasks.push(task); + if (!this._runTimeout) { + this._runTimeout = setTimeout(() => this.flush(), 0) as unknown as number; + } + } + + public flush(): void { + this.runTask(noop); + } + + public drain(): void { + if (!this.safeToRunTask()) return; + while (this._tasks.length > 0) { + this.flush(); + } + } + + public safeToRunTask(): boolean { + return !this._running; + } +} + +const _setImmediate = ((): ((fn: () => void) => void) => { + let postMessageIsAsynchronous = true; + const oldOnMessage = globalScope.onmessage; + globalScope.onmessage = () => { + postMessageIsAsynchronous = false; + }; + globalScope.postMessage('', '*'); + globalScope.onmessage = oldOnMessage; + + if (!postMessageIsAsynchronous) { + const useTimeout = (fn: () => void) => setTimeout(fn, 0); + useTimeout.implementation = 'setTimeout'; + return useTimeout; + } + + let funcIndex = 0; + const funcs: Record void> = {}; + const MESSAGE_PREFIX = `Meteor._setImmediate.${Math.random()}.`; + + globalScope.addEventListener( + 'message', + (event) => { + if (event.source === window && typeof event.data === 'string' && event.data.startsWith(MESSAGE_PREFIX)) { + const index = parseInt(event.data.substring(MESSAGE_PREFIX.length), 10); + try { + if (funcs[index]) funcs[index](); + } finally { + delete funcs[index]; + } + } + }, + false, + ); + + const usePostMessage = (fn: () => void) => { + ++funcIndex; + funcs[funcIndex] = fn; + globalScope.postMessage(MESSAGE_PREFIX + funcIndex, '*'); + }; + usePostMessage.implementation = 'postMessage'; + return usePostMessage; +})(); + +const _localStorage = localStorage; + +type AbsoluteUrlOptions = { rootUrl?: string; secure?: boolean; replaceLocalhost?: boolean }; + +const defaultAbsoluteUrlOptions: AbsoluteUrlOptions = { + rootUrl: config.ROOT_URL || `${location.protocol}//${location.host}`, + secure: typeof location !== 'undefined' && location.protocol === 'https:', +}; + +const absoluteUrl = (() => { + function absoluteUrl(path?: string | Record, options?: AbsoluteUrlOptions): string { + if (typeof path === 'object' && !options) { + options = path; + path = undefined; + } + + const opts = { ...absoluteUrl.defaultOptions, ...options }; + let url = opts.rootUrl; + + if (!url) throw new Error('Must pass options.rootUrl or set ROOT_URL in the server environment'); + if (!/^http[s]?:\/\//i.test(url)) url = `http://${url}`; + if (!url.endsWith('/')) url += '/'; + + if (path) { + if (typeof path === 'string') { + url += path.replace(/^\/+/, ''); + } + } + + if (opts.secure && /^http:/.test(url) && !/http:\/\/localhost[:\/]/.test(url) && !/http:\/\/127\.0\.0\.1[:\/]/.test(url)) { + url = url.replace(/^http:/, 'https:'); + } + + if (opts.replaceLocalhost) { + url = url.replace(/^http:\/\/localhost([:\/].*)/, 'http://127.0.0.1$1'); + } + + return url; + } + + absoluteUrl.defaultOptions = defaultAbsoluteUrlOptions; + + return absoluteUrl; +})(); + +export const defer = (fn: VoidFunction) => { + console.warn('Meteor.defer is deprecated. Use setTimeout(fn, 0) instead.'); + fn(); +}; + +const Meteor = { + isProduction: true, + isDevelopment: false, + isClient: true, + isServer: false, + isCordova: false, + isModern: true, + gitCommitHash: config.gitCommitHash, + settings: config.PUBLIC_SETTINGS ? { public: config.PUBLIC_SETTINGS } : {}, + release: config.meteorRelease, + connection: null as Connection | null, + refresh: noop, + isFibersDisabled: true, + isTest: false, + isAppTest: false, + isPackageTest: false, + isDebug: false, + Error: MeteorError, + EnvironmentVariable, + _SynchronousQueue: SynchronousQueue, + _DoubleEndedQueue: FakeDoubleEndedQueue, + _setImmediate, + _localStorage, + + promisify(fn: Callback, context?: any, errorFirst = true) { + return function (this: any, ...args: any[]) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const filteredArgs = args.filter((i) => i !== undefined); + + return new Promise((resolve, reject) => { + const callback = Meteor.bindEnvironment((error: any, result: any) => { + let _error = error; + let _result = result; + + if (!errorFirst) { + _error = result; + _result = error; + } + + if (_error) return reject(_error); + resolve(_result); + }); + + filteredArgs.push(callback); + return fn.apply(context || self, filteredArgs); + }); + }; + }, + + wrapAsync(fn: Callback, context?: any) { + return function (this: any, ...args: any[]) { + const self = context || this; + let callback: Callback | undefined; + + for (let i = args.length - 1; i >= 0; --i) { + const arg = args[i]; + if (arg !== undefined) { + if (typeof arg === 'function') { + callback = arg; + } + break; + } + } + + if (!callback) { + callback = logErr; + args.push(undefined); + } + + const callbackIndex = args.indexOf(callback); + const boundCallback = Meteor.bindEnvironment(callback); + + if (callbackIndex !== -1) { + args[callbackIndex] = boundCallback; + } else { + args.push(boundCallback); + } + + return fn.apply(self, args); + }; + }, + + _wrapAsync(fn: Callback, context?: any) { + if (!warnedAboutWrapAsync) { + console.debug('Meteor._wrapAsync has been renamed to Meteor.wrapAsync'); + warnedAboutWrapAsync = true; + } + return Meteor.wrapAsync(fn, context); + }, + + wrapFn(fn: F): F { + return fn; + }, + + _sleepForMs(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + }, + + sleep(ms: number) { + return Meteor._sleepForMs(ms); + }, + + _noYieldsAllowed(f: () => any) { + const result = f(); + if (Meteor._isPromise(result)) { + throw new Error('function is a promise when calling Meteor._noYieldsAllowed'); + } + return result; + }, + + _isPromise(r: any): boolean { + return !!r && typeof r.then === 'function'; + }, + + _runFresh(fn: () => T): T { + return fn(); + }, + + bindEnvironment any>(func: T, onException?: ((e: any) => void) | string, _this?: any): T { + const boundValues = currentValues.slice(); + + if (!onException || typeof onException === 'string') { + const description = onException || 'callback of async function'; + onException = (error: any) => { + console.debug(`Exception in ${description}:`, error); + }; + } + return function (this: any, ...args: any[]) { + const savedValues = currentValues; + let ret; + try { + currentValues = boundValues; + ret = func.apply(_this ?? this, args); + } catch (e) { + (onException as (e: any) => void)(e); + } finally { + currentValues = savedValues; + } + return ret; + } as unknown as T; + }, + + setInterval(f: VoidFunction, duration: number) { + return setInterval(bindAndCatch('setInterval callback', f), duration); + }, + + clearInterval(x: any) { + return clearInterval(x); + }, + + clearTimeout(x: any) { + return clearTimeout(x); + }, + + defer, + + _debug(...args: unknown[]) { + if (suppress > 0) { + suppress--; + return; + } + if (typeof console !== 'undefined' && console.log) { + if (args.length === 0) { + console.log(''); + } else { + const allStrings = args.every((a) => typeof a === 'string'); + if (allStrings) { + console.log(args.join(' ')); + } else { + console.log(...args); + } + } + } + }, + + _suppress_log(count: number) { + suppress += count; + }, + + _suppressed_log_expected() { + return suppress !== 0; + }, + + _escapeRegExp(string: string) { + return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }, + + deprecate(...args: any[]) { + if (typeof console === 'undefined' || !console.warn) return; + + const stackTrace = cleanStackTrace(new Error().stack || ''); + + if (config.noDeprecation) { + if (typeof config.noDeprecation === 'string') { + const pattern = new RegExp(config.noDeprecation); + if (pattern.test(stackTrace)) { + onceFixDeprecation(); + return; + } + } else if (config.noDeprecation === true) { + onceFixDeprecation(); + return; + } + } + + const messages = [...args]; + if (stackTrace.length > 0) { + messages.push('\n\nTrace:\n', stackTrace); + } + messages.push('\n\nTo disable warnings, set the `METEOR_NO_DEPRECATION` to `true` or a regex pattern.\n'); + + onceWarning(['[DEPRECATION]', ...messages]); + }, + + startup(callback: () => void) { + if (isReady) callback(); + else callbackQueue.push(callback); + }, + + absoluteUrl, + + _relativeToSiteRootUrl(link: string) { + if (config.ROOT_URL_PATH_PREFIX && link.startsWith('/')) { + return config.ROOT_URL_PATH_PREFIX + link; + } + return link; + }, +}; + +let warnedAboutWrapAsync = false; +let suppress = 0; + +function logErr(err: any) { + if (err) { + return console.debug('Exception in callback of async function', err); + } +} + +function withoutInvocation(f: () => void): () => void { + return f; +} + +function bindAndCatch(context: string, f: () => void): () => void { + return Meteor.bindEnvironment(withoutInvocation(f), context); +} + +function oncePerArgument(func: Callback) { + const cache = new Map(); + return function (this: any, ...args: any[]) { + const key = JSON.stringify(args); + if (!cache.has(key)) { + const result = func.apply(this, args); + cache.set(key, result); + } + return cache.get(key); + }; +} + +const onceWarning = oncePerArgument((messages: any[]) => { + console.warn(...messages); +}); + +function onceFixDeprecation() { + onceWarning([ + 'Deprecation warnings are hidden but crucial to address for future Meteor updates.', + '\n', + 'Remove the `METEOR_NO_DEPRECATION` env var to reveal them, then report or fix the issues.', + ]); +} + +function cleanStackTrace(stackTrace: string): string { + if (!stackTrace) return ''; + const lines = stackTrace.split('\n'); + const trace = []; + + try { + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes('Meteor.deprecate')) continue; + if (trimmed.includes('packages/') || trimmed.includes('/')) { + trace.push(trimmed); + if (!trimmed.includes('packages/')) break; + } + } + } catch (e) { + console.error('Error cleaning stack trace: ', e); + } + return trace.join('\n'); +} + +const callbackQueue: Array<() => void> = []; +let isLoadingCompleted = false; +let eagerCodeRan = false; +let isReady = false; +const readyHoldsCount = 0; + +const maybeReady = function () { + if (isReady || !eagerCodeRan || readyHoldsCount > 0) return; + isReady = true; + while (callbackQueue.length) { + const cb = callbackQueue.shift(); + if (cb) cb(); + } +}; + +function waitForEagerAsyncModules() { + function finish() { + eagerCodeRan = true; + maybeReady(); + } + + finish(); +} + +const loadingCompleted = function () { + if (isLoadingCompleted) return; + isLoadingCompleted = true; + waitForEagerAsyncModules(); +}; + +if (document.readyState === 'complete') { + window.setTimeout(loadingCompleted); +} else { + document.addEventListener('DOMContentLoaded', loadingCompleted, false); + window.addEventListener('load', loadingCompleted, false); +} + +export { Meteor, globalScope as global, meteorEnv }; diff --git a/apps/meteor/src/meteor/minimongo.ts b/apps/meteor/src/meteor/minimongo.ts new file mode 100644 index 0000000000000..fa775bda0f15c --- /dev/null +++ b/apps/meteor/src/meteor/minimongo.ts @@ -0,0 +1,2241 @@ +import { DiffSequence } from './diff-sequence'; +import { EJSON } from './ejson'; +import { GeoJSON } from './geojson-utils'; +import { IdMap } from './id-map'; +import { ObjectID } from './mongo-id'; +import { OrderedDict } from './ordered-dict'; +import { Random } from './random'; +import { Tracker } from './tracker'; + +type IdSelector = string | number | ObjectID; + +export const _selectorIsId = (selector: unknown): selector is IdSelector => + typeof selector === 'number' || typeof selector === 'string' || selector instanceof ObjectID; + +export const _selectorIsIdPerhapsAsObject = (selector: unknown) => + _selectorIsId(selector) || (_selectorIsId(selector && selector._id) && Object.keys(selector).length === 1); + +function getAsyncMethodName(method) { + return `${method.replace('_', '')}Async`; +} + +const ASYNC_CURSOR_METHODS = ['count', 'fetch', 'forEach', 'map']; + +class ObserveHandle {} + +const hasOwn = Object.prototype.hasOwnProperty; + +class MiniMongoQueryError extends Error {} + +const MinimongoError = (message, options = {}) => { + if (typeof message === 'string' && options.field) { + message += ` for field '${options.field}'`; + } + const error = new Error(message); + error.name = 'MinimongoError'; + return error; +}; + +function nothingMatcher(_docOrBranchedValues: unknown) { + return { result: false }; +} + +function everythingMatcher(_docOrBranchedValues: unknown) { + return { result: true }; +} + +const _binarySearch = (cmp, array, value) => { + let first = 0; + let range = array.length; + while (range > 0) { + const halfRange = Math.floor(range / 2); + if (cmp(value, array[first + halfRange]) >= 0) { + first += halfRange + 1; + range -= halfRange + 1; + } else range = halfRange; + } + return first; +}; + +const _modify = (doc, modifier, options = {}) => { + if (!_isPlainObject(modifier)) throw MinimongoError('Modifier must be an object'); + modifier = EJSON.clone(modifier); + const isModifier = isOperatorObject(modifier); + const newDoc = isModifier ? EJSON.clone(doc) : modifier; + if (isModifier) { + Object.keys(modifier).forEach((operator) => { + const setOnInsert = options.isInsert && operator === '$setOnInsert'; + const modFunc = MODIFIERS[setOnInsert ? '$set' : operator]; + const operand = modifier[operator]; + if (!modFunc) throw MinimongoError(`Invalid modifier specified ${operator}`); + Object.keys(operand).forEach((keypath) => { + const arg = operand[keypath]; + if (keypath === '') throw MinimongoError('An empty update path is not valid.'); + const keyparts = keypath.split('.'); + if (!keyparts.every(Boolean)) throw MinimongoError(`The update path '${keypath}' contains an empty field name`); + const target = findModTarget(newDoc, keyparts, { + arrayIndices: options.arrayIndices, + forbidArray: operator === '$rename', + noCreate: NO_CREATE_MODIFIERS[operator], + }); + modFunc(target, keyparts.pop(), arg, keypath, newDoc); + }); + }); + if (doc._id && !EJSON.equals(doc._id, newDoc._id)) + throw MinimongoError( + `After applying the update to the document {_id: "${doc._id}", ...}, the (immutable) field '_id' was found to have been altered`, + ); + } else { + if (doc._id && modifier._id && !EJSON.equals(doc._id, modifier._id)) throw MinimongoError(`The _id field cannot be changed`); + assertHasValidFieldNames(modifier); + } + Object.keys(doc).forEach((key) => { + if (key !== '_id') delete doc[key]; + }); + Object.keys(newDoc).forEach((key) => { + doc[key] = newDoc[key]; + }); +}; + +const _checkSupportedProjection = (fields) => { + if (fields !== Object(fields) || Array.isArray(fields)) throw MinimongoError('fields option must be an object'); + Object.keys(fields).forEach((keyPath) => { + if (keyPath.split('.').includes('$')) throw MinimongoError("Minimongo doesn't support $ operator in projections yet."); + const value = fields[keyPath]; + if (typeof value === 'object' && ['$elemMatch', '$meta', '$slice'].some((key) => hasOwn.call(value, key))) + throw MinimongoError("Minimongo doesn't support operators in projections yet."); + if (![1, 0, true, false].includes(value)) throw MinimongoError('Projection values should be one of 1, 0, true, or false'); + }); +}; + +const _compileProjection = (fields) => { + _checkSupportedProjection(fields); + const _idProjection = fields._id === undefined ? true : fields._id; + const details = projectionDetails(fields); + const transform = (doc, ruleTree) => { + if (Array.isArray(doc)) return doc.map((subdoc) => transform(subdoc, ruleTree)); + const result = details.including ? {} : EJSON.clone(doc); + Object.keys(ruleTree).forEach((key) => { + if (doc == null || !hasOwn.call(doc, key)) return; + const rule = ruleTree[key]; + if (rule === Object(rule)) { + if (doc[key] === Object(doc[key])) result[key] = transform(doc[key], rule); + } else if (details.including) result[key] = EJSON.clone(doc[key]); + else delete result[key]; + }); + return doc != null ? result : doc; + }; + return (doc) => { + const result = transform(doc, details.tree); + if (_idProjection && hasOwn.call(doc, '_id')) result._id = doc._id; + if (!_idProjection && hasOwn.call(result, '_id')) delete result._id; + return result; + }; +}; + +const _isModificationMod = (mod) => { + let isModify = false; + let isReplace = false; + Object.keys(mod).forEach((key) => { + if (key.substr(0, 1) === '$') isModify = true; + else isReplace = true; + }); + if (isModify && isReplace) throw new Error('Update parameter cannot have both modifier and non-modifier fields.'); + return isModify; +}; + +const _createUpsertDocument = (selector, modifier) => { + const selectorDocument = populateDocumentWithQueryFields(selector); + const isModify = _isModificationMod(modifier); + const newDoc = {}; + if (selectorDocument._id) { + newDoc._id = selectorDocument._id; + delete selectorDocument._id; + } + _modify(newDoc, { $set: selectorDocument }); + _modify(newDoc, modifier, { isInsert: true }); + if (isModify) return newDoc; + const replacement = Object.assign({}, modifier); + if (newDoc._id) replacement._id = newDoc._id; + return replacement; +}; + +const _findInOrderedResults = (query, doc) => { + if (!query.ordered) throw new Error("Can't call _findInOrderedResults on unordered query"); + for (let i = 0; i < query.results.length; i++) { + if (query.results[i] === doc) return i; + } + throw Error('object missing from query'); +}; + +const _idsMatchedBySelector = (selector): (string | number | ObjectID)[] | null => { + if (_selectorIsId(selector)) return [selector]; + if (!selector) return null; + if (hasOwn.call(selector, '_id')) { + if (_selectorIsId(selector._id)) return [selector._id]; + if (selector._id && Array.isArray(selector._id.$in) && selector._id.$in.length && selector._id.$in.every(_selectorIsId)) + return selector._id.$in; + return null; + } + if (Array.isArray(selector.$and)) { + for (let i = 0; i < selector.$and.length; ++i) { + const subIds = _idsMatchedBySelector(selector.$and[i]); + if (subIds) return subIds; + } + } + return null; +}; + +const _insertInSortedList = (cmp, array, value) => { + if (array.length === 0) { + array.push(value); + return 0; + } + const i = _binarySearch(cmp, array, value); + array.splice(i, 0, value); + return i; +}; + +const _insertInResultsSync = (query, doc) => { + const fields = EJSON.clone(doc); + delete fields._id; + if (query.ordered) { + if (!query.sorter) { + query.addedBefore(doc._id, query.projectionFn(fields), null); + query.results.push(doc); + } else { + const i = _insertInSortedList(query.sorter.getComparator({ distances: query.distances }), query.results, doc); + let next = query.results[i + 1]; + if (next) next = next._id; + else next = null; + query.addedBefore(doc._id, query.projectionFn(fields), next); + } + query.added(doc._id, query.projectionFn(fields)); + } else { + query.added(doc._id, query.projectionFn(fields)); + query.results.set(doc._id, doc); + } +}; + +const _insertInResultsAsync = async (query, doc) => { + const fields = EJSON.clone(doc); + delete fields._id; + if (query.ordered) { + if (!query.sorter) { + await query.addedBefore(doc._id, query.projectionFn(fields), null); + query.results.push(doc); + } else { + const i = _insertInSortedList(query.sorter.getComparator({ distances: query.distances }), query.results, doc); + let next = query.results[i + 1]; + if (next) next = next._id; + else next = null; + await query.addedBefore(doc._id, query.projectionFn(fields), next); + } + await query.added(doc._id, query.projectionFn(fields)); + } else { + await query.added(doc._id, query.projectionFn(fields)); + query.results.set(doc._id, doc); + } +}; + +const _observeFromObserveChanges = (cursor, observeCallbacks) => { + const transform = cursor.getTransform() || ((doc) => doc); + let suppressed = !!observeCallbacks._suppress_initial; + let observeChangesCallbacks; + if (_observeCallbacksAreOrdered(observeCallbacks)) { + const indices = !observeCallbacks._no_indices; + observeChangesCallbacks = { + addedBefore(id, fields, before) { + if (suppressed || !(observeCallbacks.addedAt || observeCallbacks.added)) return; + const doc = transform(Object.assign(fields, { _id: id })); + if (observeCallbacks.addedAt) + observeCallbacks.addedAt(doc, indices ? (before ? this.docs.indexOf(before) : this.docs.size()) : -1, before); + else observeCallbacks.added(doc); + }, + changed(id, fields) { + if (!(observeCallbacks.changedAt || observeCallbacks.changed)) return; + const doc = EJSON.clone(this.docs.get(id)); + if (!doc) throw new Error(`Unknown id for changed: ${id}`); + const oldDoc = transform(EJSON.clone(doc)); + DiffSequence.applyChanges(doc, fields); + if (observeCallbacks.changedAt) observeCallbacks.changedAt(transform(doc), oldDoc, indices ? this.docs.indexOf(id) : -1); + else observeCallbacks.changed(transform(doc), oldDoc); + }, + movedBefore(id, before) { + if (!observeCallbacks.movedTo) return; + const from = indices ? this.docs.indexOf(id) : -1; + let to = indices ? (before ? this.docs.indexOf(before) : this.docs.size()) : -1; + if (to > from) --to; + observeCallbacks.movedTo(transform(EJSON.clone(this.docs.get(id))), from, to, before || null); + }, + removed(id) { + if (!(observeCallbacks.removedAt || observeCallbacks.removed)) return; + const doc = transform(this.docs.get(id)); + if (observeCallbacks.removedAt) observeCallbacks.removedAt(doc, indices ? this.docs.indexOf(id) : -1); + else observeCallbacks.removed(doc); + }, + }; + } else { + observeChangesCallbacks = { + added(id, fields) { + if (!suppressed && observeCallbacks.added) observeCallbacks.added(transform(Object.assign(fields, { _id: id }))); + }, + changed(id, fields) { + if (observeCallbacks.changed) { + const oldDoc = this.docs.get(id); + const doc = EJSON.clone(oldDoc); + DiffSequence.applyChanges(doc, fields); + observeCallbacks.changed(transform(doc), transform(EJSON.clone(oldDoc))); + } + }, + removed(id) { + if (observeCallbacks.removed) observeCallbacks.removed(transform(this.docs.get(id))); + }, + }; + } + const changeObserver = new _CachingChangeObserver({ callbacks: observeChangesCallbacks }); + changeObserver.applyChange._fromObserve = true; + const handle = cursor.observeChanges(changeObserver.applyChange, { nonMutatingCallbacks: true }); + const setSuppressed = (h) => { + if (h.isReady) suppressed = false; + else h.isReadyPromise?.then(() => (suppressed = false)); + }; + if (handle instanceof Promise) handle.then(setSuppressed); + else setSuppressed(handle); + return handle; +}; + +const _observeCallbacksAreOrdered = (callbacks) => { + if (callbacks.added && callbacks.addedAt) throw new Error('Please specify only one of added() and addedAt()'); + if (callbacks.changed && callbacks.changedAt) throw new Error('Please specify only one of changed() and changedAt()'); + if (callbacks.removed && callbacks.removedAt) throw new Error('Please specify only one of removed() and removedAt()'); + return !!(callbacks.addedAt || callbacks.changedAt || callbacks.movedTo || callbacks.removedAt); +}; + +const _observeChangesCallbacksAreOrdered = (callbacks) => { + if (callbacks.added && callbacks.addedBefore) throw new Error('Please specify only one of added() and addedBefore()'); + return !!(callbacks.addedBefore || callbacks.movedBefore); +}; + +const _removeFromResultsSync = (query, doc) => { + if (query.ordered) { + const i = _findInOrderedResults(query, doc); + query.removed(doc._id); + query.results.splice(i, 1); + } else { + const id = doc._id; + query.removed(doc._id); + query.results.remove(id); + } +}; + +const _removeFromResultsAsync = async (query, doc) => { + if (query.ordered) { + const i = _findInOrderedResults(query, doc); + await query.removed(doc._id); + query.results.splice(i, 1); + } else { + const id = doc._id; + await query.removed(doc._id); + query.results.remove(id); + } +}; + +const _updateInResultsSync = (query, doc, old_doc) => { + if (!EJSON.equals(doc._id, old_doc._id)) throw new Error("Can't change a doc's _id while updating"); + const { projectionFn } = query; + const changedFields = DiffSequence.makeChangedFields(projectionFn(doc), projectionFn(old_doc)); + if (!query.ordered) { + if (Object.keys(changedFields).length) { + query.changed(doc._id, changedFields); + query.results.set(doc._id, doc); + } + return; + } + const old_idx = _findInOrderedResults(query, doc); + if (Object.keys(changedFields).length) query.changed(doc._id, changedFields); + if (!query.sorter) return; + query.results.splice(old_idx, 1); + const new_idx = _insertInSortedList(query.sorter.getComparator({ distances: query.distances }), query.results, doc); + if (old_idx !== new_idx) { + let next = query.results[new_idx + 1]; + if (next) next = next._id; + else next = null; + query.movedBefore && query.movedBefore(doc._id, next); + } +}; + +const _updateInResultsAsync = async (query, doc, old_doc) => { + if (!EJSON.equals(doc._id, old_doc._id)) throw new Error("Can't change a doc's _id while updating"); + const { projectionFn } = query; + const changedFields = DiffSequence.makeChangedFields(projectionFn(doc), projectionFn(old_doc)); + if (!query.ordered) { + if (Object.keys(changedFields).length) { + await query.changed(doc._id, changedFields); + query.results.set(doc._id, doc); + } + return; + } + const old_idx = _findInOrderedResults(query, doc); + if (Object.keys(changedFields).length) await query.changed(doc._id, changedFields); + if (!query.sorter) return; + query.results.splice(old_idx, 1); + const new_idx = _insertInSortedList(query.sorter.getComparator({ distances: query.distances }), query.results, doc); + if (old_idx !== new_idx) { + let next = query.results[new_idx + 1]; + if (next) next = next._id; + else next = null; + query.movedBefore && (await query.movedBefore(doc._id, next)); + } +}; + +const wrapTransform = (transform) => { + if (!transform) { + return null; + } + if (transform.__wrappedTransform__) { + return transform; + } + const wrapped = (doc) => { + if (!hasOwn.call(doc, '_id')) { + throw new Error('can only transform documents with _id'); + } + const id = doc._id; + const transformed = Tracker.nonreactive(() => transform(doc)); + if (!_isPlainObject(transformed)) { + throw new Error('transform must return object'); + } + if (hasOwn.call(transformed, '_id')) { + if (!EJSON.equals(transformed._id, id)) { + throw new Error("transformed document can't have different _id"); + } + } else { + transformed._id = id; + } + return transformed; + }; + wrapped.__wrappedTransform__ = true; + return wrapped; +}; + +class Cursor { + matcher: Matcher; + + sorter: Sorter | null; + + skip: number; + + constructor(collection, selector, options = {}) { + this.collection = collection; + this.sorter = null; + this.matcher = new Matcher(selector); + if (_selectorIsIdPerhapsAsObject(selector)) { + this._selectorId = hasOwn.call(selector, '_id') ? selector._id : selector; + } else { + this._selectorId = undefined; + if (this.matcher.hasGeoQuery() || options.sort) { + this.sorter = new Sorter(options.sort || []); + } + } + this.skip = options.skip || 0; + this.limit = options.limit; + this.fields = options.projection || options.fields; + this._projectionFn = _compileProjection(this.fields || {}); + this._transform = wrapTransform(options.transform); + if (typeof Tracker !== 'undefined') { + this.reactive = options.reactive === undefined ? true : options.reactive; + } + } + + count() { + if (this.reactive) this._depend({ added: true, removed: true }, true); + return this._getRawObjects({ ordered: true }).length; + } + + fetch() { + const result = []; + this.forEach((doc) => { + result.push(doc); + }); + return result; + } + + [Symbol.iterator]() { + if (this.reactive) this._depend({ addedBefore: true, removed: true, changed: true, movedBefore: true }); + let index = 0; + const objects = this._getRawObjects({ ordered: true }); + return { + next: () => { + if (index < objects.length) { + let element = this._projectionFn(objects[index++]); + if (this._transform) element = this._transform(element); + return { value: element }; + } + return { done: true }; + }, + }; + } + + [Symbol.asyncIterator]() { + const syncResult = this[Symbol.iterator](); + return { + async next() { + return Promise.resolve(syncResult.next()); + }, + }; + } + + forEach(callback, thisArg?: unknown) { + if (this.reactive) this._depend({ addedBefore: true, removed: true, changed: true, movedBefore: true }); + this._getRawObjects({ ordered: true }).forEach((element, i) => { + element = this._projectionFn(element); + if (this._transform) element = this._transform(element); + callback.call(thisArg, element, i, this); + }); + } + + getTransform() { + return this._transform; + } + + map(callback, thisArg) { + const result = []; + this.forEach((doc, i) => { + result.push(callback.call(thisArg, doc, i, this)); + }); + return result; + } + + observe(options) { + return _observeFromObserveChanges(this, options); + } + + observeAsync(options) { + return new Promise((resolve) => resolve(this.observe(options))); + } + + observeChanges(options) { + const ordered = _observeChangesCallbacksAreOrdered(options); + if (!options._allow_unordered && !ordered && (this.skip || this.limit)) + throw new Error('Must use an ordered observe with skip or limit'); + if (this.fields && (this.fields._id === 0 || this.fields._id === false)) + throw Error('You may not observe a cursor with {fields: {_id: 0}}'); + const distances = this.matcher.hasGeoQuery() && ordered && new _IdMap(); + const query = { + cursor: this, + dirty: false, + distances, + matcher: this.matcher, + ordered, + projectionFn: this._projectionFn, + resultsSnapshot: null, + sorter: ordered && this.sorter, + }; + let qid; + if (this.reactive) { + qid = this.collection.next_qid++; + this.collection.queries[qid] = query; + } + query.results = this._getRawObjects({ ordered, distances: query.distances }); + if (this.collection.paused) query.resultsSnapshot = ordered ? [] : new _IdMap(); + const wrapCallback = (fn) => { + if (!fn) return () => {}; + const self = this; + return function () { + if (self.collection.paused) return; + const args = arguments; + self.collection._observeQueue.queueTask(() => { + fn.apply(this, args); + }); + }; + }; + query.added = wrapCallback(options.added); + query.changed = wrapCallback(options.changed); + query.removed = wrapCallback(options.removed); + if (ordered) { + query.addedBefore = wrapCallback(options.addedBefore); + query.movedBefore = wrapCallback(options.movedBefore); + } + if (!options._suppress_initial && !this.collection.paused) { + const handler = (doc) => { + const fields = EJSON.clone(doc); + delete fields._id; + if (ordered) query.addedBefore(doc._id, this._projectionFn(fields), null); + query.added(doc._id, this._projectionFn(fields)); + }; + if (query.results.length) { + for (const doc of query.results) handler(doc); + } + if (query.results?.size?.()) query.results.forEach(handler); + } + const handle = Object.assign(new ObserveHandle(), { + collection: this.collection, + stop: () => { + if (this.reactive) delete this.collection.queries[qid]; + }, + isReady: false, + isReadyPromise: null, + }); + if (this.reactive && Tracker.active) Tracker.onInvalidate(() => handle.stop()); + const drainResult = this.collection._observeQueue.drain(); + if (drainResult instanceof Promise) { + handle.isReadyPromise = drainResult; + drainResult.then(() => (handle.isReady = true)); + } else { + handle.isReady = true; + handle.isReadyPromise = Promise.resolve(); + } + return handle; + } + + observeChangesAsync(options) { + return new Promise((resolve) => { + const handle = this.observeChanges(options); + handle.isReadyPromise.then(() => resolve(handle)); + }); + } + + _depend(changers, _allow_unordered) { + if (Tracker.active) { + const dependency = new Tracker.Dependency(); + const notify = dependency.changed.bind(dependency); + dependency.depend(); + const options = { _allow_unordered, _suppress_initial: true }; + ['added', 'addedBefore', 'changed', 'movedBefore', 'removed'].forEach((fn) => { + if (changers[fn]) options[fn] = notify; + }); + this.observeChanges(options); + } + } + + _getRawObjects(options = {}) { + const applySkipLimit = options.applySkipLimit !== false; + const results = options.ordered ? [] : new _IdMap(); + if (this._selectorId !== undefined) { + if (applySkipLimit && this.skip) return results; + const selectedDoc = this.collection._docs.get(this._selectorId); + if (selectedDoc) { + if (options.ordered) results.push(selectedDoc); + else results.set(this._selectorId, selectedDoc); + } + return results; + } + let distances; + if (this.matcher.hasGeoQuery() && options.ordered) { + if (options.distances) { + distances = options.distances; + distances.clear(); + } else distances = new _IdMap(); + } + this.collection._docs.forEach((doc, id) => { + const matchResult = this.matcher.documentMatches(doc); + if (matchResult.result) { + if (options.ordered) { + results.push(doc); + if (distances && matchResult.distance !== undefined) distances.set(id, matchResult.distance); + } else results.set(id, doc); + } + if (!applySkipLimit) return true; + return !this.limit || this.skip || this.sorter || results.length !== this.limit; + }); + if (!options.ordered) return results; + if (this.sorter) results.sort(this.sorter.getComparator({ distances })); + if (!applySkipLimit || (!this.limit && !this.skip)) return results; + return results.slice(this.skip, this.limit ? this.limit + this.skip : results.length); + } +} +ASYNC_CURSOR_METHODS.forEach((method) => { + const asyncName = getAsyncMethodName(method); + Cursor.prototype[asyncName] = function (...args) { + try { + return Promise.resolve(this[method].apply(this, args)); + } catch (error) { + return Promise.reject(error); + } + }; +}); +const _f = { + _type(v) { + if (typeof v === 'number') return 1; + if (typeof v === 'string') return 2; + if (typeof v === 'boolean') return 8; + if (Array.isArray(v)) return 4; + if (v === null) return 10; + if (v instanceof RegExp) return 11; + if (typeof v === 'function') return 13; + if (v instanceof Date) return 9; + if (EJSON.isBinary(v)) return 5; + if (v instanceof ObjectID) return 7; + return 3; + }, + + _equal(a, b) { + return EJSON.equals(a, b, { keyOrderSensitive: true }); + }, + + _typeorder(t) { + return [-1, 1, 2, 3, 4, 5, -1, 6, 7, 8, 0, 9, -1, 100, 2, 100, 1, 8, 1][t]; + }, + + _cmp(a, b) { + if (a === undefined) return b === undefined ? 0 : -1; + if (b === undefined) return 1; + let ta = this._type(a); + let tb = this._type(b); + const oa = this._typeorder(ta); + const ob = this._typeorder(tb); + if (oa !== ob) return oa < ob ? -1 : 1; + if (ta !== tb) throw Error('Missing type coercion logic in _cmp'); + if (ta === 7) { + a = a.toHexString(); + b = b.toHexString(); + ta = tb = 2; + } + if (ta === 9) { + a = isNaN(a) ? 0 : a.getTime(); + b = isNaN(b) ? 0 : b.getTime(); + ta = tb = 1; + } + if (ta === 1) return a - b; + if (tb === 2) return a < b ? -1 : a === b ? 0 : 1; + if (ta === 3) { + const toArray = (object) => { + const result = []; + Object.keys(object).forEach((key) => { + result.push(key, object[key]); + }); + return result; + }; + return this._cmp(toArray(a), toArray(b)); + } + if (ta === 4) { + for (let i = 0; ; i++) { + if (i === a.length) return i === b.length ? 0 : -1; + if (i === b.length) return 1; + const s = this._cmp(a[i], b[i]); + if (s !== 0) return s; + } + } + if (ta === 5) { + if (a.length !== b.length) return a.length - b.length; + for (let i = 0; i < a.length; i++) { + if (a[i] < b[i]) return -1; + if (a[i] > b[i]) return 1; + } + return 0; + } + if (ta === 8) { + if (a) return b ? 0 : 1; + return b ? -1 : 0; + } + if (ta === 10) return 0; + throw Error('Unknown type to sort'); + }, +}; + +const _isPlainObject = (x) => { + return x && _f._type(x) === 3; +}; + +class _CachingChangeObserver { + constructor(options = {}) { + const orderedFromCallbacks = options.callbacks && _observeChangesCallbacksAreOrdered(options.callbacks); + if (hasOwn.call(options, 'ordered')) { + this.ordered = options.ordered; + if (options.callbacks && options.ordered !== orderedFromCallbacks) throw Error("ordered option doesn't match callbacks"); + } else if (options.callbacks) this.ordered = orderedFromCallbacks; + else throw Error('must provide ordered or callbacks'); + const callbacks = options.callbacks || {}; + if (this.ordered) { + this.docs = new OrderedDict(ObjectID.stringify); + this.applyChange = { + addedBefore: (id, fields, before) => { + const doc = { ...fields }; + doc._id = id; + if (callbacks.addedBefore) callbacks.addedBefore.call(this, id, EJSON.clone(fields), before); + if (callbacks.added) callbacks.added.call(this, id, EJSON.clone(fields)); + this.docs.putBefore(id, doc, before || null); + }, + movedBefore: (id, before) => { + if (callbacks.movedBefore) callbacks.movedBefore.call(this, id, before); + this.docs.moveBefore(id, before || null); + }, + }; + } else { + this.docs = new _IdMap(); + this.applyChange = { + added: (id, fields) => { + const doc = { ...fields }; + if (callbacks.added) callbacks.added.call(this, id, EJSON.clone(fields)); + doc._id = id; + this.docs.set(id, doc); + }, + }; + } + this.applyChange.changed = (id, fields) => { + const doc = this.docs.get(id); + if (!doc) throw new Error(`Unknown id for changed: ${id}`); + if (callbacks.changed) callbacks.changed.call(this, id, EJSON.clone(fields)); + DiffSequence.applyChanges(doc, fields); + }; + this.applyChange.removed = (id) => { + if (callbacks.removed) callbacks.removed.call(this, id); + this.docs.remove(id); + }; + } +} + +class _IdMap extends IdMap { + constructor() { + super(ObjectID.stringify, ObjectID.parse); + } +} + +const _useOID = false; +class LocalCollection { + static _IdMap = _IdMap; + + static Cursor = Cursor; + + static ObserveHandle = ObserveHandle; + + static _CachingChangeObserver = _CachingChangeObserver; + + static _f = _f; + + static _isPlainObject = _isPlainObject; + + static _binarySearch = _binarySearch; + + static _modify = _modify; + + static _checkSupportedProjection = _checkSupportedProjection; + + static _compileProjection = _compileProjection; + + static _isModificationMod = _isModificationMod; + + static _createUpsertDocument = _createUpsertDocument; + + static wrapTransform = wrapTransform; + + static _diffObjects = DiffSequence.diffObjects; + + static _diffQueryChanges = DiffSequence.diffQueryChanges; + + static _diffQueryOrderedChanges = DiffSequence.diffQueryOrderedChanges; + + static _diffQueryUnorderedChanges = DiffSequence.diffQueryUnorderedChanges; + + static _findInOrderedResults = _findInOrderedResults; + + static _idsMatchedBySelector = _idsMatchedBySelector; + + static _insertInSortedList = _insertInSortedList; + + static _insertInResultsSync = _insertInResultsSync; + + static _insertInResultsAsync = _insertInResultsAsync; + + static _removeFromResultsSync = _removeFromResultsSync; + + static _removeFromResultsAsync = _removeFromResultsAsync; + + static _selectorIsId = _selectorIsId; + + static _updateInResultsSync = _updateInResultsSync; + + static _updateInResultsAsync = _updateInResultsAsync; + + static _observeChangesCallbacksAreOrdered = _observeChangesCallbacksAreOrdered; + + static _useOID = _useOID; + + constructor(name) { + this.name = name; + this._docs = new _IdMap(); + this._observeQueue = { + queueTask: (task) => task(), + drain: () => Promise.resolve(), + }; + + this.next_qid = 1; + this.queries = Object.create(null); + this._savedOriginals = null; + this.paused = false; + } + + countDocuments(selector, options) { + return this.find(selector ?? {}, options).countAsync(); + } + + estimatedDocumentCount(options) { + return this.find({}, options).countAsync(); + } + + find(selector, options) { + if (arguments.length === 0) selector = {}; + return new Cursor(this, selector, options); + } + + findOne(selector, options = {}) { + if (arguments.length === 0) selector = {}; + options.limit = 1; + return this.find(selector, options).fetch()[0]; + } + + async findOneAsync(selector, options = {}) { + if (arguments.length === 0) selector = {}; + options.limit = 1; + return (await this.find(selector, options).fetchAsync())[0]; + } + + prepareInsert(doc) { + if (!hasOwn.call(doc, '_id')) doc._id = _useOID ? new ObjectID() : Random.id(); + const id = doc._id; + if (this._docs.has(id)) throw MinimongoError(`Duplicate _id '${id}'`); + this._saveOriginal(id, undefined); + this._docs.set(id, doc); + return id; + } + + insert(doc, callback) { + doc = EJSON.clone(doc); + const id = this.prepareInsert(doc); + const queriesToRecompute = []; + for (const qid of Object.keys(this.queries)) { + const query = this.queries[qid]; + if (query.dirty) continue; + const matchResult = query.matcher.documentMatches(doc); + if (matchResult.result) { + if (query.distances && matchResult.distance !== undefined) query.distances.set(id, matchResult.distance); + if (query.cursor.skip || query.cursor.limit) queriesToRecompute.push(qid); + else _insertInResultsSync(query, doc); + } + } + queriesToRecompute.forEach((qid) => { + if (this.queries[qid]) this._recomputeResults(this.queries[qid]); + }); + this._observeQueue.drain(); + if (callback) { + setTimeout(() => callback(null, id), 0); + } + return id; + } + + async insertAsync(doc, callback) { + doc = EJSON.clone(doc); + const id = this.prepareInsert(doc); + const queriesToRecompute = []; + for (const qid of Object.keys(this.queries)) { + const query = this.queries[qid]; + if (query.dirty) continue; + const matchResult = query.matcher.documentMatches(doc); + if (matchResult.result) { + if (query.distances && matchResult.distance !== undefined) query.distances.set(id, matchResult.distance); + if (query.cursor.skip || query.cursor.limit) queriesToRecompute.push(qid); + else await _insertInResultsAsync(query, doc); + } + } + queriesToRecompute.forEach((qid) => { + if (this.queries[qid]) this._recomputeResults(this.queries[qid]); + }); + await this._observeQueue.drain(); + if (callback) { + setTimeout(() => callback(null, id), 0); + } + return id; + } + + pauseObservers() { + if (this.paused) return; + this.paused = true; + Object.keys(this.queries).forEach((qid) => { + const query = this.queries[qid]; + query.resultsSnapshot = EJSON.clone(query.results); + }); + } + + clearResultQueries(callback) { + const result = this._docs.size(); + this._docs.clear(); + Object.keys(this.queries).forEach((qid) => { + const query = this.queries[qid]; + if (query.ordered) query.results = []; + else query.results.clear(); + }); + if (callback) { + setTimeout(() => callback(null, result), 0); + } + return result; + } + + prepareRemove(selector) { + const matcher = new Matcher(selector); + const remove = []; + this._eachPossiblyMatchingDocSync(selector, (doc, id) => { + if (matcher.documentMatches(doc).result) remove.push(id); + }); + const queriesToRecompute = []; + const queryRemove = []; + for (let i = 0; i < remove.length; i++) { + const removeId = remove[i]; + const removeDoc = this._docs.get(removeId); + Object.keys(this.queries).forEach((qid) => { + const query = this.queries[qid]; + if (query.dirty) return; + if (query.matcher.documentMatches(removeDoc).result) { + if (query.cursor.skip || query.cursor.limit) queriesToRecompute.push(qid); + else queryRemove.push({ qid, doc: removeDoc }); + } + }); + this._saveOriginal(removeId, removeDoc); + this._docs.remove(removeId); + } + return { queriesToRecompute, queryRemove, remove }; + } + + remove(selector, callback) { + if (this.paused && !this._savedOriginals && EJSON.equals(selector, {})) return this.clearResultQueries(callback); + const { queriesToRecompute, queryRemove, remove } = this.prepareRemove(selector); + queryRemove.forEach((remove) => { + const query = this.queries[remove.qid]; + if (query) { + query.distances && query.distances.remove(remove.doc._id); + _removeFromResultsSync(query, remove.doc); + } + }); + queriesToRecompute.forEach((qid) => { + const query = this.queries[qid]; + if (query) this._recomputeResults(query); + }); + this._observeQueue.drain(); + const result = remove.length; + if (callback) { + setTimeout(() => callback(null, result), 0); + } + return result; + } + + async removeAsync(selector, callback) { + if (this.paused && !this._savedOriginals && EJSON.equals(selector, {})) return this.clearResultQueries(callback); + const { queriesToRecompute, queryRemove, remove } = this.prepareRemove(selector); + for (const remove of queryRemove) { + const query = this.queries[remove.qid]; + if (query) { + query.distances && query.distances.remove(remove.doc._id); + await _removeFromResultsAsync(query, remove.doc); + } + } + queriesToRecompute.forEach((qid) => { + const query = this.queries[qid]; + if (query) this._recomputeResults(query); + }); + await this._observeQueue.drain(); + const result = remove.length; + if (callback) { + setTimeout(() => callback(null, result), 0); + } + return result; + } + + _resumeObservers() { + if (!this.paused) return; + this.paused = false; + Object.keys(this.queries).forEach((qid) => { + const query = this.queries[qid]; + if (query.dirty) { + query.dirty = false; + this._recomputeResults(query, query.resultsSnapshot); + } else { + DiffSequence.diffQueryChanges(query.ordered, query.resultsSnapshot, query.results, query, { projectionFn: query.projectionFn }); + } + query.resultsSnapshot = null; + }); + } + + async resumeObserversServer() { + this._resumeObservers(); + await this._observeQueue.drain(); + } + + resumeObserversClient() { + this._resumeObservers(); + this._observeQueue.drain(); + } + + retrieveOriginals() { + if (!this._savedOriginals) throw new Error('Called retrieveOriginals without saveOriginals'); + const originals = this._savedOriginals; + this._savedOriginals = null; + return originals; + } + + saveOriginals() { + if (this._savedOriginals) throw new Error('Called saveOriginals twice without retrieveOriginals'); + this._savedOriginals = new _IdMap(); + } + + prepareUpdate(selector) { + const qidToOriginalResults = {}; + const docMap = new _IdMap(); + const idsMatched = _idsMatchedBySelector(selector); + Object.keys(this.queries).forEach((qid) => { + const query = this.queries[qid]; + if ((query.cursor.skip || query.cursor.limit) && !this.paused) { + if (query.results instanceof _IdMap) { + qidToOriginalResults[qid] = query.results.clone(); + return; + } + if (!(query.results instanceof Array)) throw new Error('Assertion failed: query.results not an array'); + const memoizedCloneIfNeeded = (doc) => { + if (docMap.has(doc._id)) return docMap.get(doc._id); + const docToMemoize = idsMatched && !idsMatched.some((id) => EJSON.equals(id, doc._id)) ? doc : EJSON.clone(doc); + docMap.set(doc._id, docToMemoize); + return docToMemoize; + }; + qidToOriginalResults[qid] = query.results.map(memoizedCloneIfNeeded); + } + }); + return qidToOriginalResults; + } + + finishUpdate({ options, updateCount, callback, insertedId }) { + let result; + if (options._returnObject) { + result = { numberAffected: updateCount }; + if (insertedId !== undefined) result.insertedId = insertedId; + } else { + result = updateCount; + } + if (callback) { + setTimeout(() => callback(null, result), 0); + } + return result; + } + + async updateAsync(selector, mod, options, callback) { + if (!callback && options instanceof Function) { + callback = options; + options = null; + } + if (!options) options = {}; + const matcher = new Matcher(selector, true); + const qidToOriginalResults = this.prepareUpdate(selector); + let recomputeQids = {}; + let updateCount = 0; + await this._eachPossiblyMatchingDocAsync(selector, async (doc, id) => { + const queryResult = matcher.documentMatches(doc); + if (queryResult.result) { + this._saveOriginal(id, doc); + recomputeQids = await this._modifyAndNotifyAsync(doc, mod, queryResult.arrayIndices); + ++updateCount; + if (!options.multi) return false; + } + return true; + }); + Object.keys(recomputeQids).forEach((qid) => { + const query = this.queries[qid]; + if (query) this._recomputeResults(query, qidToOriginalResults[qid]); + }); + await this._observeQueue.drain(); + let insertedId; + if (updateCount === 0 && options.upsert) { + const doc = _createUpsertDocument(selector, mod); + if (!doc._id && options.insertedId) doc._id = options.insertedId; + insertedId = await this.insertAsync(doc); + updateCount = 1; + } + return this.finishUpdate({ options, insertedId, updateCount, callback }); + } + + update(selector, mod, options, callback) { + if (!callback && options instanceof Function) { + callback = options; + options = null; + } + if (!options) options = {}; + const matcher = new Matcher(selector, true); + const qidToOriginalResults = this.prepareUpdate(selector); + let recomputeQids = {}; + let updateCount = 0; + this._eachPossiblyMatchingDocSync(selector, (doc, id) => { + const queryResult = matcher.documentMatches(doc); + if (queryResult.result) { + this._saveOriginal(id, doc); + recomputeQids = this._modifyAndNotifySync(doc, mod, queryResult.arrayIndices); + ++updateCount; + if (!options.multi) return false; + } + return true; + }); + Object.keys(recomputeQids).forEach((qid) => { + const query = this.queries[qid]; + if (query) this._recomputeResults(query, qidToOriginalResults[qid]); + }); + this._observeQueue.drain(); + let insertedId; + if (updateCount === 0 && options.upsert) { + const doc = _createUpsertDocument(selector, mod); + if (!doc._id && options.insertedId) doc._id = options.insertedId; + insertedId = this.insert(doc); + updateCount = 1; + } + return this.finishUpdate({ options, insertedId, updateCount, callback, selector, mod }); + } + + upsert(selector, mod, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + return this.update(selector, mod, Object.assign({}, options, { upsert: true, _returnObject: true }), callback); + } + + upsertAsync(selector, mod, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + return this.updateAsync(selector, mod, Object.assign({}, options, { upsert: true, _returnObject: true }), callback); + } + + async _eachPossiblyMatchingDocAsync(selector, fn) { + const specificIds = _idsMatchedBySelector(selector); + if (specificIds) { + for (const id of specificIds) { + const doc = this._docs.get(id); + if (doc && !(await fn(doc, id))) break; + } + } else { + await this._docs.forEachAsync(fn); + } + } + + _eachPossiblyMatchingDocSync(selector, fn) { + const specificIds = _idsMatchedBySelector(selector); + if (specificIds) { + for (const id of specificIds) { + const doc = this._docs.get(id); + if (doc && fn(doc, id) === false) break; + } + } else { + this._docs.forEach(fn); + } + } + + _getMatchedDocAndModify(doc, mod, arrayIndices) { + const matched_before = {}; + Object.keys(this.queries).forEach((qid) => { + const query = this.queries[qid]; + if (query.dirty) return; + if (query.ordered) matched_before[qid] = query.matcher.documentMatches(doc).result; + else matched_before[qid] = query.results.has(doc._id); + }); + return matched_before; + } + + _modifyAndNotifySync(doc, mod, arrayIndices) { + const matched_before = this._getMatchedDocAndModify(doc, mod, arrayIndices); + const old_doc = EJSON.clone(doc); + _modify(doc, mod, { arrayIndices }); + const recomputeQids = {}; + for (const qid of Object.keys(this.queries)) { + const query = this.queries[qid]; + if (query.dirty) continue; + const afterMatch = query.matcher.documentMatches(doc); + const after = afterMatch.result; + const before = matched_before[qid]; + if (after && query.distances && afterMatch.distance !== undefined) query.distances.set(doc._id, afterMatch.distance); + if (query.cursor.skip || query.cursor.limit) { + if (before || after) recomputeQids[qid] = true; + } else if (before && !after) _removeFromResultsSync(query, doc); + else if (!before && after) _insertInResultsSync(query, doc); + else if (before && after) _updateInResultsSync(query, doc, old_doc); + } + return recomputeQids; + } + + async _modifyAndNotifyAsync(doc, mod, arrayIndices) { + const matched_before = this._getMatchedDocAndModify(doc, mod, arrayIndices); + const old_doc = EJSON.clone(doc); + _modify(doc, mod, { arrayIndices }); + const recomputeQids = {}; + for (const qid of Object.keys(this.queries)) { + const query = this.queries[qid]; + if (query.dirty) continue; + const afterMatch = query.matcher.documentMatches(doc); + const after = afterMatch.result; + const before = matched_before[qid]; + if (after && query.distances && afterMatch.distance !== undefined) query.distances.set(doc._id, afterMatch.distance); + if (query.cursor.skip || query.cursor.limit) { + if (before || after) recomputeQids[qid] = true; + } else if (before && !after) await _removeFromResultsAsync(query, doc); + else if (!before && after) await _insertInResultsAsync(query, doc); + else if (before && after) await _updateInResultsAsync(query, doc, old_doc); + } + return recomputeQids; + } + + _recomputeResults(query, oldResults) { + if (this.paused) { + query.dirty = true; + return; + } + if (!this.paused && !oldResults) oldResults = query.results; + if (query.distances) query.distances.clear(); + query.results = query.cursor._getRawObjects({ distances: query.distances, ordered: query.ordered }); + if (!this.paused) { + DiffSequence.diffQueryChanges(query.ordered, oldResults, query.results, query, { projectionFn: query.projectionFn }); + } + } + + _saveOriginal(id, doc) { + if (!this._savedOriginals) return; + if (this._savedOriginals.has(id)) return; + this._savedOriginals.set(id, EJSON.clone(doc)); + } +} + +function isIndexable(obj) { + return Array.isArray(obj) || _isPlainObject(obj); +} + +function isNumericKey(s) { + return /^[0-9]+$/.test(s); +} + +function isOperatorObject(valueSelector, inconsistentOK) { + if (!_isPlainObject(valueSelector)) { + return false; + } + let theseAreOperators = undefined; + Object.keys(valueSelector).forEach((selKey) => { + const thisIsOperator = selKey.substr(0, 1) === '$' || selKey === 'diff'; + if (theseAreOperators === undefined) { + theseAreOperators = thisIsOperator; + } else if (theseAreOperators !== thisIsOperator) { + if (!inconsistentOK) { + throw new MiniMongoQueryError(`Inconsistent operator: ${JSON.stringify(valueSelector)}`); + } + theseAreOperators = false; + } + }); + return !!theseAreOperators; +} + +function makeInequality(cmpValueComparator) { + return { + compileElementSelector(operand) { + if (Array.isArray(operand)) return () => false; + if (operand === undefined) operand = null; + const operandType = _f._type(operand); + return (value) => { + if (value === undefined) value = null; + if (_f._type(value) !== operandType) return false; + return cmpValueComparator(_f._cmp(value, operand)); + }; + }, + }; +} + +const ELEMENT_OPERATORS = { + $lt: makeInequality((cmpValue) => cmpValue < 0), + $gt: makeInequality((cmpValue) => cmpValue > 0), + $lte: makeInequality((cmpValue) => cmpValue <= 0), + $gte: makeInequality((cmpValue) => cmpValue >= 0), + $mod: { + compileElementSelector(operand) { + if (!(Array.isArray(operand) && operand.length === 2)) + throw new MiniMongoQueryError('argument to $mod must be an array of two numbers'); + const divisor = operand[0]; + const remainder = operand[1]; + return (value) => typeof value === 'number' && value % divisor === remainder; + }, + }, + $in: { + compileElementSelector(operand) { + if (!Array.isArray(operand)) throw new MiniMongoQueryError('$in needs an array'); + const elementMatchers = operand.map((option) => { + if (option instanceof RegExp) return regexpElementMatcher(option); + if (isOperatorObject(option)) throw new MiniMongoQueryError('cannot nest $ under $in'); + return equalityElementMatcher(option); + }); + return (value) => { + if (value === undefined) value = null; + return elementMatchers.some((matcher) => matcher(value)); + }; + }, + }, + $size: { + dontExpandLeafArrays: true, + compileElementSelector(operand) { + if (typeof operand === 'string') operand = 0; + else if (typeof operand !== 'number') throw new MiniMongoQueryError('$size needs a number'); + return (value) => Array.isArray(value) && value.length === operand; + }, + }, + $type: { + dontIncludeLeafArrays: true, + compileElementSelector(operand) { + if (typeof operand === 'string') { + const operandAliasMap = { + double: 1, + string: 2, + object: 3, + array: 4, + binData: 5, + undefined: 6, + objectId: 7, + bool: 8, + date: 9, + null: 10, + regex: 11, + dbPointer: 12, + javascript: 13, + symbol: 14, + javascriptWithScope: 15, + int: 16, + timestamp: 17, + long: 18, + decimal: 19, + minKey: -1, + maxKey: 127, + }; + operand = operandAliasMap[operand]; + } + return (value) => value !== undefined && _f._type(value) === operand; + }, + }, + $regex: { + compileElementSelector(operand, valueSelector) { + if (!(typeof operand === 'string' || operand instanceof RegExp)) throw new MiniMongoQueryError('$regex has to be a string or RegExp'); + let regexp; + if (valueSelector.$options !== undefined) { + const source = operand instanceof RegExp ? operand.source : operand; + regexp = new RegExp(source, valueSelector.$options); + } else if (operand instanceof RegExp) { + regexp = operand; + } else { + regexp = new RegExp(operand); + } + return regexpElementMatcher(regexp); + }, + }, + $elemMatch: { + dontExpandLeafArrays: true, + compileElementSelector(operand, valueSelector, matcher) { + if (!_isPlainObject(operand)) throw new MiniMongoQueryError('$elemMatch need an object'); + const isDocMatcher = !isOperatorObject( + Object.keys(operand) + .filter((key) => !hasOwn.call(LOGICAL_OPERATORS, key)) + .reduce((a, b) => Object.assign(a, { [b]: operand[b] }), {}), + true, + ); + let subMatcher; + if (isDocMatcher) subMatcher = compileDocumentSelector(operand, matcher, { inElemMatch: true }); + else subMatcher = compileValueSelector(operand, matcher); + return (value) => { + if (!Array.isArray(value)) return false; + for (let i = 0; i < value.length; ++i) { + const arrayElement = value[i]; + let arg; + if (isDocMatcher) { + if (!isIndexable(arrayElement)) return false; + arg = arrayElement; + } else { + arg = [{ value: arrayElement, dontIterate: true }]; + } + if (subMatcher(arg).result) return i; + } + return false; + }; + }, + }, +}; + +const LOGICAL_OPERATORS = { + $and(subSelector, matcher, inElemMatch) { + return andDocumentMatchers(compileArrayOfDocumentSelectors(subSelector, matcher, inElemMatch)); + }, + $or(subSelector, matcher, inElemMatch) { + const matchers = compileArrayOfDocumentSelectors(subSelector, matcher, inElemMatch); + if (matchers.length === 1) return matchers[0]; + return (doc) => { + const result = matchers.some((fn) => fn(doc).result); + return { result }; + }; + }, + $nor(subSelector, matcher, inElemMatch) { + const matchers = compileArrayOfDocumentSelectors(subSelector, matcher, inElemMatch); + return (doc) => { + const result = matchers.every((fn) => !fn(doc).result); + return { result }; + }; + }, + $where(selectorValue, matcher) { + matcher._recordPathUsed(''); + matcher._hasWhere = true; + if (!(selectorValue instanceof Function)) selectorValue = Function('obj', `return ${selectorValue}`); + return (doc) => ({ result: selectorValue.call(doc, doc) }); + }, + $comment() { + return () => ({ result: true }); + }, +}; + +const VALUE_OPERATORS = { + $eq(operand) { + return convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand)); + }, + $not(operand, valueSelector, matcher) { + return invertBranchedMatcher(compileValueSelector(operand, matcher)); + }, + $ne(operand) { + return invertBranchedMatcher(convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand))); + }, + $nin(operand) { + return invertBranchedMatcher(convertElementMatcherToBranchedMatcher(ELEMENT_OPERATORS.$in.compileElementSelector(operand))); + }, + $exists(operand) { + const exists = convertElementMatcherToBranchedMatcher((value) => value !== undefined); + return operand ? exists : invertBranchedMatcher(exists); + }, + $options(operand, valueSelector) { + return everythingMatcher; + }, + $maxDistance(operand, valueSelector) { + return everythingMatcher; + }, + $all(operand, valueSelector, matcher) { + if (!Array.isArray(operand)) throw new MiniMongoQueryError('$all requires array'); + if (operand.length === 0) return nothingMatcher; + const branchedMatchers = operand.map((criterion) => { + if (isOperatorObject(criterion)) throw new MiniMongoQueryError('no $ expressions in $all'); + return compileValueSelector(criterion, matcher); + }); + return andBranchedMatchers(branchedMatchers); + }, + $near(operand, valueSelector, matcher, isRoot) { + if (!isRoot) throw new MiniMongoQueryError("$near can't be inside another $ operator"); + matcher._hasGeoQuery = true; + let maxDistance; + let point; + let distance; + if (_isPlainObject(operand) && hasOwn.call(operand, '$geometry')) { + maxDistance = operand.$maxDistance; + point = operand.$geometry; + distance = (value) => { + if (!value) return null; + if (!value.type) return GeoJSON.pointDistance(point, { type: 'Point', coordinates: pointToArray(value) }); + if (value.type === 'Point') return GeoJSON.pointDistance(point, value); + return GeoJSON.geometryWithinRadius(value, point, maxDistance) ? 0 : maxDistance + 1; + }; + } else { + maxDistance = valueSelector.$maxDistance; + point = pointToArray(operand); + distance = (value) => { + if (!isIndexable(value)) return null; + return distanceCoordinatePairs(point, value); + }; + } + return (branchedValues) => { + const result = { result: false }; + expandArraysInBranches(branchedValues).every((branch) => { + let curDistance; + if (!matcher._isUpdate) { + if (!(typeof branch.value === 'object')) return true; + curDistance = distance(branch.value); + if (curDistance === null || curDistance > maxDistance) return true; + if (result.distance !== undefined && result.distance <= curDistance) return true; + } + result.result = true; + result.distance = curDistance; + if (branch.arrayIndices) result.arrayIndices = branch.arrayIndices; + else delete result.arrayIndices; + return !matcher._isUpdate; + }); + return result; + }; + }, +}; + +function andSomeMatchers(subMatchers) { + if (subMatchers.length === 0) return everythingMatcher; + if (subMatchers.length === 1) return subMatchers[0]; + return (docOrBranches) => { + const match = {}; + match.result = subMatchers.every((fn) => { + const subResult = fn(docOrBranches); + if (subResult.result && subResult.distance !== undefined && match.distance === undefined) match.distance = subResult.distance; + if (subResult.result && subResult.arrayIndices) match.arrayIndices = subResult.arrayIndices; + return subResult.result; + }); + if (!match.result) { + delete match.distance; + delete match.arrayIndices; + } + return match; + }; +} + +const andDocumentMatchers = andSomeMatchers; +const andBranchedMatchers = andSomeMatchers; + +function compileArrayOfDocumentSelectors(selectors, matcher, inElemMatch) { + if (!Array.isArray(selectors) || selectors.length === 0) throw new MiniMongoQueryError('$and/$or/$nor must be nonempty array'); + return selectors.map((subSelector) => { + if (!_isPlainObject(subSelector)) throw new MiniMongoQueryError('$or/$and/$nor entries need to be full objects'); + return compileDocumentSelector(subSelector, matcher, { inElemMatch }); + }); +} + +function compileDocumentSelector(docSelector, matcher, options = {}) { + const docMatchers = Object.keys(docSelector) + .map((key) => { + const subSelector = docSelector[key]; + if (key.substr(0, 1) === '$') { + if (!hasOwn.call(LOGICAL_OPERATORS, key)) throw new MiniMongoQueryError(`Unrecognized logical operator: ${key}`); + matcher._isSimple = false; + return LOGICAL_OPERATORS[key](subSelector, matcher, options.inElemMatch); + } + if (!options.inElemMatch) matcher._recordPathUsed(key); + if (typeof subSelector === 'function') return undefined; + const lookUpByIndex = makeLookupFunction(key); + const valueMatcher = compileValueSelector(subSelector, matcher, options.isRoot); + return (doc) => valueMatcher(lookUpByIndex(doc)); + }) + .filter(Boolean); + return andDocumentMatchers(docMatchers); +} + +function compileValueSelector(valueSelector, matcher, isRoot) { + if (valueSelector instanceof RegExp) { + matcher._isSimple = false; + return convertElementMatcherToBranchedMatcher(regexpElementMatcher(valueSelector)); + } + if (isOperatorObject(valueSelector)) return operatorBranchedMatcher(valueSelector, matcher, isRoot); + return convertElementMatcherToBranchedMatcher(equalityElementMatcher(valueSelector)); +} + +function convertElementMatcherToBranchedMatcher(elementMatcher, options = {}) { + return (branches) => { + const expanded = options.dontExpandLeafArrays ? branches : expandArraysInBranches(branches, options.dontIncludeLeafArrays); + const match = {}; + match.result = expanded.some((element) => { + let matched = elementMatcher(element.value); + if (typeof matched === 'number') { + if (!element.arrayIndices) element.arrayIndices = [matched]; + matched = true; + } + if (matched && element.arrayIndices) match.arrayIndices = element.arrayIndices; + return matched; + }); + return match; + }; +} + +function distanceCoordinatePairs(a, b) { + const pointA = pointToArray(a); + const pointB = pointToArray(b); + return Math.hypot(pointA[0] - pointB[0], pointA[1] - pointB[1]); +} + +function equalityElementMatcher(elementSelector) { + if (isOperatorObject(elementSelector)) throw new MiniMongoQueryError("Can't create equalityValueSelector for operator object"); + if (elementSelector == null) return (value) => value == null; + return (value) => _f._equal(elementSelector, value); +} + +function expandArraysInBranches(branches, skipTheArrays) { + const branchesOut = []; + branches.forEach((branch) => { + const thisIsArray = Array.isArray(branch.value); + if (!(skipTheArrays && thisIsArray && !branch.dontIterate)) { + branchesOut.push({ arrayIndices: branch.arrayIndices, value: branch.value }); + } + if (thisIsArray && !branch.dontIterate) { + branch.value.forEach((value, i) => { + branchesOut.push({ + arrayIndices: (branch.arrayIndices || []).concat(i), + value, + }); + }); + } + }); + return branchesOut; +} + +function insertIntoDocument(document, key, value) { + Object.keys(document).forEach((existingKey) => { + if ( + (existingKey.length > key.length && existingKey.indexOf(`${key}.`) === 0) || + (key.length > existingKey.length && key.indexOf(`${existingKey}.`) === 0) + ) { + throw new MiniMongoQueryError(`cannot infer query fields to set, both paths '${existingKey}' and '${key}' are matched`); + } else if (existingKey === key) { + throw new MiniMongoQueryError(`cannot infer query fields to set, path '${key}' is matched twice`); + } + }); + document[key] = value; +} + +function invertBranchedMatcher(branchedMatcher) { + return (branchValues) => { + return { result: !branchedMatcher(branchValues).result }; + }; +} + +function makeLookupFunction(key, options = {}) { + const parts = key.split('.'); + const firstPart = parts.length ? parts[0] : ''; + const lookupRest = parts.length > 1 && makeLookupFunction(parts.slice(1).join('.'), options); + function buildResult(arrayIndices, dontIterate, value) { + return arrayIndices && arrayIndices.length + ? dontIterate + ? [{ arrayIndices, dontIterate, value }] + : [{ arrayIndices, value }] + : dontIterate + ? [{ dontIterate, value }] + : [{ value }]; + } + return (doc, arrayIndices) => { + if (Array.isArray(doc)) { + if (!(isNumericKey(firstPart) && firstPart < doc.length)) return []; + arrayIndices = arrayIndices ? arrayIndices.concat(+firstPart, 'x') : [+firstPart, 'x']; + } + const firstLevel = doc[firstPart]; + if (!lookupRest) return buildResult(arrayIndices, Array.isArray(doc) && Array.isArray(firstLevel), firstLevel); + if (!isIndexable(firstLevel)) { + if (Array.isArray(doc)) return []; + return buildResult(arrayIndices, false, undefined); + } + const result = []; + result.push(...lookupRest(firstLevel, arrayIndices)); + if (Array.isArray(firstLevel) && !(isNumericKey(parts[1]) && options.forSort)) { + firstLevel.forEach((branch, arrayIndex) => { + if (_isPlainObject(branch)) { + result.push(...lookupRest(branch, arrayIndices ? arrayIndices.concat(arrayIndex) : [arrayIndex])); + } + }); + } + return result; + }; +} + +function operatorBranchedMatcher(valueSelector, matcher, isRoot) { + const operatorMatchers = Object.keys(valueSelector).map((operator) => { + const operand = valueSelector[operator]; + if (hasOwn.call(VALUE_OPERATORS, operator)) return VALUE_OPERATORS[operator](operand, valueSelector, matcher, isRoot); + if (hasOwn.call(ELEMENT_OPERATORS, operator)) { + const options = ELEMENT_OPERATORS[operator]; + return convertElementMatcherToBranchedMatcher(options.compileElementSelector(operand, valueSelector, matcher), options); + } + throw new MiniMongoQueryError(`Unrecognized operator: ${operator}`); + }); + return andBranchedMatchers(operatorMatchers); +} + +function pathsToTree(paths, newLeafFn, conflictFn, root = {}) { + paths.forEach((path) => { + const pathArray = path.split('.'); + let tree = root; + const success = pathArray.slice(0, -1).every((key, i) => { + if (!hasOwn.call(tree, key)) tree[key] = {}; + else if (tree[key] !== Object(tree[key])) { + tree[key] = conflictFn(tree[key], pathArray.slice(0, i + 1).join('.'), path); + if (tree[key] !== Object(tree[key])) return false; + } + tree = tree[key]; + return true; + }); + if (success) { + const lastKey = pathArray[pathArray.length - 1]; + if (hasOwn.call(tree, lastKey)) tree[lastKey] = conflictFn(tree[lastKey], path, path); + else tree[lastKey] = newLeafFn(path); + } + }); + return root; +} + +function pointToArray(point) { + return Array.isArray(point) ? point.slice() : [point.x, point.y]; +} + +function populateDocumentWithKeyValue(document, key, value) { + if (value && Object.getPrototypeOf(value) === Object.prototype) populateDocumentWithObject(document, key, value); + else if (!(value instanceof RegExp)) insertIntoDocument(document, key, value); +} + +function populateDocumentWithObject(document, key, value) { + const keys = Object.keys(value); + const unprefixedKeys = keys.filter((op) => op[0] !== '$'); + if (unprefixedKeys.length > 0 || !keys.length) { + if (keys.length !== unprefixedKeys.length) throw new MiniMongoQueryError(`unknown operator: ${unprefixedKeys[0]}`); + validateObject(value, key); + insertIntoDocument(document, key, value); + } else { + Object.keys(value).forEach((op) => { + const object = value[op]; + if (op === '$eq') populateDocumentWithKeyValue(document, key, object); + else if (op === '$all') object.forEach((element) => populateDocumentWithKeyValue(document, key, element)); + }); + } +} + +function populateDocumentWithQueryFields(query, document = {}) { + if (Object.getPrototypeOf(query) === Object.prototype) { + Object.keys(query).forEach((key) => { + const value = query[key]; + if (key === '$and') value.forEach((element) => populateDocumentWithQueryFields(element, document)); + else if (key === '$or') { + if (value.length === 1) populateDocumentWithQueryFields(value[0], document); + } else if (key[0] !== '$') populateDocumentWithKeyValue(document, key, value); + }); + } else if (_selectorIsId(query)) insertIntoDocument(document, '_id', query); + return document; +} + +function projectionDetails(fields) { + let fieldsKeys = Object.keys(fields).sort(); + if (!(fieldsKeys.length === 1 && fieldsKeys[0] === '_id') && !(fieldsKeys.includes('_id') && fields._id)) + fieldsKeys = fieldsKeys.filter((key) => key !== '_id'); + let including = null; + fieldsKeys.forEach((keyPath) => { + const rule = !!fields[keyPath]; + if (including === null) including = rule; + if (including !== rule) throw MinimongoError('You cannot currently mix including and excluding fields.'); + }); + const projectionRulesTree = pathsToTree( + fieldsKeys, + (path) => including, + (node, path, fullPath) => { + throw MinimongoError(`both ${fullPath} and ${path} found in fields option.`); + }, + ); + return { including, tree: projectionRulesTree }; +} + +function regexpElementMatcher(regexp) { + return (value) => { + if (value instanceof RegExp) return value.toString() === regexp.toString(); + if (typeof value !== 'string') return false; + regexp.lastIndex = 0; + return regexp.test(value); + }; +} + +function validateKeyInPath(key, path) { + if (key.includes('.')) throw new Error(`The dotted field '${key}' in '${path}.${key} is not valid for storage.`); + if (key[0] === '$') throw new Error(`The dollar ($) prefixed field '${path}.${key} is not valid for storage.`); +} + +function validateObject(object, path) { + if (object && Object.getPrototypeOf(object) === Object.prototype) { + Object.keys(object).forEach((key) => { + validateKeyInPath(key, path); + validateObject(object[key], `${path}.${key}`); + }); + } +} + +class Matcher { + constructor(selector, isUpdate = false) { + this._paths = {}; + this._hasGeoQuery = false; + this._hasWhere = false; + this._isSimple = true; + this._matchingDocument = undefined; + this._selector = null; + this._docMatcher = this._compileSelector(selector); + this._isUpdate = isUpdate; + } + + documentMatches(doc) { + if (doc !== Object(doc)) throw Error('documentMatches needs a document'); + return this._docMatcher(doc); + } + + hasGeoQuery() { + return this._hasGeoQuery; + } + + hasWhere() { + return this._hasWhere; + } + + isSimple() { + return this._isSimple; + } + + _compileSelector(selector) { + if (selector instanceof Function) { + this._isSimple = false; + this._selector = selector; + this._recordPathUsed(''); + return (doc) => ({ result: !!selector.call(doc) }); + } + if (_selectorIsId(selector)) { + this._selector = { _id: selector }; + this._recordPathUsed('_id'); + return (doc) => ({ result: EJSON.equals(doc._id, selector) }); + } + if (!selector || (hasOwn.call(selector, '_id') && !selector._id)) { + this._isSimple = false; + return nothingMatcher; + } + if (Array.isArray(selector) || EJSON.isBinary(selector) || typeof selector === 'boolean') { + throw new Error(`Invalid selector: ${selector}`); + } + this._selector = EJSON.clone(selector); + return compileDocumentSelector(selector, this, { isRoot: true }); + } + + _getPaths() { + return Object.keys(this._paths); + } + + _recordPathUsed(path) { + this._paths[path] = true; + } +} + +class Sorter { + constructor(spec) { + this._sortSpecParts = []; + this._sortFunction = null; + const addSpecPart = (path, ascending) => { + if (!path) throw Error('sort keys must be non-empty'); + if (path.charAt(0) === '$') throw Error(`unsupported sort key: ${path}`); + this._sortSpecParts.push({ ascending, lookup: makeLookupFunction(path, { forSort: true }), path }); + }; + if (spec instanceof Array) { + spec.forEach((element) => { + if (typeof element === 'string') addSpecPart(element, true); + else addSpecPart(element[0], element[1] !== 'desc'); + }); + } else if (typeof spec === 'object') { + Object.keys(spec).forEach((key) => addSpecPart(key, spec[key] >= 0)); + } else if (typeof spec === 'function') { + this._sortFunction = spec; + } else { + throw Error(`Bad sort specification: ${JSON.stringify(spec)}`); + } + if (this._sortFunction) return; + this._keyComparator = composeComparators(this._sortSpecParts.map((spec, i) => this._keyFieldComparator(i))); + } + + getComparator(options) { + if (this._sortSpecParts.length || !options || !options.distances) return this._getBaseComparator(); + const { distances } = options; + return (a, b) => { + if (!distances.has(a._id)) throw Error(`Missing distance for ${a._id}`); + if (!distances.has(b._id)) throw Error(`Missing distance for ${b._id}`); + return distances.get(a._id) - distances.get(b._id); + }; + } + + _compareKeys(key1, key2) { + if (key1.length !== this._sortSpecParts.length || key2.length !== this._sortSpecParts.length) throw Error('Key has wrong length'); + return this._keyComparator(key1, key2); + } + + _generateKeysFromDoc(doc, cb) { + if (this._sortSpecParts.length === 0) throw new Error("can't generate keys without a spec"); + const pathFromIndices = (indices) => `${indices.join(',')},`; + let knownPaths = null; + const valuesByIndexAndPath = this._sortSpecParts.map((spec) => { + let branches = expandArraysInBranches(spec.lookup(doc), true); + if (!branches.length) branches = [{ value: void 0 }]; + const element = Object.create(null); + let usedPaths = false; + branches.forEach((branch) => { + if (!branch.arrayIndices) { + if (branches.length > 1) throw Error('multiple branches but no array used?'); + element[''] = branch.value; + return; + } + usedPaths = true; + const path = pathFromIndices(branch.arrayIndices); + if (hasOwn.call(element, path)) throw Error(`duplicate path: ${path}`); + element[path] = branch.value; + if (knownPaths && !hasOwn.call(knownPaths, path)) throw Error('cannot index parallel arrays'); + }); + if (knownPaths) { + if (!hasOwn.call(element, '') && Object.keys(knownPaths).length !== Object.keys(element).length) + throw Error('cannot index parallel arrays!'); + } else if (usedPaths) { + knownPaths = {}; + Object.keys(element).forEach((path) => { + knownPaths[path] = true; + }); + } + return element; + }); + if (!knownPaths) { + const soleKey = valuesByIndexAndPath.map((values) => { + if (!hasOwn.call(values, '')) throw Error('no value in sole key case?'); + return values['']; + }); + cb(soleKey); + return; + } + Object.keys(knownPaths).forEach((path) => { + const key = valuesByIndexAndPath.map((values) => { + if (hasOwn.call(values, '')) return values['']; + if (!hasOwn.call(values, path)) throw Error('missing path?'); + return values[path]; + }); + cb(key); + }); + } + + _getBaseComparator() { + if (this._sortFunction) return this._sortFunction; + if (!this._sortSpecParts.length) return (doc1, doc2) => 0; + return (doc1, doc2) => { + const key1 = this._getMinKeyFromDoc(doc1); + const key2 = this._getMinKeyFromDoc(doc2); + return this._compareKeys(key1, key2); + }; + } + + _getMinKeyFromDoc(doc) { + let minKey = null; + this._generateKeysFromDoc(doc, (key) => { + if (minKey === null) { + minKey = key; + return; + } + if (this._compareKeys(key, minKey) < 0) minKey = key; + }); + return minKey; + } + + _getPaths() { + return this._sortSpecParts.map((part) => part.path); + } + + _keyFieldComparator(i) { + const invert = !this._sortSpecParts[i].ascending; + return (key1, key2) => { + const compare = _f._cmp(key1[i], key2[i]); + return invert ? -compare : compare; + }; + } +} + +function composeComparators(comparatorArray) { + return (a, b) => { + for (let i = 0; i < comparatorArray.length; ++i) { + const compare = comparatorArray[i](a, b); + if (compare !== 0) return compare; + } + return 0; + }; +} +const MODIFIERS = { + $currentDate(target, field, arg) { + target[field] = new Date(); + }, + $inc(target, field, arg) { + if (typeof arg !== 'number') throw MinimongoError('Modifier $inc allowed for numbers only'); + if (field in target && typeof target[field] !== 'number') throw MinimongoError('Cannot apply $inc modifier to non-number'); + if (field in target) target[field] += arg; + else target[field] = arg; + }, + $min(target, field, arg) { + if (typeof arg !== 'number') throw MinimongoError('Modifier $min allowed for numbers only'); + if (field in target && typeof target[field] !== 'number') throw MinimongoError('Cannot apply $min modifier to non-number'); + if (!(field in target) || target[field] > arg) target[field] = arg; + }, + $max(target, field, arg) { + if (typeof arg !== 'number') throw MinimongoError('Modifier $max allowed for numbers only'); + if (field in target && typeof target[field] !== 'number') throw MinimongoError('Cannot apply $max modifier to non-number'); + if (!(field in target) || target[field] < arg) target[field] = arg; + }, + $mul(target, field, arg) { + if (typeof arg !== 'number') throw MinimongoError('Modifier $mul allowed for numbers only'); + if (field in target && typeof target[field] !== 'number') throw MinimongoError('Cannot apply $mul modifier to non-number'); + if (field in target) target[field] *= arg; + else target[field] = 0; + }, + $rename(target, field, arg, keypath, doc) { + if (target !== undefined) { + const object = target[field]; + delete target[field]; + const keyparts = arg.split('.'); + const target2 = findModTarget(doc, keyparts, { forbidArray: true }); + if (target2 === null) throw MinimongoError('$rename target field invalid'); + target2[keyparts.pop()] = object; + } + }, + $set(target, field, arg) { + if (target !== Object(target)) { + const err = MinimongoError('Cannot set property on non-object field'); + err.setPropertyError = true; + throw err; + } + if (target === null) { + const err = MinimongoError('Cannot set property on null'); + err.setPropertyError = true; + throw err; + } + assertHasValidFieldNames(arg); + target[field] = arg; + }, + $setOnInsert(target, field, arg) {}, + $unset(target, field, arg) { + if (target !== undefined) { + if (target instanceof Array) { + if (field in target) target[field] = null; + } else delete target[field]; + } + }, + $push(target, field, arg) { + if (target[field] === undefined) target[field] = []; + if (!(target[field] instanceof Array)) throw MinimongoError('Cannot apply $push modifier to non-array'); + if (!(arg && arg.$each)) { + assertHasValidFieldNames(arg); + target[field].push(arg); + return; + } + const toPush = arg.$each; + assertHasValidFieldNames(toPush); + let position = undefined; + if ('$position' in arg) position = arg.$position; + let slice = undefined; + if ('$slice' in arg) slice = arg.$slice; + let sortFunction = undefined; + if (arg.$sort) sortFunction = new Sorter(arg.$sort).getComparator(); + if (position === undefined) toPush.forEach((e) => target[field].push(e)); + else { + const args = [position, 0].concat(toPush); + target[field].splice(...args); + } + if (sortFunction) target[field].sort(sortFunction); + if (slice !== undefined) { + if (slice === 0) target[field] = []; + else if (slice < 0) target[field] = target[field].slice(slice); + else target[field] = target[field].slice(0, slice); + } + }, + $pushAll(target, field, arg) { + if (!(typeof arg === 'object' && arg instanceof Array)) throw MinimongoError('$pushAll allowed for arrays only'); + assertHasValidFieldNames(arg); + const toPush = target[field]; + if (toPush === undefined) target[field] = arg; + else if (!(toPush instanceof Array)) throw MinimongoError('Cannot apply $pushAll to non-array'); + else toPush.push(...arg); + }, + $addToSet(target, field, arg) { + let isEach = false; + if (typeof arg === 'object' && Object.keys(arg)[0] === '$each') isEach = true; + const values = isEach ? arg.$each : [arg]; + assertHasValidFieldNames(values); + const toAdd = target[field]; + if (toAdd === undefined) target[field] = values; + else if (!(toAdd instanceof Array)) throw MinimongoError('Cannot apply $addToSet to non-array'); + else + values.forEach((v) => { + if (!toAdd.some((e) => _f._equal(v, e))) toAdd.push(v); + }); + }, + $pop(target, field, arg) { + if (target === undefined) return; + const toPop = target[field]; + if (toPop === undefined) return; + if (!(toPop instanceof Array)) throw MinimongoError('Cannot apply $pop to non-array'); + if (typeof arg === 'number' && arg < 0) toPop.splice(0, 1); + else toPop.pop(); + }, + $pull(target, field, arg) { + if (target === undefined) return; + const toPull = target[field]; + if (toPull === undefined) return; + if (!(toPull instanceof Array)) throw MinimongoError('Cannot apply $pull to non-array'); + let out; + if (arg != null && typeof arg === 'object' && !(arg instanceof Array)) { + const matcher = new Matcher(arg); + out = toPull.filter((e) => !matcher.documentMatches(e).result); + } else { + out = toPull.filter((e) => !_f._equal(e, arg)); + } + target[field] = out; + }, + $pullAll(target, field, arg) { + if (!(typeof arg === 'object' && arg instanceof Array)) throw MinimongoError('$pullAll allowed for arrays only'); + if (target === undefined) return; + const toPull = target[field]; + if (toPull === undefined) return; + if (!(toPull instanceof Array)) throw MinimongoError('Cannot apply $pullAll to non-array'); + target[field] = toPull.filter((o) => !arg.some((e) => _f._equal(o, e))); + }, + $bit(target, field, arg) { + throw MinimongoError('$bit is not supported'); + }, + $v() {}, +}; + +const NO_CREATE_MODIFIERS = { $pop: true, $pull: true, $pullAll: true, $rename: true, $unset: true }; +const invalidCharMsg = { '$': "start with '$'", '.': "contain '.'", '\0': 'contain null bytes' }; +function assertHasValidFieldNames(doc) { + if (doc && typeof doc === 'object') + JSON.stringify(doc, (key, value) => { + assertIsValidFieldName(key); + return value; + }); +} +function assertIsValidFieldName(key) { + let match; + if (typeof key === 'string' && (match = key.match(/^\$|\.|\0/))) throw MinimongoError(`Key ${key} must not ${invalidCharMsg[match[0]]}`); +} +function findModTarget(doc, keyparts, options = {}) { + let usedArrayIndex = false; + for (let i = 0; i < keyparts.length; i++) { + const last = i === keyparts.length - 1; + let keypart = keyparts[i]; + if (!isIndexable(doc)) { + if (options.noCreate) return undefined; + const err = MinimongoError(`cannot use the part '${keypart}' to traverse ${doc}`); + err.setPropertyError = true; + throw err; + } + if (doc instanceof Array) { + if (options.forbidArray) return null; + if (keypart === '$') { + if (usedArrayIndex) throw MinimongoError('Too many positional elements'); + if (!options.arrayIndices || !options.arrayIndices.length) throw MinimongoError('Positional operator did not find match'); + keypart = options.arrayIndices[0]; + usedArrayIndex = true; + } else if (isNumericKey(keypart)) keypart = parseInt(keypart); + else { + if (options.noCreate) return undefined; + throw MinimongoError(`can't append to array using string field name`); + } + if (last) keyparts[i] = keypart; + if (options.noCreate && keypart >= doc.length) return undefined; + while (doc.length < keypart) doc.push(null); + if (!last) { + if (doc.length === keypart) doc.push({}); + else if (typeof doc[keypart] !== 'object') throw MinimongoError(`can't modify field '${keyparts[i + 1]}' of list value`); + } + } else { + assertIsValidFieldName(keypart); + if (!(keypart in doc)) { + if (options.noCreate) return undefined; + if (!last) doc[keypart] = {}; + } + } + if (last) return doc; + doc = doc[keypart]; + } +} + +export const Minimongo = { + LocalCollection, + Matcher, + Sorter, +}; + +export { LocalCollection }; diff --git a/apps/meteor/src/meteor/mongo-id.ts b/apps/meteor/src/meteor/mongo-id.ts new file mode 100644 index 0000000000000..8199ef69598da --- /dev/null +++ b/apps/meteor/src/meteor/mongo-id.ts @@ -0,0 +1,109 @@ +import { EJSON } from './ejson.ts'; +import { Random } from './random'; + +const _looksLikeObjectID = (str: string) => str.length === 24 && /^[0-9a-f]*$/.test(str); + +export class ObjectID { + private _str: string; + + constructor(hexString?: string) { + if (hexString) { + hexString = hexString.toLowerCase(); + if (!_looksLikeObjectID(hexString)) { + throw new Error('Invalid hexadecimal string for creating an ObjectID'); + } + this._str = hexString; + } else { + this._str = Random.hexString(24); + } + } + + equals(other: unknown): boolean { + return other instanceof ObjectID && this.valueOf() === other.valueOf(); + } + + toString(): string { + return `ObjectID("${this._str}")`; + } + + clone(): ObjectID { + return new ObjectID(this._str); + } + + typeName(): 'oid' { + return 'oid'; + } + + getTimestamp(): number { + return Number.parseInt(this._str.substr(0, 8), 16); + } + + valueOf(): string { + return this._str; + } + + toJSONValue(): string { + return this.valueOf(); + } + + toHexString(): string { + return this.valueOf(); + } + + static stringify(id: unknown): string { + if (id instanceof ObjectID) { + return id.valueOf(); + } + if (typeof id === 'string') { + const firstChar = id.charAt(0); + if (id === '') { + return id; + } + if ( + firstChar === '-' || // escape previously dashed strings + firstChar === '~' || // escape escaped numbers, true, false + _looksLikeObjectID(id) || // escape object-id-form strings + firstChar === '{' + ) { + return `-${id}`; + } + return id; // other strings go through unchanged. + } + if (id === undefined) { + return '-'; + } + if (typeof id === 'object' && id !== null) { + throw new Error('Meteor does not currently support objects other than ObjectID as ids'); + } + return `~${JSON.stringify(id)}`; + } + + static parse(id: string): ObjectID | string | undefined { + const firstChar = id.charAt(0); + if (id === '') { + return id; + } + if (id === '-') { + return undefined; + } + if (firstChar === '-') { + return id.slice(1); + } + if (firstChar === '~') { + return JSON.parse(id.slice(1)); + } + if (_looksLikeObjectID(id)) { + return new ObjectID(id); + } + return id; + } +} + +EJSON.addType('oid', (str) => new ObjectID(str)); + +export const MongoID = { + ObjectID, + _looksLikeObjectID, + idStringify: ObjectID.stringify, + idParse: ObjectID.parse, +}; diff --git a/apps/meteor/src/meteor/mongo.ts b/apps/meteor/src/meteor/mongo.ts new file mode 100644 index 0000000000000..0b3301dc4580f --- /dev/null +++ b/apps/meteor/src/meteor/mongo.ts @@ -0,0 +1,686 @@ +import { AllowDeny } from './allow-deny.ts'; +import { check, Match } from './check.ts'; +import { DDP, type Connection } from './ddp-client.ts'; +import { EJSON } from './ejson.ts'; +import { Meteor } from './meteor.ts'; +import { LocalCollection } from './minimongo.ts'; +import { ObjectID } from './mongo-id.ts'; +import { Random } from './random.ts'; + +class LocalCollectionDriver { + noConnCollections: Map = new Map(); + + open(name?: string, conn: Connection | null = null): LocalCollection { + if (!name) { + return new LocalCollection(); + } + + if (!conn) { + return ensureCollection(name, this.noConnCollections); + } + + if (!conn._mongo_livedata_collections) { + conn._mongo_livedata_collections = new Map(); + } + + return ensureCollection(name, conn._mongo_livedata_collections); + } +} + +const driver = new LocalCollectionDriver(); + +function ensureCollection(name: string, collections: Map): LocalCollection { + const collection = collections.get(name); + if (collection) { + return collection; + } + + const newCollection = new LocalCollection(name); + collections.set(name, newCollection); + + return newCollection; +} +export const ID_GENERATORS = { + MONGO(name: string) { + return function () { + const src = name ? DDP.randomStream(`/collection/${name}`) : Random.insecure; + return new ObjectID(src.hexString(24)); + }; + }, + STRING(name: string) { + return function () { + const src = name ? DDP.randomStream(`/collection/${name}`) : Random.insecure; + return src.id(); + }; + }, +}; + +export function setupConnection(name: string, options: { connection?: Connection | null }): Connection | null { + if (!name || options.connection === null) return null; + if (options.connection) return options.connection; + return DDP.connection; +} + +export function setupDriver(_name: string, _connection: Connection | null, options: { _driver?: any }): LocalCollectionDriver { + if (options._driver) return options._driver; + return driver; +} + +export function setupMutationMethods(collection, name, options) { + if (options.defineMutationMethods === false) return; + + try { + collection._defineMutationMethods({ + useExisting: options._suppressSameNameError === true, + }); + } catch (error: any) { + if (error.message === `A method named '/${name}/insertAsync' is already defined`) { + throw new Error(`There is already a collection named "${name}"`); + } + throw error; + } +} + +export function validateCollectionName(name) { + if (!name && name !== null) { + console.debug( + 'Warning: creating anonymous collection. It will not be ' + + 'saved or synchronized over the network. (Pass null for ' + + 'the collection name to turn off this warning.)', + ); + name = null; + } + + if (name !== null && typeof name !== 'string') { + throw new Error('First argument to new Mongo.Collection must be a string or null'); + } + + return name; +} + +export function normalizeOptions(options) { + if (options && options.methods) { + options = { connection: options }; + } + if (options && options.manager && !options.connection) { + options.connection = options.manager; + } + + const cleanedOptions = Object.fromEntries(Object.entries(options || {}).filter(([_, v]) => v !== undefined)); + return { + connection: undefined, + idGeneration: 'STRING', + transform: null, + _driver: undefined, + _preventAutopublish: false, + ...cleanedOptions, + }; +} +export const normalizeProjection = (options?: { fields?: any; projection?: any }) => { + const { fields, projection, ...otherOptions } = options || {}; + + return { + ...otherOptions, + ...(projection || fields ? { projection: fields || projection } : {}), + }; +}; + +export class Collection { + _connection: Connection | null; + + constructor(name, options) { + let _ID_GENERATORS$option; + let _ID_GENERATORS; + + name = validateCollectionName(name); + options = normalizeOptions(options); + + this._makeNewID = + (_ID_GENERATORS$option = (_ID_GENERATORS = ID_GENERATORS)[options.idGeneration]) === null || _ID_GENERATORS$option === void 0 + ? void 0 + : _ID_GENERATORS$option.call(_ID_GENERATORS, name); + + this._transform = options.transform; + this.resolverType = options.resolverType; + this._connection = setupConnection(name, options); + + const driver = setupDriver(name, this._connection, options); + + this._driver = driver; + this._collection = driver.open(name, this._connection); + this._name = name; + this._settingUpReplicationPromise = this._maybeSetUpReplication(name, options); + setupMutationMethods(this, name, options); + Mongo._collections.set(name, this); + } + + async _publishCursor(cursor, sub, collection) { + const observeHandle = await cursor.observeChanges( + { + added(id, fields) { + sub.added(collection, id, fields); + }, + + changed(id, fields) { + sub.changed(collection, id, fields); + }, + + removed(id) { + sub.removed(collection, id); + }, + }, + { nonMutatingCallbacks: true }, + ); + + sub.onStop(async () => { + return await observeHandle.stop(); + }); + + return observeHandle; + } + + _rewriteSelector(selector) { + const { fallbackId } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + if (_selectorIsId(selector)) selector = { _id: selector }; + + if (Array.isArray(selector)) { + throw new Error("Mongo selector can't be an array."); + } + + if (!selector || ('_id' in selector && !selector._id)) { + return { _id: fallbackId || Random.id() }; + } + + return selector; + } + + _isRemoteCollection() { + return this._connection && this._connection !== Meteor.server; + } + + _getFindSelector(args) { + if (args.length == 0) return {}; + return args[0]; + } + + _getFindOptions(args) { + const [, options] = args || []; + const newOptions = normalizeProjection(options); + const self = this; + + if (args.length < 2) { + return { transform: self._transform }; + } + check( + newOptions, + Match.Optional( + Match.ObjectIncluding({ + projection: Match.Optional(Match.OneOf(Object, undefined)), + sort: Match.Optional(Match.OneOf(Object, Array, Function, undefined)), + limit: Match.Optional(Match.OneOf(Number, undefined)), + skip: Match.Optional(Match.OneOf(Number, undefined)), + }), + ), + ); + + return { transform: self._transform, ...newOptions }; + } + + async _maybeSetUpReplication(name) { + let _registerStoreResult; + let _registerStoreResult$; + const self = this; + + if (!(self._connection && self._connection.registerStoreClient && self._connection.registerStoreServer)) { + return; + } + + const wrappedStoreCommon = { + saveOriginals() { + self._collection.saveOriginals(); + }, + + retrieveOriginals() { + return self._collection.retrieveOriginals(); + }, + + _getCollection() { + return self; + }, + }; + + const wrappedStoreClient = { + ...{ + async beginUpdate(batchSize, reset) { + if (batchSize > 1 || reset) self._collection.pauseObservers(); + if (reset) await self._collection.remove({}); + }, + + update(msg) { + const mongoId = ObjectID.parse(msg.id); + const doc = self._collection._docs.get(mongoId); + + if (msg.msg === 'added' && doc) { + msg.msg = 'changed'; + } else if (msg.msg === 'removed' && !doc) { + return; + } else if (msg.msg === 'changed' && !doc) { + msg.msg = 'added'; + + const _ref = msg.fields; + + for (const field in _ref) { + const value = _ref[field]; + + if (value === void 0) { + delete msg.fields[field]; + } + } + } + + if (msg.msg === 'replace') { + const { replace } = msg; + + if (!replace) { + if (doc) self._collection.remove(mongoId); + } else if (!doc) { + self._collection.insert(replace); + } else { + self._collection.update(mongoId, replace); + } + } else if (msg.msg === 'added') { + if (doc) { + throw new Error('Expected not to find a document already present for an add'); + } + + self._collection.insert({ _id: mongoId, ...msg.fields }); + } else if (msg.msg === 'removed') { + if (!doc) throw new Error('Expected to find a document already present for removed'); + + self._collection.remove(mongoId); + } else if (msg.msg === 'changed') { + if (!doc) throw new Error('Expected to find a document to change'); + + const keys = Object.keys(msg.fields); + + if (keys.length > 0) { + const modifier = {}; + + keys.forEach((key) => { + const value = msg.fields[key]; + + if (EJSON.equals(doc[key], value)) { + return; + } + + if (typeof value === 'undefined') { + if (!modifier.$unset) { + modifier.$unset = {}; + } + + modifier.$unset[key] = 1; + } else { + if (!modifier.$set) { + modifier.$set = {}; + } + + modifier.$set[key] = value; + } + }); + + if (Object.keys(modifier).length > 0) { + self._collection.update(mongoId, modifier); + } + } + } else { + throw new Error("I don't know how to deal with this message"); + } + }, + + endUpdate() { + self._collection.resumeObserversClient(); + }, + + getDoc(id) { + return self.findOne(id); + }, + }, + ...wrappedStoreCommon, + }; + + const registerStoreResult = self._connection.registerStoreClient(name, wrappedStoreClient); + + const message = 'There is already a collection named "'.concat(name, '"'); + + const logWarn = () => { + console.warn ? console.warn(message) : console.log(message); + }; + + if (!registerStoreResult) { + return logWarn(); + } + + return (_registerStoreResult = registerStoreResult) === null || _registerStoreResult === void 0 + ? void 0 + : (_registerStoreResult$ = _registerStoreResult.then) === null || _registerStoreResult$ === void 0 + ? void 0 + : _registerStoreResult$.call(_registerStoreResult, (ok) => { + if (!ok) { + logWarn(); + } + }); + } + + find() { + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + return this._collection.find(this._getFindSelector(args), this._getFindOptions(args)); + } + + findOne(...args) { + return this._collection.findOne(this._getFindSelector(args), this._getFindOptions(args)); + } + + _insert(doc, callback) { + if (!doc) { + throw new Error('insert requires an argument'); + } + + doc = Object.create(Object.getPrototypeOf(doc), Object.getOwnPropertyDescriptors(doc)); + + if ('_id' in doc) { + if (!doc._id || !(typeof doc._id === 'string' || doc._id instanceof ObjectID)) { + throw new Error('Meteor requires document _id fields to be non-empty strings or ObjectIDs'); + } + } else { + let generateId = true; + + if (this._isRemoteCollection()) { + const enclosing = DDP._CurrentMethodInvocation.get(); + + if (!enclosing) { + generateId = false; + } + } + + if (generateId) { + doc._id = this._makeNewID(); + } + } + + const chooseReturnValueFromCollectionResult = function (result) { + if (Meteor._isPromise(result)) return result; + + if (doc._id) { + return doc._id; + } + + doc._id = result; + + return result; + }; + + const wrappedCallback = wrapCallback(callback, chooseReturnValueFromCollectionResult); + + if (this._isRemoteCollection()) { + const result = this._callMutatorMethod('insert', [doc], wrappedCallback); + + return chooseReturnValueFromCollectionResult(result); + } + + try { + let result; + + if (wrappedCallback) { + this._collection.insert(doc, wrappedCallback); + } else { + result = this._collection.insert(doc); + } + + return chooseReturnValueFromCollectionResult(result); + } catch (e) { + if (callback) { + callback(e); + + return null; + } + + throw e; + } + } + + insert(doc, callback) { + return this._insert(doc, callback); + } + + update(selector, modifier) { + for (var _len3 = arguments.length, optionsAndCallback = new Array(_len3 > 2 ? _len3 - 2 : 0), _key3 = 2; _key3 < _len3; _key3++) { + optionsAndCallback[_key3 - 2] = arguments[_key3]; + } + + const callback = popCallbackFromArgs(optionsAndCallback); + const options = { ...(optionsAndCallback[0] || null) }; + let insertedId; + + if (options && options.upsert) { + if (options.insertedId) { + if (!(typeof options.insertedId === 'string' || options.insertedId instanceof Mongo.ObjectID)) + throw new Error('insertedId must be string or ObjectID'); + + insertedId = options.insertedId; + } else if (!selector || !selector._id) { + insertedId = this._makeNewID(); + options.generatedId = true; + options.insertedId = insertedId; + } + } + + selector = Mongo.Collection._rewriteSelector(selector, { fallbackId: insertedId }); + + const wrappedCallback = wrapCallback(callback); + + if (this._isRemoteCollection()) { + const args = [selector, modifier, options]; + + return this._callMutatorMethod('update', args, callback); + } + + try { + return this._collection.update(selector, modifier, options, wrappedCallback); + } catch (e) { + if (callback) { + callback(e); + + return null; + } + + throw e; + } + } + + remove(selector, callback) { + selector = Mongo.Collection._rewriteSelector(selector); + + if (this._isRemoteCollection()) { + return this._callMutatorMethod('remove', [selector], callback); + } + + return this._collection.remove(selector); + } + + upsert(selector, modifier, options, callback) { + if (!callback && typeof options === 'function') { + callback = options; + options = {}; + } + + return this.update(selector, modifier, { ...options, _returnObject: true, upsert: true }); + } + + findOneAsync() { + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + return this._collection.findOneAsync(this._getFindSelector(args), this._getFindOptions(args)); + } + + _insertAsync(doc) { + const options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + if (!doc) { + throw new Error('insert requires an argument'); + } + + doc = Object.create(Object.getPrototypeOf(doc), Object.getOwnPropertyDescriptors(doc)); + + if ('_id' in doc) { + if (!doc._id || !(typeof doc._id === 'string' || doc._id instanceof Mongo.ObjectID)) { + throw new Error('Meteor requires document _id fields to be non-empty strings or ObjectIDs'); + } + } else { + let generateId = true; + + if (this._isRemoteCollection()) { + const enclosing = DDP._CurrentMethodInvocation.get(); + + if (!enclosing) { + generateId = false; + } + } + + if (generateId) { + doc._id = this._makeNewID(); + } + } + + const chooseReturnValueFromCollectionResult = function (result) { + if (Meteor._isPromise(result)) return result; + + if (doc._id) { + return doc._id; + } + + doc._id = result; + + return result; + }; + + if (this._isRemoteCollection()) { + const promise = this._callMutatorMethodAsync('insertAsync', [doc], options); + + promise.then(chooseReturnValueFromCollectionResult); + promise.stubPromise = promise.stubPromise.then(chooseReturnValueFromCollectionResult); + promise.serverPromise = promise.serverPromise.then(chooseReturnValueFromCollectionResult); + + return promise; + } + + return this._collection.insertAsync(doc).then(chooseReturnValueFromCollectionResult); + } + + insertAsync(doc, options) { + return this._insertAsync(doc, options); + } + + updateAsync(selector, modifier) { + const options = { ...((arguments.length <= 2 ? undefined : arguments[2]) || null) }; + let insertedId; + + if (options && options.upsert) { + if (options.insertedId) { + if (!(typeof options.insertedId === 'string' || options.insertedId instanceof Mongo.ObjectID)) + throw new Error('insertedId must be string or ObjectID'); + + insertedId = options.insertedId; + } else if (!selector || !selector._id) { + insertedId = this._makeNewID(); + options.generatedId = true; + options.insertedId = insertedId; + } + } + + selector = Mongo.Collection._rewriteSelector(selector, { fallbackId: insertedId }); + + if (this._isRemoteCollection()) { + const args = [selector, modifier, options]; + + return this._callMutatorMethodAsync('updateAsync', args, options); + } + + return this._collection.updateAsync(selector, modifier, options); + } + + removeAsync(selector) { + const options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + selector = Mongo.Collection._rewriteSelector(selector); + + if (this._isRemoteCollection()) { + return this._callMutatorMethodAsync('removeAsync', [selector], options); + } + + return this._collection.removeAsync(selector); + } + + async upsertAsync(selector, modifier, options) { + return this.updateAsync(selector, modifier, { ...options, _returnObject: true, upsert: true }); + } + + countDocuments() { + return this._collection.countDocuments(...arguments); + } + + estimatedDocumentCount() { + return this._collection.estimatedDocumentCount(...arguments); + } +} + +export const _collections = new Map(); + +export const getCollection = (name: string) => { + return _collections.get(name); +}; + +export const Mongo = { + _collections, + getCollection, + Collection, +}; + +function wrapCallback(callback: Function | undefined, convertResult: Function | undefined = undefined): Function | undefined { + return ( + callback && + function (error, result) { + if (error) { + callback(error); + } else if (typeof convertResult === 'function') { + callback(error, convertResult(result)); + } else { + callback(error, result); + } + } + ); +} + +function popCallbackFromArgs(args: unknown[]): ((error: any, result?: any) => void) | undefined { + const last: unknown = args.at(-1); + if (typeof last === 'function') { + args.pop(); + return function (error, result) { + last(error, result); + }; + } + + if (last !== undefined) { + return; + } + + args.pop(); + return undefined; +} + +Object.assign(Mongo.Collection.prototype, AllowDeny.CollectionPrototype); diff --git a/apps/meteor/src/meteor/oauth.ts b/apps/meteor/src/meteor/oauth.ts new file mode 100644 index 0000000000000..4122d3fd04e26 --- /dev/null +++ b/apps/meteor/src/meteor/oauth.ts @@ -0,0 +1,219 @@ +import { Base64 } from './base64.ts'; +import { check } from './check.ts'; +import { Meteor } from './meteor.ts'; +import { Reload } from './reload.ts'; +import { _constructUrl } from './url.ts'; + +type PopupDimensions = { + width?: number; + height?: number; +}; + +type OAuthLoginOptions = { + loginService?: string; + loginStyle?: 'popup' | 'redirect' | undefined; + loginUrl: string; + credentialRequestCompleteCallback?: ((token?: string | Error) => void) | undefined; + credentialToken: string; + popupOptions?: PopupDimensions; + redirectUrl?: string; +}; + +type OAuthState = { + loginStyle: 'popup' | 'redirect' | undefined; + credentialToken: string; + isCordova: boolean; + redirectUrl?: string; +}; + +type OAuthConfiguration = { + loginStyle?: 'popup' | 'redirect'; + [key: string]: any; +}; + +const credentialSecrets: Record = {}; +const STORAGE_TOKEN_PREFIX = 'Meteor.oauth.credentialSecret-'; + +const openCenteredPopup = (url: string, width: number, height: number): Window | null => { + const screenX = typeof window.screenX !== 'undefined' ? window.screenX : window.screenLeft; + const screenY = typeof window.screenY !== 'undefined' ? window.screenY : window.screenTop; + const outerWidth = typeof window.outerWidth !== 'undefined' ? window.outerWidth : document.body.clientWidth; + const outerHeight = typeof window.outerHeight !== 'undefined' ? window.outerHeight : document.body.clientHeight - 22; + + const left = screenX + (outerWidth - width) / 2; + const top = screenY + (outerHeight - height) / 2; + + const features = `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`; + const newwindow = window.open(url, 'Login', features); + + if (!newwindow || newwindow.closed) { + const err: any = new Error('The login popup was blocked by the browser'); + err.attemptedUrl = url; + throw err; + } + + if (newwindow.focus) { + newwindow.focus(); + } + + return newwindow; +}; + +export const OAuth = { + _storageTokenPrefix: STORAGE_TOKEN_PREFIX, + + showPopup(url: string, callback: () => void, dimensions?: PopupDimensions) { + const width = dimensions?.width || 650; + const height = dimensions?.height || 331; + + const popup = openCenteredPopup(url, width, height); + + if (!popup) return; + + const checkPopupOpen = setInterval(() => { + let popupClosed; + try { + popupClosed = popup.closed || popup.closed === undefined; + } catch (e) { + return; + } + + if (popupClosed) { + clearInterval(checkPopupOpen); + callback(); + } + }, 100); + }, + + _loginStyle(_service: string, config: OAuthConfiguration, options?: { loginStyle?: string }): 'popup' | 'redirect' | undefined { + const loginStyle = options?.loginStyle || config.loginStyle || 'popup'; + + if (loginStyle !== 'popup' && loginStyle !== 'redirect') { + throw new Error(`Invalid login style: ${loginStyle}`); + } + + if (loginStyle === 'redirect') { + try { + sessionStorage.setItem('Meteor.oauth.test', 'test'); + sessionStorage.removeItem('Meteor.oauth.test'); + } catch (e) { + return 'popup'; + } + } + + return loginStyle; + }, + + _stateParam(loginStyle: 'popup' | 'redirect' | undefined, credentialToken: string, redirectUrl?: string) { + const state: OAuthState = { + loginStyle, + credentialToken, + isCordova: false, + }; + const setRedirectUrl = Meteor.settings?.public?.packages?.oauth?.setRedirectUrlWhenLoginStyleIsPopup; + + if (loginStyle === 'redirect' || (setRedirectUrl && loginStyle === 'popup')) { + state.redirectUrl = redirectUrl || `${window.location}`; + } + + return Base64.encode(JSON.stringify(state)); + }, + + _redirectUri(serviceName: string, _config: any, params?: any, absoluteUrlOptions?: any) { + const safeParams = params ? { ...params } : undefined; + if (safeParams) { + delete safeParams.cordova; + delete safeParams.android; + } + + const queryParams = safeParams && Object.keys(safeParams).length > 0 ? safeParams : null; + + return _constructUrl(Meteor.absoluteUrl(`_oauth/${serviceName}`, absoluteUrlOptions), null, queryParams); + }, + + saveDataForRedirect(loginService: string, credentialToken: string) { + Reload._onMigrate('oauth', () => [ + true, + { + loginService, + credentialToken, + }, + ]); + Reload._migrate(null, { + immediateMigration: true, + }); + }, + + getDataAfterRedirect() { + const migrationData = Reload._migrationData('oauth'); + + if (!migrationData?.credentialToken) { + return null; + } + + const { credentialToken } = migrationData; + const key = OAuth._storageTokenPrefix + credentialToken; + let credentialSecret; + + try { + credentialSecret = sessionStorage.getItem(key); + sessionStorage.removeItem(key); + } catch (e) { + console.debug('error retrieving credentialSecret', e); + } + + return { + loginService: migrationData.loginService, + credentialToken, + credentialSecret, + }; + }, + + launchLogin(options: OAuthLoginOptions) { + if (!options.loginService) { + throw new Error('loginService required'); + } + + if (options.loginStyle === 'popup') { + OAuth.showPopup( + options.loginUrl, + () => { + if (options.credentialRequestCompleteCallback) { + options.credentialRequestCompleteCallback(options.credentialToken); + } + }, + options.popupOptions, + ); + } else if (options.loginStyle === 'redirect') { + OAuth.saveDataForRedirect(options.loginService, options.credentialToken); + window.location.href = options.loginUrl; + } else { + throw new Error('invalid login style'); + } + }, + + _handleCredentialSecret(credentialToken: string, secret: string) { + check(credentialToken, String); + check(secret, String); + + if (!Object.prototype.hasOwnProperty.call(credentialSecrets, credentialToken)) { + credentialSecrets[credentialToken] = secret; + } else { + throw new Error('Duplicate credential token from OAuth login'); + } + }, + + _retrieveCredentialSecret(credentialToken: string) { + let secret: string | null = credentialSecrets[credentialToken] ?? null; + + if (!secret) { + const localStorageKey = OAuth._storageTokenPrefix + credentialToken; + secret = Meteor._localStorage.getItem(localStorageKey); + Meteor._localStorage.removeItem(localStorageKey); + } else { + delete credentialSecrets[credentialToken]; + } + + return secret; + }, +}; diff --git a/apps/meteor/src/meteor/ordered-dict.ts b/apps/meteor/src/meteor/ordered-dict.ts new file mode 100644 index 0000000000000..ba2434b9525b1 --- /dev/null +++ b/apps/meteor/src/meteor/ordered-dict.ts @@ -0,0 +1,162 @@ +type Node = { + key: K; + value: V; + next?: Node; + prev?: Node; +}; + +export class OrderedDict implements Iterable<[K, V]> { + readonly #map = new Map>(); + + #head?: Node; + + #tail?: Node; + + constructor(entries?: Iterable) { + if (entries) { + for (const [k, v] of entries) this.append(k, v); + } + } + + *[Symbol.iterator](): Iterator<[K, V]> { + yield* this.entries(); + } + + *entries(): IterableIterator<[K, V]> { + for (let n = this.#head; n; n = n.next) yield [n.key, n.value]; + } + + *keys(): IterableIterator { + for (let n = this.#head; n; n = n.next) yield n.key; + } + + *values(): IterableIterator { + for (let n = this.#head; n; n = n.next) yield n.value; + } + + get size(): number { + return this.#map.size; + } + + get empty(): boolean { + return this.#map.size === 0; + } + + has(key: K): boolean { + return this.#map.has(key); + } + + get(key: K): V | undefined { + return this.#map.get(key)?.value; + } + + first(): K | undefined { + return this.#head?.key; + } + + last(): K | undefined { + return this.#tail?.key; + } + + next(key: K): K | undefined { + return this.#map.get(key)?.next?.key; + } + + prev(key: K): K | undefined { + return this.#map.get(key)?.prev?.key; + } + + set(key: K, value: V): void { + const node = this.#map.get(key); + if (node) node.value = value; + else this.append(key, value); + } + + append(key: K, value: V): void { + if (this.#map.has(key)) throw new Error(`Item ${String(key)} already present.`); + const node: Node = { key, value }; + this.#insertTail(node); + this.#map.set(key, node); + } + + putBefore(key: K, value: V, beforeKey?: K | null): void { + if (this.#map.has(key)) throw new Error(`Item ${String(key)} already present.`); + const node: Node = { key, value }; + + if (!beforeKey) { + this.#insertTail(node); + } else { + const ref = this.#map.get(beforeKey); + if (!ref) throw new Error(`Reference item ${String(beforeKey)} not found.`); + this.#insertBefore(node, ref); + } + this.#map.set(key, node); + } + + remove(key: K): V { + const node = this.#map.get(key); + if (!node) throw new Error(`Item ${String(key)} not found.`); + this.#unlink(node); + this.#map.delete(key); + return node.value; + } + + moveBefore(key: K, beforeKey: K | null): void { + if (key === beforeKey) return; + + const node = this.#map.get(key); + if (!node) throw new Error(`Item to move ${String(key)} not found.`); + + this.#unlink(node); + + if (!beforeKey) { + this.#insertTail(node); + } else { + const ref = this.#map.get(beforeKey); + if (!ref) throw new Error(`Reference item ${String(beforeKey)} not found.`); + this.#insertBefore(node, ref); + } + } + + forEach(callback: (value: V, key: K, index: number) => void | { break: boolean }): void { + let index = 0; + for (let n = this.#head; n; n = n.next) { + const result = callback(n.value, n.key, index++); + if (result && typeof result === 'object' && result.break) return; + } + } + + clear(): void { + this.#map.clear(); + this.#head = undefined; + this.#tail = undefined; + } + + #unlink(node: Node): void { + if (node.prev) node.prev.next = node.next; + else this.#head = node.next; + + if (node.next) node.next.prev = node.prev; + else this.#tail = node.prev; + + node.next = undefined; + node.prev = undefined; + } + + #insertTail(node: Node): void { + node.prev = this.#tail; + if (this.#tail) this.#tail.next = node; + else this.#head = node; + this.#tail = node; + } + + #insertBefore(node: Node, ref: Node): void { + node.next = ref; + node.prev = ref.prev; + + if (ref.prev) ref.prev.next = node; + else this.#head = node; + + ref.prev = node; + } +} diff --git a/apps/meteor/src/meteor/random.ts b/apps/meteor/src/meteor/random.ts new file mode 100644 index 0000000000000..b279fb6d5100b --- /dev/null +++ b/apps/meteor/src/meteor/random.ts @@ -0,0 +1 @@ +export { Random } from '@rocket.chat/random'; diff --git a/apps/meteor/src/meteor/reactive-dict.ts b/apps/meteor/src/meteor/reactive-dict.ts new file mode 100644 index 0000000000000..84e9b20465399 --- /dev/null +++ b/apps/meteor/src/meteor/reactive-dict.ts @@ -0,0 +1,238 @@ +import { EJSON } from './ejson.ts'; +import { ObjectID } from './mongo-id.ts'; +import { Tracker } from './tracker.ts'; + +type DictValue = any; + +export class ReactiveDict { + static _dictsToMigrate: Record = {}; + + private name: string | undefined; + + private _map = new Map(); + + private _allDep = new Tracker.Dependency(); + + private _keyDeps = new Map(); + + private _keyValueDeps = new Map>(); + + constructor(dictName?: string | object, dictData?: object) { + let initialData: Record = {}; + + if (dictName) { + if (typeof dictName === 'string') { + this.name = dictName; + ReactiveDict._registerDictForMigrate(dictName, this); + const migratedData = ReactiveDict._loadMigratedDict(dictName); + + if (migratedData) { + for (const key of Object.keys(migratedData)) { + try { + const val = migratedData[key]; + const parsed = val === 'undefined' ? undefined : EJSON.parse(val); + this._map.set(key, parsed); + } catch (e) { + console.error(`ReactiveDict: Failed to migrate key "${key}"`, e); + } + } + return; + } + initialData = (dictData || {}) as Record; + } else if (typeof dictName === 'object') { + initialData = dictName as Record; + } else { + throw new Error(`Invalid ReactiveDict argument: ${dictName}`); + } + } else if (typeof dictData === 'object') { + initialData = dictData as Record; + } + if (initialData) { + for (const key of Object.keys(initialData)) { + this._map.set(key, initialData[key]); + } + } + } + + set(keyOrObject: string | object, value?: any): void { + if (typeof keyOrObject === 'object' && value === undefined) { + this._setObject(keyOrObject); + return; + } + + const key = keyOrObject as string; + const oldValue = this._map.get(key); + if (this._map.has(key) && EJSON.equals(oldValue, value)) { + return; + } + + this._map.set(key, value); + this._allDep.changed(); + this._keyDeps.get(key)?.changed(); + const valDeps = this._keyValueDeps.get(key); + if (valDeps) { + if (oldValue !== undefined) { + const oldStr = EJSON.stringify(oldValue); + valDeps.get(oldStr)?.changed(); + } else { + valDeps.get('undefined')?.changed(); + } + if (value !== undefined) { + const newStr = EJSON.stringify(value); + valDeps.get(newStr)?.changed(); + } else { + valDeps.get('undefined')?.changed(); + } + } + } + + setDefault(keyOrObject: string | object, value?: any): void { + if (typeof keyOrObject === 'object' && value === undefined) { + const obj = keyOrObject as Record; + for (const key of Object.keys(obj)) { + this.setDefault(key, obj[key]); + } + return; + } + + const key = keyOrObject as string; + if (!this._map.has(key)) { + this.set(key, value); + } + } + + get(key: string): any { + this._ensureKeyDep(key).depend(); + + const val = this._map.get(key); + return val === undefined ? undefined : EJSON.clone(val); + } + + equals(key: string, value: string | number | boolean | null | undefined | Date | ObjectID): boolean { + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + typeof value !== 'undefined' && + !(value instanceof Date) && + !(value instanceof ObjectID) && + value !== null + ) { + throw new Error('ReactiveDict.equals: value must be scalar'); + } + + if (Tracker.active) { + const serializedValue = value === undefined ? 'undefined' : EJSON.stringify(value); + let valDeps = this._keyValueDeps.get(key); + if (!valDeps) { + valDeps = new Map(); + this._keyValueDeps.set(key, valDeps); + } + let dep = valDeps.get(serializedValue); + if (!dep) { + dep = new Tracker.Dependency(); + valDeps.set(serializedValue, dep); + } + + const isNew = dep.depend(); + if (isNew) { + Tracker.onInvalidate(() => { + if (!dep.hasDependents()) { + valDeps.delete(serializedValue); + if (valDeps.size === 0) { + this._keyValueDeps.delete(key); + } + } + }); + } + } + + const currentValue = this._map.get(key); + return EJSON.equals(currentValue, value); + } + + all(): Record { + this._allDep.depend(); + const ret: Record = {}; + for (const [key, val] of this._map.entries()) { + ret[key] = EJSON.clone(val); + } + return ret; + } + + clear(): void { + const oldKeys = Array.from(this._map.keys()); + this._map.clear(); + + this._allDep.changed(); + + for (const key of oldKeys) { + this._keyDeps.get(key)?.changed(); + const valDeps = this._keyValueDeps.get(key); + if (valDeps) { + for (const dep of valDeps.values()) { + dep.changed(); + } + valDeps.clear(); // Safe to clear since we deleted the key + } + } + } + + delete(key: string): boolean { + if (!this._map.has(key)) return false; + + const oldValue = this._map.get(key); + this._map.delete(key); + + this._allDep.changed(); + this._keyDeps.get(key)?.changed(); + + const valDeps = this._keyValueDeps.get(key); + if (valDeps) { + if (oldValue !== undefined) { + valDeps.get(EJSON.stringify(oldValue))?.changed(); + } + valDeps.get('undefined')?.changed(); + } + + return true; + } + + destroy(): void { + this.clear(); + if (this.name && ReactiveDict._dictsToMigrate[this.name]) { + delete ReactiveDict._dictsToMigrate[this.name]; + } + } + + private _setObject(object: Record) { + for (const key of Object.keys(object)) { + this.set(key, object[key]); + } + } + + private _ensureKeyDep(key: string): Tracker.Dependency { + let dep = this._keyDeps.get(key); + if (!dep) { + dep = new Tracker.Dependency(); + this._keyDeps.set(key, dep); + } + return dep; + } + + _getMigrationData(): Record { + const migrationData: Record = {}; + for (const [key, value] of this._map.entries()) { + migrationData[key] = value === undefined ? 'undefined' : EJSON.stringify(value); + } + return migrationData; + } + + static _registerDictForMigrate(dictName: string, dict: ReactiveDict) { + ReactiveDict._dictsToMigrate[dictName] = dict; + } + + static _loadMigratedDict(_dictName: string) { + return null; + } +} diff --git a/apps/meteor/src/meteor/reactive-var.ts b/apps/meteor/src/meteor/reactive-var.ts new file mode 100644 index 0000000000000..f9ef6aa127bc0 --- /dev/null +++ b/apps/meteor/src/meteor/reactive-var.ts @@ -0,0 +1,37 @@ +import { Tracker } from './tracker.ts'; + +type EqualsFunc = (oldValue: T, newValue: T) => boolean; + +const isEqual = (a: unknown, b: unknown): boolean => { + if (a !== b) return false; + return a === null || (typeof a !== 'object' && typeof a !== 'function'); +}; + +export class ReactiveVar { + #value: T; + + readonly #equals: EqualsFunc; + + readonly #dep = new Tracker.Dependency(); + + constructor(initialValue: T, equalsFunc: EqualsFunc = isEqual) { + this.#value = initialValue; + this.#equals = equalsFunc; + } + + get(): T { + if (Tracker.active) this.#dep.depend(); + return this.#value; + } + + set(newValue: T): void { + if (this.#equals(this.#value, newValue)) return; + + this.#value = newValue; + this.#dep.changed(); + } + + toString(): string { + return `ReactiveVar{${this.get()}}`; + } +} diff --git a/apps/meteor/src/meteor/reload.ts b/apps/meteor/src/meteor/reload.ts new file mode 100644 index 0000000000000..2fb6c9fd9b7d1 --- /dev/null +++ b/apps/meteor/src/meteor/reload.ts @@ -0,0 +1,191 @@ +function debug(message: string, context?: any) { + console.debug(`[reload] ${message}`, JSON.stringify(context)); +} + +const KEY_NAME = 'Meteor_Reload'; + +let oldData: any = {}; +let oldJson: string | null = null; +let safeSessionStorage: any = null; +try { + safeSessionStorage = window.sessionStorage; + if (safeSessionStorage) { + safeSessionStorage.setItem('__dummy__', '1'); + safeSessionStorage.removeItem('__dummy__'); + } else { + safeSessionStorage = null; + } +} catch (e) { + safeSessionStorage = null; +} +function _getData() { + return safeSessionStorage?.getItem(KEY_NAME); +} + +if (safeSessionStorage) { + oldJson = _getData(); + safeSessionStorage.removeItem(KEY_NAME); +} else { + console.debug('Browser does not support sessionStorage. Not retrieving migration state.'); +} + +if (!oldJson) { + oldJson = '{}'; +} +let oldParsed: any = {}; +try { + oldParsed = JSON.parse(oldJson); + if (typeof oldParsed !== 'object') { + console.debug('Got bad data on reload. Ignoring.'); + oldParsed = {}; + } +} catch (err) { + console.debug('Got invalid JSON on reload. Ignoring.'); +} + +if (oldParsed.reload && typeof oldParsed.data === 'object') { + oldData = oldParsed.data; +} + +let providers: any[] = []; +function _onMigrate(...args: [string, (...args: any[]) => any] | [(...args: any[]) => any]) { + let name: string | undefined; + let callback: ((...args: any[]) => any) | undefined; + + if (args.length === 1) { + callback = args[0]; + } else if (args.length === 2) { + name = args[0] as string; + callback = args[1]; + } + + debug('_onMigrate', { name }); + if (!callback) { + callback = name as unknown as (...args: any[]) => any; + name = undefined as unknown as string; + debug('_onMigrate no callback'); + } + + providers.push({ name, callback }); +} +function _migrationData(name: string) { + debug('_migrationData', { name }); + return oldData[name]; +} +const pollProviders = function (tryReload: ((...args: any[]) => any) | null, options: any) { + debug('pollProviders', { options }); + tryReload = + tryReload || + function () { + // noop + }; + options = options || {}; + + const { immediateMigration } = options; + debug(`pollProviders is ${immediateMigration ? '' : 'NOT '}immediateMigration`, { options }); + const migrationData: any = {}; + let allReady = true; + providers.forEach((p) => { + const { callback, name } = p || {}; + const [ready, data] = callback(tryReload, options) || []; + + debug(`pollProviders provider ${name || 'unknown'} is ${ready ? 'ready' : 'NOT ready'}`, { options }); + if (!ready) { + allReady = false; + } + + if (data !== undefined && name) { + migrationData[name] = data; + } + }); + + if (allReady) { + debug('pollProviders allReady', { options, migrationData }); + return migrationData; + } + + if (immediateMigration) { + debug('pollProviders immediateMigration', { options, migrationData }); + return migrationData; + } + + return null; +}; +function _migrate(tryReload: ((...args: any[]) => any) | null, options: any) { + debug('_migrate', { options }); + const migrationData = pollProviders(tryReload, options); + if (migrationData === null) { + return false; // not ready yet.. + } + + let json; + try { + json = JSON.stringify({ + data: migrationData, + reload: true, + }); + } catch (err) { + console.debug("Couldn't serialize data for migration", migrationData); + throw err; + } + + if (safeSessionStorage) { + try { + safeSessionStorage.setItem(KEY_NAME, json); + } catch (err) { + console.debug("Couldn't save data for migration to sessionStorage", err); + } + } else { + console.debug('Browser does not support sessionStorage. Not saving migration state.'); + } + + return true; +} +function _withFreshProvidersForTest(f: () => void) { + const originalProviders = providers.slice(0); + providers = []; + try { + f(); + } finally { + providers = originalProviders; + } +} +let reloading = false; +function _reload(options: any) { + debug('_reload', { options }); + options = options || {}; + + if (reloading) { + debug('reloading in progress already', { options }); + return; + } + reloading = true; + + function tryReload() { + debug('tryReload'); + setTimeout(reload, 1); + } + + function forceBrowserReload() { + debug('forceBrowserReload'); + if (window.location.hash || window.location.href.endsWith('#')) { + window.location.reload(); + return; + } + + window.location.replace(window.location.href); + } + + function reload() { + debug('reload'); + if (!_migrate(tryReload, options)) { + return; + } + + forceBrowserReload(); + } + + tryReload(); +} + +export const Reload = { _getData, _onMigrate, _migrationData, _migrate, _withFreshProvidersForTest, _reload }; diff --git a/apps/meteor/src/meteor/retry.ts b/apps/meteor/src/meteor/retry.ts new file mode 100644 index 0000000000000..e470779dc6edc --- /dev/null +++ b/apps/meteor/src/meteor/retry.ts @@ -0,0 +1,51 @@ +import { Random } from './random.ts'; + +export class Retry { + baseTimeout: number; + + exponent: number; + + maxTimeout: number; + + minTimeout: number; + + minCount: number; + + fuzz: number; + + retryTimer: ReturnType | null; + + constructor({ baseTimeout = 1000, exponent = 2.2, maxTimeout = 5 * 60 * 1000, minTimeout = 10, minCount = 2, fuzz = 0.5 } = {}) { + this.baseTimeout = baseTimeout; + this.exponent = exponent; + this.maxTimeout = maxTimeout; + this.minTimeout = minTimeout; + this.minCount = minCount; + this.fuzz = fuzz; + this.retryTimer = null; + } + + clear() { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + } + this.retryTimer = null; + } + + _timeout(count: number) { + if (count < this.minCount) { + return this.minTimeout; + } + + return ( + Math.min(this.maxTimeout, this.baseTimeout * Math.pow(this.exponent, count)) * (Random.fraction() * this.fuzz + (1 - this.fuzz / 2)) + ); + } + + retryLater(count: number, fn: () => void) { + const timeout = this._timeout(count); + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = setTimeout(fn, timeout); + return timeout; + } +} diff --git a/apps/meteor/src/meteor/service-configuration.ts b/apps/meteor/src/meteor/service-configuration.ts new file mode 100644 index 0000000000000..39519ed80bb9c --- /dev/null +++ b/apps/meteor/src/meteor/service-configuration.ts @@ -0,0 +1,27 @@ +import { Accounts } from './accounts-base.ts'; +import { Collection } from './mongo.ts'; + +export class ConfigError extends Error { + constructor(serviceName?: string) { + super(); + this.name = 'ServiceConfiguration.ConfigError'; + + if (!Accounts.loginServicesConfigured()) { + this.message = 'Login service configuration not yet loaded'; + } else if (serviceName) { + this.message = `Service ${serviceName} not configured`; + } else { + this.message = 'Service not configured'; + } + } +} + +export const configurations = new Collection('meteor_accounts_loginServiceConfiguration', { + _preventAutopublish: true, + connection: Accounts.connection, +}); + +export const ServiceConfiguration = { + configurations, + ConfigError, +}; diff --git a/apps/meteor/src/meteor/session.ts b/apps/meteor/src/meteor/session.ts new file mode 100644 index 0000000000000..bcd7ef117c04b --- /dev/null +++ b/apps/meteor/src/meteor/session.ts @@ -0,0 +1,3 @@ +import { ReactiveDict } from './reactive-dict.ts'; + +export const Session = new ReactiveDict('session'); diff --git a/apps/meteor/src/meteor/sha.ts b/apps/meteor/src/meteor/sha.ts new file mode 100644 index 0000000000000..b9b1bbf284767 --- /dev/null +++ b/apps/meteor/src/meteor/sha.ts @@ -0,0 +1 @@ +export { SHA256 } from '@rocket.chat/sha256'; diff --git a/apps/meteor/src/meteor/socket-stream-client.ts b/apps/meteor/src/meteor/socket-stream-client.ts new file mode 100644 index 0000000000000..321d9776149c7 --- /dev/null +++ b/apps/meteor/src/meteor/socket-stream-client.ts @@ -0,0 +1,361 @@ +import { Meteor } from './meteor.ts'; +import { Retry } from './retry.ts'; +import { Tracker } from './tracker.ts'; + +const forcedReconnectError = new Error('forced reconnect'); + +export type ClientStreamOptions = { + bufferedWritesInterval?: number; + bufferedWrritesMaxAge?: number; + heartbeatInterval: number; + heartbeatTimeout: number; + retry?: boolean; + connectTimeoutMs?: number; + ConnectionError?: new (...args: any[]) => any; + onDDPVersionNegotiationFailure?: (description: string) => void; +}; + +type StreamStatus = { + status: 'connected' | 'connecting' | 'failed' | 'offline' | 'waiting'; + connected: boolean; + retryCount: number; + retryTime?: number; + reason?: unknown; +}; + +type StreamEvents = { + message: (data: string) => void; + reset: () => void; + disconnect: () => void; +}; + +type EventCallbacks = { + [K in keyof StreamEvents]?: Array; +}; + +class ClientStream { + currentStatus: StreamStatus = { status: 'connecting', connected: false, retryCount: 0 }; + + statusListeners = new Tracker.Dependency(); + + CONNECT_TIMEOUT: number; + + _retry = new Retry(); + + connectionTimer: ReturnType | null = null; + + _forcedToDisconnect = false; + + eventCallbacks: EventCallbacks = Object.create(null); + + options: ClientStreamOptions; + + rawUrl: string; + + socket: WebSocket | null = null; + + heartbeatTimer: ReturnType | null = null; + + lastError: unknown = null; + + HEARTBEAT_TIMEOUT: number; + + constructor( + url: string, + { + connectTimeoutMs = 10000, + retry = true, + heartbeatInterval = 10000, + heartbeatTimeout = 100 * 1000, + ...options + }: Partial = {}, + ) { + this.options = { retry, connectTimeoutMs, heartbeatInterval, heartbeatTimeout, ...options }; + this.CONNECT_TIMEOUT = connectTimeoutMs; + this.HEARTBEAT_TIMEOUT = heartbeatTimeout; + + this.rawUrl = url; + this._onOpen = this._onOpen.bind(this); + this._onMessage = this._onMessage.bind(this); + this._onError = this._onError.bind(this); + this._lostConnection = this._lostConnection.bind(this); + this._online = this._online.bind(this); + + window.addEventListener('online', this._online, false); + this._launchConnection(); + } + + on(name: K, callback: StreamEvents[K]) { + if (name !== 'message' && name !== 'reset' && name !== 'disconnect') { + throw new Error(`unknown event type: ${name}`); + } + if (!this.eventCallbacks[name]) this.eventCallbacks[name] = []; + this.eventCallbacks[name].push(callback); + } + + status() { + if (this.statusListeners) { + this.statusListeners.depend(); + } + return this.currentStatus; + } + + reconnect(options?: { url?: string; _force?: boolean }) { + if (options?.url) { + this._changeUrl(options.url); + } + + if (this.currentStatus.connected) { + if (options?._force || options?.url) { + this._lostConnection(forcedReconnectError); + } + return; + } + + if (this.currentStatus.status === 'connecting') { + this._lostConnection(); + } + + this._retry.clear(); + this.currentStatus.retryCount -= 1; + this._retryNow(); + } + + disconnect(options: { _permanent?: boolean; _error?: unknown } = {}) { + if (this._forcedToDisconnect) return; + + if (options._permanent) { + this._forcedToDisconnect = true; + } + + this._cleanup(); + this._retry.clear(); + + this.currentStatus = { + status: options._permanent ? 'failed' : 'offline', + connected: false, + retryCount: 0, + }; + + if (options._permanent && options._error) { + this.currentStatus.reason = options._error; + } + + this._statusChanged(); + } + + send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { + if (this.currentStatus.connected) { + this.socket?.send(data); + } + } + + protected forEachCallback(name: K, cb: (callback: StreamEvents[K]) => void) { + if (!this.eventCallbacks[name]?.length) { + return; + } + for (const callback of this.eventCallbacks[name]) { + cb(callback); + } + } + + private _statusChanged() { + if (this.statusListeners) { + this.statusListeners.changed(); + } + } + + private _changeUrl(url: string) { + this.rawUrl = url; + } + + private _launchConnection() { + this._cleanup(); + + try { + this.socket = new WebSocket(toWebsocketUrl(this.rawUrl)); + + this.socket.addEventListener('open', this._onOpen); + this.socket.addEventListener('message', this._onMessage); + this.socket.addEventListener('close', this._lostConnection); + this.socket.addEventListener('error', this._onError); + + if (this.connectionTimer) clearTimeout(this.connectionTimer); + + this.connectionTimer = setTimeout(() => { + this._lostConnection(new Error('DDP connection timed out')); + }, this.CONNECT_TIMEOUT); + } catch (e) { + this._onError(e); + } + } + + private _connected() { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + this.connectionTimer = null; + } + + if (this.currentStatus.connected) { + return; + } + + this.currentStatus.status = 'connected'; + this.currentStatus.connected = true; + this.currentStatus.retryCount = 0; + this._statusChanged(); + + this.forEachCallback('reset', (callback: () => void) => { + callback(); + }); + } + + private _cleanup(maybeError?: unknown) { + this._clearConnectionAndHeartbeatTimers(); + + if (this.socket) { + this.socket.removeEventListener('open', this._onOpen); + this.socket.removeEventListener('message', this._onMessage); + this.socket.removeEventListener('close', this._lostConnection); + this.socket.removeEventListener('error', this._onError); + this.socket.close(); + this.socket = null; + } + + this.forEachCallback('disconnect', (callback: (arg0: any) => void) => { + callback(maybeError); + }); + } + + public _lostConnection(maybeError?: unknown) { + const errorToPass = maybeError instanceof Event ? undefined : maybeError; + + this._cleanup(errorToPass); + this._retryLater(errorToPass); + } + + private _online() { + if (this.currentStatus.status !== 'offline') this.reconnect(); + } + + private _retryLater(maybeError?: unknown) { + let timeout = 0; + + if (this.options.retry || maybeError === forcedReconnectError) { + timeout = this._retry.retryLater(this.currentStatus.retryCount, this._retryNow.bind(this)); + this.currentStatus.status = 'waiting'; + this.currentStatus.retryTime = new Date().getTime() + timeout; + } else { + this.currentStatus.status = 'failed'; + delete this.currentStatus.retryTime; + } + + this.currentStatus.connected = false; + this._statusChanged(); + } + + private _retryNow() { + if (this._forcedToDisconnect) return; + + this.currentStatus.retryCount += 1; + this.currentStatus.status = 'connecting'; + this.currentStatus.connected = false; + delete this.currentStatus.retryTime; + this._statusChanged(); + this._launchConnection(); + } + + private _clearConnectionAndHeartbeatTimers() { + if (this.connectionTimer) { + clearTimeout(this.connectionTimer); + this.connectionTimer = null; + } + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private _heartbeat_timeout() { + console.log('Connection timeout. No sockjs heartbeat received.'); + this._lostConnection(new Error('Heartbeat timed out')); + } + + private _heartbeat_received() { + if (this._forcedToDisconnect) return; + if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer); + + this.heartbeatTimer = setTimeout(this._heartbeat_timeout.bind(this), this.HEARTBEAT_TIMEOUT); + } + + private _onOpen() { + this.lastError = null; + this._connected(); + } + + private _onMessage(event: MessageEvent) { + this.lastError = null; + this._heartbeat_received(); + + if (this.currentStatus.connected) { + this.forEachCallback('message', (callback) => { + callback(event.data); + }); + } + } + + private _onError(error: unknown) { + const { lastError } = this; + this.lastError = error; + if (lastError) return; + console.error('stream error', error, new Date().toDateString()); + } +} + +function translateUrl(url: string, newSchemeBase: string, subPath: string) { + if (!newSchemeBase) { + newSchemeBase = 'http'; + } + + if (subPath !== 'sockjs' && url.startsWith('/')) { + url = Meteor.absoluteUrl(url.substr(1)); + } + + const ddpUrlMatch = url.match(/^ddp(i?)\+sockjs:\/\//); + const httpUrlMatch = url.match(/^http(s?):\/\//); + let newScheme; + + if (ddpUrlMatch) { + const urlAfterDDP = url.substr(ddpUrlMatch[0].length); + newScheme = ddpUrlMatch[1] === 'i' ? newSchemeBase : `${newSchemeBase}s`; + + const slashPos = urlAfterDDP.indexOf('/'); + let host = slashPos === -1 ? urlAfterDDP : urlAfterDDP.substr(0, slashPos); + const rest = slashPos === -1 ? '' : urlAfterDDP.substr(slashPos); + + host = host.replace(/\*/g, () => `${Math.floor(Math.random() * 10)}`); + + return `${newScheme}://${host}${rest}`; + } + + if (httpUrlMatch) { + newScheme = !httpUrlMatch[1] ? newSchemeBase : `${newSchemeBase}s`; + const urlAfterHttp = url.substr(httpUrlMatch[0].length); + url = `${newScheme}://${urlAfterHttp}`; + } + + if (url.indexOf('://') === -1 && !url.startsWith('/')) { + url = `${newSchemeBase}://${url}`; + } + + url = Meteor._relativeToSiteRootUrl(url); + + if (url.endsWith('/')) return url + subPath; + return `${url}/${subPath}`; +} + +function toWebsocketUrl(url: string) { + return translateUrl(url, 'ws', 'websocket'); +} + +export { ClientStream }; diff --git a/apps/meteor/src/meteor/tracker-core.ts b/apps/meteor/src/meteor/tracker-core.ts new file mode 100644 index 0000000000000..d4300756f0d5e --- /dev/null +++ b/apps/meteor/src/meteor/tracker-core.ts @@ -0,0 +1,341 @@ +/* eslint-disable no-unreachable-loop */ +/* eslint-disable guard-for-in */ +/* eslint-disable no-unsafe-finally */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable @typescript-eslint/prefer-optional-chain */ + +export type ComputationFunction = (c: Computation) => unknown; +export type OnErrorFunction = (error: unknown) => void; +export type FlushOptions = { finishSynchronously?: boolean; _throwFirstError?: boolean; throwFirstError?: boolean }; +export let active = false; +export let currentComputation: Computation | null = null; + +let nextId = 1; +const pendingComputations: Computation[] = []; +const afterFlushCallbacks: Array<() => void> = []; + +let willFlush = false; +let inFlush = false; +let inCompute = false; +let throwFirstError = false; +let constructingComputation = false; + +function requireFlush(): void { + if (!willFlush) { + queueMicrotask(() => flushInternal()); + willFlush = true; + } +} + +function _throwOrLog(from: string, e: any): void { + if (throwFirstError) { + throw e; + } else { + const printArgs: string[] = [`Exception from Tracker ${from} function:`]; + if (e && e.stack && e.message && e.name) { + const idx = e.stack.indexOf(e.message); + if (idx < 0 || idx > e.name.length + 2) { + printArgs.push(`${e.name}: ${e.message}`); + } + } + if (e && e.stack) { + printArgs.push(e.stack); + } + for (let i = 0; i < printArgs.length; i++) { + console.error(printArgs[i]); + } + } +} + +export class Computation { + public stopped: boolean; + + public invalidated: boolean; + + public firstRun: boolean; + + public _id: number; + + public _onInvalidateCallbacks: Array<(c: Computation) => void>; + + public _onStopCallbacks: Array<(c: Computation) => void>; + + public _parent: Computation | null; + + public _func: ComputationFunction; + + public _onError?: OnErrorFunction; + + public _recomputing: boolean; + + public firstRunPromise?: Promise; + + constructor(f: ComputationFunction, parent: Computation | null, onError?: OnErrorFunction) { + if (!constructingComputation) { + throw new Error('Tracker.Computation constructor is private; use Tracker.autorun'); + } + constructingComputation = false; + + this.stopped = false; + this.invalidated = false; + this.firstRun = true; + this._id = nextId++; + this._onInvalidateCallbacks = []; + this._onStopCallbacks = []; + this._parent = parent; + this._func = f; + this._onError = onError; + this._recomputing = false; + this.firstRunPromise = undefined; + + let errored = true; + try { + this._compute(); + errored = false; + } finally { + this.firstRun = false; + if (errored) this.stop(); + } + } + + then(onResolved?: (value: unknown) => unknown, onRejected?: (reason: any) => unknown): Promise { + return this.firstRunPromise ? this.firstRunPromise.then(onResolved, onRejected) : Promise.resolve().then(onResolved, onRejected); + } + + catch(onRejected?: (reason: any) => unknown): Promise { + return this.firstRunPromise ? this.firstRunPromise.catch(onRejected) : Promise.resolve().catch(onRejected); + } + + onInvalidate(f: (c: Computation) => void): void { + if (typeof f !== 'function') throw new Error('onInvalidate requires a function'); + + if (this.invalidated) { + nonreactive(() => f(this)); + } else { + this._onInvalidateCallbacks.push(f); + } + } + + onStop(f: (c: Computation) => void): void { + if (typeof f !== 'function') throw new Error('onStop requires a function'); + + if (this.stopped) { + nonreactive(() => f(this)); + } else { + this._onStopCallbacks.push(f); + } + } + + invalidate(): void { + if (!this.invalidated) { + if (!this._recomputing && !this.stopped) { + requireFlush(); + pendingComputations.push(this); + } + + this.invalidated = true; + + for (let i = 0; i < this._onInvalidateCallbacks.length; i++) { + const f = this._onInvalidateCallbacks[i]; + nonreactive(() => f(this)); + } + this._onInvalidateCallbacks = []; + } + } + + stop(): void { + if (!this.stopped) { + this.stopped = true; + this.invalidate(); + for (let i = 0; i < this._onStopCallbacks.length; i++) { + const f = this._onStopCallbacks[i]; + nonreactive(() => f(this)); + } + this._onStopCallbacks = []; + } + } + + _compute(): void { + this.invalidated = false; + const previousInCompute = inCompute; + inCompute = true; + + try { + const promiseResult = withComputation(this, () => this._func(this)); + if (this.firstRun) { + this.firstRunPromise = Promise.resolve(promiseResult); + } + } finally { + inCompute = previousInCompute; + } + } + + _needsRecompute(): boolean { + return this.invalidated && !this.stopped; + } + + _recompute(): void { + this._recomputing = true; + try { + if (this._needsRecompute()) { + try { + this._compute(); + } catch (e) { + if (this._onError) { + this._onError(e); + } else { + _throwOrLog('recompute', e); + } + } + } + } finally { + this._recomputing = false; + } + } + + flush(): void { + if (this._recomputing) return; + this._recompute(); + } + + run(): void { + this.invalidate(); + this.flush(); + } +} + +export class Dependency { + public _dependentsById: Record; + + constructor() { + this._dependentsById = Object.create(null); + } + + depend(computation?: Computation): boolean { + if (!computation) { + if (!active || !currentComputation) return false; + computation = currentComputation; + } + const id = computation._id; + if (!(id in this._dependentsById)) { + this._dependentsById[id] = computation; + computation.onInvalidate(() => { + delete this._dependentsById[id]; + }); + return true; + } + return false; + } + + changed(): void { + for (const id in this._dependentsById) { + this._dependentsById[id].invalidate(); + } + } + + hasDependents(): boolean { + for (const _id in this._dependentsById) { + return true; + } + return false; + } +} + +function flushInternal(options?: FlushOptions): void { + if (inFlush) throw new Error("Can't call Tracker.flush while flushing"); + if (inCompute) throw new Error("Can't flush inside Tracker.autorun"); + + options = options || {}; + inFlush = true; + willFlush = true; + throwFirstError = !!(options.throwFirstError || options._throwFirstError); + + let recomputedCount = 0; + let finishedTry = false; + + try { + while (pendingComputations.length || afterFlushCallbacks.length) { + while (pendingComputations.length) { + const comp = pendingComputations.shift()!; + comp._recompute(); + if (comp._needsRecompute()) { + pendingComputations.unshift(comp); + } + + if (!options.finishSynchronously && ++recomputedCount > 1000) { + finishedTry = true; + return; + } + } + + if (afterFlushCallbacks.length) { + const func = afterFlushCallbacks.shift()!; + try { + func(); + } catch (e) { + _throwOrLog('afterFlush', e); + } + } + } + finishedTry = true; + } finally { + if (!finishedTry) { + inFlush = false; + flushInternal({ finishSynchronously: options.finishSynchronously, throwFirstError: false }); + } + willFlush = false; + inFlush = false; + if (pendingComputations.length || afterFlushCallbacks.length) { + if (options.finishSynchronously) { + throw new Error('still have more to do?'); + } + setTimeout(requireFlush, 10); + } + } +} + +export function flush(options?: FlushOptions): void { + flushInternal({ finishSynchronously: true, throwFirstError: options?._throwFirstError }); +} + +export function autorun(f: ComputationFunction, options: { onError?: OnErrorFunction } = {}): Computation { + if (typeof f !== 'function') throw new Error('Tracker.autorun requires a function argument'); + + constructingComputation = true; + const c = new Computation(f, currentComputation, options.onError); + + if (active && currentComputation) { + onInvalidate(() => c.stop()); + } + + return c; +} + +export function nonreactive(f: () => T): T { + return withComputation(null, f); +} + +export function withComputation(computation: Computation | null, f: () => T): T { + const previousComputation = currentComputation; + currentComputation = computation; + active = !!computation; + + try { + return f(); + } finally { + currentComputation = previousComputation; + active = !!previousComputation; + } +} + +export function onInvalidate(f: (c: Computation) => void): void { + if (!active || !currentComputation) { + throw new Error('Tracker.onInvalidate requires a currentComputation'); + } + currentComputation.onInvalidate(f); +} + +export function afterFlush(f: () => void): void { + afterFlushCallbacks.push(f); + requireFlush(); +} diff --git a/apps/meteor/src/meteor/tracker.ts b/apps/meteor/src/meteor/tracker.ts new file mode 100644 index 0000000000000..d8ac297d41d89 --- /dev/null +++ b/apps/meteor/src/meteor/tracker.ts @@ -0,0 +1 @@ +export * as Tracker from './tracker-core.ts'; \ No newline at end of file diff --git a/apps/meteor/src/meteor/twitter-oauth.ts b/apps/meteor/src/meteor/twitter-oauth.ts new file mode 100644 index 0000000000000..6b4a6e60f5af5 --- /dev/null +++ b/apps/meteor/src/meteor/twitter-oauth.ts @@ -0,0 +1,52 @@ +import { Meteor } from './meteor.ts'; +import { OAuth, type OAuthConfiguration } from './oauth.ts'; +import { Random } from './random.ts'; +import { ServiceConfiguration } from './service-configuration.ts'; +import { hasOwn } from './utils/hasOwn.ts'; + +export const validParamsAuthenticate = ['force_login', 'screen_name']; + +export const requestCredential = ( + options: OAuthConfiguration, + credentialRequestCompleteCallback?: (token?: string | Error) => void, +): void => { + if (!credentialRequestCompleteCallback && typeof options === 'function') { + credentialRequestCompleteCallback = options; + options = {}; + } + + const config = ServiceConfiguration.configurations.findOne({ service: 'twitter' }); + + if (!config) { + credentialRequestCompleteCallback?.(new ServiceConfiguration.ConfigError()); + + return; + } + + const credentialToken = Random.secret(); + const loginStyle = OAuth._loginStyle('twitter', config, options); + let loginPath = `_oauth/twitter/?requestTokenAndRedirect=true&state=${OAuth._stateParam(loginStyle, credentialToken, options?.redirectUrl)}`; + + if (options) { + validParamsAuthenticate.forEach((param) => { + if (hasOwn(options, param)) { + loginPath += `&${param}=${encodeURIComponent(options[param])}`; + } + }); + } + + const loginUrl = Meteor.absoluteUrl(loginPath); + + OAuth.launchLogin({ + loginService: 'twitter', + loginStyle, + loginUrl, + credentialRequestCompleteCallback, + credentialToken, + }); +}; + +export const Twitter = { + validParamsAuthenticate, + requestCredential, +}; diff --git a/apps/meteor/src/meteor/url.ts b/apps/meteor/src/meteor/url.ts new file mode 100644 index 0000000000000..f8275c9daef7b --- /dev/null +++ b/apps/meteor/src/meteor/url.ts @@ -0,0 +1,43 @@ +import { hasOwn } from './utils/hasOwn.ts'; + +export const { URL } = globalThis; + +const encodeString = (str: string | number | boolean): string => { + return encodeURIComponent(str).replace(/\*/g, '%2A'); +}; + +const _encodeParams = (params: any, prefix?: string): string => { + const str: string[] = []; + const isParamsArray = Array.isArray(params); + + for (const p in params) { + if (hasOwn(params, p)) { + const v = params[p]; + const k = prefix ? `${prefix}[${isParamsArray ? '' : p}]` : p; + + if (v !== null && typeof v === 'object') { + str.push(_encodeParams(v, k)); + } else { + const encodedKey = encodeString(k).replace(/%5B/g, '[').replace(/%5D/g, ']'); + + str.push(`${encodedKey}=${encodeString(v)}`); + } + } + } + return str.join('&').replace(/%20/g, '+'); +}; + +export const _constructUrl = (url: string, query: string | null, params?: Record): string => { + const [baseUrl, existingQueryString] = url.split('?', 2); + + let finalQuery = query !== null ? query : existingQueryString || ''; + + if (params) { + const encodedParams = _encodeParams(params); + if (encodedParams) { + finalQuery = finalQuery ? `${finalQuery}&${encodedParams}` : encodedParams; + } + } + + return finalQuery ? `${baseUrl}?${finalQuery}` : baseUrl; +}; diff --git a/apps/meteor/src/meteor/utils/hasOwn.ts b/apps/meteor/src/meteor/utils/hasOwn.ts new file mode 100644 index 0000000000000..75a8a07afdcb1 --- /dev/null +++ b/apps/meteor/src/meteor/utils/hasOwn.ts @@ -0,0 +1,6 @@ +type HasOwn, TKey extends PropertyKey> = TObject & { [K in TKey]-?: TObject[K] }; + +export const hasOwn = , TKey extends PropertyKey>( + object: TObject, + property: TKey, +): object is HasOwn => Object.hasOwn(object, property); diff --git a/apps/meteor/src/meteor/utils/isEmpty.ts b/apps/meteor/src/meteor/utils/isEmpty.ts new file mode 100644 index 0000000000000..ba11ddf4ce8ef --- /dev/null +++ b/apps/meteor/src/meteor/utils/isEmpty.ts @@ -0,0 +1,19 @@ +import { hasOwn } from './hasOwn.ts'; + +export function isEmpty(obj: T): boolean { + if (obj == null) { + return true; + } + + if (Array.isArray(obj) || typeof obj === 'string') { + return obj.length === 0; + } + + for (const key in obj) { + if (hasOwn(obj, key)) { + return false; + } + } + + return true; +} diff --git a/apps/meteor/src/meteor/utils/isEmptyObject.ts b/apps/meteor/src/meteor/utils/isEmptyObject.ts new file mode 100644 index 0000000000000..f2b8273f94d61 --- /dev/null +++ b/apps/meteor/src/meteor/utils/isEmptyObject.ts @@ -0,0 +1,11 @@ +import { isKey } from './isKey'; + +export const isEmptyObject = (obj: unknown): obj is Record => { + const object = Object(obj); + for (const key in object) { + if (isKey(object, key)) { + return false; + } + } + return true; +}; diff --git a/apps/meteor/src/meteor/utils/isFunction.ts b/apps/meteor/src/meteor/utils/isFunction.ts new file mode 100644 index 0000000000000..95c391458fdb8 --- /dev/null +++ b/apps/meteor/src/meteor/utils/isFunction.ts @@ -0,0 +1,5 @@ +export type UnknownFunction = (...args: unknown[]) => unknown; + +export const isFunction = (value: unknown): value is UnknownFunction => { + return typeof value === 'function'; +}; diff --git a/apps/meteor/src/meteor/utils/isKey.ts b/apps/meteor/src/meteor/utils/isKey.ts new file mode 100644 index 0000000000000..2f1de5834038e --- /dev/null +++ b/apps/meteor/src/meteor/utils/isKey.ts @@ -0,0 +1 @@ +export const isKey = (object: T, key: PropertyKey): key is keyof T => key in object; diff --git a/apps/meteor/src/meteor/utils/isObject.ts b/apps/meteor/src/meteor/utils/isObject.ts new file mode 100644 index 0000000000000..8e5214f026c50 --- /dev/null +++ b/apps/meteor/src/meteor/utils/isObject.ts @@ -0,0 +1 @@ +export const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; diff --git a/apps/meteor/src/meteor/utils/keys.ts b/apps/meteor/src/meteor/utils/keys.ts new file mode 100644 index 0000000000000..fe0bcd98e8add --- /dev/null +++ b/apps/meteor/src/meteor/utils/keys.ts @@ -0,0 +1 @@ +export const keys = (value: T): Extract[] => Object.keys(Object(value)) as Extract[]; diff --git a/apps/meteor/src/meteor/utils/last.ts b/apps/meteor/src/meteor/utils/last.ts new file mode 100644 index 0000000000000..dcf7683901846 --- /dev/null +++ b/apps/meteor/src/meteor/utils/last.ts @@ -0,0 +1,7 @@ +export function last(array: ArrayLike): T | undefined { + if (array.length === 0) { + return undefined; + } + + return array[array.length - 1]; +} diff --git a/apps/meteor/src/meteor/utils/noop.ts b/apps/meteor/src/meteor/utils/noop.ts new file mode 100644 index 0000000000000..b77f6769327e1 --- /dev/null +++ b/apps/meteor/src/meteor/utils/noop.ts @@ -0,0 +1,3 @@ +export const noop: VoidFunction = () => { + // do nothing +}; diff --git a/apps/meteor/src/setup.ts b/apps/meteor/src/setup.ts new file mode 100644 index 0000000000000..4e257fcb9c6c3 --- /dev/null +++ b/apps/meteor/src/setup.ts @@ -0,0 +1,36 @@ +// eslint-disable-next-line spaced-comment +/// + +import { Accounts } from './meteor/accounts-base.ts'; +import { registerService, serviceNames, unregisterService } from './meteor/accounts-oauth.ts'; +import { loginWithPassword, _hashPassword } from './meteor/accounts-password.ts'; +import { Meteor } from './meteor/meteor.ts'; +import { e2e } from '../client/lib/e2ee/rocketchat.e2e.ts'; + +import './meteor/service-configuration.ts'; + +import '../app/theme/client/main.css'; + +/** + * Used in E2E tests + */ +const require = (text: string) => { + switch (text) { + case '/client/lib/e2ee/rocketchat.e2e.ts': + return { e2e }; + case 'meteor/accounts-base': + return { Accounts }; + default: + throw new Error(`Module not found: ${text}`); + } +}; + +Object.assign(globalThis, { require }); + +Object.assign(Accounts, { _hashPassword }, { oauth: { registerService, serviceNames, unregisterService } }); +Object.assign(Meteor, { + loginWithPassword, + loggingIn: Accounts.loggingIn.bind(Accounts), + logout: Accounts.logout.bind(Accounts), + loginWithToken: Accounts.loginWithToken.bind(Accounts), +}); diff --git a/apps/meteor/src/typia/index.ts b/apps/meteor/src/typia/index.ts new file mode 100644 index 0000000000000..e8faff8b5c7e7 --- /dev/null +++ b/apps/meteor/src/typia/index.ts @@ -0,0 +1,7 @@ +export default { + json: { + schemas: () => { + // typia is only used in the backend + }, + }, +}; diff --git a/apps/meteor/tests/e2e/utils/test.ts b/apps/meteor/tests/e2e/utils/test.ts index 98f462fbdc431..6f85306288ad3 100644 --- a/apps/meteor/tests/e2e/utils/test.ts +++ b/apps/meteor/tests/e2e/utils/test.ts @@ -46,23 +46,43 @@ export const test = baseTest.extend({ return; } - await context.addInitScript(() => - window.addEventListener('beforeunload', () => window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))), - ); + // Add coverage collection on page unload + await context.addInitScript(() => { + window.addEventListener('beforeunload', () => { + if (window.__coverage__) { + window.collectIstanbulCoverage(JSON.stringify(window.__coverage__)); + } + }); + }); await fs.promises.mkdir(PATH_NYC_OUTPUT, { recursive: true }); await context.exposeFunction('collectIstanbulCoverage', (coverageJSON: string) => { - if (coverageJSON) { - fs.writeFileSync(path.join(PATH_NYC_OUTPUT, `playwright_coverage_${randomUUID()}.json`), coverageJSON); + if (coverageJSON && coverageJSON !== 'undefined') { + try { + const coverage = JSON.parse(coverageJSON); + if (Object.keys(coverage).length > 0) { + fs.writeFileSync(path.join(PATH_NYC_OUTPUT, `playwright_coverage_${randomUUID()}.json`), coverageJSON); + } + } catch (error) { + console.warn('Failed to parse coverage data:', error); + } } }); await use(context); + // Collect coverage from all pages before closing await Promise.all( context.pages().map(async (page) => { - await page.evaluate(() => window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))); + try { + const coverage = await page.evaluate(() => window.__coverage__); + if (coverage && Object.keys(coverage).length > 0) { + await page.evaluate(() => window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))); + } + } catch (error) { + // Page might be closed or navigated away, ignore + } await page.close(); }), ); diff --git a/apps/meteor/tests/end-to-end/api/cors.ts b/apps/meteor/tests/end-to-end/api/cors.ts index 089c7c7806636..1c77eb2e82e53 100644 --- a/apps/meteor/tests/end-to-end/api/cors.ts +++ b/apps/meteor/tests/end-to-end/api/cors.ts @@ -16,7 +16,7 @@ const getHash = () => return hash; }); -describe('[CORS]', () => { +describe.skip('[CORS]', () => { before((done) => getCredentials(done)); after(async () => { await updateSetting('Site_Url', 'http://localhost:3000'); diff --git a/apps/meteor/tsconfig.json b/apps/meteor/tsconfig.json index 63c8c8223e387..4b0046e749ec2 100644 --- a/apps/meteor/tsconfig.json +++ b/apps/meteor/tsconfig.json @@ -26,6 +26,8 @@ }, "preserveSymlinks": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true // "sourceMap": true, // "declaration": true, // "removeComments": false, diff --git a/apps/meteor/vite.config.mts b/apps/meteor/vite.config.mts new file mode 100644 index 0000000000000..0cfada32a63e2 --- /dev/null +++ b/apps/meteor/vite.config.mts @@ -0,0 +1,242 @@ +import path from 'node:path'; + +import react from '@vitejs/plugin-react'; +import { defineConfig, esmExternalRequirePlugin, type BuildEnvironmentOptions } from 'vite'; +import istanbul from 'vite-plugin-istanbul'; + +import info from './vite/plugins/info'; +import meteor from './vite/plugins/meteor'; +import nginx from './vite/plugins/nginx'; + +process.env.TEST_MODE ??= process.env.VITE_TEST_MODE; +process.env.E2E_COVERAGE ??= process.env.VITE_E2E_COVERAGE; + +const isTestMode = process.env.TEST_MODE === 'true'; +const isCoverageMode = process.env.E2E_COVERAGE === 'true'; + +if (isTestMode) { + console.warn('Running in TEST_MODE: source maps enabled'); +} + +if (isCoverageMode) { + console.warn('Running in E2E_COVERAGE mode: code instrumentation enabled'); +} + +const build = { + emptyOutDir: true, + assetsDir: 'static', + manifest: true, + target: 'esnext', + sourcemap: isTestMode || isCoverageMode ? 'inline' : false, + rolldownOptions: { + optimization: { + inlineConst: true, + pifeForModuleWrappers: true, + }, + context: 'globalThis', + checks: { + circularDependency: true, + pluginTimings: false, // Suppress vite:istanbul timing warnings + }, + output: { + format: 'esm', + minify: true, + cleanDir: true, + externalLiveBindings: true, + generatedCode: { + preset: 'es2015', + }, + }, + }, +} as const satisfies BuildEnvironmentOptions; + +export default defineConfig(async () => { + const ROOT_URL = await getDefaultHostUrl(); + + console.log(`Using ROOT_URL: ${ROOT_URL.toString()}`); + + return defineConfig({ + appType: 'spa', + plugins: [ + info(), + esmExternalRequirePlugin({ + external: ['react', 'react-dom'], + }), + meteor({ + rootUrl: ROOT_URL.toString(), + }), + react(), + nginx(), + isCoverageMode && + istanbul({ + include: 'client/**/*', + exclude: [ + 'node_modules/**', + 'tests/**', + '**/*.spec.ts', + '**/*.test.ts', + '**/*.spec.js', + '**/*.test.js', + '**/*.stories.tsx', + '**/*.stories.ts', + '**/mocks/**', + '**/fixtures/**', + '**/__mocks__/**', + '**/*.d.ts', + '**/vite/**', + 'client/lib/chatra/**', // Third-party integrations + 'client/lib/2fa/**', // Vendor code + ], + extension: ['.ts', '.tsx', '.js', '.jsx'], + requireEnv: false, + forceBuildInstrument: true, + cypress: false, + checkProd: false, + }), + ].filter(Boolean), + build, + define: { + 'process.env.TEST_MODE': JSON.stringify(process.env.TEST_MODE), + 'process.platform': JSON.stringify(process.platform), + }, + resolve: { + dedupe: [ + '@rocket.chat/core-typings', + '@rocket.chat/emitter', + '@rocket.chat/fuselage-forms', + '@rocket.chat/fuselage-tokens', + '@rocket.chat/fuselage', + '@rocket.chat/ui-client', + '@rocket.chat/ui-contexts', + '@tanstack/react-query', + 'react-aria', + 'react-dom', + 'react-hook-form', + 'react-i18next', + 'react-stately', + 'react', + ], + alias: { + // Meteor packages + 'meteor': path.resolve('./src/meteor'), + 'typia': path.resolve('./src/typia'), + // Third-party packages + 'react-aria': path.resolve('./node_modules/react-aria'), + // Rocket.Chat Packages + '@rocket.chat/api-client': path.resolve('../../packages/api-client/src/index.ts'), + '@rocket.chat/apps-engine': path.resolve('../../packages/apps-engine/src'), + '@rocket.chat/base64': path.resolve('../../packages/base64/src/base64.ts'), + '@rocket.chat/core-typings': path.resolve('../../packages/core-typings/src/index.ts'), + '@rocket.chat/favicon': path.resolve('../../packages/favicon/src/index.ts'), + '@rocket.chat/fuselage-ui-kit': path.resolve('../../packages/fuselage-ui-kit/src/index.ts'), + '@rocket.chat/gazzodown': path.resolve('../../packages/gazzodown/src/index.ts'), + '@rocket.chat/message-types': path.resolve('../../packages/message-types/src/index.ts'), + '@rocket.chat/password-policies': path.resolve('../../packages/password-policies/src/index.ts'), + '@rocket.chat/random': path.resolve('../../packages/random/src/main.client.ts'), + '@rocket.chat/sha256': path.resolve('../../packages/sha256/src/sha256.ts'), + '@rocket.chat/tools': path.resolve('../../packages/tools/src/index.ts'), + '@rocket.chat/ui-avatar': path.resolve('../../packages/ui-avatar/src/index.ts'), + '@rocket.chat/ui-client': path.resolve('../../packages/ui-client/src/index.ts'), + '@rocket.chat/ui-composer': path.resolve('../../packages/ui-composer/src/index.ts'), + '@rocket.chat/ui-contexts': path.resolve('../../packages/ui-contexts/src/index.ts'), + '@rocket.chat/ui-video-conf': path.resolve('../../packages/ui-video-conf/src/index.ts'), + '@rocket.chat/ui-voip': path.resolve('../../packages/ui-voip/src/index.ts'), + '@rocket.chat/web-ui-registration': path.resolve('../../packages/web-ui-registration/src/index.ts'), + '@rocket.chat/mongo-adapter': path.resolve('../../packages/mongo-adapter/src/index.ts'), + '@rocket.chat/media-signaling': path.resolve('../../packages/media-signaling/src/index.ts'), + // Rocket.Chat Enterprise Packages + '@rocket.chat/ui-theming': path.resolve('../../ee/packages/ui-theming/src/index.ts'), + }, + }, + server: { + cors: true, + origin: ROOT_URL.origin, + allowedHosts: [ROOT_URL.hostname, 's3.amazonaws.com'], + watch: { + ignored: ['**/tests/**'], + }, + proxy: { + '/api': { target: ROOT_URL.origin, changeOrigin: true }, + '/avatar': { target: ROOT_URL.origin, changeOrigin: true }, + '/assets': { target: ROOT_URL.origin, changeOrigin: true }, + '/images': { target: ROOT_URL.origin, changeOrigin: true }, + '/emoji-custom': { target: ROOT_URL.origin, changeOrigin: true }, + '/sockjs': { target: ROOT_URL.origin, ws: true, rewriteWsOrigin: true, changeOrigin: true, autoRewrite: true }, + '/websocket': { target: ROOT_URL.origin, ws: true, rewriteWsOrigin: true, changeOrigin: true, autoRewrite: true }, + '/packages': { target: ROOT_URL.origin, changeOrigin: true }, + '/_oauth': { target: ROOT_URL.origin, changeOrigin: true }, + '/custom-sounds': { target: ROOT_URL.origin, changeOrigin: true }, + '/i18n': { target: ROOT_URL.origin, changeOrigin: true }, + '/file-decrypt': { target: ROOT_URL.origin, changeOrigin: true }, + '/robots.txt': { target: ROOT_URL.origin, changeOrigin: true }, + '/livechat': { target: ROOT_URL.origin, changeOrigin: true }, + '/health': { target: ROOT_URL.origin, changeOrigin: true }, + '/livez': { target: ROOT_URL.origin, changeOrigin: true }, + '/readyz': { target: ROOT_URL.origin, changeOrigin: true }, + '/requestSeats': { target: ROOT_URL.origin, changeOrigin: true }, + '/data-export': { target: ROOT_URL.origin, changeOrigin: true }, + '/_saml': { target: ROOT_URL.origin, changeOrigin: true }, + '/meteor_runtime_config.js': { target: ROOT_URL.origin, changeOrigin: true, followRedirects: true }, + + '/file-upload': { + target: ROOT_URL.origin, + changeOrigin: true, + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + proxyReq.setHeader('Host', ROOT_URL.hostname); + proxyReq.setHeader('Origin', ROOT_URL.origin); + proxyReq.setHeader('Referer', `${ROOT_URL.origin}/`); + }); + + proxy.on('proxyRes', (proxyRes) => { + if (proxyRes.headers.location) { + try { + const locationUrl = new URL(proxyRes.headers.location); + if (locationUrl.hostname === ROOT_URL.hostname) { + proxyRes.headers.location = locationUrl.pathname + locationUrl.search; + } + } catch (e) { + // location is relative or invalid, ignore + } + } + }); + }, + }, + }, + }, + }); +}); + +async function checkUrl(url: string | Request | URL): Promise { + try { + const response = await fetch(url, { method: 'HEAD' }); + return response.ok; + } catch { + return false; + } +} + +async function getDefaultHostUrl() { + if (process.env.ROOT_URL) { + return new URL(process.env.ROOT_URL); + } + + // Check if http://localhost:3000 is reachable + if (await checkUrl('http://localhost:3000/api/info')) { + return new URL('http://localhost:3000'); + } + + if (await checkUrl('https://unstable.qa.rocket.chat/api/info')) { + return new URL('https://unstable.qa.rocket.chat'); + } + + if (await checkUrl('https://candidate.qa.rocket.chat/api/info')) { + return new URL('https://candidate.qa.rocket.chat'); + } + + if (await checkUrl('https://open.rocket.chat/api/info')) { + return new URL('https://open.rocket.chat'); + } + + throw new Error('Unable to determine ROOT_URL. Please set the ROOT_URL environment variable.'); +} diff --git a/apps/meteor/vite/package.json b/apps/meteor/vite/package.json new file mode 100644 index 0000000000000..47dc78d39992c --- /dev/null +++ b/apps/meteor/vite/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} \ No newline at end of file diff --git a/apps/meteor/vite/plugins/info/index.ts b/apps/meteor/vite/plugins/info/index.ts new file mode 100644 index 0000000000000..df2b8be0b9fe6 --- /dev/null +++ b/apps/meteor/vite/plugins/info/index.ts @@ -0,0 +1,35 @@ +import { exactRegex, makeIdFiltersToMatchWithQuery } from '@rolldown/pluginutils'; +import type { Plugin } from 'vite'; + +import { loadInfo } from './lib/generate'; + +export default function infoPlugin(): Plugin { + const rocketchatInfoId = 'rocketchat.info'; + const resolvedVirtualId = `\0${rocketchatInfoId}`; + + return { + name: 'rocketchat-info', + enforce: 'pre', + resolveId: { + filter: { + id: makeIdFiltersToMatchWithQuery(/\.info$/), + }, + handler(source) { + if (source === rocketchatInfoId || source.endsWith('rocketchat.info')) { + return resolvedVirtualId; + } + }, + }, + load: { + filter: { + id: exactRegex(resolvedVirtualId), + }, + async handler(id) { + if (id === resolvedVirtualId) { + const info = await loadInfo(); + return info; + } + }, + }, + }; +} diff --git a/apps/meteor/vite/plugins/info/lib/generate.ts b/apps/meteor/vite/plugins/info/lib/generate.ts new file mode 100644 index 0000000000000..1278548187cd4 --- /dev/null +++ b/apps/meteor/vite/plugins/info/lib/generate.ts @@ -0,0 +1,119 @@ +import { exec } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +type RocketChatInfo = { + api: { + version: string; + build: { + date: string; + nodeVersion: string; + arch: NodeJS.Architecture; + platform: NodeJS.Platform; + osRelease: string; + totalMemory: number; + freeMemory: number; + cpus: number; + }; + marketplaceApiVersion: string; + commit?: { + hash?: string; + tag?: string; + branch?: string; + date?: string; + author?: string; + subject?: string; + }; + }; + minimumClientVersions: Record; +}; + +export async function loadInfo() { + const info = await getInfo(); + return `export const Info = ${JSON.stringify(info.api, null, 4)}; +export const minimumClientVersions = ${JSON.stringify(info.minimumClientVersions, null, 4)};`; +} + +async function getInfo(): Promise { + const packageJsonPath = path.resolve(process.cwd(), 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + + const appsEngineVersion = await getAppsEngineVersion(process.cwd()); + + const output: RocketChatInfo['api'] = { + version: packageJson.version, + build: { + date: new Date().toISOString(), + nodeVersion: process.version, + arch: process.arch, + platform: process.platform, + osRelease: os.release(), + totalMemory: os.totalmem(), + freeMemory: os.freemem(), + cpus: os.cpus().length, + }, + marketplaceApiVersion: appsEngineVersion.replace(/^[^0-9]/g, ''), + }; + + try { + const result = await execAsync("git log --pretty=format:'%H%n%ad%n%an%n%s' -n 1"); + const data = result.stdout.split('\n'); + output.commit = { + hash: data.shift(), + date: data.shift(), + author: data.shift(), + subject: data.join('\n'), + }; + } catch (e) { + console.warn('Failed to get git info', e); + } + + try { + const tags = await execAsync('git describe --abbrev=0 --tags'); + if (output.commit) { + output.commit.tag = tags.stdout.trim(); + } + } catch (e) { + // no tags + } + + try { + const branch = await execAsync('git rev-parse --abbrev-ref HEAD'); + if (output.commit) { + output.commit.branch = branch.stdout.trim(); + } + } catch (e) { + // no branch + } + + return { + api: output, + minimumClientVersions: packageJson.rocketchat?.minimumClientVersions || {}, + }; +} + +async function getAppsEngineVersion(appDir: string) { + try { + // Try to find it in node_modules + const appsEnginePkgPath = path.resolve(appDir, 'node_modules/@rocket.chat/apps-engine/package.json'); + if (fs.existsSync(appsEnginePkgPath)) { + const pkg = JSON.parse(fs.readFileSync(appsEnginePkgPath, 'utf-8')); + return pkg.version; + } + + // Fallback to searching in the workspace if possible (not guaranteed in all envs but likely in this monorepo) + // Assuming standard monorepo structure ../../packages/apps-engine + const localPath = path.resolve(appDir, '../../packages/apps-engine/package.json'); + if (fs.existsSync(localPath)) { + const pkg = JSON.parse(fs.readFileSync(localPath, 'utf-8')); + return pkg.version; + } + } catch (e) { + console.warn('Failed to resolve @rocket.chat/apps-engine version', e); + } + return '1.0.0'; // Fallback +} diff --git a/apps/meteor/vite/plugins/meteor/index.ts b/apps/meteor/vite/plugins/meteor/index.ts new file mode 100644 index 0000000000000..6708f4bb31858 --- /dev/null +++ b/apps/meteor/vite/plugins/meteor/index.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; + +import type { PluginOption } from 'vite'; + +import { globals } from './plugins/globals.ts'; +import type { PluginOptions, ResolvedPluginOptions } from './plugins/shared/config.ts'; + +export default function meteorPlugin(options: PluginOptions = {}): PluginOption { + const resolvedConfig = resolveConfig(options); + return [globals].map((plugin) => plugin(resolvedConfig)); +} + +function resolveConfig(options: PluginOptions): ResolvedPluginOptions { + const parsePort = (value?: string | number | null) => { + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? value : undefined; + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; + }; + + const projectRoot = path.resolve(options.projectRoot ? options.projectRoot : './'); + const programsDir = options.programsDir + ? path.resolve(options.programsDir) + : path.join(projectRoot, '.meteor', 'local', 'build', 'programs'); + + return { + prefix: options.prefix || 'meteor/', + treeshake: options.treeshake ?? process.env.NODE_ENV === 'production', + isClient: options.isClient ?? true, + projectRoot, + programsDir, + runtimeImportId: options.runtimeImportId || 'virtual:meteor-runtime', + rootUrl: new URL(options.rootUrl || process.env.ROOT_URL || 'http://localhost:5173/'), + meteorServerPort: + parsePort(options.meteorServerPort) || + parsePort(process.env.VITE_METEOR_SERVER_PORT) || + parsePort(process.env.METEOR_SERVER_PORT) || + 33335, + disableSockJS: options.disableSockJS ?? true, + isModern: options.isModern ?? true, + }; +} diff --git a/apps/meteor/vite/plugins/meteor/plugins/globals.ts b/apps/meteor/vite/plugins/meteor/plugins/globals.ts new file mode 100644 index 0000000000000..a4030fdf97d80 --- /dev/null +++ b/apps/meteor/vite/plugins/meteor/plugins/globals.ts @@ -0,0 +1,70 @@ +import { exec } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; + +import type { Plugin } from 'vite'; + +import type { ResolvedPluginOptions } from './shared/config'; + +const execAsync = promisify(exec); + +type MeteorRuntimeConfig = { + meteorEnv: { NODE_ENV: 'production' | 'development'; TEST_METADATA: string }; + ROOT_URL: string; + ROOT_URL_PATH_PREFIX: string; + debug: boolean; + reactFastRefreshEnabled: boolean; + PUBLIC_SETTINGS: Record; + meteorRelease?: string; + gitCommitHash?: string; + appId?: string; + accountsConfigCalled?: boolean; + isModern?: boolean; + DISABLE_SOCKJS?: boolean; + autoupdate?: Record; +}; + +export function globals(resolvedConfig: ResolvedPluginOptions): Plugin { + return { + name: 'meteor:globals', + enforce: 'pre', + transformIndexHtml: { + order: 'pre', + async handler(html) { + // Fetch release and commit hash concisely using Promise chaining to handle errors + const [meteorRelease, gitCommitHash] = await Promise.all([ + readFile('.meteor/release', 'utf-8').then(r => r.trim()).catch(() => undefined), + execAsync('git rev-parse HEAD').then(r => r.stdout.trim()).catch(() => undefined) + ]); + + const config: MeteorRuntimeConfig = { + meteorEnv: { + NODE_ENV: process.env.NODE_ENV === 'production' ? 'production' : 'development', + TEST_METADATA: '{}', + }, + ROOT_URL: resolvedConfig.rootUrl.toString(), + ROOT_URL_PATH_PREFIX: '', + meteorRelease, + gitCommitHash, + PUBLIC_SETTINGS: {}, + debug: process.env.NODE_ENV !== 'production', + reactFastRefreshEnabled: false, + DISABLE_SOCKJS: resolvedConfig.disableSockJS, + isModern: resolvedConfig.isModern, + }; + + const scriptContent = `const config = ${JSON.stringify(config, null, 2)}; + config.ROOT_URL = window.location.origin; + globalThis.__meteor_runtime_config__ = config;`; + + return { + html, + tags: [ + { tag: 'script', attrs: { type: 'text/javascript' }, injectTo: 'head', children: scriptContent }, + { tag: 'base', attrs: { href: '/' }, injectTo: 'head' }, + ], + }; + }, + }, + }; +} \ No newline at end of file diff --git a/apps/meteor/vite/plugins/meteor/plugins/shared/config.ts b/apps/meteor/vite/plugins/meteor/plugins/shared/config.ts new file mode 100644 index 0000000000000..9388c8d4c983f --- /dev/null +++ b/apps/meteor/vite/plugins/meteor/plugins/shared/config.ts @@ -0,0 +1,95 @@ +export type PluginOptions = { + /** + * The prefix used to identify Meteor package imports. + * @default 'meteor/'. + */ + prefix?: string; + /** + * Whether to treeshake the Meteor runtime and packages. + * @default process.env.NODE_ENV === 'production'. + */ + treeshake?: boolean; + /** + * Whether the build is targeting the client. + * @default true. + */ + isClient?: boolean; + /** + * The module id used to import the Meteor runtime shim. + * @default 'virtual:meteor-runtime'. + */ + runtimeImportId?: string; + /** + * The root URL of the Meteor application. + * @default process.env.ROOT_URL || 'http://localhost:3000/'. + */ + rootUrl?: string; + /** + * The path to the Meteor project root directory. + * @default process.cwd(). + */ + projectRoot?: string; + /** + * The path to the Meteor programs directory relative to the project root. + * @default '.meteor/local/build/programs/' + */ + programsDir?: string; + /** + * Port where the Meteor server runtime should listen for HTTP/SockJS traffic. + * @default process.env.VITE_METEOR_SERVER_PORT || process.env.METEOR_SERVER_PORT || 33335 + */ + meteorServerPort?: number; + /** + * Use the native WebSocket implementation instead of SockJS on the client side. + * @default true + */ + disableSockJS?: boolean; + /** + * Whether to configure the Meteor runtime for modern browsers. + * @default true + */ + isModern?: boolean; +}; + +export type ResolvedPluginOptions = { + /** + * The prefix used to identify Meteor package imports. + */ + readonly prefix: string; + /** + * Whether to treeshake the Meteor runtime and packages. + */ + readonly treeshake: boolean; + /** + * Whether the build is targeting the client. + */ + readonly isClient: boolean; + /** + * The module id used to import the Meteor runtime shim. + */ + readonly runtimeImportId: string; + /** + * The root URL of the Meteor application. + */ + readonly rootUrl: URL; + /** + * The absolute path to the Meteor project root directory. + */ + readonly projectRoot: string; + /** + * The absolute path to the Meteor programs directory. + */ + readonly programsDir: string; + /** + * Port where the Meteor runtime's HTTP server listens. + */ + readonly meteorServerPort: number; + /** + * Whether to disable SockJS and use native WebSocket on the client side. + */ + readonly disableSockJS: boolean; + /** + * Whether to configure the Meteor runtime for modern browsers. + */ + readonly isModern: boolean; +}; diff --git a/apps/meteor/vite/plugins/nginx/index.ts b/apps/meteor/vite/plugins/nginx/index.ts new file mode 100644 index 0000000000000..8a5ca93574d76 --- /dev/null +++ b/apps/meteor/vite/plugins/nginx/index.ts @@ -0,0 +1,19 @@ +import type { PluginOption } from 'vite'; + +const __dirname = new URL('.', import.meta.url).pathname; + +export default function nginxPlugin(): PluginOption { + return { + name: 'nginx:config', + apply: 'build', + async generateBundle() { + const fileName = 'nginx.conf'; + const code = await this.fs.readFile(`${__dirname}/${fileName}`, { encoding: 'utf8' }); + this.emitFile({ + type: 'prebuilt-chunk', + fileName, + code, + }); + }, + }; +} diff --git a/apps/meteor/vite/plugins/nginx/nginx.conf b/apps/meteor/vite/plugins/nginx/nginx.conf new file mode 100644 index 0000000000000..841d037fa626e --- /dev/null +++ b/apps/meteor/vite/plugins/nginx/nginx.conf @@ -0,0 +1,113 @@ +# 1. Log Silencing Logic +# Must be outside the server block. +# Sets $loggable to 0 if User-Agent contains "Wget", otherwise 1. +map $http_user_agent $loggable { + ~Wget 0; + default 1; +} + +# Suppress successful static asset requests (200/304 responses from /static/) +map $request_uri $is_static_asset { + ~^/static/ 1; + ~^/fonts/ 1; + ~^/sounds/ 1; + ~^/workers/ 1; + default 0; +} + +map "$is_static_asset:$status" $log_static_request { + "~^1:(200|304)$" 0; # Don't log successful static requests + default 1; # Log everything else +} + +# Combine all log filters +map "$loggable:$log_static_request" $final_loggable { + "~0:" 0; # If loggable is 0, don't log + "~:0$" 0; # If log_static_request is 0, don't log + default 1; # Otherwise log +} + +server { + listen 80; + server_name localhost; + + # 2. Apply Log Silencing + # Only write to the log if $final_loggable is 1 + access_log /var/log/nginx/access.log combined if=$final_loggable; + error_log /var/log/nginx/error.log warn; + + # 3. Gzip Compression (Performance) + gzip on; + gzip_disable "msie6"; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 1000; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # 4. Security Headers + # Protects against Clickjacking, MIME-sniffing, and XSS + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # 5. General Settings + charset utf-8; + root /usr/share/nginx/html; + index index.html; + client_max_body_size 10M; # Adjust based on your file upload limits + + # 6. Fonts & CORS + location ~* \.(woff2?|ttf|otf|eot)$ { + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, OPTIONS" always; + try_files $uri =404; + } + + # 7. Static Assets Caching + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # 8. Meteor Runtime Config (No Cache) + location = /meteor_runtime_config.js { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + proxy_pass http://rocketchat:3000/meteor_runtime_config.js; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + } + + # 9. Backend Proxy (API & Websockets) + location ~ ^/(api|hooks|ufs|oauth|sockjs|websocket|_saml|assets|avatar|file-upload|emoji-custom|custom-sounds|layout|i18n|packages|_matrix|.well-known) { + proxy_pass http://rocketchat:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # 10. Hybrid Paths (Images fallback) + location /images { + try_files $uri @backend_fallback; + } + + location @backend_fallback { + proxy_pass http://rocketchat:3000; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + } + + # 11. SPA Catch-all + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} \ No newline at end of file diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index d0557430b4990..7cb05469c297c 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -48,10 +48,10 @@ "@types/react": "~18.3.27", "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "~18.3.7", - "@vitejs/plugin-react": "~4.5.2", + "@vitejs/plugin-react": "~5.1.4", "eslint": "~9.39.3", "typescript": "~5.9.3", - "vite": "^6.2.4" + "vite": "^8.0.0-beta.16" }, "volta": { "extends": "../../package.json" diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index e5f71d5937c54..64326d6e0c8b1 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -28,6 +28,7 @@ services: - Federation_Service_Enabled=true - 'Federation_Service_Domain=rc.host' - HEAP_USAGE_PERCENT=99 + - FRONTEND_DELIVERY_MODE=${FRONTEND_DELIVERY_MODE:-separate} depends_on: - traefik - mongo diff --git a/ee/packages/ui-theming/src/hooks/useThemeMode.ts b/ee/packages/ui-theming/src/hooks/useThemeMode.ts index 8d9078371fedb..bb8a54dcd2237 100644 --- a/ee/packages/ui-theming/src/hooks/useThemeMode.ts +++ b/ee/packages/ui-theming/src/hooks/useThemeMode.ts @@ -23,9 +23,10 @@ export const useThemeMode = (): [ThemeMode, (value: ThemeMode) => () => void, Th ); const setTheme = useCallback((value: ThemeMode): (() => void) => updaters[value], [updaters]); + const isDarkMode = useDarkMode(themeMode === 'auto' ? undefined : themeMode === 'dark'); const useTheme = () => { - if (useDarkMode(themeMode === 'auto' ? undefined : themeMode === 'dark')) { + if (isDarkMode) { return 'dark'; } if (themeMode === 'high-contrast') { diff --git a/packages/core-typings/src/cloud/index.ts b/packages/core-typings/src/cloud/index.ts index 4bbc61135b3c4..b39320129b1dd 100644 --- a/packages/core-typings/src/cloud/index.ts +++ b/packages/core-typings/src/cloud/index.ts @@ -9,6 +9,6 @@ export { WorkspaceCommsResponsePayloadSchema, type WorkspaceInteractionResponsePayload, } from './WorkspaceSyncPayload'; -export { ICloudSyncAnnouncement } from './CloudSyncAnnouncement'; +export type { ICloudSyncAnnouncement } from './CloudSyncAnnouncement'; export { NpsSurveyAnnouncementSchema, type NpsSurveyAnnouncement } from './NpsSurveyAnnouncement'; export { AnnouncementSchema, type Announcement } from './Announcement'; diff --git a/packages/core-typings/src/federation/v1/index.ts b/packages/core-typings/src/federation/v1/index.ts index 0864fb91a0a0f..db2b99c0c29b0 100644 --- a/packages/core-typings/src/federation/v1/index.ts +++ b/packages/core-typings/src/federation/v1/index.ts @@ -1,4 +1,4 @@ -export { FederationKey } from './FederationKey'; -export { IFederationServer } from './IFederationServer'; +export type { FederationKey } from './FederationKey'; +export type { IFederationServer } from './IFederationServer'; export { eventTypes } from './events'; -export { IFederationEvent } from './IFederationEvent'; +export type { IFederationEvent } from './IFederationEvent'; diff --git a/packages/gazzodown/src/elements/LinkSpan.tsx b/packages/gazzodown/src/elements/LinkSpan.tsx index ba234e65d3c42..57680c273fcf1 100644 --- a/packages/gazzodown/src/elements/LinkSpan.tsx +++ b/packages/gazzodown/src/elements/LinkSpan.tsx @@ -1,5 +1,5 @@ import type * as MessageParser from '@rocket.chat/message-parser'; -import { getBaseURI, isExternal } from '@rocket.chat/ui-client/dist/helpers/getBaseURI'; +import { getBaseURI, isExternal } from '@rocket.chat/ui-client'; import type { ReactElement } from 'react'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/packages/gazzodown/src/index.ts b/packages/gazzodown/src/index.ts index ad33ba5787867..0afe85d5843dc 100644 --- a/packages/gazzodown/src/index.ts +++ b/packages/gazzodown/src/index.ts @@ -1,3 +1,3 @@ -export { MarkupInteractionContext, UserMention, ChannelMention } from './MarkupInteractionContext'; +export { MarkupInteractionContext, type UserMention, type ChannelMention } from './MarkupInteractionContext'; export { default as Markup } from './Markup'; export { default as PreviewMarkup } from './PreviewMarkup'; diff --git a/packages/livechat/package.json b/packages/livechat/package.json index a62bef7984897..d62f63d8ad233 100644 --- a/packages/livechat/package.json +++ b/packages/livechat/package.json @@ -44,6 +44,7 @@ "mem": "^8.1.1", "path-to-regexp": "^6.3.0", "preact": "~10.25.4", + "preact-render-to-string": "^6.6.5", "preact-router": "^4.1.2", "query-string": "^7.1.3", "react-hook-form": "~7.45.4", diff --git a/packages/mongo-adapter/src/index.ts b/packages/mongo-adapter/src/index.ts index f458e62f2dd08..0b460dffb4052 100644 --- a/packages/mongo-adapter/src/index.ts +++ b/packages/mongo-adapter/src/index.ts @@ -1,4 +1,4 @@ -export { ArrayIndices } from './types'; +export type { ArrayIndices } from './types'; export { getBSONType } from './bson'; export { createPredicateFromFilter, createDocumentMatcherFromFilter } from './filter'; export { createComparatorFromSort } from './sort'; diff --git a/packages/ui-client/src/components/GenericMenu/index.ts b/packages/ui-client/src/components/GenericMenu/index.ts index a0753e281481d..f0a3ba6260b55 100644 --- a/packages/ui-client/src/components/GenericMenu/index.ts +++ b/packages/ui-client/src/components/GenericMenu/index.ts @@ -1,3 +1,3 @@ export { default as GenericMenu } from './GenericMenu'; -export { default as GenericMenuItem, GenericMenuItemProps } from './GenericMenuItem'; +export { default as GenericMenuItem, type GenericMenuItemProps } from './GenericMenuItem'; export { useHandleMenuAction } from './hooks/useHandleMenuAction'; diff --git a/packages/ui-client/src/components/index.ts b/packages/ui-client/src/components/index.ts index 943eabd144dda..cbc820d0d42f1 100644 --- a/packages/ui-client/src/components/index.ts +++ b/packages/ui-client/src/components/index.ts @@ -1,4 +1,4 @@ -export { default as AnchorPortal, AnchorPortalProps } from './AnchorPortal'; +export { default as AnchorPortal, type AnchorPortalProps } from './AnchorPortal'; export * from './EmojiPicker'; export * from './ExternalLink'; export * from './DotLeader'; diff --git a/packages/ui-contexts/src/hooks/useRoomRoute.ts b/packages/ui-contexts/src/hooks/useRoomRoute.ts new file mode 100644 index 0000000000000..ef4e19f812fbd --- /dev/null +++ b/packages/ui-contexts/src/hooks/useRoomRoute.ts @@ -0,0 +1,30 @@ +import type { IRoom, RoomType } from '@rocket.chat/core-typings'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; + +import { useRouter } from './useRouter'; + +type RoomRouteData = { + rid: IRoom['_id']; + t: RoomType; + name?: IRoom['name']; +}; + +/** + * Returns a function to navigate to a room using existing room data. + * Unlike `useGoToRoom`, this doesn't make an API call - use it when you already have the room data. + */ +export const useRoomRoute = ({ replace = false }: { replace?: boolean } = {}): ((room: RoomRouteData) => void) => { + const router = useRouter(); + + return useEffectEvent((room: RoomRouteData) => { + const { t, name, rid } = room; + const { path } = router.getRoomRoute(t, ['c', 'p'].includes(t) ? { name } : { rid }); + + router.navigate( + { + pathname: path, + }, + { replace }, + ); + }); +}; diff --git a/packages/ui-contexts/src/index.ts b/packages/ui-contexts/src/index.ts index bd209b70abb81..9199021b0e6de 100644 --- a/packages/ui-contexts/src/index.ts +++ b/packages/ui-contexts/src/index.ts @@ -1,23 +1,28 @@ -export { AttachmentContext, AttachmentContextValue } from './AttachmentContext'; -export { AuthenticationContextValue, AuthenticationContext, LoginService } from './AuthenticationContext'; -export { AuthorizationContext, AuthorizationContextValue } from './AuthorizationContext'; -export { AvatarUrlContext, AvatarUrlContextValue } from './AvatarUrlContext'; -export { CustomSoundContext, CustomSoundContextValue } from './CustomSoundContext'; -export { LayoutContext, LayoutContextValue } from './LayoutContext'; -export { ModalContext, ModalContextValue } from './ModalContext'; +export { AttachmentContext, type AttachmentContextValue } from './AttachmentContext'; +export { type AuthenticationContextValue, AuthenticationContext, type LoginService } from './AuthenticationContext'; +export { AuthorizationContext, type AuthorizationContextValue } from './AuthorizationContext'; +export { AvatarUrlContext, type AvatarUrlContextValue } from './AvatarUrlContext'; +export { CustomSoundContext, type CustomSoundContextValue } from './CustomSoundContext'; +export { LayoutContext, type LayoutContextValue } from './LayoutContext'; +export { ModalContext, type ModalContextValue } from './ModalContext'; export * from './RouterContext'; -export { RoomToolboxContext, RoomToolboxContextValue, RoomToolboxActionConfig, RenderToolboxItemParams } from './RoomToolboxContext'; -export { ServerContext, ServerContextValue } from './ServerContext'; -export { SessionContext, SessionContextValue } from './SessionContext'; -export { SettingsContext, SettingsContextValue, SettingsContextQuery } from './SettingsContext'; -export { ToastMessagesContext, ToastMessagesContextValue } from './ToastMessagesContext'; -export { TooltipContext, TooltipContextValue } from './TooltipContext'; -export { TranslationContext, TranslationContextValue } from './TranslationContext'; -export { UserContext, UserContextValue } from './UserContext'; +export { + RoomToolboxContext, + type RoomToolboxContextValue, + type RoomToolboxActionConfig, + type RenderToolboxItemParams, +} from './RoomToolboxContext'; +export { ServerContext, type ServerContextValue } from './ServerContext'; +export { SessionContext, type SessionContextValue } from './SessionContext'; +export { SettingsContext, type SettingsContextValue, type SettingsContextQuery } from './SettingsContext'; +export { ToastMessagesContext, type ToastMessagesContextValue } from './ToastMessagesContext'; +export { TooltipContext, type TooltipContextValue } from './TooltipContext'; +export { TranslationContext, type TranslationContextValue } from './TranslationContext'; +export { UserContext, type UserContextValue } from './UserContext'; export { UserCardContext, type UserCardContextValue } from './UserCardContext'; -export { UserPresenceContext, UserPresenceContextValue } from './UserPresenceContext'; -export { DeviceContext, Device, DeviceContextValue } from './DeviceContext'; -export { ActionManagerContext, IActionManager } from './ActionManagerContext'; +export { UserPresenceContext, type UserPresenceContextValue } from './UserPresenceContext'; +export { DeviceContext, type Device, type DeviceContextValue } from './DeviceContext'; +export { ActionManagerContext, type IActionManager } from './ActionManagerContext'; export { useAbsoluteUrl } from './hooks/useAbsoluteUrl'; export { useAllPermissions } from './hooks/useAllPermissions'; @@ -33,6 +38,7 @@ export { useCurrentRoutePath } from './hooks/useCurrentRoutePath'; export { useCustomSound } from './hooks/useCustomSound'; export { useEndpoint } from './hooks/useEndpoint'; export { useGoToRoom } from './hooks/useGoToRoom'; +export { useRoomRoute } from './hooks/useRoomRoute'; export type { EndpointFunction } from './hooks/useEndpoint'; export { useIsLoggingIn } from './hooks/useIsLoggingIn'; export { useIsPrivilegedSettingsContext } from './hooks/useIsPrivilegedSettingsContext'; @@ -105,8 +111,8 @@ export { useMediaDeviceMicrophonePermission, type requestDevice } from './hooks/ export { useWriteStream } from './hooks/useWriteStream'; export { useUserCard } from './hooks/useUserCard'; -export { UploadResult } from './ServerContext'; -export { TranslationKey, TranslationLanguage } from './TranslationContext'; -export { Fields, FindOptions } from './UserContext'; +export type { UploadResult } from './ServerContext'; +export type { TranslationKey, TranslationLanguage } from './TranslationContext'; +export type { Fields, FindOptions } from './UserContext'; -export { SubscriptionWithRoom } from './types/SubscriptionWithRoom'; +export type { SubscriptionWithRoom } from './types/SubscriptionWithRoom'; diff --git a/yarn.lock b/yarn.lock index 382991665667f..6dba58adb41a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -188,7 +188,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.24.4": +"@babel/core@npm:^7.24.4, @babel/core@npm:^7.29.0": version: 7.29.0 resolution: "@babel/core@npm:7.29.0" dependencies: @@ -2748,6 +2748,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.7.1": + version: 1.8.1 + resolution: "@emnapi/core@npm:1.8.1" + dependencies: + "@emnapi/wasi-threads": "npm:1.1.0" + tslib: "npm:^2.4.0" + checksum: 10/904ea60c91fc7d8aeb4a8f2c433b8cfb47c50618f2b6f37429fc5093c857c6381c60628a5cfbc3a7b0d75b0a288f21d4ed2d4533e82f92c043801ef255fd6a5c + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.2.0, @emnapi/runtime@npm:^1.4.3": version: 1.4.3 resolution: "@emnapi/runtime@npm:1.4.3" @@ -2757,6 +2767,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.7.1": + version: 1.8.1 + resolution: "@emnapi/runtime@npm:1.8.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/26725e202d4baefdc4a6ba770f703dfc80825a27c27a08c22bac1e1ce6f8f75c47b4fe9424d9b63239463c33ef20b650f08d710da18dfa1164a95e5acb865dba + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.0.2": version: 1.0.2 resolution: "@emnapi/wasi-threads@npm:1.0.2" @@ -2766,6 +2785,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/wasi-threads@npm:1.1.0": + version: 1.1.0 + resolution: "@emnapi/wasi-threads@npm:1.1.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/0d557e75262d2f4c95cb2a456ba0785ef61f919ce488c1d76e5e3acfd26e00c753ef928cd80068363e0c166ba8cc0141305daf0f81aad5afcd421f38f11e0f4e + languageName: node + linkType: hard + "@emotion/hash@npm:^0.9.0": version: 0.9.0 resolution: "@emotion/hash@npm:0.9.0" @@ -3854,7 +3882,7 @@ __metadata: languageName: node linkType: hard -"@istanbuljs/load-nyc-config@npm:^1.0.0": +"@istanbuljs/load-nyc-config@npm:^1.0.0, @istanbuljs/load-nyc-config@npm:^1.1.0": version: 1.1.0 resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" dependencies: @@ -4720,13 +4748,50 @@ __metadata: languageName: node linkType: hard +"@meteorjs/browserify-sign@npm:^4.2.3": + version: 4.2.6 + resolution: "@meteorjs/browserify-sign@npm:4.2.6" + dependencies: + bn.js: "npm:^5.2.1" + brorand: "npm:^1.1.0" + browserify-rsa: "npm:^4.1.0" + create-hash: "npm:^1.2.0" + create-hmac: "npm:^1.1.7" + hash-base: "npm:~3.0" + hash.js: "npm:^1.0.0" + hmac-drbg: "npm:^1.0.1" + inherits: "npm:^2.0.4" + minimalistic-assert: "npm:^1.0.1" + minimalistic-crypto-utils: "npm:^1.0.1" + parse-asn1: "npm:^5.1.7" + readable-stream: "npm:^2.3.8" + safe-buffer: "npm:^5.2.1" + checksum: 10/a4e5dc58d348f373a28ba3e55b27967780e8b674a180f6408db944de888e647f6d10c1b2f7a544f8fafcfcd50e0e990a4a5cc974f38346655c3a4f029001c640 + languageName: node + linkType: hard + +"@meteorjs/create-ecdh@npm:^4.0.4": + version: 4.0.5 + resolution: "@meteorjs/create-ecdh@npm:4.0.5" + dependencies: + bn.js: "npm:^4.11.9" + brorand: "npm:^1.1.0" + hash.js: "npm:^1.0.0" + hmac-drbg: "npm:^1.0.1" + inherits: "npm:^2.0.4" + minimalistic-assert: "npm:^1.0.1" + minimalistic-crypto-utils: "npm:^1.0.1" + checksum: 10/e2173d7594eb0be2dd5cdd8840a9c49f0c653948ab73a6b5370bbfc22519255cc44cbd7dcd093cf9900064bf275adba3feb3de710e1fe98428e6a2f228f8c2ec + languageName: node + linkType: hard + "@meteorjs/crypto-browserify@npm:^3.12.1": - version: 3.12.1 - resolution: "@meteorjs/crypto-browserify@npm:3.12.1" + version: 3.12.4 + resolution: "@meteorjs/crypto-browserify@npm:3.12.4" dependencies: + "@meteorjs/browserify-sign": "npm:^4.2.3" + "@meteorjs/create-ecdh": "npm:^4.0.4" browserify-cipher: "npm:^1.0.1" - browserify-sign: "npm:^4.2.3" - create-ecdh: "npm:^4.0.4" create-hash: "npm:^1.2.0" create-hmac: "npm:^1.1.7" diffie-hellman: "npm:^5.0.3" @@ -4736,7 +4801,7 @@ __metadata: public-encrypt: "npm:^4.0.3" randombytes: "npm:^2.1.0" randomfill: "npm:^1.0.4" - checksum: 10/6b15dab879d0717768280f852bb6358eee294695fffc75a5d44d2eb6c814d307803fd8c36e7b579a84d5291229e9c10a910c5941bd2f21604acd4e0ffd0a737c + checksum: 10/c6c033ee5efda6e2340dc8719eb17e838c256f0c52c7cfedb711f0e002c78d97f1466c3ececa7ba4cd042dda804987fa093113f81e65028a132728c7c5c75cad languageName: node linkType: hard @@ -4914,6 +4979,17 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.1": + version: 1.1.1 + resolution: "@napi-rs/wasm-runtime@npm:1.1.1" + dependencies: + "@emnapi/core": "npm:^1.7.1" + "@emnapi/runtime": "npm:^1.7.1" + "@tybys/wasm-util": "npm:^0.10.1" + checksum: 10/080e7f2aefb84e09884d21c650a2cbafdf25bfd2634693791b27e36eec0ddaa3c1656a943f8c913ac75879a0b04e68f8a827897ee655ab54a93169accf05b194 + languageName: node + linkType: hard + "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1": version: 5.1.1-v1 resolution: "@nicolo-ribaudo/eslint-scope-5-internals@npm:5.1.1-v1" @@ -5749,6 +5825,20 @@ __metadata: languageName: node linkType: hard +"@oxc-project/runtime@npm:0.115.0": + version: 0.115.0 + resolution: "@oxc-project/runtime@npm:0.115.0" + checksum: 10/602bfb56b4c60a4a4734c7eff5648d9f86734e1f6aeccc4d4da415f51f229d5a9cddeea65b1433d809fb7106dff56df2ec9b954f5ab82d755fac4a5d05e5ad43 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.115.0": + version: 0.115.0 + resolution: "@oxc-project/types@npm:0.115.0" + checksum: 10/14456080abfe29f720aa925b333b9db019d437c5a11eb128650b37092fd324e8884fce5fdf11242dc1a5b934e13d4ac8396885c76f8db9fe46e2a965a2286f5f + languageName: node + linkType: hard + "@paralleldrive/cuid2@npm:^2.2.2": version: 2.2.2 resolution: "@paralleldrive/cuid2@npm:2.2.2" @@ -8908,6 +8998,7 @@ __metadata: postcss-scss: "npm:^4.0.9" postcss-selector-not: "npm:^8.0.1" preact: "npm:~10.25.4" + preact-render-to-string: "npm:^6.6.5" preact-router: "npm:^4.1.2" query-string: "npm:^7.1.3" react: "npm:~18.3.1" @@ -9162,9 +9253,9 @@ __metadata: "@rocket.chat/web-ui-registration": "workspace:^" "@slack/bolt": "npm:^3.22.0" "@slack/rtm-api": "npm:~7.0.4" - "@storybook/addon-a11y": "npm:^8.6.17" - "@storybook/addon-essentials": "npm:^8.6.17" - "@storybook/addon-interactions": "npm:^8.6.17" + "@storybook/addon-a11y": "npm:^8.6.15" + "@storybook/addon-essentials": "npm:^8.6.15" + "@storybook/addon-interactions": "npm:^8.6.15" "@storybook/addon-styling-webpack": "npm:^1.0.1" "@storybook/addon-webpack5-compiler-swc": "npm:~3.0.0" "@storybook/react": "npm:^8.6.17" @@ -9243,6 +9334,7 @@ __metadata: "@types/underscore": "npm:^1.13.0" "@types/xml-crypto": "npm:~1.4.6" "@types/xml-encryption": "npm:~1.2.4" + "@vitejs/plugin-react": "npm:~5.1.4" "@xmldom/xmldom": "npm:~0.8.11" adm-zip: "npm:0.5.16" ajv: "npm:^8.17.1" @@ -9431,6 +9523,8 @@ __metadata: ua-parser-js: "npm:~1.0.41" underscore: "npm:^1.13.7" universal-perf-hooks: "npm:^1.0.1" + vite: "npm:^8.0.0-beta.16" + vite-plugin-istanbul: "npm:^7.2.1" webdav: "npm:^4.11.5" webpack: "npm:~5.99.9" xml-crypto: "npm:~3.2.1" @@ -10422,7 +10516,7 @@ __metadata: "@types/react": "npm:~18.3.27" "@types/react-beautiful-dnd": "npm:^13.1.8" "@types/react-dom": "npm:~18.3.7" - "@vitejs/plugin-react": "npm:~4.5.2" + "@vitejs/plugin-react": "npm:~5.1.4" codemirror: "npm:^6.0.2" eslint: "npm:~9.39.3" eslint4b-prebuilt: "npm:^6.7.2" @@ -10437,7 +10531,7 @@ __metadata: react-virtuoso: "npm:^4.12.0" reactflow: "npm:^11.11.4" typescript: "npm:~5.9.3" - vite: "npm:^6.2.4" + vite: "npm:^8.0.0-beta.16" languageName: unknown linkType: soft @@ -10493,143 +10587,110 @@ __metadata: languageName: unknown linkType: soft -"@rolldown/pluginutils@npm:1.0.0-beta.11": - version: 1.0.0-beta.11 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.11" - checksum: 10/6e0d9193e748cda0185d325973edf7516a2b94183345aa6ef59aeaf07ec6c0a044094257da56fe3d4b51646cc1dd824f5216e2a0aac15678206a1c06704e98e9 - languageName: node - linkType: hard - -"@rollup/rollup-android-arm-eabi@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.34.4" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@rollup/rollup-android-arm64@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-android-arm64@npm:4.34.4" +"@rolldown/binding-android-arm64@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.6" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-darwin-arm64@npm:4.34.4" +"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.6" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-darwin-x64@npm:4.34.4" +"@rolldown/binding-darwin-x64@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.6" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.34.4" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - -"@rollup/rollup-freebsd-x64@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-freebsd-x64@npm:4.34.4" +"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.6" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.34.4" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-arm-musleabihf@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.34.4" - conditions: os=linux & cpu=arm & libc=musl +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.6" + conditions: os=linux & cpu=arm languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.34.4" +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.6" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.34.4" +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.6" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.34.4" - conditions: os=linux & cpu=loong64 & libc=glibc - languageName: node - linkType: hard - -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.34.4" - conditions: os=linux & cpu=ppc64 & libc=glibc +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.6" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.34.4" - conditions: os=linux & cpu=riscv64 & libc=glibc +"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.6" + conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.34.4" - conditions: os=linux & cpu=s390x & libc=glibc +"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.6" + conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.34.4" - conditions: os=linux & cpu=x64 & libc=glibc +"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.6" + dependencies: + "@napi-rs/wasm-runtime": "npm:^1.1.1" + conditions: cpu=wasm32 languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.34.4" - conditions: os=linux & cpu=x64 & libc=musl +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.6" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.34.4" - conditions: os=win32 & cpu=arm64 +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.6" + conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.34.4" - conditions: os=win32 & cpu=ia32 +"@rolldown/pluginutils@npm:1.0.0-rc.3": + version: 1.0.0-rc.3 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.3" + checksum: 10/b181a693b70e0e5de736458d46b31f72862cd7f36f955656f61ccbf4de11d9206bc3b55404317a65e5714559490444e9fdd83b4097706496e96b082fb584d049 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.34.4": - version: 4.34.4 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.34.4" - conditions: os=win32 & cpu=x64 +"@rolldown/pluginutils@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "@rolldown/pluginutils@npm:1.0.0-rc.6" + checksum: 10/7a66a7c01b9542ba7312e6b26dc5f4516b5a427484cfa852eb8fad9010796faac6ebe053fc29503e09c3cd3a3cd60c86151d351a51463e878a946c544b21f29f languageName: node linkType: hard @@ -10978,7 +11039,7 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-a11y@npm:^8.6.17": +"@storybook/addon-a11y@npm:^8.6.15, @storybook/addon-a11y@npm:^8.6.17": version: 8.6.17 resolution: "@storybook/addon-a11y@npm:8.6.17" dependencies: @@ -11050,7 +11111,7 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-essentials@npm:^8.6.17": +"@storybook/addon-essentials@npm:^8.6.15, @storybook/addon-essentials@npm:^8.6.17": version: 8.6.17 resolution: "@storybook/addon-essentials@npm:8.6.17" dependencies: @@ -11081,7 +11142,7 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-interactions@npm:^8.6.17": +"@storybook/addon-interactions@npm:^8.6.15, @storybook/addon-interactions@npm:^8.6.17": version: 8.6.17 resolution: "@storybook/addon-interactions@npm:8.6.17" dependencies: @@ -12171,6 +12232,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.1": + version: 0.10.1 + resolution: "@tybys/wasm-util@npm:0.10.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/7fe0d239397aebb002ac4855d30c197c06a05ea8df8511350a3a5b1abeefe26167c60eda8a5508337571161e4c4b53d7c1342296123f9607af8705369de9fa7f + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.9.0": version: 0.9.0 resolution: "@tybys/wasm-util@npm:0.9.0" @@ -12227,6 +12297,15 @@ __metadata: languageName: node linkType: hard +"@types/babel__generator@npm:7.6.8": + version: 7.6.8 + resolution: "@types/babel__generator@npm:7.6.8" + dependencies: + "@babel/types": "npm:^7.0.0" + checksum: 10/b53c215e9074c69d212402990b0ca8fa57595d09e10d94bda3130aa22b55d796e50449199867879e4ea0ee968f3a2099e009cfb21a726a53324483abbf25cd30 + languageName: node + linkType: hard + "@types/babel__template@npm:*": version: 7.4.1 resolution: "@types/babel__template@npm:7.4.1" @@ -12782,7 +12861,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:*, @types/estree@npm:1.0.6, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6": +"@types/estree@npm:*, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" checksum: 10/9d35d475095199c23e05b431bcdd1f6fec7380612aed068b14b2a08aa70494de8a9026765a5a91b1073f636fb0368f6d8973f518a31391d519e20c59388ed88d @@ -14232,19 +14311,19 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:~4.5.2": - version: 4.5.2 - resolution: "@vitejs/plugin-react@npm:4.5.2" +"@vitejs/plugin-react@npm:~5.1.4": + version: 5.1.4 + resolution: "@vitejs/plugin-react@npm:5.1.4" dependencies: - "@babel/core": "npm:^7.27.4" + "@babel/core": "npm:^7.29.0" "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" - "@rolldown/pluginutils": "npm:1.0.0-beta.11" + "@rolldown/pluginutils": "npm:1.0.0-rc.3" "@types/babel__core": "npm:^7.20.5" - react-refresh: "npm:^0.17.0" + react-refresh: "npm:^0.18.0" peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - checksum: 10/a92fde5e50d73f7948609b19b04fe18f700b13ab0ba542a0d488fce5202f5e917655adbbbb99b73682b9fa27a00444c9302dd0f7b67d27001499d0f65f850211 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10/6f3f802600fadb980735ecfa3c7ce286d4736dd10bb3740b7799371dd8e7b2dc4d3831273b3be5181d199095d5407ac592bdb029dfe4e3b35eb79158cf60c3f2 languageName: node linkType: hard @@ -16394,7 +16473,7 @@ __metadata: languageName: node linkType: hard -"browserify-sign@npm:^4.0.0, browserify-sign@npm:^4.2.3": +"browserify-sign@npm:^4.0.0": version: 4.2.3 resolution: "browserify-sign@npm:4.2.3" dependencies: @@ -17937,7 +18016,7 @@ __metadata: languageName: node linkType: hard -"create-ecdh@npm:^4.0.0, create-ecdh@npm:^4.0.4": +"create-ecdh@npm:^4.0.0": version: 4.0.4 resolution: "create-ecdh@npm:4.0.4" dependencies: @@ -20140,7 +20219,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0, esbuild@npm:^0.25.0": +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0": version: 0.25.5 resolution: "esbuild@npm:0.25.5" dependencies: @@ -20810,7 +20889,7 @@ __metadata: languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.4.0": +"espree@npm:^10.0.1, espree@npm:^10.3.0, espree@npm:^10.4.0": version: 10.4.0 resolution: "espree@npm:10.4.0" dependencies: @@ -22258,6 +22337,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.4.1": + version: 10.5.0 + resolution: "glob@npm:10.5.0" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10/ab3bccfefcc0afaedbd1f480cd0c4a2c0e322eb3f0aa7ceaa31b3f00b825069f17cf0f1fc8b6f256795074b903f37c0ade37ddda6a176aa57f1c2bbfe7240653 + languageName: node + linkType: hard + "glob@npm:^11.0.0": version: 11.0.0 resolution: "glob@npm:11.0.0" @@ -22664,7 +22759,7 @@ __metadata: languageName: node linkType: hard -"hash-base@npm:~3.0, hash-base@npm:~3.0.4": +"hash-base@npm:~3.0": version: 3.0.4 resolution: "hash-base@npm:3.0.4" dependencies: @@ -22674,6 +22769,16 @@ __metadata: languageName: node linkType: hard +"hash-base@npm:~3.0.4": + version: 3.0.5 + resolution: "hash-base@npm:3.0.5" + dependencies: + inherits: "npm:^2.0.4" + safe-buffer: "npm:^5.2.1" + checksum: 10/6a82675a5de2ea9347501bbe655a2334950c7ec972fd9810ae9529e06aeab8f7e8ef68fc2112e5e6f0745561a7e05326efca42ad59bb5fd116537f5f8b0a216d + languageName: node + linkType: hard + "hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": version: 1.1.7 resolution: "hash.js@npm:1.1.7" @@ -24308,7 +24413,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": +"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2, istanbul-lib-instrument@npm:^6.0.3": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" dependencies: @@ -26222,6 +26327,126 @@ __metadata: languageName: node linkType: hard +"lightningcss-android-arm64@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-android-arm64@npm:1.31.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-darwin-arm64@npm:1.31.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-darwin-x64@npm:1.31.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-freebsd-x64@npm:1.31.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.31.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-linux-arm64-gnu@npm:1.31.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-linux-arm64-musl@npm:1.31.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-linux-x64-gnu@npm:1.31.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-linux-x64-musl@npm:1.31.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-win32-arm64-msvc@npm:1.31.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.31.1": + version: 1.31.1 + resolution: "lightningcss-win32-x64-msvc@npm:1.31.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.31.1": + version: 1.31.1 + resolution: "lightningcss@npm:1.31.1" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.31.1" + lightningcss-darwin-arm64: "npm:1.31.1" + lightningcss-darwin-x64: "npm:1.31.1" + lightningcss-freebsd-x64: "npm:1.31.1" + lightningcss-linux-arm-gnueabihf: "npm:1.31.1" + lightningcss-linux-arm64-gnu: "npm:1.31.1" + lightningcss-linux-arm64-musl: "npm:1.31.1" + lightningcss-linux-x64-gnu: "npm:1.31.1" + lightningcss-linux-x64-musl: "npm:1.31.1" + lightningcss-win32-arm64-msvc: "npm:1.31.1" + lightningcss-win32-x64-msvc: "npm:1.31.1" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10/3c2b2c2f648b12d9cba623d2e558f74fcce35911077e3d33f97ed521e0ad7a84e2c814628f6e16f64095c4483f6b180dee7b2e441b3ff5f44d142a510785a0c6 + languageName: node + linkType: hard + "lilconfig@npm:^2.0.6": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -30381,7 +30606,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.2.14, postcss@npm:^8.3.11, postcss@npm:^8.4.24, postcss@npm:^8.4.32, postcss@npm:^8.4.33, postcss@npm:^8.4.47, postcss@npm:^8.5.3": +"postcss@npm:^8.2.14, postcss@npm:^8.3.11, postcss@npm:^8.4.24, postcss@npm:^8.4.32, postcss@npm:^8.4.33, postcss@npm:^8.4.47": version: 8.5.3 resolution: "postcss@npm:8.5.3" dependencies: @@ -30421,6 +30646,15 @@ __metadata: languageName: node linkType: hard +"preact-render-to-string@npm:^6.6.5": + version: 6.6.5 + resolution: "preact-render-to-string@npm:6.6.5" + peerDependencies: + preact: ">=10 || >= 11.0.0-0" + checksum: 10/8d5bbde63857e3b4a0113c8e8d53b1f700cd14c6ee0e0a841aba30939930877e59ae1850bc74e1f369abdc21fd069af04f47fa7943f1b377ecd05366ab1353e6 + languageName: node + linkType: hard + "preact-router@npm:^4.1.2": version: 4.1.2 resolution: "preact-router@npm:4.1.2" @@ -31412,10 +31646,10 @@ __metadata: languageName: node linkType: hard -"react-refresh@npm:^0.17.0": - version: 0.17.0 - resolution: "react-refresh@npm:0.17.0" - checksum: 10/5e94f07d43bb1cfdc9b0c6e0c8c73e754005489950dcff1edb53aa8451d1d69a47b740b195c7c80fb4eb511c56a3585dc55eddd83f0097fb5e015116a1460467 +"react-refresh@npm:^0.18.0": + version: 0.18.0 + resolution: "react-refresh@npm:0.18.0" + checksum: 10/504c331c19776bf8320c23bad7f80b3a28de03301ed7523b0dd21d3f02bf2b53bbdd5aa52469b187bc90f358614b2ba303c088a0765c95f4f0a68c43a7d67b1d languageName: node linkType: hard @@ -32461,75 +32695,55 @@ __metadata: languageName: unknown linkType: soft -"rollup@npm:^4.30.1": - version: 4.34.4 - resolution: "rollup@npm:4.34.4" - dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.34.4" - "@rollup/rollup-android-arm64": "npm:4.34.4" - "@rollup/rollup-darwin-arm64": "npm:4.34.4" - "@rollup/rollup-darwin-x64": "npm:4.34.4" - "@rollup/rollup-freebsd-arm64": "npm:4.34.4" - "@rollup/rollup-freebsd-x64": "npm:4.34.4" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.34.4" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.34.4" - "@rollup/rollup-linux-arm64-gnu": "npm:4.34.4" - "@rollup/rollup-linux-arm64-musl": "npm:4.34.4" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.34.4" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.34.4" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.34.4" - "@rollup/rollup-linux-s390x-gnu": "npm:4.34.4" - "@rollup/rollup-linux-x64-gnu": "npm:4.34.4" - "@rollup/rollup-linux-x64-musl": "npm:4.34.4" - "@rollup/rollup-win32-arm64-msvc": "npm:4.34.4" - "@rollup/rollup-win32-ia32-msvc": "npm:4.34.4" - "@rollup/rollup-win32-x64-msvc": "npm:4.34.4" - "@types/estree": "npm:1.0.6" - fsevents: "npm:~2.3.2" +"rolldown@npm:1.0.0-rc.6": + version: 1.0.0-rc.6 + resolution: "rolldown@npm:1.0.0-rc.6" + dependencies: + "@oxc-project/types": "npm:=0.115.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-rc.6" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.6" + "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.6" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.6" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.6" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.6" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.6" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.6" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.6" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.6" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.6" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.6" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.6" + "@rolldown/pluginutils": "npm:1.0.0-rc.6" dependenciesMeta: - "@rollup/rollup-android-arm-eabi": - optional: true - "@rollup/rollup-android-arm64": - optional: true - "@rollup/rollup-darwin-arm64": - optional: true - "@rollup/rollup-darwin-x64": - optional: true - "@rollup/rollup-freebsd-arm64": - optional: true - "@rollup/rollup-freebsd-x64": - optional: true - "@rollup/rollup-linux-arm-gnueabihf": + "@rolldown/binding-android-arm64": optional: true - "@rollup/rollup-linux-arm-musleabihf": + "@rolldown/binding-darwin-arm64": optional: true - "@rollup/rollup-linux-arm64-gnu": + "@rolldown/binding-darwin-x64": optional: true - "@rollup/rollup-linux-arm64-musl": + "@rolldown/binding-freebsd-x64": optional: true - "@rollup/rollup-linux-loongarch64-gnu": + "@rolldown/binding-linux-arm-gnueabihf": optional: true - "@rollup/rollup-linux-powerpc64le-gnu": + "@rolldown/binding-linux-arm64-gnu": optional: true - "@rollup/rollup-linux-riscv64-gnu": + "@rolldown/binding-linux-arm64-musl": optional: true - "@rollup/rollup-linux-s390x-gnu": + "@rolldown/binding-linux-x64-gnu": optional: true - "@rollup/rollup-linux-x64-gnu": + "@rolldown/binding-linux-x64-musl": optional: true - "@rollup/rollup-linux-x64-musl": + "@rolldown/binding-openharmony-arm64": optional: true - "@rollup/rollup-win32-arm64-msvc": + "@rolldown/binding-wasm32-wasi": optional: true - "@rollup/rollup-win32-ia32-msvc": + "@rolldown/binding-win32-arm64-msvc": optional: true - "@rollup/rollup-win32-x64-msvc": - optional: true - fsevents: + "@rolldown/binding-win32-x64-msvc": optional: true bin: - rollup: dist/bin/rollup - checksum: 10/909584375565e113ddeaee4565779901ff4bd1d115f4dcca649519b70b5b80171a0e2795c257663c237158975fe62deb8186aa6a05ce944de941ffb30bbbcfae + rolldown: bin/cli.mjs + checksum: 10/e0f5f93374bf10575a4564552dca7c0c5942646b48f6c7de0daf1fc2cf7e36a7256d20273d27a259a9a88e777f34c177670e0ecbdf3dfecc98605f53e5d82061 languageName: node linkType: hard @@ -34869,6 +35083,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.2 + resolution: "test-exclude@npm:7.0.2" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^10.2.2" + checksum: 10/45920af7556a58333c75c3d5011b4087f7ac41366319fb0f6ccc4ac8d0e9d2c81442d7ebf6efd13813dcf18c135eda3c52e4ad3e00407a3ee041d8dec6bfd753 + languageName: node + linkType: hard + "text-decoder@npm:^1.1.0": version: 1.2.0 resolution: "text-decoder@npm:1.2.0" @@ -35054,13 +35279,13 @@ __metadata: linkType: hard "to-buffer@npm:^1.2.0": - version: 1.2.1 - resolution: "to-buffer@npm:1.2.1" + version: 1.2.2 + resolution: "to-buffer@npm:1.2.2" dependencies: isarray: "npm:^2.0.5" safe-buffer: "npm:^5.2.1" typed-array-buffer: "npm:^1.0.3" - checksum: 10/f8d03f070b8567d9c949f1b59c8d47c83ed2e59b50b5449258f931df9a1fcb751aa8bb8756a9345adc529b6b1822521157c48e1a7d01779a47185060d7bf96d4 + checksum: 10/69d806c20524ff1e4c44d49276bc96ff282dcae484780a3974e275dabeb75651ea430b074a2a4023701e63b3e1d87811cd82c0972f35280fe5461710e4872aba languageName: node linkType: hard @@ -36537,23 +36762,45 @@ __metadata: languageName: node linkType: hard -"vite@npm:^6.2.4": - version: 6.2.4 - resolution: "vite@npm:6.2.4" +"vite-plugin-istanbul@npm:^7.2.1": + version: 7.2.1 + resolution: "vite-plugin-istanbul@npm:7.2.1" dependencies: - esbuild: "npm:^0.25.0" + "@babel/generator": "npm:^7.28.0" + "@istanbuljs/load-nyc-config": "npm:^1.1.0" + "@types/babel__generator": "npm:7.6.8" + espree: "npm:^10.3.0" + istanbul-lib-instrument: "npm:^6.0.3" + picocolors: "npm:^1.1.1" + source-map: "npm:^0.7.4" + test-exclude: "npm:^7.0.1" + peerDependencies: + vite: ">=4 <=7" + checksum: 10/1e2f34f42f61436c8e3560c815e830c024d5d4d2ea3ce386cdb90e6a40948d928d6a1571bafff1e0fdcc21d6290e2ef48c7e7b0eb936381c33ce98c4d59b0054 + languageName: node + linkType: hard + +"vite@npm:^8.0.0-beta.16": + version: 8.0.0-beta.16 + resolution: "vite@npm:8.0.0-beta.16" + dependencies: + "@oxc-project/runtime": "npm:0.115.0" fsevents: "npm:~2.3.3" - postcss: "npm:^8.5.3" - rollup: "npm:^4.30.1" + lightningcss: "npm:^1.31.1" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rolldown: "npm:1.0.0-rc.6" + tinyglobby: "npm:^0.2.15" peerDependencies: - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.0.0-alpha.31 + esbuild: ^0.27.0 jiti: ">=1.21.0" - less: "*" - lightningcss: ^1.21.0 - sass: "*" - sass-embedded: "*" - stylus: "*" - sugarss: "*" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -36563,12 +36810,14 @@ __metadata: peerDependenciesMeta: "@types/node": optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true jiti: optional: true less: optional: true - lightningcss: - optional: true sass: optional: true sass-embedded: @@ -36585,7 +36834,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10/3734c8695b4d35a5b3ea617159594835e370b428745f37e90d9c1daf82b53af5248578c1f1d9977fc1460320c0cdd4aef135095d378b2eba2736c03e2cfa019e + checksum: 10/f2a5acd068c51cbe45222445c27bc75a11dfb98c6f4a2deda612bc524448e884de1f197fe472e1500182ea515e667e5bd085dc4274ef77c234cff6874776f5c8 languageName: node linkType: hard