diff --git a/.graphqlrc b/.graphqlrc new file mode 100644 index 000000000..3a4356aa2 --- /dev/null +++ b/.graphqlrc @@ -0,0 +1,4 @@ +schema: 'http://localhost:3000/graphql' +require: + - ts-node/register + - tsconfig-paths/register diff --git a/lint-staged.config.js b/lint-staged.config.js index dfbf747a3..31cc62749 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -7,7 +7,9 @@ module.exports = { // Filter out files that start with a period, since they're ignored by default `**/.*.{${extList}}`, // Filter out generated folder files - `**/generated/**/*` + `**/generated/**/*`, + // Filter out types in object loader + '**/packages/objectloader/types/**/*' ]) return 'eslint --cache --max-warnings=0 ' + finalFiles.join(' ') diff --git a/package.json b/package.json index 2bfbe46d4..259249825 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,9 @@ "eslint-config-prettier": "^8.5.0", "husky": "^7.0.4", "lint-staged": "^12.3.7", - "prettier": "^2.5.1" + "prettier": "^2.5.1", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.0.0" }, "resolutions": { "tslib": "^2.3.1", diff --git a/packages/frontend/.graphqlconfig b/packages/frontend/.graphqlconfig deleted file mode 100644 index 888ca942d..000000000 --- a/packages/frontend/.graphqlconfig +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Speckle Schema", - "schemaPath": "schema.graphql", - "extensions": { - "endpoints": { - "Default GraphQL Endpoint": { - "url": "http://localhost:3000/graphql", - "headers": { - "user-agent": "JS GraphQL" - }, - "introspect": false - } - } - } -} diff --git a/packages/frontend/src/main/components/viewer/ObjectProperties.vue b/packages/frontend/src/main/components/viewer/ObjectProperties.vue index bd9f5a984..aab181a5b 100644 --- a/packages/frontend/src/main/components/viewer/ObjectProperties.vue +++ b/packages/frontend/src/main/components/viewer/ObjectProperties.vue @@ -53,7 +53,7 @@ export default { currItems: 20, loading: false, ignoredProps: [ - // '__closure', + '__closure', // 'displayMesh', // 'displayValue', '__importedUrl', diff --git a/packages/frontend/src/utils/localStorage.ts b/packages/frontend/src/utils/localStorage.ts index 691f95125..c344f2482 100644 --- a/packages/frontend/src/utils/localStorage.ts +++ b/packages/frontend/src/utils/localStorage.ts @@ -1,76 +1,2 @@ -import { Nullable } from '@/helpers/typeHelpers' - -function checkLocalStorageAvailability(): boolean { - try { - const testKey = '___localStorageAvailabilityTest' - const storage = window.localStorage - storage.setItem(testKey, testKey) - storage.getItem(testKey) - storage.removeItem(testKey) - return true - } catch (e) { - return false - } -} - -/** - * In memory implementation of the Storage interface, for use when LocalStorage - * isn't available - */ -class FakeStorage implements Storage { - #internalStorage = new Map() - - clear(): void { - this.#internalStorage.clear() - } - - getItem(key: string): string | null { - return this.#internalStorage.get(key) || null - } - - key(index: number): string | null { - return [...this.#internalStorage.keys()][index] || null - } - - removeItem(key: string): void { - this.#internalStorage.delete(key) - } - - setItem(key: string, value: string): void { - this.#internalStorage.set(key, value) - } - - get length(): number { - return this.#internalStorage.size - } -} - -/** - * Whether or not the local storage is available in this session - */ -const isLocalStorageAvailable = checkLocalStorageAvailability() - -/** - * Localstorage (real or faked) to use in this session - */ -const internalStorage: Storage = isLocalStorageAvailable - ? window.localStorage - : new FakeStorage() - -/** - * Utility for nicer reads/writes from/to LocalStorage without having to worry about the browser - * throwing a hissy fit because the page is opened in Incognito mode or whatever - */ -export const AppLocalStorage = { - get(key: string): Nullable { - return internalStorage.getItem(key) - }, - - set(key: string, value: string): void { - internalStorage.setItem(key, value) - }, - - remove(key: string): void { - internalStorage.removeItem(key) - } -} +import { SafeLocalStorage } from '@speckle/shared' +export const AppLocalStorage = SafeLocalStorage diff --git a/packages/objectloader/.eslintrc.cjs b/packages/objectloader/.eslintrc.cjs index d8b138d77..06188f7eb 100644 --- a/packages/objectloader/.eslintrc.cjs +++ b/packages/objectloader/.eslintrc.cjs @@ -6,12 +6,14 @@ /** @type {import("eslint").Linter.Config} */ const config = { env: { - browser: true + browser: true, + node: true }, parserOptions: { - sourceType: 'module' + sourceType: 'module', + ecmaVersion: 2022 }, - ignorePatterns: ['examples/browser/objectloader.web.js'] + ignorePatterns: ['examples/browser/objectloader.web.js', 'types/**/*'] } module.exports = config diff --git a/packages/objectloader/examples/node/script.mjs b/packages/objectloader/examples/node/script.mjs index 2bdce8375..4fb2f0e5e 100644 --- a/packages/objectloader/examples/node/script.mjs +++ b/packages/objectloader/examples/node/script.mjs @@ -1,5 +1,7 @@ -// Since Node v<18 does not provide fetch, we need to pass it in the options object. Note that fetch must return a WHATWG compliant stream, so cross-fetch won't work, but node/undici's implementation will. -import { fetch } from 'undici' +/** + * Since Node v<18 does not provide fetch, we need to pass it in the options object. Popular fetch implementations like cross-fetch or node-fetch should work + */ +import fetch from 'cross-fetch' import ObjectLoader from '../../dist/objectloader.js' const loader = new ObjectLoader({ diff --git a/packages/objectloader/package.json b/packages/objectloader/package.json index 5d0e22e96..ea626c70d 100644 --- a/packages/objectloader/package.json +++ b/packages/objectloader/package.json @@ -4,6 +4,7 @@ "description": "Simple API helper to stream in objects from the Speckle Server.", "main": "dist/objectloader.js", "module": "dist/objectloader.esm.js", + "types": "types/index.d.ts", "homepage": "https://speckle.systems", "repository": { "type": "git", @@ -30,6 +31,7 @@ "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.17.9", + "@speckle/shared": "workspace:^", "core-js": "^3.21.1", "regenerator-runtime": "^0.13.7" }, @@ -40,14 +42,14 @@ "@rollup/plugin-babel": "^5.3.1", "@rollup/plugin-commonjs": "^21.0.3", "@rollup/plugin-node-resolve": "^13.1.3", + "cross-fetch": "^3.1.5", "eslint": "^8.11.0", "eslint-config-prettier": "^8.5.0", "http-server": "^14.1.0", "prettier": "^2.5.1", "rollup": "^2.70.1", "rollup-plugin-delete": "^2.0.0", - "rollup-plugin-terser": "^7.0.2", - "undici": "^4.14.1" + "rollup-plugin-terser": "^7.0.2" }, "gitHead": "5627e490f9a3ecadf19cc4686ad15f344d9ad2d3" } diff --git a/packages/objectloader/readme.md b/packages/objectloader/readme.md index 16f92ddfe..df5dfc31a 100644 --- a/packages/objectloader/readme.md +++ b/packages/objectloader/readme.md @@ -64,11 +64,11 @@ let obj = await loader.getAndConstructObject((e) => console.log('Progress', e)) ### On the server -Since Node.js does not yet support the [`fetch API`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch), you'll need to provide your own `fetch` function in the options object. Note that `fetch` must return a [Web Stream](https://nodejs.org/api/webstreams.html), so [node-fetch](https://github.com/node-fetch/node-fetch) won't work, but [node/undici's](https://undici.nodejs.org/) implementation will. +Since Node.js does not yet support the [`fetch API`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch), you'll need to provide your own `fetch` function in the options object, e.g., from `node-fetch` or `cross-fetch` ```js import ObjectLoader from '@speckle/objectloader' -import { fetch } from 'undici' +import fetch from 'cross-fetch' let loader = new ObjectLoader({ serverUrl: 'https://latest.speckle.dev', @@ -83,6 +83,12 @@ let loader = new ObjectLoader({ Run `yarn build` to build prod release, run `yarn build:dev` to build dev release. Or run `yarn dev` to run the build in `watch` mode. +### TS types + +The library isn't written in TypeScript so there's no typing information to be generated out of the box, but since we do want this library to be usable in TypeScript projects we write the types ourselves (for now). + +So whenever you make any changes to the API, make sure the types file in `types/index.d.ts` is updated + ## Community If in trouble, the Speckle Community hangs out on [the forum](https://speckle.community). Do join and introduce yourself! We're happy to help. diff --git a/packages/objectloader/src/errors/index.js b/packages/objectloader/src/errors/index.js new file mode 100644 index 000000000..aa0b9d717 --- /dev/null +++ b/packages/objectloader/src/errors/index.js @@ -0,0 +1,26 @@ +/** + * Base ObjectLoader error + */ +class BaseError extends Error { + /** + * Default message if none is passed + */ + static defaultMessage = 'Unexpected error occurred' + + /** + * @param {string} [message] + * @param {ErrorOptions} options + */ + constructor(message, options) { + message ||= new.target.defaultMessage + super(message, options) + } +} + +export class ObjectLoaderConfigurationError extends BaseError { + static defaultMessage = 'Object loader configured incorrectly!' +} + +export class ObjectLoaderRuntimeError extends BaseError { + static defaultMessage = 'Object loader encountered a runtime problem!' +} diff --git a/packages/objectloader/src/helpers/stream.js b/packages/objectloader/src/helpers/stream.js new file mode 100644 index 000000000..f306b6da5 --- /dev/null +++ b/packages/objectloader/src/helpers/stream.js @@ -0,0 +1,18 @@ +/** + * This adjusts a browser ReadableStream to make it work similarly to Node streams, which further enables us to use the + * same code to read both kinds of streams. We don't mutate the ReadableStream prototype cause this specific polyfill + * might not work well in other circumstances (https://github.com/node-fetch/node-fetch/issues/387#issuecomment-417433509) + * + * See more: https://github.com/node-fetch/node-fetch/issues/754#issuecomment-602184022 + * @param {ReadableStream} stream + */ +export function polyfillReadableStreamForAsyncIterator(stream) { + stream.iterator = async function* () { + const reader = this.getReader() + while (1) { + const chunk = await reader.read() + if (chunk.done) return chunk.value + yield chunk.value + } + } +} diff --git a/packages/objectloader/src/index.js b/packages/objectloader/src/index.js index 0522c54c5..ef32ad269 100644 --- a/packages/objectloader/src/index.js +++ b/packages/objectloader/src/index.js @@ -2,6 +2,13 @@ import 'core-js' import 'regenerator-runtime/runtime' +import { SafeLocalStorage } from '@speckle/shared' +import { + ObjectLoaderConfigurationError, + ObjectLoaderRuntimeError +} from './errors/index.js' +import { polyfillReadableStreamForAsyncIterator } from './helpers/stream.js' + /** * Simple client that streams object info from a Speckle Server. * TODO: Object construction progress reporting is weird. @@ -21,18 +28,34 @@ export default class ObjectLoader { enableCaching: true, fullyTraverseArrays: false, excludeProps: [], - fetch: null + fetch: null, + customLogger: undefined, + customWarner: undefined } }) { + this.logger = options.customLogger || console.log + this.warner = options.customWarner || console.warn + this.INTERVAL_MS = 20 this.TIMEOUT_MS = 180000 // three mins - this.serverUrl = serverUrl || window.location.origin + this.serverUrl = serverUrl || globalThis?.location?.origin + if (!this.serverUrl) { + throw new ObjectLoaderConfigurationError('Invalid serverUrl specified!') + } + this.streamId = streamId this.objectId = objectId - console.log('Object loader constructor called!') + if (!this.streamId) { + throw new ObjectLoaderConfigurationError('Invalid streamId specified!') + } + if (!this.objectId) { + throw new ObjectLoaderConfigurationError('Invalid objectId specified!') + } + + this.logger('Object loader constructor called!') try { - this.token = token || localStorage.getItem('AuthToken') + this.token = token || SafeLocalStorage.get('AuthToken') } catch (error) { // Accessing localStorage may throw when executing on sandboxed document, ignore. } @@ -56,6 +79,7 @@ export default class ObjectLoader { this.options = options this.options.numConnections = this.options.numConnections || 4 + /** @type {IDBDatabase | null} */ this.cacheDB = null this.lastAsyncPause = Date.now() @@ -64,8 +88,16 @@ export default class ObjectLoader { // we can't simply bind fetch to this.fetch, so instead we have to do some acrobatics: // https://stackoverflow.com/questions/69337187/uncaught-in-promise-typeerror-failed-to-execute-fetch-on-workerglobalscope#comment124731316_69337187 this.preferredFetch = options.fetch + + /** @type {globalThis.fetch} */ this.fetch = function (...args) { const currentFetch = this.preferredFetch || fetch + if (!currentFetch) { + throw new ObjectLoaderRuntimeError( + "Couldn't find fetch implementation! If running in a node environment, make sure you pass it in through the constructor!" + ) + } + return currentFetch(...args) } } @@ -81,7 +113,7 @@ export default class ObjectLoader { await this.existingAsyncPause this.existingAsyncPause = null if (Date.now() - this.lastAsyncPause > 500) - console.log('Loader Event loop lag: ', Date.now() - this.lastAsyncPause) + this.logger('Loader Event loop lag: ', Date.now() - this.lastAsyncPause) } } @@ -237,7 +269,7 @@ export default class ObjectLoader { } if (this.intervals[id].elapsed > this.TIMEOUT_MS) { - console.warn(`Timeout resolving ${id}. HIC SVNT DRACONES.`) + this.warner(`Timeout resolving ${id}. HIC SVNT DRACONES.`) clearInterval(this.intervals[id].interval) this.promises.filter((p) => p.id === id).forEach((p) => p.reject()) this.promises = this.promises.filter((p) => p.id !== p.id) // clear out @@ -253,7 +285,7 @@ export default class ObjectLoader { count += 1 yield obj } - console.log(`Loaded ${count} objects in: ${(Date.now() - t0) / 1000}`) + this.logger(`Loaded ${count} objects in: ${(Date.now() - t0) / 1000}`) } processLine(chunk) { @@ -261,17 +293,26 @@ export default class ObjectLoader { return { id: pieces[0], obj: JSON.parse(pieces[1]) } } + supportsCache() { + return !!(this.options.enableCaching && globalThis.indexedDB) + } + + async setupCacheDb() { + if (!this.supportsCache() || this.cacheDB !== null) return + + // Initialize + await safariFix() + const idbOpenRequest = indexedDB.open('speckle-object-cache', 1) + idbOpenRequest.onupgradeneeded = () => + idbOpenRequest.result.createObjectStore('objects') + this.cacheDB = await this.promisifyIdbRequest(idbOpenRequest) + } + async *getRawObjectIterator() { - if (this.options.enableCaching && window.indexedDB && this.cacheDB === null) { - await safariFix() - const idbOpenRequest = indexedDB.open('speckle-object-cache', 1) - idbOpenRequest.onupgradeneeded = () => - idbOpenRequest.result.createObjectStore('objects') - this.cacheDB = await this.promisifyIdbRequest(idbOpenRequest) - } + await this.setupCacheDb() const rootObjJson = await this.getRawRootObject() - // console.log("Root in: ", Date.now() - tSTART) + // this.logger("Root in: ", Date.now() - tSTART) yield `${this.objectId}\t${rootObjJson}` @@ -304,7 +345,7 @@ export default class ObjectLoader { splitBeforeCacheCheck[3].push(childrenIds[crtChildIndex]) } - console.log('Cache check for: ', splitBeforeCacheCheck) + this.logger('Cache check for: ', splitBeforeCacheCheck) const newChildren = [] let nextCachePromise = this.cacheGetObjects(splitBeforeCacheCheck[0]) @@ -386,9 +427,15 @@ export default class ObjectLoader { headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ objects: JSON.stringify(splitHttpRequests[i]) }) }).then((crtResponse) => { - const crtReader = crtResponse.body.getReader() + // Polyfill web streams so that we can work with them the same way we do in Node + if (crtResponse.body.getReader) { + polyfillReadableStreamForAsyncIterator(crtResponse.body) + } + + // Get stream async iterator + const crtReader = crtResponse.body.iterator() readers[i] = crtReader - const crtReadPromise = crtReader.read().then((x) => { + const crtReadPromise = crtReader.next().then((x) => { x.reqId = i return x }) @@ -418,7 +465,7 @@ export default class ObjectLoader { // Replace read promise on this request with a new `read` call if (!readerDone) { - const crtReadPromise = readers[reqId].read().then((x) => { + const crtReadPromise = readers[reqId].next().then((x) => { x.reqId = reqId return x }) @@ -465,7 +512,7 @@ export default class ObjectLoader { } async cacheGetObjects(ids) { - if (!this.options.enableCaching || !window.indexedDB) { + if (!this.supportsCache()) { return {} } @@ -482,7 +529,7 @@ export default class ObjectLoader { ) const cachedData = await Promise.all(idbChildrenPromises) - // console.log("Cache check for : ", idsChunk.length, Date.now() - t0) + // this.logger("Cache check for : ", idsChunk.length, Date.now() - t0) for (const cachedObj of cachedData) { if (!cachedObj.data) @@ -496,7 +543,7 @@ export default class ObjectLoader { } cacheStoreObjects(objects) { - if (!this.options.enableCaching || !window.indexedDB) { + if (!this.supportsCache()) { return {} } @@ -512,7 +559,10 @@ export default class ObjectLoader { } } -// Credits and more info: https://github.com/jakearchibald/safari-14-idb-fix +/** + * Fixes a Safari bug where IndexedDB requests get lost and never resolve - invoke before you use IndexedDB + * @link Credits and more info: https://github.com/jakearchibald/safari-14-idb-fix + */ function safariFix() { const isSafari = !navigator.userAgentData && diff --git a/packages/objectloader/types/index.d.ts b/packages/objectloader/types/index.d.ts new file mode 100644 index 000000000..1fb31ed6f --- /dev/null +++ b/packages/objectloader/types/index.d.ts @@ -0,0 +1,49 @@ +/** + * This is written manually & should be kept up to date when the API changes + */ + +export interface SpeckleObject extends Record { + totalChildrenCount?: number +} + +type Logger = (...args: unknown[]) => void + +export type ProgressStage = 'download' | 'construction' + +/** + * ObjectLoader class + */ +export default class ObjectLoader { + constructor(params: { + serverUrl: string + streamId: string + objectId: string + token?: string + options?: Partial<{ + /** + * Whether IndexedDB caching is enabled (disabled by default in node envs where IndexedDB is not available) + */ + enableCaching: boolean + fullyTraverseArrays: boolean + excludeProps: Array + /** + * Override fetch implementation (necessary in node environment) + */ + fetch: GlobalFetch['fetch'] + /** + * Optionally provide alternative for console.log + */ + customLogger: Logger + /** + * Optionally provide alternative for console.warn + */ + customWarner: Logger + }> + }) + + async getAndConstructObject( + onProgress: (e: { stage: ProgressStage; current: number; total: number }) => void + ): SpeckleObject | SpeckleObject[] + + async *getObjectIterator(): Generator +} diff --git a/packages/preview-service/Dockerfile b/packages/preview-service/Dockerfile index f3a86be15..23d676df4 100644 --- a/packages/preview-service/Dockerfile +++ b/packages/preview-service/Dockerfile @@ -17,10 +17,12 @@ COPY package.json yarn.lock ./ COPY packages/preview-service/package.json ./packages/preview-service/ COPY packages/viewer/package.json ./packages/viewer/ COPY packages/objectloader/package.json ./packages/objectloader/ +COPY packages/shared/package.json ./packages/shared/ RUN yarn workspaces focus -A && yarn # Onyl copy in the relevant source files for the dependencies +COPY packages/shared ./packages/shared/ COPY packages/objectloader ./packages/objectloader/ COPY packages/viewer ./packages/viewer/ COPY packages/preview-service ./packages/preview-service/ @@ -82,6 +84,7 @@ COPY packages/preview-service/package.json ./packages/preview-service/ WORKDIR /speckle-server/packages +COPY --from=build-stage /speckle-server/packages/shared ./shared COPY --from=build-stage /speckle-server/packages/objectloader ./objectloader COPY --from=build-stage /speckle-server/packages/viewer ./viewer COPY --from=build-stage /speckle-server/packages/preview-service ./preview-service diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index d1e6ba0f5..09d4d8377 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -19,11 +19,13 @@ COPY package.json yarn.lock ./ COPY packages/server/package.json ./packages/server/ COPY packages/shared/package.json ./packages/shared/ +COPY packages/objectloader/package.json ./packages/objectloader/ RUN yarn workspaces focus -A COPY packages/server ./packages/server/ COPY packages/shared ./packages/shared/ +COPY packages/objectloader ./packages/objectloader/ RUN yarn workspaces foreach run build @@ -45,6 +47,7 @@ COPY --from=build-stage /speckle-server/.yarn ./.yarn COPY --from=build-stage /speckle-server/package.json /speckle-server/yarn.lock ./ COPY --from=build-stage /speckle-server/node_modules ./node_modules COPY --from=build-stage /speckle-server/packages/shared /speckle-server/packages/shared +COPY --from=build-stage /speckle-server/packages/objectloader /speckle-server/packages/objectloader WORKDIR /speckle-server/packages/server diff --git a/packages/server/modules/activitystream/services/commitActivity.ts b/packages/server/modules/activitystream/services/commitActivity.ts new file mode 100644 index 000000000..f0c738beb --- /dev/null +++ b/packages/server/modules/activitystream/services/commitActivity.ts @@ -0,0 +1,32 @@ +import { saveActivity } from '@/modules/activitystream/services' +import { ActionTypes } from '@/modules/activitystream/helpers/types' +import { CommitPubsubEvents, pubsub } from '@/modules/shared' +import { CommitCreateInput } from '@/modules/core/graph/generated/graphql' + +/** + * Save "new commit created" activity item + */ +export async function addCommitCreatedActivity(params: { + commitId: string + streamId: string + userId: string + commit: CommitCreateInput + branchName: string +}) { + const { commitId, commit, streamId, userId, branchName } = params + await Promise.all([ + saveActivity({ + streamId, + resourceType: 'commit', + resourceId: commitId, + actionType: ActionTypes.Commit.Create, + userId, + info: { id: commitId, commit }, + message: `Commit created on branch ${branchName}: ${commitId} (${commit.message})` + }), + pubsub.publish(CommitPubsubEvents.CommitCreated, { + commitCreated: { ...commit, id: commitId, authorId: userId }, + streamId + }) + ]) +} diff --git a/packages/server/modules/cli/commands/download.ts b/packages/server/modules/cli/commands/download.ts new file mode 100644 index 000000000..88c468286 --- /dev/null +++ b/packages/server/modules/cli/commands/download.ts @@ -0,0 +1,13 @@ +import { noop } from 'lodash' +import { CommandModule } from 'yargs' + +const command: CommandModule = { + command: 'download', + describe: 'Download data from other Speckle server instances (e.g. latest or xyz)', + builder(yargs) { + return yargs.commandDir('download', { extensions: ['js', 'ts'] }).demandCommand() + }, + handler: noop +} + +export = command diff --git a/packages/server/modules/cli/commands/download/commit.ts b/packages/server/modules/cli/commands/download/commit.ts new file mode 100644 index 000000000..79e1b3ee7 --- /dev/null +++ b/packages/server/modules/cli/commands/download/commit.ts @@ -0,0 +1,291 @@ +import fetch from 'cross-fetch' +import { + ApolloClient, + InMemoryCache, + NormalizedCacheObject, + gql, + HttpLink, + ApolloQueryResult +} from '@apollo/client/core' +import { cliDebug } from '@/modules/shared/utils/logger' +import { CommandModule } from 'yargs' +import { getBaseUrl, getServerVersion } from '@/modules/shared/helpers/envHelper' +import { Commit } from '@/test/graphql/generated/graphql' +import { getStreamBranchByName } from '@/modules/core/repositories/branches' +import { getStream, getStreamCollaborators } from '@/modules/core/repositories/streams' +import { createCommitByBranchId } from '@/modules/core/services/commits' +import { Roles } from '@speckle/shared' +import { addCommitCreatedActivity } from '@/modules/activitystream/services/commitActivity' +import { createObject } from '@/modules/core/services/objects' +import { getObject } from '@/modules/core/repositories/objects' +import ObjectLoader from '@speckle/objectloader' +import { noop } from 'lodash' + +type LocalResources = Awaited> +type ParsedCommitUrl = ReturnType +type GraphQLClient = ApolloClient +type ObjectLoaderObject = Record & { + id: string + speckle_type: string + totalChildrenCount: number +} + +const COMMIT_URL_RGX = /((https?:\/\/)?[\w.]+)\/streams\/([\w]+)\/commits\/([\w]+)/i + +const testQuery = gql` + query CommitDownloadTest { + _ + } +` + +const commitMetadataQuery = gql` + query CommitDownloadMetadata($streamId: String!, $commitId: String!) { + stream(id: $streamId) { + commit(id: $commitId) { + id + referencedObject + authorId + message + createdAt + sourceApplication + totalChildrenCount + parents + } + } + } +` + +const assertValidGraphQLResult = ( + res: ApolloQueryResult, + operationName: string +) => { + if (res.errors?.length) { + throw new Error( + `GQL operation '${operationName}' failed because of errors: ` + + JSON.stringify(res.errors) + ) + } +} + +const parseCommitUrl = (url: string) => { + const [, origin, , streamId, commitId] = COMMIT_URL_RGX.exec(url) || [] + if (!origin || !streamId || !commitId) { + throw new Error("Couldn't parse commit URL! Does it follow the expected format?") + } + + return { origin, streamId, commitId } +} + +const getLocalResources = async (targetStreamId: string, branchName: string) => { + const targetStream = await getStream({ streamId: targetStreamId }) + if (!targetStream) { + throw new Error(`Couldn't find local stream with id ${targetStreamId}`) + } + + const targetBranch = await getStreamBranchByName(targetStreamId, branchName) + if (!targetBranch) { + throw new Error( + `Couldn't find local branch ${branchName} in stream ${targetStreamId}` + ) + } + + const streamOwners = await getStreamCollaborators(targetStreamId, Roles.Stream.Owner) + const owner = streamOwners[0] + + return { targetStream, targetBranch, owner } +} + +const createApolloClient = async (origin: string): Promise => { + const cache = new InMemoryCache() + const client = new ApolloClient({ + link: new HttpLink({ uri: `${origin}/graphql`, fetch }), + cache, + name: 'cli', + version: getServerVersion(), + defaultOptions: { + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all' + } + } + }) + + // Test it out + const res = await client.query({ + query: testQuery + }) + + assertValidGraphQLResult(res, 'Target server test query') + + if (!res.data?._) { + throw new Error( + "Couldn't construct working Apollo Client, test query failed cause of unexpected response: " + + JSON.stringify(res.data) + ) + } + + return client +} + +const getCommitMetadata = async (client: GraphQLClient, params: ParsedCommitUrl) => { + const { streamId, commitId } = params + const results = await client.query({ + query: commitMetadataQuery, + variables: { streamId, commitId } + }) + assertValidGraphQLResult(results, 'Commit Metadata Query') + + const commit = results.data?.stream?.commit + if (!commit) { + throw new Error('Unexpectedly received invalid commit structure') + } + + return commit as Commit +} + +const saveNewCommit = async (commit: Commit, localResources: LocalResources) => { + const { targetStream, targetBranch, owner } = localResources + + const streamId = targetStream.id + const message = commit.message + const objectId = commit.referencedObject + const parents = commit.parents + const sourceApplication = commit.sourceApplication + const totalChildrenCount = commit.totalChildrenCount + + const id = await createCommitByBranchId({ + streamId, + branchId: targetBranch.id, + objectId, + authorId: owner.id, + message, + sourceApplication, + totalChildrenCount, + parents + }) + + await addCommitCreatedActivity({ + commitId: id, + streamId, + userId: owner.id, + commit: { + branchName: targetBranch.name, + message, + objectId, + parents, + sourceApplication, + streamId, + totalChildrenCount + }, + branchName: targetBranch.name + }) + + return id +} + +const createNewObject = async ( + newObject: ObjectLoaderObject, + targetStreamId: string +) => { + if (!newObject) { + cliDebug('Encountered falsy object!') + return + } + + const newObjectId = await createObject(targetStreamId, { + ...newObject, + id: newObject.id, + speckleType: newObject.speckleType || newObject.speckle_type || 'Base' + }) + + const newRecord = await getObject(newObjectId) + if (!newRecord) { + throw new Error("Unexpected error! Just inserted an object, but can't find it!") + } + + return newRecord +} + +const loadAllObjectsFromParent = async (params: { + targetStreamId: string + sourceCommit: Commit + parsedCommitUrl: ParsedCommitUrl +}) => { + const { + targetStreamId, + sourceCommit, + parsedCommitUrl: { origin, streamId: sourceStreamId } + } = params + + // Initialize ObjectLoader + const objectLoader = new ObjectLoader({ + serverUrl: origin, + streamId: sourceStreamId, + objectId: sourceCommit.referencedObject, + options: { fetch, customLogger: noop } + }) + + // Iterate over all objects and download them into the DB + const totalObjectCount = (sourceCommit.totalChildrenCount || 0) + 1 + let processedObjectCount = 1 + for await (const obj of objectLoader.getObjectIterator()) { + const typedObj = obj as ObjectLoaderObject + cliDebug(`Processing ${obj.id} - ${processedObjectCount++}/${totalObjectCount}`) + await createNewObject(typedObj, targetStreamId) + } +} + +const command: CommandModule< + unknown, + { commitUrl: string; targetStreamId: string; branchName: string } +> = { + command: 'commit [branchName]', + describe: 'Download a commit from an external Speckle server instance', + builder: { + commitUrl: { + describe: + 'Commit URL (e.g. https://speckle.xyz/streams/f0532359ac/commits/98678e2a3d)', + type: 'string' + }, + targetStreamId: { + describe: 'ID of the local stream that should receive the commit', + type: 'string' + }, + branchName: { + describe: 'Stream branch that should receive the commit', + type: 'string', + default: 'main' + } + }, + handler: async (argv) => { + const { commitUrl, targetStreamId, branchName } = argv + cliDebug(`Process started at: ${new Date().toISOString()}`) + + const localResources = await getLocalResources(targetStreamId, branchName) + cliDebug( + `Using local branch ${branchName} of stream ${targetStreamId} to dump the incoming commit` + ) + + const parsedCommitUrl = parseCommitUrl(commitUrl) + cliDebug('Loading the following commit: ', parsedCommitUrl) + + const client = await createApolloClient(parsedCommitUrl.origin) + const commit = await getCommitMetadata(client, parsedCommitUrl) + cliDebug('Loaded commit metadata', commit) + + const newCommitId = await saveNewCommit(commit, localResources) + cliDebug(`Created new local commit: ${newCommitId}`) + + cliDebug(`Pulling & saving all objects! (${commit.totalChildrenCount})`) + await loadAllObjectsFromParent({ + targetStreamId, + sourceCommit: commit, + parsedCommitUrl + }) + + const linkToNewCommit = `${getBaseUrl()}/streams/${targetStreamId}/commits/${newCommitId}` + cliDebug(`All done! Find your commit here: ${linkToNewCommit}`) + } +} + +export = command diff --git a/packages/server/modules/core/dbSchema.ts b/packages/server/modules/core/dbSchema.ts index b047e0838..7fd2d69a8 100644 --- a/packages/server/modules/core/dbSchema.ts +++ b/packages/server/modules/core/dbSchema.ts @@ -278,4 +278,14 @@ export const ScheduledTasks = buildTableHelper('scheduled_tasks', [ 'lockExpiresAt' ]) +export const Objects = buildTableHelper('objects', [ + 'id', + 'speckleType', + 'totalChildrenCount', + 'totalChildrenCountByDepth', + 'createdAt', + 'data', + 'streamId' +]) + export { knex } diff --git a/packages/server/modules/core/graph/resolvers/commits.js b/packages/server/modules/core/graph/resolvers/commits.js index 871fc7aa8..3d44b1d9d 100644 --- a/packages/server/modules/core/graph/resolvers/commits.js +++ b/packages/server/modules/core/graph/resolvers/commits.js @@ -2,7 +2,7 @@ const { ForbiddenError, UserInputError, ApolloError } = require('apollo-server-express') const { withFilter } = require('graphql-subscriptions') -const { authorizeResolver, pubsub } = require('@/modules/shared') +const { authorizeResolver, pubsub, CommitPubsubEvents } = require('@/modules/shared') const { saveActivity } = require('@/modules/activitystream/services') const { ActionTypes } = require('@/modules/activitystream/helpers/types') @@ -30,11 +30,14 @@ const { validateStreamAccess } = require('@/modules/core/services/streams/streamAccessService') const { StreamInvalidAccessError } = require('@/modules/core/errors/stream') +const { + addCommitCreatedActivity +} = require('@/modules/activitystream/services/commitActivity') // subscription events -const COMMIT_CREATED = 'COMMIT_CREATED' -const COMMIT_UPDATED = 'COMMIT_UPDATED' -const COMMIT_DELETED = 'COMMIT_DELETED' +const COMMIT_CREATED = CommitPubsubEvents.CommitCreated +const COMMIT_UPDATED = CommitPubsubEvents.CommitUpdated +const COMMIT_DELETED = CommitPubsubEvents.CommitDeleted /** * @param {boolean} publicOnly @@ -164,18 +167,12 @@ module.exports = { authorId: context.userId }) if (id) { - await saveActivity({ + await addCommitCreatedActivity({ + commitId: id, streamId: args.commit.streamId, - resourceType: 'commit', - resourceId: id, - actionType: ActionTypes.Commit.Create, userId: context.userId, - info: { id, commit: args.commit }, - message: `Commit created on branch ${args.commit.branchName}: ${id} (${args.commit.message})` - }) - await pubsub.publish(COMMIT_CREATED, { - commitCreated: { ...args.commit, id, authorId: context.userId }, - streamId: args.commit.streamId + commit: args.commit, + branchName: args.commit.branchName }) } diff --git a/packages/server/modules/core/helpers/types.ts b/packages/server/modules/core/helpers/types.ts index aaae62b86..01ed96038 100644 --- a/packages/server/modules/core/helpers/types.ts +++ b/packages/server/modules/core/helpers/types.ts @@ -110,3 +110,13 @@ export type ScheduledTaskRecord = { taskName: string lockExpiresAt: Date } + +export type ObjectRecord = { + id: string + speckleType: string + totalChildrenCount: Nullable + totalChildrenCountByDepth: Nullable> + createdAt: Date + data: Nullable> + streamId: string +} diff --git a/packages/server/modules/core/repositories/objects.ts b/packages/server/modules/core/repositories/objects.ts new file mode 100644 index 000000000..e4b325b99 --- /dev/null +++ b/packages/server/modules/core/repositories/objects.ts @@ -0,0 +1,7 @@ +import { Optional } from '@speckle/shared' +import { Objects } from '@/modules/core/dbSchema' +import { ObjectRecord } from '@/modules/core/helpers/types' + +export async function getObject(objectId: string): Promise> { + return await Objects.knex().where(Objects.col.id, objectId).first() +} diff --git a/packages/server/modules/core/services/commits.js b/packages/server/modules/core/services/commits.js index c4d3fb3ac..3f80c553c 100644 --- a/packages/server/modules/core/services/commits.js +++ b/packages/server/modules/core/services/commits.js @@ -43,6 +43,9 @@ const getCommitsByUserIdBase = ({ userId, publicOnly }) => { } module.exports = { + /** + * @returns {Promise} + */ async createCommitByBranchId({ streamId, branchId, diff --git a/packages/server/modules/core/services/objects.js b/packages/server/modules/core/services/objects.js index b3799f458..8930cba1c 100644 --- a/packages/server/modules/core/services/objects.js +++ b/packages/server/modules/core/services/objects.js @@ -11,6 +11,9 @@ const Objects = () => knex('objects') const Closures = () => knex('object_children_closure') module.exports = { + /** + * @returns {Promise} + */ async createObject(streamId, object) { const insertionObject = prepInsertionObject(streamId, object) diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 16c5a4e2f..3a6626c3e 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -1,4 +1,5 @@ import { MisconfiguredEnvironmentError } from '@/modules/shared/errors' +import { trimEnd } from 'lodash' export function isTestEnv() { return process.env.NODE_ENV === 'test' @@ -44,7 +45,7 @@ export function getBaseUrl() { throw new MisconfiguredEnvironmentError('CANONICAL_URL env var not configured') } - return process.env.CANONICAL_URL + return trimEnd(process.env.CANONICAL_URL, '/') } /** diff --git a/packages/server/modules/shared/index.js b/packages/server/modules/shared/index.js index cd3c1455f..0af24a2bd 100644 --- a/packages/server/modules/shared/index.js +++ b/packages/server/modules/shared/index.js @@ -13,6 +13,12 @@ const StreamPubsubEvents = Object.freeze({ StreamDeleted: 'STREAM_DELETED' }) +const CommitPubsubEvents = Object.freeze({ + CommitCreated: 'COMMIT_CREATED', + CommitUpdated: 'COMMIT_UPDATED', + CommitDeleted: 'COMMIT_DELETED' +}) + /** * GraphQL Subscription PubSub instance */ @@ -210,5 +216,6 @@ module.exports = { authorizeResolver, pubsub, getRoles, - StreamPubsubEvents + StreamPubsubEvents, + CommitPubsubEvents } diff --git a/packages/server/package.json b/packages/server/package.json index aa5e2dbf7..8278a86c2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -97,6 +97,7 @@ "@graphql-codegen/typescript": "2.7.2", "@graphql-codegen/typescript-operations": "^2.5.2", "@graphql-codegen/typescript-resolvers": "2.7.2", + "@speckle/objectloader": "workspace:^", "@swc/core": "^1.2.222", "@tiptap/core": "^2.0.0-beta.176", "@types/bull": "^3.15.9", @@ -124,6 +125,7 @@ "chai-http": "^4.3.0", "concurrently": "^7.0.0", "cross-env": "^7.0.3", + "cross-fetch": "^3.1.5", "deep-equal-in-any-order": "^1.1.15", "eslint": "^8.11.0", "eslint-config-prettier": "^8.5.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index 3beccf105..627884f48 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -37,6 +37,7 @@ "@tiptap/core": "^2.0.0-beta.176" }, "devDependencies": { + "@tiptap/core": "^2.0.0-beta.176", "@types/lodash": "^4.14.184", "@typescript-eslint/eslint-plugin": "^5.39.0", "@typescript-eslint/parser": "^5.39.0", diff --git a/packages/shared/src/core/helpers/batch.ts b/packages/shared/src/core/helpers/batch.ts new file mode 100644 index 000000000..afc2b0c55 --- /dev/null +++ b/packages/shared/src/core/helpers/batch.ts @@ -0,0 +1,122 @@ +import { range } from 'lodash' + +/** + * Utility for batching async operations. Useful when you have thousands of async operations and you can't + * just invoke them all at once because of resource constraints (e.g. network bandwidth). + * + * 'operationParams' should be an array of parameters for each async operation. The size of this array coresponds + * to the amount of async operations that will be invoked. + * 'operationPromiseGenerator' will be invoked sequentially with each params of 'operationParams' and with it + * you can specify what the actual async operation should be + * + * TODO: Some tests would be nice, although it does work when tested through `yarn cli download commit` in speckle-server + */ +export async function batchAsyncOperations( + name: string, + operationParams: Params[], + operationPromiseGenerator: (params: Params) => Promise, + options?: Partial<{ + /** + * If promise returned by generator fails, re-execute it this many times + */ + retryCount: number + /** + * How many concurrent promises can be executed at once + */ + batchSize: number + + /** + * Optionally override the logger with a custom one + */ + logger: (...args: unknown[]) => void + + /** + * If set to true, the function won't collect all of the returns of each operation in an effort to reduce + * memory usage. The function will return 'true' instead of an array of results. + */ + dropReturns: boolean + }> +) { + const { + retryCount = 3, + batchSize = 100, + dropReturns = false, + logger = (...args: unknown[]) => console.log(...args) + } = options || {} + + const finalLogger = (message: string, ...args: unknown[]) => + logger(`[${name}] ${message}`, ...args) + + finalLogger('Starting batched operation...') + + const operationCount = operationParams.length + let allResults: Res[] = [] + + let executedOperationCount = 0 + const batchCount = Math.ceil(operationCount / batchSize) + for (let i = 0; i < batchCount; i++) { + finalLogger(`Processing batch ${i + 1} out of ${batchCount}...`) + + // Figure out how many operations we can execute in this batch + const newExecutedOperationCount = Math.min( + executedOperationCount + batchSize, + operationCount + ) + const operationsToExecuteCount = newExecutedOperationCount - executedOperationCount + if (operationsToExecuteCount <= 0) return + + // Invoke operation generator + const batchParams = operationParams.slice( + executedOperationCount, + newExecutedOperationCount + ) + const batchRequests: Promise[] = [] + + let currentOperationIdx = executedOperationCount + for (const params of batchParams) { + const currentOperationNumber = currentOperationIdx + 1 + const label = `${currentOperationNumber}/${operationCount}` + + const operationPromise = (async () => { + finalLogger(`Queuing operation ${label}...`) + + const execute = () => operationPromiseGenerator(params) + let promise = execute().then((res) => { + finalLogger(`...finished operation ${label}`) + return res + }) + + // Attach retries + range(retryCount).forEach((retry) => { + promise = promise.catch((e) => { + finalLogger( + `...failure in operation ${label}: "${e}". Triggering retry ${ + retry + 1 + }...` + ) + return execute() + }) + }) + promise = promise.catch((e) => { + finalLogger(`...final failure in operation ${label}!`) + throw e + }) + + return promise + })() + + batchRequests.push(operationPromise) + currentOperationIdx++ + } + + const batchResults = await Promise.all(batchRequests) + + if (!dropReturns) { + allResults = allResults.concat(batchResults) + } + + executedOperationCount = newExecutedOperationCount + } + + return dropReturns ? true : allResults +} diff --git a/packages/shared/src/core/helpers/utility.ts b/packages/shared/src/core/helpers/utility.ts new file mode 100644 index 000000000..b61058d50 --- /dev/null +++ b/packages/shared/src/core/helpers/utility.ts @@ -0,0 +1,6 @@ +import { isNull, isUndefined } from 'lodash' + +export const isNullOrUndefined = (val: unknown): val is null | undefined => + isNull(val) || isUndefined(val) + +export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/packages/shared/src/core/index.ts b/packages/shared/src/core/index.ts index 6a3ed48ed..2e51ca38d 100644 --- a/packages/shared/src/core/index.ts +++ b/packages/shared/src/core/index.ts @@ -1,2 +1,5 @@ export * from './constants' export * from './helpers/utilityTypes' +export * from './helpers/utility' +export * from './helpers/batch' +export * from './utils/localStorage' diff --git a/packages/shared/src/core/utils/localStorage.ts b/packages/shared/src/core/utils/localStorage.ts new file mode 100644 index 000000000..98564abd0 --- /dev/null +++ b/packages/shared/src/core/utils/localStorage.ts @@ -0,0 +1,81 @@ +import { Nullable } from '../helpers/utilityTypes' + +function checkLocalStorageAvailability(): boolean { + try { + const testKey = '___localStorageAvailabilityTest' + const storage = globalThis.localStorage + storage.setItem(testKey, testKey) + storage.getItem(testKey) + storage.removeItem(testKey) + return true + } catch (e) { + return false + } +} + +/** + * In memory implementation of the Storage interface, for use when LocalStorage + * isn't available + */ +class FakeStorage implements Storage { + #internalStorage = new Map() + + clear(): void { + this.#internalStorage.clear() + } + + getItem(key: string): string | null { + return this.#internalStorage.get(key) || null + } + + key(index: number): string | null { + return [...this.#internalStorage.keys()][index] || null + } + + removeItem(key: string): void { + this.#internalStorage.delete(key) + } + + setItem(key: string, value: string): void { + this.#internalStorage.set(key, value) + } + + get length(): number { + return this.#internalStorage.size + } +} + +/** + * Whether or not the local storage is available in this session + */ +const isLocalStorageAvailable = checkLocalStorageAvailability() + +/** + * Localstorage (real or faked) to use in this session + */ +const internalStorage: Storage = isLocalStorageAvailable + ? globalThis.localStorage + : new FakeStorage() + +/** + * Utility for nicer reads/writes from/to LocalStorage without having to worry about the browser + * throwing a hissy fit because the page is opened in Incognito mode or node not having localStorage at all + */ +export const SafeLocalStorage = { + get(key: string): Nullable { + return internalStorage.getItem(key) + }, + + set(key: string, value: string): void { + internalStorage.setItem(key, value) + }, + + remove(key: string): void { + internalStorage.removeItem(key) + }, + + /** + * Flag for telling if we're using a real localStorage or faking it with a basic in-memory collection + */ + isRealLocalStorage: isLocalStorageAvailable +} diff --git a/workspace.code-workspace b/workspace.code-workspace index 453b5d4fe..24ad20ea6 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -66,7 +66,8 @@ }, "files.eol": "\n", "volar.completion.preferredTagNameCase": "kebab", - "volar.vueserver.maxOldSpaceSize": 4000 + "volar.vueserver.maxOldSpaceSize": 4000, + "vscode-graphql.cacheSchemaFileForLookup": true }, "extensions": { // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. diff --git a/yarn.lock b/yarn.lock index e93cc8866..89e0cb0ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4923,7 +4923,9 @@ __metadata: "@rollup/plugin-babel": ^5.3.1 "@rollup/plugin-commonjs": ^21.0.3 "@rollup/plugin-node-resolve": ^13.1.3 + "@speckle/shared": "workspace:^" core-js: ^3.21.1 + cross-fetch: ^3.1.5 eslint: ^8.11.0 eslint-config-prettier: ^8.5.0 http-server: ^14.1.0 @@ -4932,7 +4934,6 @@ __metadata: rollup: ^2.70.1 rollup-plugin-delete: ^2.0.0 rollup-plugin-terser: ^7.0.2 - undici: ^4.14.1 languageName: unknown linkType: soft @@ -4990,6 +4991,7 @@ __metadata: "@graphql-tools/schema": ^9.0.4 "@sentry/node": ^6.17.9 "@sentry/tracing": ^6.17.9 + "@speckle/objectloader": "workspace:^" "@speckle/shared": "workspace:^" "@swc/core": ^1.2.222 "@tiptap/core": ^2.0.0-beta.176 @@ -5025,6 +5027,7 @@ __metadata: connect-redis: ^6.1.1 cors: ^2.8.5 cross-env: ^7.0.3 + cross-fetch: ^3.1.5 crypto-random-string: ^3.2.0 dataloader: ^2.0.0 dayjs: ^1.11.5 @@ -5092,6 +5095,7 @@ __metadata: version: 0.0.0-use.local resolution: "@speckle/shared@workspace:packages/shared" dependencies: + "@tiptap/core": ^2.0.0-beta.176 "@types/lodash": ^4.14.184 "@typescript-eslint/eslint-plugin": ^5.39.0 "@typescript-eslint/parser": ^5.39.0 @@ -19359,6 +19363,8 @@ __metadata: husky: ^7.0.4 lint-staged: ^12.3.7 prettier: ^2.5.1 + ts-node: ^10.9.1 + tsconfig-paths: ^4.0.0 languageName: unknown linkType: soft @@ -21326,13 +21332,6 @@ __metadata: languageName: node linkType: hard -"undici@npm:^4.14.1": - version: 4.16.0 - resolution: "undici@npm:4.16.0" - checksum: 5e88c2b3381085e25ed1d1a308610ac7ee985f478ac705af7a8e03213536e10f73ef8dd8d85e6ed38948d1883fa0ae935e04357c317b0f5d3d3c0211d0c8c393 - languageName: node - linkType: hard - "undici@npm:^5.1.0, undici@npm:^5.4.0": version: 5.5.1 resolution: "undici@npm:5.5.1"