From a84c0654e2d9ecaf624fbc00c3e4f32447550c11 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Tue, 24 Oct 2023 08:33:17 +0200 Subject: [PATCH] Feat resumability (#547) * add resumability functionality --------- Co-authored-by: arc-alex Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com> --- apps/electron/package.json | 2 +- apps/server/package.json | 5 +- apps/server/src/app.ts | 29 +++- .../src/classes/event-loader/EventLoader.ts | 16 +- apps/server/src/config/config.js | 2 +- apps/server/src/services/PlaybackService.ts | 30 +++- apps/server/src/services/RestoreService.ts | 160 ++++++++++++++++++ apps/server/src/services/TimerService.ts | 88 ++++++++-- .../services/__tests__/RestoreService.test.ts | 128 ++++++++++++++ apps/server/src/setup.ts | 3 + apps/test-db/db.json | 11 +- pnpm-lock.yaml | 12 +- 12 files changed, 453 insertions(+), 33 deletions(-) create mode 100644 apps/server/src/services/RestoreService.ts create mode 100644 apps/server/src/services/__tests__/RestoreService.test.ts diff --git a/apps/electron/package.json b/apps/electron/package.json index d75cbd5fc..e04fa41fc 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -20,7 +20,7 @@ }, "scripts": { "postinstall": "", - "dev:electron": "NODE_ENV=development electron .", + "dev:electron": "cross-env NODE_ENV=development electron .", "dist-win": "electron-builder --publish=never --x64 --win", "dist-mac": "electron-builder --publish=never --mac", "dist-linux": "electron-builder --publish=never --x64 --linux", diff --git a/apps/server/package.json b/apps/server/package.json index c20ad222d..ef572878c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -19,6 +19,7 @@ "ontime-utils": "workspace:*", "passport": "^0.6.0", "passport-local": "~1.0.0", + "steno": "^3.1.0", "ws": "^8.13.0" }, "devDependencies": { @@ -52,8 +53,8 @@ "build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs", "build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs", "lint": "eslint .", - "test": "vitest", - "test:pipeline": "vitest run", + "test": "cross-env IS_TEST=true vitest", + "test:pipeline": "cross-env IS_TEST=true vitest run", "typecheck": "tsc --noEmit", "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index d09baaa02..10e61c50e 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,3 +1,5 @@ +import { LogOrigin, OSCSettings } from 'ontime-types'; + import 'dotenv/config'; import express from 'express'; import expressStaticGzip from 'express-static-gzip'; @@ -6,10 +8,8 @@ import cors from 'cors'; // import utils import { join, resolve } from 'path'; - import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; -import { LogOrigin, OSCSettings } from 'ontime-types'; // Import Routes import { router as rundownRouter } from './routes/rundownRouter.js'; @@ -31,6 +31,8 @@ import { logger } from './classes/Logger.js'; import { oscIntegration } from './services/integration-service/OscIntegration.js'; import { populateStyles } from './modules/loadStyles.js'; import { eventStore, getInitialPayload } from './stores/EventStore.js'; +import { PlaybackService } from './services/PlaybackService.js'; +import { RestorePoint, restoreService } from './services/RestoreService.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -137,10 +139,21 @@ export const startServer = async () => { expressServer = http.createServer(app); socket.init(expressServer); + eventLoader.init(); + + // load restore point if it exists + const maybeRestorePoint = restoreService.load(); + + if (maybeRestorePoint) { + logger.info(LogOrigin.Server, 'Found resumable state'); + PlaybackService.resume(maybeRestorePoint); + } + + eventTimer.setRestoreCallback(async (newState: RestorePoint) => restoreService.save(newState)); // provide initial payload to event store - eventLoader.init(); - eventStore.init(getInitialPayload()); + const initialPayload = getInitialPayload(); + eventStore.init(initialPayload); expressServer.listen(serverPort, '0.0.0.0'); @@ -202,6 +215,14 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => { export const shutdown = async (exitCode = 0) => { console.log(`Ontime shutting down with code ${exitCode}`); + // clear the restore file if it was a normal exit + // 0 means it was a SIGNAL + // 1 means crash -> keep the file + // 99 means it was the UI + if (exitCode === 0 || exitCode === 99) { + await restoreService.clear(); + } + expressServer?.close(); oscServer?.shutdown(); eventTimer.shutdown(); diff --git a/apps/server/src/classes/event-loader/EventLoader.ts b/apps/server/src/classes/event-loader/EventLoader.ts index 254040696..eb8fc655b 100644 --- a/apps/server/src/classes/event-loader/EventLoader.ts +++ b/apps/server/src/classes/event-loader/EventLoader.ts @@ -23,6 +23,18 @@ export class EventLoader { // eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton instance = this; + this.eventNow = null; + this.publicEventNow = null; + this.eventNext = null; + this.publicEventNext = null; + this.loaded = { + selectedEventIndex: null, + selectedEventId: null, + selectedPublicEventId: null, + nextEventId: null, + nextPublicEventId: null, + numEvents: 0, + }; } // we need to delay init until the store is ready @@ -71,7 +83,7 @@ export class EventLoader { * @param {string} eventId * @return {object | undefined} */ - static getEventWithId(eventId) { + static getEventWithId(eventId): OntimeEvent | undefined { const timedEvents = EventLoader.getTimedEvents(); return timedEvents.find((event) => event.id === eventId); } @@ -223,7 +235,7 @@ export class EventLoader { * loads an event given its id * @param {object} event */ - loadEvent(event) { + loadEvent(event?: OntimeEvent) { if (typeof event === 'undefined') { return null; } diff --git a/apps/server/src/config/config.js b/apps/server/src/config/config.js index 57f9af2c0..f11da3cdc 100644 --- a/apps/server/src/config/config.js +++ b/apps/server/src/config/config.js @@ -3,10 +3,10 @@ export const config = { testdb: 'test-db', directory: 'db', filename: 'db.json', - tablename: 'events', }, styles: { directory: 'styles', filename: 'override.css', }, + restoreFile: 'ontime.restore', }; diff --git a/apps/server/src/services/PlaybackService.ts b/apps/server/src/services/PlaybackService.ts index dd3c30c3e..65dcb1d8a 100644 --- a/apps/server/src/services/PlaybackService.ts +++ b/apps/server/src/services/PlaybackService.ts @@ -1,4 +1,4 @@ -import { LogOrigin, OntimeEvent } from 'ontime-types'; +import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; import { validatePlayback } from 'ontime-utils'; import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js'; @@ -6,6 +6,7 @@ import { eventStore } from '../stores/EventStore.js'; import { eventTimer } from './TimerService.js'; import { clock } from './Clock.js'; import { logger } from '../classes/Logger.js'; +import { RestorePoint } from './RestoreService.js'; /** * Service manages playback status of app @@ -241,6 +242,33 @@ export class PlaybackService { } } + /** + * @description resume playback state given a restore point + * @param restorePoint + */ + static resume(restorePoint: RestorePoint) { + const willResume = () => logger.info(LogOrigin.Server, 'Resuming playback'); + + if (restorePoint.playback === Playback.Roll) { + willResume(); + PlaybackService.roll(); + } + + if (restorePoint.selectedEventId) { + const event = EventLoader.getEventWithId(restorePoint.selectedEventId); + // the db would have to change for the event not to exist + // we do not kow the reason for the crash, so we check anyway + if (!event) { + return; + } + + eventLoader.loadEvent(event); + eventTimer.resume(event, restorePoint); + eventStore.broadcast(); + return; + } + } + /** * Adds delay to current event * @param {number} delayTime time in minutes diff --git a/apps/server/src/services/RestoreService.ts b/apps/server/src/services/RestoreService.ts new file mode 100644 index 000000000..0edbf47a5 --- /dev/null +++ b/apps/server/src/services/RestoreService.ts @@ -0,0 +1,160 @@ +import { Playback } from 'ontime-types'; + +import { readFileSync } from 'fs'; +import { Writer } from 'steno'; + +import { resolveRestoreFile } from '../setup.js'; + +export type RestorePoint = { + playback: Playback; + selectedEventId: string | null; + startedAt: number | null; + addedTime: number | null; + pausedAt: number | null; +}; + +/** + * Utility validates a RestorePoint + * @param obj + * @return boolean + */ +export function isRestorePoint(obj: unknown): obj is RestorePoint { + if (!obj) { + return false; + } + + const restorePoint = obj as RestorePoint; + + if (typeof restorePoint.playback !== 'string' || !Object.values(Playback).includes(restorePoint.playback)) { + return false; + } + + if (typeof restorePoint.selectedEventId !== 'string' && restorePoint.selectedEventId !== null) { + return false; + } + + if (typeof restorePoint.startedAt !== 'number' && restorePoint.startedAt !== null) { + return false; + } + + if (typeof restorePoint.addedTime !== 'number' && restorePoint.addedTime !== null) { + return false; + } + + if (typeof restorePoint.pausedAt !== 'number' && restorePoint.pausedAt !== null) { + return false; + } + + return true; +} + +/** + * Utility interface to allow dependency injection during test + */ + +/** + * Service manages saving of application state + * that can then be restored when reopening + */ +export class RestoreService { + private readonly filePath: string | null; + + private lastStore: string | null; + private file: Writer | null; + private failedCreateAttempts: number; + + constructor(filePath: string) { + this.filePath = filePath; + + this.lastStore = null; + this.file = null; + this.failedCreateAttempts = 0; + } + + /** + * Utility, creates a restore file + */ + create() { + this.file = new Writer(this.filePath); + } + + /** + * Utility, reads from file + * @private + */ + private read() { + return readFileSync(this.filePath, 'utf-8'); + } + + /** + * Utility writes payload to file + * @throws + * @param stringifiedState + */ + private async write(stringifiedState: string) { + // Create a file if it doesnt exist + if (!this.file) { + this.create(); + } + // steno is async, and it uses a queue to avoid unnecessary re-writes + await this.file.write(stringifiedState); + } + + /** + * Saves runtime data to restore file + * @param newState RestorePoint + */ + async save(newState: RestorePoint) { + // after three failed attempts, mark the service as unavailable + if (this.failedCreateAttempts > 3) { + return; + } + + const stringifiedStore = JSON.stringify(newState); + if (stringifiedStore !== this.lastStore) { + try { + await this.write(stringifiedStore); + this.lastStore = stringifiedStore; + this.failedCreateAttempts = 0; + } catch (_err) { + this.failedCreateAttempts += 1; + } + } + } + + /** + * Attempts reading a restore point from a given file path + * Returns null if none found, restore point otherwise + */ + load(): RestorePoint | null { + try { + const data = this.read(); + const maybeRestorePoint = JSON.parse(data); + + if (!isRestorePoint(maybeRestorePoint)) { + return null; + } + + return maybeRestorePoint; + } catch (_error) { + // no need to notify the user + return null; + } + } + + /** + * Clears the restore file + */ + async clear() { + if (this.file && this.failedCreateAttempts <= 3) { + try { + await this.file.write(''); + } catch (_error) { + // nothing to do + } + } + this.file = undefined; + } +} + +export const restoreService = new RestoreService(resolveRestoreFile); diff --git a/apps/server/src/services/TimerService.ts b/apps/server/src/services/TimerService.ts index 7154d3c99..db90fe02c 100644 --- a/apps/server/src/services/TimerService.ts +++ b/apps/server/src/services/TimerService.ts @@ -1,4 +1,4 @@ -import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types'; +import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types'; import { calculateDuration, dayInMs } from 'ontime-utils'; import { eventStore } from '../stores/EventStore.js'; @@ -8,6 +8,7 @@ import { integrationService } from './integration-service/IntegrationService.js' import { getCurrent, getExpectedFinish } from './timerUtils.js'; import { clock } from './Clock.js'; import { logger } from '../classes/Logger.js'; +import type { RestorePoint } from './RestoreService.js'; type initialLoadingData = { startedAt?: number | null; @@ -15,6 +16,8 @@ type initialLoadingData = { current?: number | null; }; +type RestoreCallback = (newState: RestorePoint) => Promise; + export class TimerService { private readonly _interval: NodeJS.Timer; private _updateInterval: number; @@ -31,6 +34,7 @@ export class TimerService { private pausedAt: number | null; private secondaryTarget: number | null; + private saveRestorePoint: RestoreCallback; /** * @constructor * @param {object} [timerConfig] @@ -43,6 +47,14 @@ export class TimerService { this._updateInterval = timerConfig?.updateInterval ?? 1000; } + /** + * Provides callback to save restore point + * @param cb + */ + setRestoreCallback(cb: RestoreCallback) { + this.saveRestorePoint = cb; + } + /** * Clears internal state * @private @@ -74,6 +86,44 @@ export class TimerService { this._lastUpdate = null; } + /** + * Resumes a given playback state, same as load + * @param {RestorePoint} restorePoint + * @param {OntimeEvent} timer + */ + resume(timer: OntimeEvent, restorePoint: RestorePoint) { + this._clear(); + + // this is pretty much the same as load, with a few exceptions + this.loadedTimerId = timer.id; + this.loadedTimerStart = timer.timeStart; + this.loadedTimerEnd = timer.timeEnd; + + this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd); + this.playback = restorePoint.playback; + this.timer.timerType = timer.timerType; + this.timer.endAction = timer.endAction; + this.timer.startedAt = restorePoint.startedAt; + this.timer.addedTime = restorePoint.addedTime; + this.pausedTime = 0; + this.pausedAt = restorePoint.pausedAt; + + this.timer.current = this.timer.duration; + if (this.timer.timerType === TimerType.TimeToEnd) { + const now = clock.timeNow(); + this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType); + } + + this._onResume(); + } + + _onResume() { + eventStore.batchSet({ + playback: this.playback, + timer: this.timer, + }); + } + /** * Reloads information for currently running timer * @param timer @@ -121,17 +171,10 @@ export class TimerService { /** * Loads given timer to object - * @param {object} timer - * @param initialData - * @param {number} timer.id - * @param {number} timer.timeStart - * @param {number} timer.timeEnd - * @param {number} timer.duration - * @param {string} timer.timerBehaviour - * @param {string} timer.timerType - * @param {boolean} timer.skip + * @param {OntimeEvent} timer + * @param {initialLoadingData} initialData */ - load(timer, initialData?: initialLoadingData) { + load(timer: OntimeEvent, initialData?: initialLoadingData) { if (timer.skip) { throw new Error('Refuse load of skipped event'); } @@ -155,7 +198,7 @@ export class TimerService { this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType); } - if (typeof initialData !== 'undefined') { + if (initialData) { this.timer = { ...this.timer, ...initialData }; } @@ -172,12 +215,13 @@ export class TimerService { timer: this.timer, }); integrationService.dispatch(TimerLifeCycle.onLoad); + this._saveState(); } start() { if (!this.loadedTimerId) { if (this.playback === Playback.Roll) { - logger.error('PLAYBACK', 'Cannot start while waiting for event'); + logger.error(LogOrigin.Playback, 'Cannot start while waiting for event'); } return; } @@ -222,6 +266,7 @@ export class TimerService { timer: this.timer, }); integrationService.dispatch(TimerLifeCycle.onStart); + this._saveState(); } pause() { @@ -237,6 +282,7 @@ export class TimerService { timer: this.timer, }); integrationService.dispatch(TimerLifeCycle.onPause); + this._saveState(); } stop() { @@ -254,6 +300,7 @@ export class TimerService { timer: this.timer, }); integrationService.dispatch(TimerLifeCycle.onStop); + this._saveState(); } /** @@ -280,6 +327,7 @@ export class TimerService { // force an update this.update(true); + this._saveState(); } private updateRoll() { @@ -394,6 +442,7 @@ export class TimerService { PlaybackService.startNext(); } } + this._saveState(); } /** @@ -435,6 +484,19 @@ export class TimerService { _onRoll() { eventStore.set('playback', this.playback); + this._saveState(); + } + + async _saveState() { + if (this.saveRestorePoint) { + await this.saveRestorePoint({ + playback: this.playback, + selectedEventId: this.loadedTimerId, + startedAt: this.timer.startedAt, + addedTime: this.timer.addedTime, + pausedAt: this.pausedAt, + }); + } } shutdown() { diff --git a/apps/server/src/services/__tests__/RestoreService.test.ts b/apps/server/src/services/__tests__/RestoreService.test.ts new file mode 100644 index 000000000..05bb679f1 --- /dev/null +++ b/apps/server/src/services/__tests__/RestoreService.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Playback } from 'ontime-types'; + +import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.js'; + +describe('isRestorePoint()', () => { + it('validates a well defined object', () => { + let restorePoint = { + playback: 'play', + selectedEventId: '123', + startedAt: 1, + addedTime: 2, + pausedAt: 3, + }; + expect(isRestorePoint(restorePoint)).toBe(true); + + restorePoint = { + playback: 'roll', + selectedEventId: '123', + startedAt: null, + addedTime: null, + pausedAt: null, + }; + expect(isRestorePoint(restorePoint)).toBe(true); + }); + + describe('rejects a badly formatted file', () => { + it('with invalid playback value', () => { + const restorePoint = { + playback: 'unknown', + selectedEventId: '123', + startedAt: null, + addedTime: null, + pausedAt: null, + }; + expect(isRestorePoint(restorePoint)).toBe(false); + }); + it('with missing playback value', () => { + const restorePoint = { + selectedEventId: '123', + startedAt: null, + addedTime: null, + pausedAt: null, + }; + expect(isRestorePoint(restorePoint)).toBe(false); + }); + it('with incorrect value', () => { + const restorePoint = { + playback: 'roll', + selectedEventId: '123', + startedAt: 'testing', + addedTime: null, + pausedAt: null, + }; + expect(isRestorePoint(restorePoint)).toBe(false); + }); + }); +}); + +describe('RestoreService()', () => { + describe('load()', () => { + it('loads working file with times', () => { + const expected = { + playback: Playback.Play, + selectedEventId: 'da5b4', + startedAt: 1234, + addedTime: 5678, + pausedAt: 9087, + }; + + const restoreService = new RestoreService('/path/to/restore/file'); + vi.spyOn(restoreService, 'read').mockImplementation(() => JSON.stringify(expected)); + + const testLoad = restoreService.load(); + expect(testLoad).toStrictEqual(expected); + }); + + it('loads working file without times', () => { + const expected = { + playback: Playback.Stop, + selectedEventId: null, + startedAt: null, + addedTime: null, + pausedAt: null, + }; + + const restoreService = new RestoreService('/path/to/restore/file'); + vi.spyOn(restoreService, 'read').mockImplementation(() => JSON.stringify(expected)); + + const testLoad = restoreService.load(); + expect(testLoad).toStrictEqual(expected); + }); + + it('does not load wrong play state', () => { + const expected = { + playback: 'does-not-exist', + selectedEventId: 'da5b4', + startedAt: 1234, + addedTime: 1234, + pausedAt: 1234, + }; + + const restoreService = new RestoreService('/path/to/restore/file'); + vi.spyOn(restoreService, 'read').mockImplementation(() => JSON.stringify(expected)); + + const testLoad = restoreService.load(); + expect(testLoad).toBe(null); + }); + }); + + describe('save()', () => { + it('saves data to file', async () => { + const testData: RestorePoint = { + playback: Playback.Play, + selectedEventId: '1234', + startedAt: 1234, + addedTime: 1234, + pausedAt: 1234, + }; + + const restoreService = new RestoreService('/path/to/restore/file'); + const writeSpy = vi.spyOn(restoreService, 'write').mockImplementation(() => undefined); + await restoreService.save(testData); + expect(writeSpy).toHaveBeenCalledWith(JSON.stringify(testData)); + }); + }); +}); diff --git a/apps/server/src/setup.ts b/apps/server/src/setup.ts index 4684362a1..efd83524e 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup.ts @@ -85,3 +85,6 @@ export const resolveStylesDirectory = join(externalsStartDirectory, config.style export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename); export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename); + +// path to restore file +export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile); diff --git a/apps/test-db/db.json b/apps/test-db/db.json index 6d56294d8..fad9cb591 100644 --- a/apps/test-db/db.json +++ b/apps/test-db/db.json @@ -25,7 +25,8 @@ "user9": "", "type": "event", "revision": 0, - "id": "aa42f" + "id": "aa42f", + "cue": "1" }, { "duration": 600000, @@ -58,7 +59,8 @@ "user9": "", "type": "event", "revision": 0, - "id": "d71bc" + "id": "d71bc", + "cue": "2" }, { "title": "Lunch", @@ -74,7 +76,7 @@ "timerType": "count-down", "timeStart": 39600000, "timeEnd": 720000, - "duration": 0, + "duration": 47520000, "isPublic": true, "skip": false, "colour": "", @@ -90,7 +92,8 @@ "user9": "", "type": "event", "revision": 0, - "id": "da5b4" + "id": "da5b4", + "cue": "3" } ], "project": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26c69ec68..b4e5eaee7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,7 @@ importers: passport-local: ~1.0.0 prettier: ^2.8.3 shx: ^0.3.4 + steno: ^3.1.0 ts-node: ^10.9.1 typescript: ^4.9.4 vitest: ^0.30.1 @@ -215,6 +216,7 @@ importers: ontime-utils: link:../../packages/utils passport: 0.6.0 passport-local: 1.0.0 + steno: 3.1.0 ws: 8.13.0 devDependencies: '@types/express': 4.17.17 @@ -6012,7 +6014,7 @@ packages: resolution: {integrity: sha512-7EWKmIMhNKA8TXFhL8t0p6N2LC53l3ZqsWQGSksGhhjrcms9rbKlyrAh2PzSGK5v0KPJ2W5VItBnC3NDRzOnzQ==} engines: {node: '>=14.16'} dependencies: - steno: 3.0.0 + steno: 3.1.0 dev: false /lowercase-keys/2.0.0: @@ -7327,9 +7329,9 @@ packages: resolution: {integrity: sha512-uUZI65yrV2Qva5gqE0+A7uVAvO40iPo6jGhs7s8keRfHCmtg+uB2X6EiLGCI9IgL1J17xGhvoOqSz79lzICPTA==} dev: true - /steno/3.0.0: - resolution: {integrity: sha512-uZtn7Ht9yXLiYgOsmo8btj4+f7VxyYheMt8g6F1ANjyqByQXEE2Gygjgenp3otHH1TlHsS4JAaRGv5wJ1wvMNw==} - engines: {node: '>=14.16'} + /steno/3.1.0: + resolution: {integrity: sha512-U9mIkOthSBoLxa+4QAXv0aDDHeLn6merFMkjSblSz+WgezKQ0EkS1znRY6hNBZz3kGDm/0ZaP+E+/1X1ho37IQ==} + engines: {node: '>=16'} dev: false /stop-iteration-iterator/1.0.0: @@ -8500,4 +8502,4 @@ packages: dependencies: react: 18.2.0 use-sync-external-store: 1.2.0_react@18.2.0 - dev: false \ No newline at end of file + dev: false