diff --git a/.changeset/calm-stacks-persist.md b/.changeset/calm-stacks-persist.md new file mode 100644 index 000000000..3ff508f4c --- /dev/null +++ b/.changeset/calm-stacks-persist.md @@ -0,0 +1,5 @@ +--- +"@stackflow/plugin-stack-persistence": major +--- + +Package initial release; Persist and restore complete Stackflow navigation snapshots with optional metadata reuse strategies and explicit load/save error handling. diff --git a/.pnp.cjs b/.pnp.cjs index 6a194ab38..617794768 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -78,6 +78,10 @@ const RAW_RUNTIME_STATE = "name": "@stackflow/plugin-stack-depth-change",\ "reference": "workspace:extensions/plugin-stack-depth-change"\ },\ + {\ + "name": "@stackflow/plugin-stack-persistence",\ + "reference": "workspace:extensions/plugin-stack-persistence"\ + },\ {\ "name": "@stackflow/react-ui-core",\ "reference": "workspace:extensions/react-ui-core"\ @@ -112,6 +116,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-renderer-web", ["workspace:extensions/plugin-renderer-web"]],\ ["@stackflow/plugin-sentry", ["workspace:extensions/plugin-sentry"]],\ ["@stackflow/plugin-stack-depth-change", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-stack-depth-change", "workspace:extensions/plugin-stack-depth-change"]],\ + ["@stackflow/plugin-stack-persistence", ["workspace:extensions/plugin-stack-persistence"]],\ ["@stackflow/react", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "workspace:integrations/react"]],\ ["@stackflow/react-ui-core", ["virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#workspace:extensions/react-ui-core", "workspace:extensions/react-ui-core"]]\ ],\ @@ -7069,6 +7074,21 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@stackflow/plugin-stack-persistence", [\ + ["workspace:extensions/plugin-stack-persistence", {\ + "packageLocation": "./extensions/plugin-stack-persistence/",\ + "packageDependencies": [\ + ["@stackflow/plugin-stack-persistence", "workspace:extensions/plugin-stack-persistence"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@types/node", "npm:20.14.9"],\ + ["esbuild", "npm:0.23.0"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@stackflow/react", [\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", {\ "packageLocation": "./.yarn/__virtual__/@stackflow-react-virtual-eeae00ab9c/1/integrations/react/",\ diff --git a/extensions/plugin-stack-persistence/esbuild.config.js b/extensions/plugin-stack-persistence/esbuild.config.js new file mode 100644 index 000000000..b84dfb4db --- /dev/null +++ b/extensions/plugin-stack-persistence/esbuild.config.js @@ -0,0 +1,29 @@ +const { context } = require("esbuild"); +const config = require("@stackflow/esbuild-config"); +const pkg = require("./package.json"); + +const watch = process.argv.includes("--watch"); +const external = Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, +}); + +Promise.all([ + context({ + ...config({}), + format: "cjs", + external, + }).then((ctx) => + watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()), + ), + context({ + ...config({}), + format: "esm", + outExtension: { + ".js": ".mjs", + }, + external, + }).then((ctx) => + watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()), + ), +]).catch(() => process.exit(1)); diff --git a/extensions/plugin-stack-persistence/package.json b/extensions/plugin-stack-persistence/package.json new file mode 100644 index 000000000..816d51f20 --- /dev/null +++ b/extensions/plugin-stack-persistence/package.json @@ -0,0 +1,52 @@ +{ + "name": "@stackflow/plugin-stack-persistence", + "version": "0.0.0", + "repository": { + "type": "git", + "url": "https://github.com/daangn/stackflow.git", + "directory": "extensions/plugin-stack-persistence" + }, + "license": "MIT", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.mjs" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "yarn build:js && yarn build:dts", + "build:dts": "tsc --emitDeclarationOnly", + "build:js": "node ./esbuild.config.js", + "clean": "rimraf dist", + "dev": "yarn build:js --watch && yarn build:dts --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@stackflow/core": "^3.0.0", + "@stackflow/esbuild-config": "^1.0.3", + "@types/node": "^20.14.9", + "esbuild": "^0.23.0", + "rimraf": "^3.0.2", + "typescript": "^5.5.3" + }, + "peerDependencies": { + "@stackflow/core": "^3.0.0" + }, + "publishConfig": { + "access": "public" + }, + "ultra": { + "concurrent": [ + "dev", + "build" + ] + } +} diff --git a/extensions/plugin-stack-persistence/src/StackSnapshotRecord.ts b/extensions/plugin-stack-persistence/src/StackSnapshotRecord.ts new file mode 100644 index 000000000..1fb6cabdb --- /dev/null +++ b/extensions/plugin-stack-persistence/src/StackSnapshotRecord.ts @@ -0,0 +1,6 @@ +import type { StackSnapshot } from "@stackflow/core"; + +export type StackSnapshotRecord = { + snapshot: StackSnapshot; + metadata: Metadata; +}; diff --git a/extensions/plugin-stack-persistence/src/StackSnapshotStorage.ts b/extensions/plugin-stack-persistence/src/StackSnapshotStorage.ts new file mode 100644 index 000000000..226b78d07 --- /dev/null +++ b/extensions/plugin-stack-persistence/src/StackSnapshotStorage.ts @@ -0,0 +1,6 @@ +import type { StackSnapshotRecord } from "./StackSnapshotRecord"; + +export interface StackSnapshotStorage { + load(): StackSnapshotRecord | null; + save(record: StackSnapshotRecord): Promise; +} diff --git a/extensions/plugin-stack-persistence/src/StackSnapshotStrategy.ts b/extensions/plugin-stack-persistence/src/StackSnapshotStrategy.ts new file mode 100644 index 000000000..7348b6486 --- /dev/null +++ b/extensions/plugin-stack-persistence/src/StackSnapshotStrategy.ts @@ -0,0 +1,13 @@ +import type { StackSnapshot } from "@stackflow/core"; +import type { StackSnapshotRecord } from "./StackSnapshotRecord"; + +export interface StackSnapshotStrategy { + createMetadata(args: { + snapshot: StackSnapshot; + }): Metadata; + + shouldReuse(args: { + record: StackSnapshotRecord; + initialContext: unknown; + }): boolean; +} diff --git a/extensions/plugin-stack-persistence/src/errors.ts b/extensions/plugin-stack-persistence/src/errors.ts new file mode 100644 index 000000000..b155fb6ba --- /dev/null +++ b/extensions/plugin-stack-persistence/src/errors.ts @@ -0,0 +1,19 @@ +export class StackSnapshotRecordSaveError extends Error { + cause: unknown; + + constructor(cause: unknown, message?: string) { + super(message ?? "failed to save stack snapshot record"); + this.name = "StackSnapshotRecordSaveError"; + this.cause = cause; + } +} + +export class StackSnapshotRecordLoadError extends Error { + cause: unknown; + + constructor(cause: unknown, message?: string) { + super(message ?? "failed to load stack snapshot record"); + this.name = "StackSnapshotRecordLoadError"; + this.cause = cause; + } +} diff --git a/extensions/plugin-stack-persistence/src/index.ts b/extensions/plugin-stack-persistence/src/index.ts new file mode 100644 index 000000000..8cbfcae5a --- /dev/null +++ b/extensions/plugin-stack-persistence/src/index.ts @@ -0,0 +1,11 @@ +export { + StackSnapshotRecordSaveError, + StackSnapshotRecordLoadError +} from "./errors"; +export { StackSnapshotRecord } from "./StackSnapshotRecord"; +export { StackSnapshotStorage } from "./StackSnapshotStorage"; +export { StackSnapshotStrategy } from "./StackSnapshotStrategy"; +export { + StackPersistencePluginOptions, + stackPersistencePlugin, +} from "./stackPersistencePlugin"; diff --git a/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts new file mode 100644 index 000000000..10955067e --- /dev/null +++ b/extensions/plugin-stack-persistence/src/stackPersistencePlugin.ts @@ -0,0 +1,67 @@ +import type { + StackflowActions, + StackflowPlugin, +} from "@stackflow/core"; +import { StackSnapshotRecordSaveError, StackSnapshotRecordLoadError } from "./errors"; +import type { StackSnapshotStorage } from "./StackSnapshotStorage"; +import type { StackSnapshotStrategy } from "./StackSnapshotStrategy"; + +export type StackPersistencePluginOptions = { + storage: StackSnapshotStorage; + strategy: StackSnapshotStrategy; + onRecordLoadError?: (error: StackSnapshotRecordLoadError) => void; + onRecordSaveError?: (error: StackSnapshotRecordSaveError) => void; + onLoadError?: NonNullable['onLoadError']>; +} + +export function stackPersistencePlugin( + { storage, strategy, onRecordLoadError, onRecordSaveError, onLoadError }: StackPersistencePluginOptions, +): StackflowPlugin { + return () => { + const saveIfIdle = (actions: StackflowActions) => { + const stack = actions.getStack(); + + if (stack.globalTransitionState !== "idle") return; + + const snapshot = actions.captureSnapshot(); + const metadata = strategy.createMetadata({ snapshot }); + + storage.save({ + snapshot, + metadata + }).catch(error => { + if (onRecordSaveError) return onRecordSaveError(error); + else throw error; + }); + } + + return { + key: "@stackflow/plugin-stack-persistence", + provideSnapshot({ initialContext }) { + try { + const record = storage.load(); + + if (!record) + return null; + if (strategy?.shouldReuse({ record, initialContext }) === false) + return null; + + return record.snapshot; + } catch (error) { + onRecordLoadError?.(new StackSnapshotRecordLoadError(error)); + + return null; + } + }, + onLoadError(...args) { + return onLoadError?.(...args) ?? { policy: "recover" } + }, + onInit({ actions }) { + saveIfIdle(actions); + }, + onChanged({ actions }) { + saveIfIdle(actions); + }, + }; + }; +} diff --git a/extensions/plugin-stack-persistence/tsconfig.json b/extensions/plugin-stack-persistence/tsconfig.json new file mode 100644 index 000000000..e62f52a6c --- /dev/null +++ b/extensions/plugin-stack-persistence/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "module": "preserve", + "declaration": true, + "declarationMap": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist" + }, + "include": ["./src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 351de1117..daafa55a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6000,6 +6000,21 @@ __metadata: languageName: unknown linkType: soft +"@stackflow/plugin-stack-persistence@workspace:extensions/plugin-stack-persistence": + version: 0.0.0-use.local + resolution: "@stackflow/plugin-stack-persistence@workspace:extensions/plugin-stack-persistence" + dependencies: + "@stackflow/core": "npm:^3.0.0" + "@stackflow/esbuild-config": "npm:^1.0.3" + "@types/node": "npm:^20.14.9" + esbuild: "npm:^0.23.0" + rimraf: "npm:^3.0.2" + typescript: "npm:^5.5.3" + peerDependencies: + "@stackflow/core": ^3.0.0 + languageName: unknown + linkType: soft + "@stackflow/react-ui-core@npm:^1.3.6, @stackflow/react-ui-core@workspace:extensions/react-ui-core": version: 0.0.0-use.local resolution: "@stackflow/react-ui-core@workspace:extensions/react-ui-core"