diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index af3ad128..00000000 --- a/.gitattributes +++ /dev/null @@ -1,4 +0,0 @@ -/.yarn/** linguist-vendored -/.yarn/releases/* binary -/.yarn/plugins/**/* binary -/.pnp.* binary linguist-generated diff --git a/.gitignore b/.gitignore index 7eec523b..462414e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,268 +1,50 @@ -# production -/build - -# misc -.DS_Store -*.pem -.idea - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# local env files -.env*.local +# Dependencies and package-manager state +node_modules/ +.pnpm-store/ +*.tgz -# typescript +# Build output and caches +build/ +dist/ +.next/ +out/ +.turbo/ +.cache/ +coverage/ +.nyc_output/ +playwright-report/ +test-results/ +*.lcov *.tsbuildinfo next-env.d.ts +# Environment and local configuration +.env +.env.* +!.env.example +*.local -# compiled output -lib -**/lib - -# OS -.DS_Store - -# Tests -coverage -**/coverage -.nyc_output - -.data -ormconfig.json - -## Terraform -**/.terraform/**/ - -.terraform.lock.hcl -.terraform -# .tfstate files -*.tfstate -*.tfstate.* -*.tfvars - -# Logs -logs +# Logs and diagnostics +logs/ *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) +*-debug.log* report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -*.lcov - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions - -# Swap the comments on the following lines if you don't wish to use zero-installs -# Documentation here: https://yarnpkg.com/features/zero-installs -!.yarn/cache -#.pnp.* - -# Yarn Not-Zero-Installs -# https://yarnpkg.com/getting-started/qa/\#which-files-should-be-gitignored -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/sdks -!.yarn/versions -.turbo +# Local vendored reference repositories +@repos/ +# Editors and operating systems .DS_Store - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files +.idea/ .vscode/* !.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln +!.vscode/settings.json *.sw? - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts - +# Credentials and certificates *.key *.pem *.p12 *.pfx *.crt -*.cer - -.pnpm-store \ No newline at end of file +*.cer \ No newline at end of file diff --git a/.rev-dep.config.jsonc b/.rev-dep.config.jsonc index 5691d0be..232369a1 100644 --- a/.rev-dep.config.jsonc +++ b/.rev-dep.config.jsonc @@ -1,15 +1,16 @@ { - "$schema": "https://github.com/jayu/rev-dep/blob/master/config-schema/1.7.schema.json?raw=true", - "configVersion": "1.7", + "configVersion": "1.11", + "$schema": "https://raw.githubusercontent.com/jayu/rev-dep/master/config-schema/1.11.schema.json", "rules": [ { "path": "packages/widget", "prodEntryPoints": [ "src/index.package.ts", "src/index.bundle.ts", + "src/public-api/index.package.ts", + "src/public-api/index.bundle.ts", "src/main.tsx", "src/translation/i18next.d.ts", - "src/types/purify-extend.d.ts", "src/types/window.d.ts", "src/vite-env.d.ts", "postcss.config.js", @@ -18,16 +19,119 @@ ], "devEntryPoints": [ "scripts/generate-effect-openapi.ts", - "tests/utils/setup.ts", + "tests/utils/setup.browser.ts", + "tests/utils/setup.dom.ts", "tests/**/*.test.ts", "tests/**/*.test.tsx" ], - "orphanFilesDetection": { - "enabled": true + "moduleBoundaries": [ + { + "name": "app", + "pattern": "src/app/**", + "allow": [ + "src/app/**", + "src/features/**", + "src/services/**", + "src/domain/**", + "src/shared/**", + "src/public-api/**", + "src/translation/**" + ] + }, + { + "name": "features", + "pattern": "src/features/**", + "allow": [ + "src/features/**", + "src/app/config/**", + "src/app/runtime/**", + "src/services/**", + "src/domain/**", + "src/shared/**", + "src/public-api/**" + ] + }, + { + "name": "services", + "pattern": "src/services/**", + "allow": [ + "src/services/**", + "src/domain/**", + "src/shared/**", + "src/generated/**", + "src/public-api/**" + ] + }, + { + "name": "domain", + "pattern": "src/domain/**", + "allow": [ + "src/domain/**", + "src/generated/**", + "src/public-api/**", + "src/shared/**" + ] + }, + { + "name": "shared", + "pattern": "src/shared/**", + "allow": ["src/shared/**", "src/domain/**"] + }, + { + "name": "generated", + "pattern": "src/generated/**", + "allow": ["src/generated/**"] + }, + { + "name": "translation", + "pattern": "src/translation/**", + "allow": ["src/translation/**", "src/shared/**"] + }, + { + "name": "public-api", + "pattern": "src/public-api/**", + "allow": ["src/public-api/**"] + } + ], + "restrictedDirectImportersDetection": { + "enabled": true, + "files": ["src/generated/api/**"], + "allowImporters": [ + "src/domain/borrow/**", + "src/domain/schema/**", + "src/services/api/**", + "scripts/**", + "tests/generated/**" + ] }, + "orphanFilesDetection": [ + { + "enabled": true + }, + { + // check for runtime files that are only reachable from tests + "enabled": true, + "validEntryPoints": [ + "src/index.package.ts", + "src/index.bundle.ts", + "src/public-api/index.package.ts", + "src/public-api/index.bundle.ts", + "src/main.tsx", + "src/translation/i18next.d.ts", + "src/types/window.d.ts", + "src/vite-env.d.ts", + "postcss.config.js", + "public/mockServiceWorker.js", + "vite/*.ts", + "scripts/generate-effect-openapi.ts", + "tests/**/*.ts", + "tests/**/*.tsx" + ], + "graphExclude": ["tests/**/*.ts", "tests/**/*.tsx"] + } + ], "unusedExportsDetection": { - "enabled": true, - "ignoreFiles": ["src/types/yield-api-schema.d.ts"] + "enabled": true }, "unusedNodeModulesDetection": { "enabled": true, @@ -35,6 +139,7 @@ "@effect/openapi-generator", "@effect/platform-node", "babel-plugin-react-compiler", + "jsdom", "playwright", "postcss", "swagger2openapi", diff --git a/.vscode/settings.json b/.vscode/settings.json index 49e9f9c6..4e103a2a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,21 +1,16 @@ { - "typescript.preferences.importModuleSpecifier": "relative", + "js/ts.preferences.importModuleSpecifier": "relative", + "js/ts.preferences.autoImportFileExcludePatterns": ["@repos/**"], "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit" }, - - "typescript.preferences.autoImportFileExcludePatterns": ["repos/**"], - "javascript.preferences.autoImportFileExcludePatterns": ["repos/**"], - "files.exclude": { - "repos/**": true + "@repos/**": true }, - "files.watcherExclude": { - "repos/**": true + "@repos/**": true }, - "search.exclude": { - "repos/**": true + "@repos/**": true } } diff --git a/AGENTS.md b/AGENTS.md index 57d73946..db9b5389 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,20 +16,34 @@ - `packages/widget/src/hooks/*` — feature and API hooks. - `packages/widget/src/domain/*` — shared domain types/helpers. - `packages/widget/src/translation/*` — i18n resources (`English`, `French`). -- `packages/widget/tests/*` — Vitest browser tests (MSW-backed). +- `packages/widget/tests/*` — Vitest Node and browser tests; browser tests use the `.browser.test.*` suffix and MSW. - `packages/examples/*` — integration examples (`with-vite`, `with-vite-bundled`, `with-nextjs`, `with-cdn-script`). ## Commands Agents Should Use +### Package manager and dependency installation + +- Run all pnpm commands through the version pinned by mise: `mise exec -- pnpm ...`. +- Do not install dependencies unless a dependency manifest or lockfile changed, `node_modules` is missing or invalid, or the requested work otherwise requires it. +- When sandboxed, request approval to run dependency-installing or dependency-modifying commands outside the sandbox so pnpm can use the normal global store. +- Do not fall back to a sandboxed install or create a project-local `.pnpm-store`. If approval is denied, report the blocked installation instead of changing the store configuration. + ### From repo root (all workspaces via Turbo) -- `pnpm build` — build all packages. -- `pnpm lint` — lint/type-check all packages. -- `pnpm test` — run all workspace tests. -- `pnpm format` — run formatting checks/tasks. -- `pnpm check-hygiene` — check unused deps, unresolved imports, circular deps, etc. + +- `mise exec -- pnpm build` — build all packages. +- `mise exec -- pnpm lint` — lint/type-check all packages. +- `mise exec -- pnpm test` — run all workspace tests. +- `mise exec -- pnpm format` — run formatting checks/tasks. +- `mise exec -- pnpm check-hygiene` — check unused deps, unresolved imports, circular deps, etc. ### Focused widget commands (recommended for most tasks) -- `pnpm --filter @stakekit/widget {command}` + +- `mise exec -- pnpm --filter @stakekit/widget {command}` +- `mise exec -- pnpm --filter @stakekit/widget test:unit` — run the fast Node test project. +- `mise exec -- pnpm --filter @stakekit/widget test:dom` — run React/DOM tests in jsdom. +- `mise exec -- pnpm --filter @stakekit/widget test:browser` — run the Chromium test project. +- `mise exec -- pnpm --filter @stakekit/widget test:changed` — run affected Node + jsdom tests. +- `mise exec -- pnpm --filter @stakekit/widget test:changed:all` — run all affected projects, including Chromium. ## Agent Working Guidelines (short) - Keep public API compatibility in `src/index.package.ts` and `src/index.bundle.ts`. @@ -44,7 +58,7 @@ - React Query defaults are in `packages/widget/src/providers/query-client/index.tsx`. - App-level config/env mapping is in `packages/widget/src/config/index.ts`. - Test bootstrapping + MSW worker setup: - - `packages/widget/tests/utils/setup.ts` + - `packages/widget/tests/utils/setup.browser.ts` - `packages/widget/tests/mocks/worker.ts` ## Vendored Repositories diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..bc27fa4b --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +- fix ast linting \ No newline at end of file diff --git a/agent-patterns/README.md b/agent-patterns/README.md new file mode 100644 index 00000000..edbd0e9d --- /dev/null +++ b/agent-patterns/README.md @@ -0,0 +1,47 @@ +# Effect Agent Patterns + +These guides describe the Effect APIs used by this repository. They target the +vendored Effect snapshot under `@repos/effect`; unstable APIs can change, so the +vendored source wins whenever a guide and a signature disagree. + +## Pick The Smallest Relevant Guide + +| Work involves | Read | +| --- | --- | +| Reactive state, async resources, hydration, or atom lifetimes | `effect-atoms.md` | +| Outgoing HTTP requests, generated clients, retries, or response bodies | `effect-http-client.md` | +| Polling, pagination, callback sources, queues, or incremental processing | `effect-stream.md` | + +Read more than one guide when a boundary crosses modules. In particular: + +- An HTTP response body exposed as a stream needs both the HTTP and Stream + guides. +- An atom backed by a stream needs both the Atom and Stream guides. +- An atom-backed HTTP query needs all three when it incrementally consumes the + response. + +## Source-Checking Workflow + +Before writing Effect code: + +1. Read `@repos/effect/LLMS.md`. +2. Read the relevant guide from this directory. +3. Inspect the exact exported signature and nearby implementation in + `@repos/effect/packages`. +4. Inspect the corresponding vendored tests for lifecycle or concurrency + behavior that the type signature cannot express. + +Use `rg --no-ignore` when searching `@repos/effect`, because the vendored clone +may be ignored by Git locally. Do not use `node_modules` or web documentation as +the authority for Effect behavior in this repository. + +## Repository Rules Still Apply + +- Match nearby imports and project abstractions before introducing a new one. +- Keep generated API clients generated. Put hand-written behavior in transport + or service layers rather than editing generated files. +- Prefer `Effect.gen` and named `Effect.fn` functions for domain operations. +- Keep service requirements in the type until the application boundary provides + the layer. +- Test interruption, finalization, and time-dependent behavior when they are part + of correctness, not only the happy-path value. diff --git a/agent-patterns/effect-atoms.md b/agent-patterns/effect-atoms.md index e0b801e4..fcaefeb9 100644 --- a/agent-patterns/effect-atoms.md +++ b/agent-patterns/effect-atoms.md @@ -1,28 +1,51 @@ # Effect Atom Patterns -Use this when writing or changing code that uses Effect's unstable atom -reactivity APIs. The source of truth reviewed for these patterns is the vendored -Effect repo: `@repos/effect/LLMS.md`, -`@repos/effect/packages/effect/src/unstable/reactivity/Atom.ts`, -`AtomRegistry.ts`, `AsyncResult.ts`, `Reactivity.ts`, `Hydration.ts`, -`AtomHttpApi.ts`, `AtomRpc.ts`, and the tests in -`@repos/effect/packages/effect/test/reactivity`. +Use this guide for Effect's unstable reactivity APIs: local state, derived +state, async resources, mutations, pagination, invalidation, persistence, and +hydration. + +The source of truth is the vendored Effect repository, especially: + +- `@repos/effect/packages/effect/src/unstable/reactivity/Atom.ts` +- `@repos/effect/packages/effect/src/unstable/reactivity/AtomRegistry.ts` +- `@repos/effect/packages/effect/src/unstable/reactivity/AsyncResult.ts` +- `@repos/effect/packages/effect/src/unstable/reactivity/Reactivity.ts` +- `@repos/effect/packages/effect/src/unstable/reactivity/Hydration.ts` +- `@repos/effect/packages/effect/src/unstable/reactivity/AtomHttpApi.ts` +- `@repos/effect/packages/effect/src/unstable/reactivity/AtomRpc.ts` +- `@repos/effect/packages/effect/test/reactivity` ## Imports -Import atom APIs from the unstable reactivity barrel. +Match nearby code. The reactivity barrel is convenient when several modules are +used together; module imports are useful for namespace-style or type-only +imports. ```ts -import { Effect, Layer, Schema, Stream } from "effect" -import { AsyncResult, Atom, AtomRegistry, Hydration } from "effect/unstable/reactivity" +import { Effect, Option, Schema, Stream } from "effect" +import { + AsyncResult, + Atom, + AtomRegistry, + Hydration +} from "effect/unstable/reactivity" ``` -## Registry Owns State +## Mental Model + +An `Atom` is a stable description of how to read or write a value. An +`AtomRegistry` owns the mutable runtime state: -An `Atom` is a value description. An `AtomRegistry` owns cached values, -dependency edges, subscriptions, running fibers, stream scopes, and disposal. -Use one registry per isolated lifetime: UI root, request, route boundary, or -test. +- cached values and serializable preloads +- parent/child dependency edges +- subscriptions and mounted nodes +- running fibers, stream scopes, and finalizers +- idle timers and disposal + +The same atom can hold different values in different registries. Use one +registry per isolated lifetime, such as a UI root or test. Prefer +`AtomRegistry.layer` when an Effect scope owns that lifetime; its finalizer +disposes the registry. ```ts const count = Atom.make(0) @@ -33,31 +56,25 @@ registry.set(count, 21) registry.get(doubled) // 42 ``` -The same atom object can have different values in different registries. After -`registry.dispose()`, later atom access is an error. +A bare `registry.get(atom)` does not mount the atom. An unobserved node is +eligible for disposal on a later scheduler turn. This matters for async work: +mount or subscribe when the computation must remain alive. -## Keep Atom Identity Stable +After `registry.dispose()`, creating or accessing nodes throws. `reset()` clears +nodes but leaves the registry reusable; `dispose()` is terminal. -Do not create parameterized atoms inline during reads or renders. Atom identity -is the cache key unless the atom is serializable. Use `Atom.family` for -parameterized atoms so the same input returns the same atom object. +## Choose The Right Shape -```ts -const userAtom = Atom.family((id: string) => - UserClient.runtime.atom(UserClient.use((client) => client.getUser({ id }))).pipe( - Atom.withLabel(`user:${id}`) - ) -) -``` - -Use `Atom.withLabel` on important atoms. It only adds diagnostic metadata and -does not change behavior. - -## Choose The Right Constructor - -Use `Atom.make(value)` for writable local state, `Atom.make((get) => value)` for -derived synchronous state, and `Atom.make(effectOrStream)` for read atoms that -produce `AsyncResult`. +| Need | API | Exposed value | +| --- | --- | --- | +| Writable local state | `Atom.make(initial)` | `A` | +| Synchronous derivation | `Atom.make((get) => value)` | `A` | +| Async or stream resource | `Atom.make(effectOrStream)` | `AsyncResult` | +| Synchronous command | `Atom.fnSync` | `Option` or configured initial `A` | +| Async command/mutation | `Atom.fn` | `AsyncResult` | +| Incremental page/chunk loading | `Atom.pull` | `PullResult` | +| Existing mutable Effect state | `Atom.subscriptionRef` | writable resource atom | +| Parameterized atom | `Atom.family` | same shape returned by the family | ```ts const search = Atom.make("") @@ -72,57 +89,97 @@ const users = Atom.make((get) => ) ``` -Use `Atom.fn` for command-style effects that run when written to. Use -`Atom.fnSync` for synchronous commands. Before the first write, `Atom.fn` -returns `AsyncResult.initial()` unless `initialValue` is supplied, and -`Atom.fnSync` returns `Option.none()` unless `initialValue` is supplied. +Use `Atom.fn` for work that starts only when a value is written. Before the +first write it is `AsyncResult.initial()` unless `initialValue` is supplied. +The default latest write wins: refreshing the command atom disposes its prior +lifetime and interrupts its computation. ```ts -const saveUser = Atom.fn<{ readonly id: string; readonly name: string }>()( - Effect.fn("saveUser")(function*(input) { - return yield* UserApi.use((api) => api.save(input)) - }) -) +const saveUser = Atom.fn<{ + readonly id: string + readonly name: string +}>()(Effect.fn("saveUser")(function*(input) { + return yield* UserApi.use((api) => api.save(input)) +})) registry.set(saveUser, { id: "1", name: "Ada" }) ``` -Write `Atom.Reset` to clear an `Atom.fn` result back to its initial state and -`Atom.Interrupt` to interrupt the current computation. Set `{ concurrent: true }` -only when multiple writes should run at the same time; the default interrupts -and replaces the previous run. +Write `Atom.Reset` to restore the initial command state and `Atom.Interrupt` to +interrupt the active computation. Use `{ concurrent: true }` only when writes +may overlap and a single shared result atom is still the correct interface. If +callers need individually correlated results, model the operation as a normal +Effect instead. + +## Keep Identity Stable + +Registry caching normally uses atom identity. Do not create parameterized atoms +inline during reads or React renders. Use `Atom.family` so equal inputs return +the same live atom object. + +```ts +const userAtom = Atom.family((id: string) => + UserRuntime.atom( + UserApi.use((api) => api.getUser(id)) + ).pipe(Atom.withLabel(`user:${id}`)) +) +``` + +`Atom.family` uses Effect hashing/equality and weakly holds produced objects when +the platform supports `WeakRef`. Prefer primitive ids or immutable Effect data +as family keys; mutating an object used as a hashed key breaks lookup +assumptions. + +Serializable atoms are different: their serialization key becomes the registry +key. Two atom objects with the same serializable key alias the same registry +node. Keys must therefore be unique and their schemas compatible throughout one +registry. + +Use `Atom.withLabel` on important atoms for diagnostics. It does not change +runtime behavior. ## Read Dependencies Deliberately -Inside an atom read, `get(atom)` records a dependency. When that dependency -changes, the current atom is invalidated. Use `get.once(atom)` when you need the -current value without creating a dependency edge. +In a normal atom read: -Use `get.result(asyncAtom)` to await an `AsyncResult` atom from another effect -atom. It waits while the result is `Initial`; pass `{ suspendOnWaiting: true }` -when stale values marked `waiting` should also suspend. +- `get(atom)` or `get.get(atom)` records a dependency. +- `get.once(atom)` reads without a dependency edge. +- `get.result(asyncAtom)` records a dependency and suspends the current atom + effect while the result is `Initial`. +- `get.resultOnce(asyncAtom)` waits once without making it a reactive + dependency. + +Pass `{ suspendOnWaiting: true }` when stale values marked `waiting` must also +suspend. ```ts const enrichedUser = Atom.make((get) => Effect.gen(function*() { - const user = yield* get.result(userAtom("1"), { suspendOnWaiting: true }) + const user = yield* get.result(userAtom("1"), { + suspendOnWaiting: true + }) return { ...user, displayName: user.name.toUpperCase() } }) ) ``` -In `Atom.fn` bodies, `get.result` behaves as a one-shot wait instead of a normal -dependency read. If the command should rerun from another atom changing, read -that atom with `get(atom)` as part of the command trigger or use reactivity keys. +`Atom.fn` and `Atom.fnSync` are commands, not reactive derivations. Reads through +their `FnContext` are one-shot and do **not** add dependency edges. A command +reruns only when written again. Use a normal derived atom when work should rerun +because another atom changed, or pass the current value as part of the command +input. -## Handle AsyncResult As State +Use `get.subscribe`, `get.mount`, and `get.addFinalizer` only for explicit +lifecycle integration. Their cleanup is tied to the current atom lifetime. + +## Treat AsyncResult As A State Machine `AsyncResult` has three variants: `Initial`, `Success`, and `Failure`. The `waiting` flag is an overlay, not a fourth variant. A waiting success or failure -can still contain the previous usable value. +can retain useful stale data while a refresh runs. Prefer `AsyncResult.matchWithWaiting`, `AsyncResult.builder`, or explicit -refinements instead of assuming any non-success is a loading state. +refinements instead of equating every non-success with loading. ```ts const view = AsyncResult.matchWithWaiting(result, { @@ -133,33 +190,42 @@ const view = AsyncResult.matchWithWaiting(result, { }) ``` -`AsyncResult.value(result)` and `AsyncResult.getOrElse(result, fallback)` may -return a previous success stored inside a failure. Inspect -`AsyncResult.cause(result)` or `AsyncResult.error(result)` when current failure -versus stale data matters. +`AsyncResult.value(result)` and `AsyncResult.getOrElse(result, fallback)` can +return a previous success stored inside a failure. This is useful for keeping +already loaded pages or stale server data visible. Inspect +`AsyncResult.cause(result)` or `AsyncResult.error(result)` when the current +failure must be shown separately from the fallback value. + +Do not drop the `waiting` flag when mapping UI state. It is the signal that a +displayed success or failure is stale and a new computation is active. -## Manage Lifetime Explicitly +## Make Lifetime A Product Decision -Unobserved atoms are auto-disposed by default. That means local state can reset, -effects can restart, streams can resubscribe, and finalizers can run after the -last listener or dependent child disappears. +Unobserved atoms auto-dispose by default. Disposal can reset local state, +interrupt effects, close stream scopes, remove subscriptions, and run +finalizers. -Use the narrowest lifetime tool that matches the behavior: +Use the narrowest lifetime tool that matches the requirement: -- `Atom.keepAlive` keeps an atom cached even when unobserved. -- `Atom.setIdleTTL(duration)` keeps an unused atom around for a finite idle time. -- `Atom.autoDispose` restores default disposal on a copied atom. -- `registry.mount(atom)` keeps an atom alive until the returned release function - is called. -- `Atom.mount(atom)` keeps an atom alive for the current Effect scope. +- `registry.subscribe(atom, listener)` observes until its release callback runs. +- `registry.mount(atom)` keeps it alive until its release callback runs. +- `Atom.mount(atom)` keeps it alive for the current Effect scope. +- `Atom.setIdleTTL(duration)` retains it for a finite unobserved interval. +- `Atom.keepAlive` retains it for the entire registry lifetime. +- `Atom.autoDispose` removes a copied atom's `keepAlive` behavior. -Always release `registry.subscribe` and `registry.mount` callbacks when -integrating with external callback-based code. +Always release registry subscriptions and mounts. Be especially careful with a +high-cardinality `Atom.family`: combining it with `keepAlive` or a long TTL can +turn a weak family into an effectively unbounded registry cache. -## Batch Related Writes +Registry `initialValues` seed a node but do not mount it. The seeded value is +still subject to normal disposal and recomputation. -Use `Atom.batch` when multiple synchronous writes should invalidate dependents -and notify listeners once after the final state is known. +## Batch Related Synchronous Writes + +Use `Atom.batch` when several synchronous writes form one logical state change. +Dependents can rebuild from the latest values inside the batch, but listeners +are notified after the outermost batch commits. ```ts Atom.batch(() => { @@ -168,23 +234,21 @@ Atom.batch(() => { }) ``` -Reads inside a batch can still rebuild from the latest written state, but -listeners are notified after the batch commits. +`Atom.batch` is not an Effect transaction and does not wait for async work. Use +it only around synchronous registry operations. ## Use Runtime Atoms For Services -Use `Atom.runtime(layer)` or `Atom.context({ memoMap })` when atom effects need -Effect services. The runtime builds the layer with a memo map, provides -`AtomRegistry`, `Scope`, `Scheduler`, and `Reactivity`, and exposes -`runtime.atom`, `runtime.fn`, `runtime.pull`, and `runtime.subscriptionRef`. +Plain async atoms may require only `Scope` and `AtomRegistry`. Use an atom +runtime when effects also require application services. ```ts const UserRuntime = Atom.runtime(UserApi.layer) const user = Atom.family((id: string) => - UserRuntime.atom(UserApi.use((api) => api.getUser(id)), { - initialValue: { id, name: "Loading" } - }) + UserRuntime.atom( + UserApi.use((api) => api.getUser(id)) + ) ) const saveUser = UserRuntime.fn( @@ -195,49 +259,114 @@ const saveUser = UserRuntime.fn( ) ``` -Use registry `initialValues` with `Atom.initialValue(runtime.layer, testLayer)` -to replace runtime services in tests. +`Atom.runtime(layer)` uses the default shared `Layer.MemoMap`. +`Atom.context({ memoMap })` creates a factory with an explicit memoization +boundary and exposes `addGlobalLayer`. The runtime supplies `AtomRegistry`, +`Scope`, `Scheduler`, and `Reactivity` while building its layer. -## Invalidate Server State With Reactivity Keys +In tests, replace a runtime layer with registry initial values: -Use `Atom.withReactivity(keys)` for reads that should refresh after matching -invalidations. Use `runtime.fn(..., { reactivityKeys })`, -`Reactivity.mutation(effect, keys)`, or `Reactivity.invalidate(keys)` for writes -that should trigger those refreshes after success. +```ts +const registry = AtomRegistry.make({ + initialValues: [ + Atom.initialValue(UserRuntime.layer, UserApi.testLayer) + ] +}) +``` + +Mount the resource under test so the seeded runtime and async work are not +disposed between assertions. + +## Model Server Resources With SWR And TTL -Keys can be a flat array or a record. Prefer stable primitive keys or stable ids; -non-primitive keys are matched by `Hash.hash`. +`Atom.swr` adds stale-while-revalidate behavior to an `AsyncResult` atom. +`staleTime` controls freshness; `revalidateOnMount` and `revalidateOnFocus` +control automatic refresh triggers. ```ts -const user = UserRuntime.atom(UserApi.use((api) => api.getUser("1"))).pipe( - UserRuntime.factory.withReactivity({ users: ["1"] }) +const usersResource = UserRuntime.atom( + UserApi.use((api) => api.listUsers()) +).pipe( + Atom.swr({ + staleTime: "30 seconds", + revalidateOnMount: true, + revalidateOnFocus: true, + focusSignal: appFocusSignal + }), + Atom.setIdleTTL("5 minutes") ) +``` -const saveUser = UserRuntime.fn( - (input: User) => UserApi.use((api) => api.saveUser(input)), - { reactivityKeys: { users: ["1"] } } +Important details: + +- A manual registry refresh is forceful even while data is fresh. +- A stale success or failure keeps its previous success while revalidation + runs. +- Focus revalidation requires both `revalidateOnFocus` and a `focusSignal`. +- `true` respects `staleTime`; `"always"` forces a refresh on every signal. +- Freshness uses `AsyncResult` timestamps and host `Date.now`. + +Apply resource policy combinators before `Atom.serializable`. Transforming an +atom can intentionally remove its serializable marker; serialize the final +public resource that should cross registries. + +## Invalidate Server State With Reactivity Keys + +Attach keys to reads with `Atom.withReactivity` or +`runtime.factory.withReactivity`. Invalidate them through +`runtime.fn(..., { reactivityKeys })`, `Reactivity.mutation`, or +`Reactivity.invalidate`. + +```ts +const user = UserRuntime.atom( + UserApi.use((api) => api.getUser("1")) +).pipe( + UserRuntime.factory.withReactivity({ users: ["1"] }) ) ``` +Key matching is hash based, not deep structural matching. Prefer primitives or +immutable Effect data. Record keys have hierarchical behavior: +`{ users: ["1"] }` registers both `"users"` and `"users:1"`. Consequently, +two records with the same `users` group overlap even when their ids differ. Use +a flat composite key such as `["users:1"]` when invalidation must be strictly +per id. + +`Reactivity.mutation(effect, keys)` invalidates only after an Effect succeeds. +A stream-returning runtime command uses stream finalization to invalidate, so it +also invalidates when that stream ends through failure or interruption. Choose +the command shape with that distinction in mind. + ## Streams, Pulls, And SubscriptionRefs -An `Atom.make(stream)` stores the latest emitted item in an `AsyncResult`. An -empty stream completes as `NoSuchElementError`. Failures preserve the latest -previous success when possible. +`Atom.make(stream)` subscribes while the atom is alive and stores the latest +emitted item as an `AsyncResult`. A stream that completes without emitting fails +with `NoSuchElementError`. A later failure preserves a previous success when one +exists. -Use `Atom.pull(stream)` or `runtime.pull(stream)` for paginated or incremental -streams that should advance only when the atom is written to. It accumulates -items by default; pass `{ disableAccumulation: true }` when each pull should -replace the previous batch. +Use `Atom.pull` for demand-driven pagination. The first chunk is pulled when the +atom is read; each write requests another chunk. + +```ts +const pages = Atom.pull( + Stream.paginate(initialCursor, fetchPage) +) +``` + +With default accumulation, the success value contains `{ items, done }` and +`done` becomes true after the terminal pull. Pass +`{ disableAccumulation: true }` when each pull should replace the prior batch or +when retaining the entire history would be too expensive. In that mode, a +terminal pull with no new items can surface `NoSuchElementError` instead of a +final accumulated `{ done: true }` value. Avoid issuing another write while the +pull result is already waiting unless overlapping pulls are intentional. Use `Atom.subscriptionRef(refOrEffect)` when state already lives in a -`SubscriptionRef`; writes to the atom update the ref. +`SubscriptionRef`; atom writes update the ref. ## Persistence And Hydration -Mark atoms that cross registry boundaries with `Atom.serializable({ key, -schema })`. Only serializable atoms are included in `Hydration.dehydrate`, and -`Hydration.hydrate` must run before the matching atoms are first read. +Mark only state that crosses registry boundaries as serializable. ```ts const user = userAtom("1").pipe( @@ -254,53 +383,66 @@ const state = Hydration.dehydrate(serverRegistry) Hydration.hydrate(clientRegistry, state) ``` -Stable keys matter more than atom identity during hydration. The target atom must -use a compatible schema. `Hydration.dehydrate(..., { encodeInitialAs: "promise" })` -uses a live JavaScript promise for initial async results, so do not serialize -that form across JSON or processes. - -For browser URL state, `Atom.searchParam` requires synchronous schemas with no -Effect context. For storage-backed state, use `Atom.kvs` with an atom runtime -that provides `KeyValueStore`. - -## Remote API Helpers - -Use `AtomHttpApi.Service` or `AtomRpc.Service` when typed HTTP API or RPC -clients should participate in atom caching, invalidation, and hydration. - -- Queries return `Atom>` for non-streaming endpoints. -- Mutations return `AtomResultFn`. -- `reactivityKeys` connect successful mutations to query refreshes. -- `timeToLive` maps to `Atom.setIdleTTL` for finite durations and - `Atom.keepAlive` for infinite durations. -- `serializationKey` is required for serializable queries, and should uniquely - identify the endpoint plus request. -- RPC streaming queries return pull atoms and are not serializable query atoms. +Hydrate before the target atom is first read. Hydration preloads encoded values +by key; it does not replace an already-built node. Serialization uses +synchronous JSON codecs, so schemas must not require Effect services. -## Testing Patterns +`dehydrate` ignores `AsyncResult.Initial` by default. The other modes are: -Use `it.effect` for Effect-based tests, `AtomRegistry.make()` for an isolated -cache, fake timers or `TestClock` for delayed atoms, and explicit -`registry.mount(atom)` when async work must stay alive during the test. +- `"value-only"` encodes the initial value itself. +- `"promise"` includes a live promise that later updates the target registry. -```ts -it.effect("refreshes after mutation", () => - Effect.gen(function*() { - const registry = AtomRegistry.make() - const unmount = registry.mount(user) +The promise mode is process-local JavaScript state. Do not JSON-serialize it or +send it across a network/process boundary. - registry.set(saveUser, { id: "1", name: "Grace" }) - yield* Effect.yieldNow +For browser URL state, `Atom.searchParam` requires a synchronous schema with no +context. For storage state, use `Atom.kvs` with a runtime providing +`KeyValueStore`. - const result = registry.get(user) - assert(AsyncResult.isSuccess(result)) - assert.strictEqual(result.value.name, "Grace") +## Remote API Helpers - unmount() - })) -``` +Use `AtomHttpApi.Service` or `AtomRpc.Service` when a typed HTTP API or RPC +client should participate directly in atom caching and invalidation. -Prefer `yield* Effect.yieldNow`, fake timer advancement, or `TestClock` over -real sleeps. Assert `AsyncResult` variants and `waiting` flags directly. For -lifetime behavior, assert node disposal by reading again after yielding, or use -`keepAlive` / `mount` when state should persist. +- Non-streaming queries return `Atom>`. +- Mutations return `AtomResultFn`. +- `reactivityKeys` connect commands to query refreshes. +- `timeToLive` maps finite values to idle TTL and infinity to `keepAlive`. +- Serializable queries require a unique `serializationKey` per endpoint and + request. +- Streaming RPC queries return pull atoms and are not serializable query atoms. + +Prefer the project's generated client plus hand-written transport/service layer +when those helpers would duplicate existing abstractions. + +## Testing + +Create a fresh registry per test and dispose it or release every mount. Test the +state machine, not only the final success: + +- initial and waiting state +- stale success during refresh +- failure with and without previous success +- interruption or latest-write-wins behavior +- finalizers and disposal +- pagination completion and accumulated items + +Use regular Vitest tests for purely imperative registry logic. Run effectful +assertions with the repository's existing `Effect.runPromise` pattern. Mount +async atoms before yielding or advancing time. + +Registry idle TTL, `Atom.swr`, `Atom.debounce`, and several focus/storage helpers +use host timers or `Date.now`; control those with the test runner's fake timers. +Use `TestClock` only when the Effect inside the runtime is explicitly receiving +the test clock service. + +## Review Checklist + +- Atom and serializable identities are stable and unique. +- Command reads are not mistaken for reactive dependencies. +- Async UI preserves both stale values and the `waiting` flag. +- Every subscription or mount has a matching release. +- High-cardinality families have a bounded cache policy. +- Reactivity keys have the intended broad or per-id overlap. +- Hydration happens before first read and uses a synchronous compatible schema. +- Time and finalization behavior are covered by tests. diff --git a/agent-patterns/effect-http-client.md b/agent-patterns/effect-http-client.md index 7d6ad916..50b3fa2d 100644 --- a/agent-patterns/effect-http-client.md +++ b/agent-patterns/effect-http-client.md @@ -1,31 +1,54 @@ # Effect HttpClient Patterns -Use this when writing project code that talks to external HTTP APIs with Effect. -The source of truth reviewed for these patterns is the vendored Effect repo: -`repos/effect/LLMS.md`, `repos/effect/ai-docs/src/50_http-client/10_basics.ts`, -and the `repos/effect/packages/effect/src/unstable/http/HttpClient*.ts` modules -plus their tests. +Use this guide for outgoing HTTP APIs: request construction, generated clients, +middleware, retries, rate limits, response decoding, streaming bodies, and +tests. -## Imports +The source of truth is the vendored Effect repository, especially: -Prefer the unstable HTTP barrel unless nearby code imports individual modules. +- `@repos/effect/packages/effect/src/unstable/http/HttpClient.ts` +- `@repos/effect/packages/effect/src/unstable/http/HttpClientRequest.ts` +- `@repos/effect/packages/effect/src/unstable/http/HttpClientResponse.ts` +- `@repos/effect/packages/effect/src/unstable/http/HttpClientError.ts` +- `@repos/effect/packages/effect/src/unstable/http/HttpIncomingMessage.ts` +- `@repos/effect/packages/effect/src/unstable/http/FetchHttpClient.ts` +- `@repos/effect/packages/effect/src/unstable/persistence/RateLimiter.ts` +- `@repos/effect/packages/effect/test/unstable/http` + +Read `effect-stream.md` as well when a request or response body is streamed. + +## Imports And Transport + +Match nearby code. The unstable HTTP barrel is convenient for hand-written +services; generated clients in this repository use module imports. ```ts import { Context, Effect, flow, Layer, Schedule, Schema, Stream } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse +} from "effect/unstable/http" import { RateLimiter } from "effect/unstable/persistence" ``` -Platform implementations are provided as layers. Use `FetchHttpClient.layer` for -portable fetch-based code unless the runtime has a more specific layer nearby -(`NodeHttpClient.layerFetch`, `NodeHttpClient.layerNodeHttp`, -`NodeHttpClient.layerUndici`, `BrowserHttpClient.layerXMLHttpRequest`, etc.). +An `HttpClient` describes request preprocessing and response postprocessing. A +platform layer supplies the low-level transport: + +- `FetchHttpClient.layer` for browsers, edge runtimes, and portable Fetch code +- a nearby Node or browser-specific client when its transport behavior is + required -## Build API Clients As Services +Fetch behavior such as CORS, credentials, redirect defaults, and streaming +support still depends on the runtime. Do not assume all platform layers behave +identically. -Wrap API-specific HTTP behavior in a `Context.Service` layer. Acquire -`HttpClient.HttpClient` once, then apply shared middleware such as base URL, -headers, status filtering, retries, tracing spans, and error mapping. +## Put API Policy In A Service Boundary + +Generated code should describe endpoints. Hand-written transport or service +layers should own base URLs, authentication, retry policy, observability, and +domain error mapping. ```ts class Todo extends Schema.Class("Todo")({ @@ -57,27 +80,29 @@ export class TodoApi extends Context.Service new TodoApiError({ cause })), - Effect.withSpan("TodoApi.getTodo") + Effect.mapError((cause) => new TodoApiError({ cause })) ) }) return TodoApi.of({ getTodo }) }) - ).pipe( - Layer.provide(FetchHttpClient.layer) - ) + ).pipe(Layer.provide(FetchHttpClient.layer)) } -export class TodoApiError extends Schema.TaggedErrorClass()("TodoApiError", { - cause: Schema.Defect -}) {} +export class TodoApiError extends Schema.TaggedErrorClass()( + "TodoApiError", + { cause: Schema.Defect() } +) {} ``` -## Requests Are Immutable Values +Acquire and configure the shared client once while building the service. Keep +endpoint functions small and map library errors into domain errors at this +boundary. Do not leak authentication headers or raw response bodies into logs. + +## Build Immutable Requests Use `HttpClientRequest` constructors and combinators instead of hand-building -fetch options. Each combinator returns a new request. +Fetch options. Every combinator returns a new request. ```ts const request = HttpClientRequest.post("/todos").pipe( @@ -90,9 +115,13 @@ const request = HttpClientRequest.post("/todos").pipe( const response = yield* client.execute(request) ``` -Prefer schema-backed encoders when a schema exists. `schemaBodyJson` and -`bodyJson` fail in the Effect error channel; `bodyJsonUnsafe` may throw during -JSON encoding and is best reserved for generated code or already-safe payloads. +Choose body encoding deliberately: + +- `schemaBodyJson(schema)(value)` validates/encodes through a schema and can + require encoding services. +- `bodyJson(value)` catches JSON encoding failures as `HttpBodyError`. +- `bodyJsonUnsafe(value)` is synchronous but may throw. Reserve it for generated + code or values whose serializability is already guaranteed. ```ts const createTodo = (input: typeof NewTodo.Type) => @@ -103,47 +132,92 @@ const createTodo = (input: typeof NewTodo.Type) => ) ``` -Use `setUrlParams` when replacing query values and `appendUrlParams` when -multiple values with the same key must be preserved. Passing a `URL` to a -request constructor extracts search parameters and hash into structured request -fields. +Use `setUrlParams` to replace values and `appendUrlParams` to preserve repeated +keys. Passing a `URL` to a request constructor extracts its query and hash into +the request's structured fields. + +Be careful with middleware that sets headers: later `setHeader` calls replace +the same header. Prefer one clearly owned authentication transform. -## Status Codes Are Not Errors By Default +## Decide Status Policy Before Decoding -`HttpClient` succeeds with an `HttpClientResponse` for non-2xx statuses. Choose -one of these explicitly: +HTTP status is data by default. `HttpClient` succeeds with a response for 4xx +and 5xx statuses unless a filter turns them into failures. + +Use a client-wide filter only when every endpoint has the same status contract: ```ts -// Simple API: fail on anything outside 2xx. -const json = yield* client.get("/todos/1").pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), +const okClient = client.pipe(HttpClient.filterStatusOk) + +const todo = yield* okClient.get("/todos/1").pipe( Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)) ) ``` +For typed non-2xx responses, do not apply `filterStatusOk` before matching. +Branch on the raw response first: + ```ts -// Typed API: branch by exact status or status class. const result = yield* client.post("/todos").pipe( Effect.flatMap(HttpClientResponse.matchStatus({ 201: HttpClientResponse.schemaBodyJson(Todo), 400: (response) => - Effect.flatMap( - HttpClientResponse.schemaBodyJson(ApiProblem)(response), - (problem) => Effect.fail(new BadRequest({ problem })) + HttpClientResponse.schemaBodyJson(ApiProblem)(response).pipe( + Effect.flatMap((problem) => Effect.fail(new BadRequest({ problem }))) ), - "5xx": (response) => Effect.fail(new RemoteServiceUnavailable({ status: response.status })), - orElse: (response) => Effect.fail(new UnexpectedStatus({ status: response.status })) + "5xx": (response) => + Effect.fail(new RemoteServiceUnavailable({ status: response.status })), + orElse: (response) => + Effect.fail(new UnexpectedStatus({ status: response.status })) })) ) ``` -Use `HttpClient.filterStatusOk` on the client when every request made by that -client expects 2xx responses. Use `HttpClientResponse.matchStatus` when the API -has typed non-2xx responses. +Exact status handlers win over status-class handlers. Always include `orElse` +so new or undocumented statuses have an explicit path. + +## Know The Error Layers + +There is not one universal HTTP error type. The operation determines the error +channel: -## Decode Responses Deliberately +| Operation | Typical failure | +| --- | --- | +| Request schema/JSON encoding before execution | `HttpBodyError` | +| Transport, URL construction, status filtering, body reading | `HttpClientError` | +| Schema validation of decoded body/headers | `SchemaError` | +| Hand-written API boundary | mapped domain error | -Body readers are effects and can fail with `HttpClientError`: +`HttpClientError` wraps a more specific `reason`: + +- `TransportError`, `EncodeError`, and `InvalidUrlError` have no response. +- `StatusCodeError`, `DecodeError`, and `EmptyBodyError` include a response. + +Catch the outer `HttpClientError`, then inspect `reason._tag`. Catching +`StatusCodeError` directly with `Effect.catchTag` does not work on a normal +client call because it is nested. + +```ts +const recovered = client.get("/todos/1").pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.catchTag("HttpClientError", (error) => { + if ( + error.reason._tag === "StatusCodeError" && + error.response?.status === 404 + ) { + return Effect.succeedNone + } + return Effect.fail(error) + }) +) +``` + +This handler does not catch `SchemaError` from a later schema decoder. Map both +at the service boundary when the public API exposes one domain error. + +## Decode A Body Once, Deliberately + +Raw body readers are effects and can fail with `HttpClientError`: ```ts yield* response.text @@ -152,7 +226,7 @@ yield* response.arrayBuffer yield* response.urlParamsBody ``` -Prefer schema decoders for JSON and headers: +Prefer schema decoders at trust boundaries: ```ts yield* HttpClientResponse.schemaBodyJson(Todo)(response) @@ -160,72 +234,84 @@ yield* HttpClientResponse.schemaJson(ResponseEnvelope)(response) yield* HttpClientResponse.schemaNoBody(HeaderOnlyResponse)(response) ``` -Use `schemaBodyJson` for body-only JSON. Use `schemaJson` when the schema covers -the whole `{ status, headers, body }` response shape. `response.json` parses an -empty text body as `null`. `response.stream` fails if there is no body. +- `schemaBodyJson` decodes only the JSON body. +- `schemaJson` decodes `{ status, headers, body }` together. +- `schemaNoBody` decodes status and headers without consuming a body. +- `response.json` treats an empty text body as `null`. +- `response.stream` fails with `EmptyBodyError` when there is no body. -## Error Handling +Text and array-buffer access are cached by the Web response wrapper, but a body +stream is a live consumable resource. Do not inspect a body through `text` or +`json` and then expect to stream the same body, or stream it and then decode it. +Choose buffered decoding or streaming at the endpoint boundary. -The public failure is usually `HttpClientError.HttpClientError`. Inspect -`error.reason._tag` for the precise cause: +## Understand Middleware Ordering -- `TransportError`, `EncodeError`, and `InvalidUrlError` happen before a - response exists. -- `StatusCodeError`, `DecodeError`, and `EmptyBodyError` include the response. +Request transforms and response transforms compose differently: -Do not try to catch `StatusCodeError` directly with `Effect.catchTag` on a normal -client call; the outer tag is `HttpClientError`. Catch `HttpClientError` and -branch on `reason._tag`, or map errors at the API service boundary. +- `mapRequest` appends preprocessing; successive calls execute in pipe order. +- `mapRequestInput` prepends preprocessing before transforms already installed. +- Each later response combinator wraps the response behavior built before it. ```ts -const recovered = client.get("/todos/1").pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.catchTag("HttpClientError", (error) => { - if (error.reason._tag === "StatusCodeError" && error.response?.status === 404) { - return Effect.succeedNone - } - return Effect.fail(error) - }) +const client = baseClient.pipe( + HttpClient.mapRequest( + HttpClientRequest.prependUrl("https://api.example.com") + ), + HttpClient.followRedirects(), + HttpClient.filterStatusOk, + HttpClient.retryTransient({ times: 3 }) ) ``` -## Middleware Ordering +Ordering consequences: + +- Put `followRedirects` before `filterStatusOk`; otherwise a visible 3xx can be + rejected before redirect handling sees it. +- Put `tap` before a status filter to inspect every response, including non-2xx. + Put it after the filter to observe accepted responses only; use `tapError` for + rejected ones. +- Middleware inside a retry wrapper runs for every attempt. Middleware added + after the retry sees only the final outcome. +- `filterStatusOk` may appear before `retryTransient`: transient + `StatusCodeError` reasons are recognized. Without a filter, transient raw + responses are also recognized by the default response retry mode. -`HttpClient` middleware transforms a client and is usually read left to right in -the `pipe`. +Use `mapRequestInput` rarely. Most base URLs, headers, and auth belong in normal +`mapRequest` transforms whose order is visible in the pipe. + +## Retry Only Safe Operations + +`retryTransient` retries timeouts, transport errors, and these statuses: +`408`, `429`, `500`, `502`, `503`, and `504`. Its default mode is +`"errors-and-responses"`. ```ts -const client = baseClient.pipe( - HttpClient.mapRequest(HttpClientRequest.prependUrl("https://api.example.com")), - HttpClient.mapRequest(HttpClientRequest.bearerToken(token)), - HttpClient.filterStatusOk, - HttpClient.retryTransient({ times: 3 }) +const retried = client.pipe( + HttpClient.retryTransient({ + schedule: Schedule.exponential("100 millis"), + times: 3 + }) ) ``` -Use `mapRequest` for transformations that should run after existing request -preprocessing. Use `mapRequestInput` only when a transformation must run before -previously installed request middleware. - -Useful middleware: - -- `mapRequest` / `mapRequestEffect` for base URLs, headers, auth, and request - normalization. -- `filterStatus` / `filterStatusOk` for turning unacceptable statuses into - failures. -- `retryTransient` for transport failures, timeouts, and transient statuses - (`408`, `429`, `500`, `502`, `503`, `504`). The default `retryOn` is - `"errors-and-responses"`. -- `followRedirects()` for following 3xx `location` headers, defaulting to 10 - redirects. -- `withCookiesRef` for cookie jars across requests. -- `tap`, `tapError`, and `tapRequest` for logging/metrics without changing the - response. +`times` counts retries, not total attempts. `times: 3` can execute four total +attempts. A custom `while` predicate adds errors to the built-in transient set; +it does not replace that set, and it is ignored in `"response-only"` mode. + +The client does **not** check whether the HTTP method is idempotent. The same +policy retries GET and POST. Before applying retries to mutations, require an +API-supported idempotency key or other proof that replay cannot duplicate a +side effect. Prefer separate read and mutation clients when their retry policies +differ. + +Also confirm the request body can be replayed. A live stream or external handle +may not be safe to execute again even when the method is idempotent. ## Rate Limiting -Use `HttpClient.withRateLimiter` with the `RateLimiter` service when requests -must share a limit by key. +`HttpClient.withRateLimiter` coordinates requests that share a key through the +`RateLimiter` service. ```ts const limited = client.pipe( @@ -238,73 +324,116 @@ const limited = client.pipe( ) ``` -By default, it inspects common rate-limit headers such as `ratelimit-limit`, -`x-ratelimit-limit`, `ratelimit-remaining`, `retry-after`, and reset headers. -It also retries `429` responses, including `HttpClientError` values wrapping a -429 `StatusCodeError`, by sending the retry through the limiter again. Set -`disableResponseInspection: true` only when response headers should not affect -future limits. +Pick keys with intentional cardinality: per upstream, account, or endpoint. +Avoid accidentally including unique query data when all requests should share a +limit. + +By default the middleware learns from common `RateLimit-*`, `X-RateLimit-*`, and +`Retry-After` headers. It sends 429 responses, including filtered 429 +`HttpClientError` values, back through the limiter. This 429 loop is not bounded +by a `times` option, so use an enclosing timeout/cancellation policy when the +upstream may remain rate-limited indefinitely. + +`disableResponseInspection: true` stops header-based learning. It does not turn +off 429 retries; those still pass through the configured limiter. + +Provide both the limiter and its store layer at the application/test boundary. +Use `TestClock` in rate-limit tests rather than real waits. -## Streaming And Scope +## Stream Bodies And Control Scope -For response bodies, prefer `HttpClientResponse.stream(effect)` or unwrap -`response.stream` and consume it with `Stream` combinators. +Convert a response effect directly into a byte stream: ```ts const text = yield* client.get("/events").pipe( - Effect.map((response) => response.stream), - Stream.unwrap, + HttpClientResponse.stream, Stream.decodeText(), Stream.mkString ) ``` -Non-scoped responses are tied to an abort controller so interrupted body reads -and early-ending streams abort the underlying request. If a request lifetime -must be controlled by a surrounding `Scope`, apply `HttpClient.withScope`. +Keep the body incremental for large or open-ended responses. Do not use +`Stream.mkString`, `runCollect`, or `mkUint8Array` unless the body is known to be +finite and bounded. -## Tracing +Normal responses use an abort controller. Interrupting a body read or ending a +response stream early aborts the underlying request. Apply +`HttpClient.withScope` when the request lifetime must instead be attached to an +explicit surrounding `Scope`; closing that scope aborts it. + +## Tracing And Inspection HttpClient creates client spans by default and records method, URL, status, and -redacted headers. Use `Effect.withSpan` around domain operations and -`Effect.annotateCurrentSpan` for request-specific attributes. +redacted headers. Wrap domain operations in named `Effect.fn` functions or +`Effect.withSpan`, and add safe request identifiers with +`Effect.annotateCurrentSpan`. -Context references can tune tracing: +Context references can tune client tracing: - `HttpClient.TracerDisabledWhen` disables spans for matching requests. - `HttpClient.TracerPropagationEnabled` controls outgoing trace headers. -- `HttpClient.SpanNameGenerator` customizes span names. +- `HttpClient.SpanNameGenerator` customizes client span names. -## Testing Patterns +Use `tap`, `tapError`, and `tapRequest` for logging or metrics without changing +results. Body inspection is a real decode and can consume a streamed body; do +it only for endpoints that use buffered bodies. Keep secrets in headers covered +by Effect's redaction configuration. -Use `it.effect`, `assert` / `strictEqual` helpers from `@effect/vitest`, and -`TestClock` for time-dependent behavior. Avoid `Effect.runSync` in tests. +## Testing -Test API code with `HttpClient.make` and `HttpClientResponse.fromWeb` rather -than real network calls. +Use a fake `HttpClient.make` transport instead of the network. Build responses +with `HttpClientResponse.fromWeb` and assert the fully preprocessed request when +middleware behavior matters. ```ts -it.effect("decodes todo", () => - Effect.gen(function*() { - const client = HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify({ id: 1, title: "Test", completed: false }), { +import { expect, it } from "vitest" + +it("decodes a todo", async () => { + const client = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + JSON.stringify({ + id: 1, + title: "Test", + completed: false + }), + { status: 200, headers: { "content-type": "application/json" } - }) + } ) ) ) + ) - const todo = yield* program.pipe( + const todo = await Effect.runPromise( + HttpClient.get("https://example.com/todos/1").pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)), Effect.provide(Layer.succeed(HttpClient.HttpClient, client)) ) + ) - strictEqual(todo.id, 1) - })) + expect(todo.id).toBe(1) +}) ``` -For retry and rate-limit tests, count attempts in a `Ref`, fork the request, and -advance `TestClock` instead of waiting in real time. +Cover at least the status and decode cases in the public contract. For retry, +redirect, timeout, and rate-limit behavior: + +- count attempts with `Ref` +- fork with `startImmediately: true` when the operation must reach a sleep +- provide `TestClock.layer()` and advance `TestClock` from `effect/testing` +- assert interruption and abort signals when lifetime is part of correctness + +## Review Checklist + +- Generated clients remain generated; policy lives in hand-written layers. +- Every endpoint explicitly filters or matches status. +- Request encoding, `HttpClientError`, and `SchemaError` are mapped intentionally. +- Middleware order matches which attempts/responses each observer should see. +- Retried methods and bodies are safe to replay. +- Rate-limit keys are bounded and persistent 429 behavior is cancellable. +- Large or infinite bodies remain streamed and are consumed once. +- Tests cover non-2xx, invalid payloads, timing, and cancellation as applicable. diff --git a/agent-patterns/effect-stream.md b/agent-patterns/effect-stream.md index a32eaef1..32bead04 100644 --- a/agent-patterns/effect-stream.md +++ b/agent-patterns/effect-stream.md @@ -1,14 +1,24 @@ # Effect Stream Patterns -Use this when writing project code that models incremental, pull-based data with -Effect `Stream`. The source of truth reviewed for these patterns is the vendored -Effect repo: `@repos/effect/LLMS.md`, -`@repos/effect/ai-docs/src/02_stream/*`, and -`@repos/effect/packages/effect/src/Stream.ts`. +Use this guide when data is incremental, pull-based, potentially large, or +open-ended: polling, pagination, events, queues, byte streams, concurrent +pipelines, and resource-backed producers. + +The source of truth is the vendored Effect repository, especially: + +- `@repos/effect/packages/effect/src/Stream.ts` +- `@repos/effect/ai-docs/src/02_stream` +- `@repos/effect/packages/effect/test/Stream.test.ts` +- platform adapters such as + `@repos/effect/packages/platform-node-shared/src/NodeStream.ts` + +Read `effect-http-client.md` as well for HTTP response or request bodies, and +`effect-atoms.md` when a stream backs an atom. ## Imports -Prefer the stable `effect` barrel unless nearby code imports individual modules. +Prefer the stable `effect` barrel unless nearby code uses module imports. +Testing utilities have a separate entry point. ```ts import { @@ -19,52 +29,54 @@ import { Queue, Schedule, Sink, - Stream, - TestClock + Stream } from "effect" +import { TestClock } from "effect/testing" ``` -For Node.js readable streams, use the platform adapter in Node-only code. +For Node.js readable streams, use the platform adapter only in Node-specific +code. ```ts import { NodeStream } from "@effect/platform-node" ``` -## Streams Are Lazy Descriptions +## Mental Model -A `Stream.Stream` describes a sequence that can emit zero or more `A` -values, fail with `E`, and require services from `R`. It does not run until it -is consumed with `Stream.run*`, `Stream.run`, `Stream.toQueue`, -`Stream.toReadableStream*`, or a similar destructor. +`Stream.Stream` is a lazy description that can emit zero or more `A` +values, fail with `E`, and require services `R`. It does not start until a +destructor consumes it. ```ts -const stream = Stream.fromEffect(loadConfig) - -const program = stream.pipe( - Stream.map((config) => config.region), - Stream.runCollect +const regions = Stream.fromEffect(loadConfig).pipe( + Stream.map((config) => config.region) ) -``` - -Reusing the same stream value reruns the description for each consumer. If -several consumers must observe one running producer, use `Stream.share`, -`Stream.broadcast`, `Stream.broadcastN`, a `Queue`, or a `PubSub`. -## Choose The Smallest Constructor - -Use the constructor that matches the source shape. +const program = regions.pipe(Stream.runCollect) +``` -- `Stream.empty`, `Stream.succeed`, `Stream.make`, `Stream.fromIterable` for - in-memory values. -- `Stream.fromEffect` for one effectful value. -- `Stream.fromEffectSchedule` for polling an effect over a schedule. -- `Stream.paginate` for cursor or page APIs. -- `Stream.fromAsyncIterable` for existing async iterables. -- `Stream.fromEventListener` for DOM-style event targets. -- `Stream.callback` for callback APIs that need explicit queue control. -- `Stream.fromQueue` and `Stream.fromPubSub` for Effect concurrency primitives. -- `Stream.fromReadableStream` or `NodeStream.fromReadable` for web or Node - readable streams. +Running `program` twice runs the stream description twice. Reusing a stream +value does not memoize or share a producer. Sharing requires an explicit scoped +combinator such as `share`, `broadcast`, or `broadcastN`. + +## Choose The Smallest Source + +| Source shape | Constructor | +| --- | --- | +| In-memory values | `empty`, `succeed`, `make`, `fromIterable` | +| One effectful value | `fromEffect` | +| Effect repeated on a schedule | `fromEffectSchedule` | +| Cursor/page API | `paginate` | +| Existing async iterable | `fromAsyncIterable` | +| DOM/EventTarget events | `fromEventListener` | +| General callback API | `callback` | +| Effect queue or pubsub | `fromQueue`, `fromPubSub` | +| Web readable stream | `fromReadableStream` | +| Node readable stream | `NodeStream.fromReadable` | + +Prefer a structure-preserving constructor over mutable state inside +`Stream.callback`. Pagination should usually be `Stream.paginate`; polling +should usually be `Stream.fromEffectSchedule`. ```ts const jobs = Stream.paginate(0, (page) => @@ -77,42 +89,45 @@ const jobs = Stream.paginate(0, (page) => ) ``` -Prefer `Stream.paginate` over building a mutable loop with `Stream.callback` for -normal paginated APIs. Prefer `Stream.fromEffectSchedule` over hand-written -sleep loops for polling. +`paginate` emits each item from the iterable returned in the first tuple slot +and continues only while the second slot is `Option.some(nextState)`. Derive the +next cursor from validated source metadata, not from the number of domain items +left after filtering or tolerant decoding. -## Consume With Intent +`fromEffectSchedule` runs the effect immediately, then follows the schedule. +Bound it with `take` when the resulting stream must be finite. -Every stream pipeline should end in a clear consumer. +## End Every Pipeline With An Intentional Consumer -```ts -const writeEvents = events.pipe( - Stream.runForEach((event) => EventStore.use((store) => store.write(event))) -) -``` +Common destructors: -Use `Stream.runCollect` only for finite streams with bounded output. For large -or infinite streams, prefer `Stream.runForEach`, `Stream.runFold`, -`Stream.runFoldEffect`, `Stream.runDrain`, or `Stream.run(Sink...)`. +| Need | Destructor | +| --- | --- | +| Execute an effect per element | `runForEach` | +| Ignore values but run effects | `runDrain` | +| Fold incrementally | `runFold`, `runFoldEffect`, `run(Sink...)` | +| Read an optional edge | `runHead`, `runLast` | +| Collect a bounded stream | `runCollect` | +| Count without retaining values | `runCount` | +| Concatenate bounded text/bytes | `mkString`, `mkUint8Array` | ```ts -const firstTen = source.pipe( - Stream.take(10), - Stream.runCollect -) - -const total = source.pipe( - Stream.map((event) => event.value), - Stream.run(Sink.sum) +const writeEvents = events.pipe( + Stream.runForEach((event) => + EventStore.use((store) => store.write(event)) + ) ) ``` -`Stream.runHead` and `Stream.runLast` return `Option`. `runLast` waits for the -stream to complete, so do not use it on open-ended streams. +Use `runCollect`, `mkString`, and `mkUint8Array` only when output is finite and +bounded. Apply `take` first when a test or caller needs a bounded prefix. -## Transform Per Element Or Per Chunk Deliberately +`runHead` can stop after one element. `runLast` must wait for completion and +therefore never finishes for a healthy infinite stream. -Use element operators for ordinary domain logic. +## Transform Elements, Arrays, And Windows Deliberately + +Use element operators for domain logic: ```ts const enriched = orders.pipe( @@ -121,36 +136,49 @@ const enriched = orders.pipe( ) ``` -Use chunk-aware operators only when chunk boundaries matter or performance is -worth the extra complexity: `Stream.mapArray`, `Stream.mapArrayEffect`, -`Stream.runForEachArray`, `Stream.grouped`, and `Stream.groupedWithin`. +Use array-aware operators only when source chunking or batching matters: + +- `mapArray` and `mapArrayEffect` transform emitted non-empty arrays. +- `runForEachArray` consumes arrays without flattening first. +- `grouped(n)` creates size-bounded batches. +- `groupedWithin(n, duration)` flushes on size or time. +- `bufferArray` preserves source arrays; `buffer` buffers elements and destroys + the original chunking. ```ts const batched = events.pipe( Stream.groupedWithin(100, "1 second"), - Stream.mapEffect((batch) => EventStore.use((store) => store.writeBatch(batch))) + Stream.mapEffect((batch) => + EventStore.use((store) => store.writeBatch(batch)) + ) ) ``` -Do not insert `runCollect` in the middle of a pipeline just to batch values. -Batch with stream operators so backpressure and interruption still work. +Do not insert `runCollect` in the middle of a pipeline merely to batch values. +That ends streaming, retains all prior elements, and changes interruption and +backpressure behavior. -## Bound Concurrency And Buffers +## Bound Concurrency And Know Ordering -`Stream.mapEffect`, `Stream.flatMap`, `Stream.mergeAll`, and related operators -can run work concurrently. Choose a concrete concurrency limit for I/O and only -use `"unbounded"` when the upstream is already tightly bounded. +`mapEffect` defaults to sequential execution. With concurrent execution it +preserves input order unless `{ unordered: true }` is set. ```ts const results = ids.pipe( - Stream.mapEffect((id) => RemoteApi.use((api) => api.fetch(id)), { - concurrency: 8 - }) + Stream.mapEffect( + (id) => RemoteApi.use((api) => api.fetch(id)), + { concurrency: 8 } + ) ) ``` -Set `unordered: true` only when output order does not matter. Concurrent -`Stream.mergeAll` emits values as they arrive. +Use `unordered: true` only when output order is irrelevant and head-of-line +blocking is undesirable. + +`flatMap` has different semantics: with concurrency greater than one, inner +streams are merged and their values arrive in runtime order. It has no ordered +concurrent mode. `mergeAll` likewise emits from whichever active stream +produces first. ```ts const merged = Stream.mergeAll(streams, { @@ -159,21 +187,43 @@ const merged = Stream.mergeAll(streams, { }) ``` -When creating queues, pubsubs, callbacks, broadcasts, or shared streams, avoid -unbounded capacity by default. Pick a bounded `capacity` and a strategy: +Choose a concrete I/O concurrency limit. Use `"unbounded"` only when upstream +cardinality is already tightly bounded and reviewed. + +When processing stateful updates, ask whether concurrent work is valid at all. +Ordered output does not prevent effects themselves from running concurrently. + +## Treat Buffers As A Correctness Choice + +Bound queues, callbacks, pubsubs, shared streams, and explicit buffers by +default. Choose the overflow strategy from the product semantics: + +- `"suspend"` applies backpressure and retains every value. +- `"sliding"` drops older buffered values and keeps recent state. +- `"dropping"` keeps older buffered values and drops new arrivals. + +```ts +const buffered = source.pipe( + Stream.buffer({ capacity: 32, strategy: "suspend" }) +) +``` + +Backpressure works only when the producer can await an effectful offer. An +external synchronous callback cannot pause for `Queue.offer`; with bounded +callback sources, dropped/coalesced values must be acceptable or the external +API must provide its own pause/resume mechanism. -- `suspend` applies backpressure. -- `sliding` keeps newer values and drops older buffered values. -- `dropping` keeps older buffered values and drops newer values. +Capacity is measured in elements for `buffer` and `toQueue`, but in emitted +arrays for `bufferArray` and broadcast internals. Do not treat every +`bufferSize` as the same unit without checking the signature and source. -## Manage Scope And Cleanup +## Manage Scope And Finalization -Streams run resources for the duration of consumption. Use `Stream.scoped` when -the stream requires `Scope`, and use `Stream.ensuring` or -`Effect.acquireRelease` inside constructors to register finalizers. +Streams hold acquired resources for the duration of one consumption. Use +`Stream.scoped` to internalize a `Scope` requirement. ```ts -const resourceStream = Stream.scoped( +const connectionStream = Stream.scoped( Stream.fromEffect( Effect.acquireRelease( Connection.open, @@ -183,114 +233,185 @@ const resourceStream = Stream.scoped( ) ``` -`Stream.broadcast`, `Stream.broadcastN`, `Stream.share`, `Stream.toQueue`, and -`Stream.toPubSub` return scoped effects. Acquire them inside `Effect.scoped` or -inside a layer so the producer and subscribers are finalized. +Use `Stream.ensuring` for a finalizer that should run after every consumption, +regardless of success, failure, or interruption. Use `Effect.acquireRelease` +when cleanup belongs to an acquired handle. -```ts -const program = Effect.scoped( - Effect.gen(function*() { - const shared = yield* updates.pipe( - Stream.share({ capacity: 16, replay: 1 }) - ) +Scoped destructors and sharing operations include: - yield* shared.pipe(Stream.take(1), Stream.runCollect) - }) -) -``` +- `Stream.toPull` +- `Stream.toQueue` and `Stream.toPubSub*` +- `Stream.broadcast`, `broadcastN`, and `share` + +Acquire them inside `Effect.scoped` or a layer. Closing the scope must stop the +producer and release queues, subscriptions, and external handles. -When bridging to web or async protocols, make sure cancellation closes the -underlying handle. `Stream.fromReadableStream` cancels the reader by default. -`NodeStream.fromReadable` can close Node streams when done. +Web readable streams are canceled by default when their Effect stream +finalizes. `NodeStream.fromReadable` can close Node streams on completion. Keep +those defaults unless ownership belongs elsewhere. ## Bridge Callback APIs Safely -Use `Stream.fromEventListener` for event targets when it fits. It registers and -removes the listener for the stream lifetime. +Use `fromEventListener` for EventTarget-like APIs. It removes the listener when +the stream ends. ```ts -const clicks = Stream.fromEventListener(button, "click", { - passive: true, - bufferSize: 16 -}) +const clicks = Stream.fromEventListener( + button, + "click", + { passive: true, bufferSize: 16 } +) ``` -Use `Stream.callback` for custom callback APIs. Register cleanup with -`Effect.acquireRelease`, and signal completion with the queue API instead of -leaving consumers waiting forever. +`fromEventListener` exposes `bufferSize` but not an overflow strategy. Its +synchronous listener cannot suspend; when a bounded buffer is full, a new event +may be rejected. Use `Stream.callback` when sliding or dropping behavior must be +chosen explicitly. + +Use `Stream.callback` when registration, error, completion, or overflow behavior +needs explicit control. ```ts -const messages = Stream.callback((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const unsubscribeMessage = socket.onMessage((message) => { - Queue.offerUnsafe(queue, message) - }) - - const unsubscribeClose = socket.onClose(() => { - Queue.endUnsafe(queue) - }) - - return { unsubscribeClose, unsubscribeMessage } - }), - ({ unsubscribeClose, unsubscribeMessage }) => +const messages = Stream.callback( + (queue) => + Effect.acquireRelease( Effect.sync(() => { - unsubscribeMessage() - unsubscribeClose() - }) - ), + const unsubscribeMessage = socket.onMessage((message) => { + Queue.offerUnsafe(queue, message) + }) + + const unsubscribeClose = socket.onClose(() => { + Queue.endUnsafe(queue) + }) + + const unsubscribeError = socket.onError((error) => { + Queue.failCauseUnsafe(queue, Cause.fail(error)) + }) + + return { + unsubscribeClose, + unsubscribeError, + unsubscribeMessage + } + }), + (subscriptions) => + Effect.sync(() => { + subscriptions.unsubscribeMessage() + subscriptions.unsubscribeClose() + subscriptions.unsubscribeError() + }) + ), { bufferSize: 64, strategy: "sliding" } ) ``` -Prefer effectful `Queue.offer` when producer code is already inside Effect and -can honor backpressure. Use `Queue.offerUnsafe` only from external synchronous -callbacks where an Effect cannot be yielded. +Signal normal completion with `Queue.endUnsafe` and failure with +`Queue.failCauseUnsafe(queue, Cause.fail(error))`; otherwise consumers can wait +forever. Register every external cleanup through the supplied scope. + +Use effectful `Queue.offer` when producer code is already inside Effect and can +honor backpressure. Use `offerUnsafe` only at a synchronous callback boundary, +and choose an overflow policy that remains correct if it cannot enqueue. + +## Choose Sharing Semantics Explicitly + +Several consumers of a plain stream each run an independent producer. Use the +sharing primitive that matches subscriber lifetime: + +| Need | Primitive | +| --- | --- | +| Dynamic, reference-counted subscribers | `Stream.share` | +| Dynamic subscribers to one scoped pubsub producer | `Stream.broadcast` | +| Fixed number of consumers that must all subscribe before start | `Stream.broadcastN` | +| Competing work consumers | `Queue` / `Stream.toQueue` | +| Every subscriber receives each published event | `PubSub` | + +`share` starts upstream for the first subscriber and stops it after the last +subscriber leaves. A later subscriber restarts the source unless +`idleTimeToLive` keeps it alive. `replay` controls what a late subscriber can +receive from the shared pubsub; it does not turn the source into a permanent +cache. + +```ts +const program = Effect.scoped( + Effect.gen(function*() { + const shared = yield* updates.pipe( + Stream.share({ capacity: 16, replay: 1 }) + ) + + yield* Effect.all([ + shared.pipe(Stream.runForEach(handleForLeftConsumer)), + shared.pipe(Stream.runForEach(handleForRightConsumer)) + ], { concurrency: "unbounded" }) + }) +) +``` + +`broadcastN` is safer when exactly N consumers must observe a finite source: it +does not start until all N returned streams are subscribed. For dynamic +`broadcast` subscribers, use replay or an external readiness protocol if early +values cannot be missed. -## Use Queues And PubSubs For Boundaries +## Use Queues And PubSubs At Ownership Boundaries -Use `Queue` when one producer coordinates with one or more competing consumers -that pull work. `Stream.toQueue` creates a scoped dequeue and signals completion -with `Cause.Done`; stream failures fail the queue. +`Stream.toQueue` creates a scoped dequeue, feeds it in a child fiber, ends it +with `Cause.Done`, and fails it with the stream failure. ```ts const program = Effect.scoped( Effect.gen(function*() { - const queue = yield* source.pipe(Stream.toQueue({ capacity: 32 })) - const next = yield* Queue.take(queue) - return next + const queue = yield* source.pipe( + Stream.toQueue({ capacity: 32 }) + ) + + return yield* Queue.take(queue) }) ) ``` -Use `PubSub` when each subscriber should receive published events. Use -`Stream.fromPubSub` or `Stream.broadcast` instead of manually copying events -into several queues. +Multiple queue consumers compete for values. Use a PubSub when every subscriber +needs a copy. `Stream.fromQueue` treats `Cause.Done` as normal stream completion; +other queue failures become stream failures. + +Prefer `Stream.broadcast` or `Stream.fromPubSub` over manually copying each +event into multiple queues. -## Provide Services At The Stream Boundary +## Keep Service Requirements Until The Boundary -Service requirements flow through stream types the same way they flow through -`Effect`. Provide a layer to the stream or provide a larger program that runs -the stream. +Stream service requirements compose like Effect requirements. Provide the +smallest layer at a stable application or test boundary. ```ts -const stream = Stream.fromEffect( +const users = Stream.fromEffect( UserApi.use((api) => api.listUsers()) ).pipe( - Stream.flatMap(Stream.fromIterable), - Stream.provide(UserApi.layer) + Stream.flatMap(Stream.fromIterable) +) + +const program = users.pipe( + Stream.runForEach(renderUser), + Effect.provide(UserApi.layer) ) ``` -When converting to web APIs outside Effect, use the service-aware variants: -`Stream.toReadableStreamEffect`, `Stream.toReadableStreamWith`, -`Stream.toAsyncIterableEffect`, or `Stream.toAsyncIterableWith`. +When converting a serviceful stream for non-Effect consumers, capture services +explicitly with `toReadableStreamEffect`, `toReadableStreamWith`, +`toAsyncIterableEffect`, or `toAsyncIterableWith`. The plain conversion +variants work only when the stream requires no services. + +Cancellation of the returned readable stream or async iterator is what closes +the Effect scope. Consumers that abandon a manual iterator must call `return()`; +`for await...of` does this when the loop exits normally. -## Handle Errors In The Pipeline +## Recover At The Correct Level -Use stream error combinators when recovery should continue as a stream: -`Stream.catchTag`, `Stream.catchTags`, `Stream.catchIf`, `Stream.catchCause`, -`Stream.mapError`, and `Stream.retry`. +Use stream combinators when recovery should continue as a stream: + +- `catchTag`, `catchTags`, and `catchIf` for typed recovery +- `catchCause` when defects/interruption are deliberately part of policy +- `mapError` to translate the stream failure channel +- `retry` to restart a failed source +- `result` to expose successes and the first failure as values ```ts const recovered = source.pipe( @@ -303,15 +424,20 @@ const recovered = source.pipe( ) ``` -Use `Effect.catchTag` after a `Stream.run*` call when the whole consumed stream -should fail or recover as one effect. Use `Stream.result` when downstream code -needs successes and the first failure as values; the stream still ends after -that failure. +Use `Effect.catchTag` after `Stream.run*` when the entire consumption should be +handled as one effect. + +`Stream.retry` restarts the source description. Values emitted before failure +can be emitted again after the restart. Retry only when duplicated prefixes and +reacquiring the source are safe, or add an explicit cursor/checkpoint protocol. -## Decode And Encode Streaming Data +`Stream.result` emits success results and then one failure result; it still ends +after that first failure. It is not a way to resume the failed source. -For byte streams, decode text before string operations and split lines with -`Stream.splitLines` so delimiters spanning chunks are handled correctly. +## Decode Incremental Bytes Incrementally + +Decode bytes before string operations, and use `splitLines` so delimiters split +across source arrays are handled correctly. ```ts const lines = responseBytes.pipe( @@ -321,8 +447,8 @@ const lines = responseBytes.pipe( ) ``` -For NDJSON or Msgpack, use the encoding channels and schema-backed variants -instead of hand-parsing inside `Stream.map`. +For NDJSON and Msgpack, use the encoding channels and schema-backed variants +instead of `JSON.parse` in `Stream.map`. ```ts import { Ndjson } from "effect/unstable/encoding" @@ -333,36 +459,61 @@ const events = bytes.pipe( ) ``` -Use `ignoreEmptyLines: true` for NDJSON inputs that may contain blank lines. +Use `ignoreEmptyLines: true` only when blank lines are valid transport noise. +Schema decoding should remain at the trust boundary so malformed input has a +typed failure path. -## Testing Patterns +## Testing -Use `it.effect` and consume the stream in the test. Bound infinite streams with -`Stream.take`, and use `TestClock` for schedules, debounce, throttle, retries, -or `groupedWithin`. +A stream test must consume the stream. Bound infinite streams with `take` and +test finalization as well as output. ```ts -import { strictEqual } from "node:assert" -import { Effect, Fiber, Schedule, Stream, TestClock } from "effect" +import { Effect, Fiber, Schedule, Stream } from "effect" +import { TestClock } from "effect/testing" +import { expect, it } from "vitest" + +it("polls three times", async () => { + const values = await Effect.runPromise( + Effect.gen(function*() { + const fiber = yield* Stream.fromEffectSchedule( + Effect.succeed("tick"), + Schedule.spaced("1 second") + ).pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }) + ) + + yield* TestClock.adjust("2 seconds") + + return yield* Fiber.join(fiber) + }).pipe(Effect.provide(TestClock.layer())) + ) -it.effect("polls three times", () => - Effect.gen(function*() { - const fiber = yield* Stream.fromEffectSchedule( - Effect.succeed("tick"), - Schedule.spaced("1 second") - ).pipe( - Stream.take(3), - Stream.runCollect, - Effect.fork - ) + expect(values).toEqual(["tick", "tick", "tick"]) +}) +``` - yield* TestClock.adjust("2 seconds") +For callback, queue, readable-stream, and sharing tests: - const values = yield* Fiber.join(fiber) - strictEqual(values.length, 3) - })) -``` +- start live consumers before publishing +- assert overflow behavior with a deliberately slow consumer +- close or interrupt the scope and assert external cleanup +- test normal completion and source failure +- use `TestClock` for schedules, debounce, throttle, retry, and time windows + +Avoid real sleeps. A time-controlled test may need a start-immediate fork so the +stream reaches its scheduled suspension before the clock advances. + +## Review Checklist -For queue and callback streams, assert finalization as well as emitted values. -Fork consumers before publishing when the source is live, then interrupt or close -the scope to verify cleanup. +- The source constructor matches the real source shape. +- Every collection is proven finite and bounded. +- Concurrency and output ordering are both intentional. +- Buffer capacity and overflow strategy match loss/backpressure requirements. +- Every acquired handle and shared producer has a scoped finalizer. +- Callback sources signal end/failure and tolerate unsafe-offer overflow. +- Sharing semantics match dynamic, fixed, competing, or fan-out consumers. +- Retry cannot duplicate unsafe work or already emitted values. +- Tests consume the stream and cover time, failure, and cleanup behavior. diff --git a/amplify.yml b/amplify.yml index 8dbd9635..7aa7ee25 100644 --- a/amplify.yml +++ b/amplify.yml @@ -11,6 +11,7 @@ frontend: - echo "VITE_YIELDS_API_URL=$VITE_YIELDS_API_URL" >> packages/widget/.env - echo "VITE_API_URL=$VITE_API_URL" >> packages/widget/.env - echo "VITE_API_KEY=$VITE_API_KEY" >> packages/widget/.env + - echo "VITE_FORCE_BORROW=$VITE_FORCE_BORROW" >> packages/widget/.env build: commands: diff --git a/biome.json b/biome.json index 21dc2774..01f916c2 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,7 @@ "!**/region-iso-3166-codes.ts", "!**/mockServiceWorker.js", "!**/package.json", - "!**/repos" + "!**/@repos" ] }, "formatter": { @@ -35,7 +35,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "style": { "noNonNullAssertion": "off", "noParameterAssign": "error", diff --git a/mise.lock b/mise.lock index 51b14104..85c22ae7 100644 --- a/mise.lock +++ b/mise.lock @@ -41,5 +41,5 @@ version = "11.14.1" backend = "npm:npm" [[tools."npm:pnpm"]] -version = "10.33.2" +version = "11.12.0" backend = "npm:pnpm" diff --git a/mise.toml b/mise.toml index 8cc2234b..119a523d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] node = "24.15.0" "npm:npm" = "11.14.1" -"npm:pnpm" = "10.33.2" \ No newline at end of file +"npm:pnpm" = "11.12.0" \ No newline at end of file diff --git a/package.json b/package.json index 9487ca9e..2acca1de 100644 --- a/package.json +++ b/package.json @@ -6,28 +6,35 @@ ], "author": "Petar Todorovic (https://github.com)", "license": "MIT", + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "11.12.0" + } + }, "scripts": { "build": "turbo run build", "lint": "turbo run lint", + "lint:ast": "ast-grep scan --off=no-react-memoization --error=no-suppress-all --error=unused-suppression packages/widget", + "test:ast": "ast-grep test --skip-snapshot-tests", "test": "turbo run test", "format": "turbo run format", "clean": "turbo run clean", "prepare": "husky", - "check-hygiene": "rev-dep config run --list-all-issues", + "check-hygiene": "pnpm run check-hygiene:rev-dep && pnpm run check-hygiene:test-only-exports", + "check-hygiene:rev-dep": "rev-dep config run --list-all-issues", + "check-hygiene:test-only-exports": "tsx scripts/check-test-only-exports.ts", "check": "turbo run lint && pnpm run check-hygiene && turbo run test && turbo run build" }, "devDependencies": { + "@ast-grep/cli": "catalog:", "@biomejs/biome": "catalog:", "@commitlint/cli": "catalog:", "@commitlint/config-conventional": "catalog:", "husky": "catalog:", + "knip": "catalog:", "rev-dep": "catalog:", + "tsx": "catalog:", "turbo": "catalog:" - }, - "pnpm": { - "patchedDependencies": { - "purify-ts": "patches/purify-ts.patch", - "@stakekit/rainbowkit@2.2.11": "patches/@stakekit__rainbowkit@2.2.11.patch" - } } } diff --git a/packages/examples/with-nextjs/package.json b/packages/examples/with-nextjs/package.json index 03dc0503..a6e77834 100644 --- a/packages/examples/with-nextjs/package.json +++ b/packages/examples/with-nextjs/package.json @@ -18,6 +18,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@typescript/native": "catalog:", "typescript": "catalog:" } } diff --git a/packages/examples/with-vite-bundled/package.json b/packages/examples/with-vite-bundled/package.json index 78a96980..93bd85fb 100644 --- a/packages/examples/with-vite-bundled/package.json +++ b/packages/examples/with-vite-bundled/package.json @@ -14,7 +14,7 @@ "@stakekit/widget": "workspace:*" }, "devDependencies": { - "typescript": "catalog:", + "@typescript/native": "catalog:", "vite": "catalog:" } } diff --git a/packages/examples/with-vite-bundled/src/typescript.svg b/packages/examples/with-vite-bundled/src/typescript.svg deleted file mode 100644 index 351f16e6..00000000 --- a/packages/examples/with-vite-bundled/src/typescript.svg +++ /dev/null @@ -1,17 +0,0 @@ - diff --git a/packages/examples/with-vite/package.json b/packages/examples/with-vite/package.json index c947f694..c1b24fc5 100644 --- a/packages/examples/with-vite/package.json +++ b/packages/examples/with-vite/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@types/react": "catalog:", "@types/react-dom": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vite": "catalog:", "@vitejs/plugin-react": "catalog:" } diff --git a/packages/widget/.env.example b/packages/widget/.env.example index 77baaa97..81fe7033 100644 --- a/packages/widget/.env.example +++ b/packages/widget/.env.example @@ -6,5 +6,6 @@ VITE_FORCE_WALLET_CONNECT_ONLY=false VITE_ENABLE_MSW_MOCK=true VITE_SHOW_QUERY_DEVTOOLS=true VITE_FORCE_ADDRESS= +VITE_FORCE_BORROW= VITE_FORCE_DASHBOARD= -VITE_APP_VARIANT= \ No newline at end of file +VITE_APP_VARIANT= diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md new file mode 100644 index 00000000..f99b1db3 --- /dev/null +++ b/packages/widget/ARCHITECTURE.md @@ -0,0 +1,117 @@ +# Widget architecture + +## Module ownership + +Production code is organized by ownership rather than by React mechanism: + +- `src/app` owns public-input normalization, runtime construction, provider + composition, and classic/dashboard route composition. +- `src/services` owns Effect services and side effects: API transport, + persistence, tracking, wallet integration, workflow execution, and borrow + execution. +- `src/features/` owns feature state, resources, React adapters, and + screens. A feature exposes cross-feature collaboration only through an + intentional root entry such as `index.ts`, `support.ts`, `state.ts`, `ui.ts`, + or another narrowly named entrypoint. +- `src/domain` owns framework-independent schemas, identifiers, and business + rules. Approved domain schema modules may adapt generated schemas, but domain + code must not depend on React, services, or features. +- `src/shared` owns framework-neutral utilities and genuinely reusable React or + UI primitives. Shared modules must not depend on app, services, or features. + +The retired top-level `hooks`, `providers`, `pages`, `pages-dashboard`, +`components`, `common`, `atoms`, and `borrow` ownership buckets must not be +reintroduced. A hook belongs in its feature's `react` or `ui` area; a provider +belongs in `app/composition` only when it composes the application or adapts a +third-party tree-scoped contract. + +## Dependency direction and public entries + +The intended direction is: + +`public entry -> app composition/routes -> feature public entries -> app runtime -> services -> domain/shared` + +Feature-to-feature collaboration must use an explicit supported entrypoint; +deep imports into another feature are forbidden. Services may depend on other +services, domain, shared code, and generated clients where approved, but never +on React or features. Rev-dep enforces module direction, public-entry usage, +cycles, unresolved imports, and orphaned modules. Biome confines generated API +imports, rejects React dependencies in services, and blocks retired +architecture paths. + +## Effect services + +Each Effect service keeps its service definition and common layer builders +colocated. Alternative implementations are additional layer builders on the +service or layers defined next to the integrating adapter; contract and default +implementation are not split into files without a concrete reason. + +React hooks and components invoke effects through feature-owned atoms. Network, +persistence, tracking, wallet, polling, and transaction side effects belong in +services rather than UI hooks. Generated runtime API clients are private to +`services/api`; approved `domain/schema` and `domain/borrow` modules may import +generated schema artifacts only. + +## Application runtime + +`src/app/runtime/app-runtime.ts` contains the only production `Atom.runtime`. +Its fresh application layer composes bootstrap configuration, focused yield, +legacy and borrow API services, rich errors, persistence, tracking, wallet, +workflow execution, and borrow execution services. Borrow configuration is +optional at construction time; invoking an unavailable borrow operation +produces the typed error. + +All application atoms resolve dependencies through `appRuntime`. Feature-local +atoms may own synchronous state directly, but must not construct another +runtime or hide an independent service graph. Mounting a widget creates a new +registry and lifecycle-sensitive service state; remounting therefore starts +cleanly. + +## Effect Atom state conventions + +Effect atoms own application configuration, asynchronous resources, workflow +state, mutations, and cross-feature read models. Resource keys must describe +their complete input, failures remain typed, and mutation success refreshes +only declared dependent resources. React hooks should be thin adapters over +atoms or derived read models, not alternate state owners. + +Use React Context only when the value is inherently tree-scoped, such as a +compound component, host DOM element, router history adapter, or required +third-party provider. New page or application-state contexts are rejected by +the architecture check. + +## Supported lifecycle + +The package supports one active StakeKit widget instance per document. +Mounting multiple widgets concurrently on the same page is unsupported. A +single widget may be unmounted and mounted again; registry-owned workflow and +lifecycle state is recreated for that new mount. + +## React Context policy + +Effect atoms own widget configuration, feature workflow state, shared read +models, and application lifecycle state. React Context is reserved for values +whose meaning is the React subtree itself or for libraries that require their +own provider. + +The remaining widget-owned contexts are intentional: + +- `CollapsibleContext`, `CopyTextContext`, `SelectModalContext`, and the amount + toggle context are private compound-component contracts. Their state belongs + to one component subtree. +- `BackButtonContext` is a compound layout override that marks the dashboard + subtree in which a back button is rendered. +- `CurrentLayoutContext` coordinates measurements between the active routed + page and its surrounding animated layout. +- `SKLocationContext` adapts the current and previous React Router locations + for the routed subtree. +- `RootElementContext` exposes the widget host element to portal and overlay + descendants in that host subtree. + +Effect Atom's registry context and the contexts supplied by i18next, TanStack +Query, Wagmi, RainbowKit, and the Solana wallet adapters are third-party runtime +contracts and remain at application composition boundaries. + +Page workflows must not introduce React contexts. Earn, position details, +activity, completion, transaction flow, tracking, summary, configuration, and +mount-animation state are registry-scoped atoms or models derived from atoms. diff --git a/packages/widget/package.json b/packages/widget/package.json index c26d1b74..af9f5aac 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -45,7 +45,15 @@ "start": "pnpm dev", "build": "pnpm lint && pnpm clean && pnpm build:package && pnpm build:bundle && pnpm build:website && pnpm build:types", "lint": "biome check . && tsc", - "test": "vitest -c vite/vite.config.dev.ts --retry 2 run", + "test": "vitest -c vite/vitest.config.ts run", + "test:unit": "vitest -c vite/vitest.config.ts --project unit run", + "test:unit:watch": "vitest -c vite/vitest.config.ts --project unit", + "test:dom": "vitest -c vite/vitest.config.ts --project dom run", + "test:dom:watch": "vitest -c vite/vitest.config.ts --project dom", + "test:browser": "vitest -c vite/vitest.config.ts --project browser run", + "test:changed": "vitest -c vite/vitest.config.ts run --project unit --project dom --changed", + "test:changed:all": "vitest -c vite/vitest.config.ts run --changed", + "test:changed:unit": "vitest -c vite/vitest.config.ts run --project unit --changed", "format": "biome check --write . --unsafe", "build:website": "vite -c vite/vite.config.website build", "build:package": "vite -c vite/vite.config.package.ts build", @@ -54,7 +62,7 @@ "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "preview": "vite -c vite/vite.config.dev.ts preview --outDir dist/website", - "gen:api": "tsx scripts/generate-effect-openapi.ts" + "gen:api": "node --import tsx scripts/generate-effect-openapi.ts" }, "peerDependencies": { "react": ">=18", @@ -75,7 +83,9 @@ "@cosmos-kit/keplr": "catalog:", "@cosmos-kit/leap": "catalog:", "@cosmos-kit/walletconnect": "catalog:", + "@effect/atom-react": "catalog:", "@effect/openapi-generator": "catalog:", + "@effect/platform-browser": "catalog:", "@effect/platform-node": "catalog:", "@faker-js/faker": "catalog:", "@ledgerhq/wallet-api-client": "catalog:", @@ -109,6 +119,7 @@ "@types/lodash.uniqwith": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@typescript/native": "catalog:", "@vanilla-extract/css": "catalog:", "@vanilla-extract/dynamic": "catalog:", "@vanilla-extract/recipes": "catalog:", @@ -116,8 +127,6 @@ "@vanilla-extract/vite-plugin": "catalog:", "@vitejs/plugin-react": "catalog:", "@vitest/browser-playwright": "catalog:", - "@xstate/react": "catalog:", - "@xstate/store": "catalog:", "autoprefixer": "catalog:", "babel-plugin-react-compiler": "catalog:", "bignumber.js": "catalog:", @@ -129,6 +138,7 @@ "eventemitter3": "catalog:", "i18next": "catalog:", "i18next-browser-languagedetector": "catalog:", + "jsdom": "catalog:", "lodash.merge": "catalog:", "lodash.uniqwith": "catalog:", "mipd": "catalog:", @@ -137,17 +147,13 @@ "msw": "catalog:", "playwright": "catalog:", "postcss": "catalog:", - "purify-ts": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-i18next": "catalog:", "react-loading-skeleton": "catalog:", "react-router": "catalog:", - "reselect": "catalog:", - "rxjs": "catalog:", "swagger2openapi": "catalog:", "tsx": "catalog:", - "typescript": "catalog:", "unplugin-macros": "catalog:", "viem": "catalog:", "vite": "catalog:", @@ -155,7 +161,6 @@ "vitest": "catalog:", "vitest-browser-react": "catalog:", "wagmi": "catalog:", - "xstate": "catalog:", "yaml": "catalog:", "recharts": "catalog:" }, diff --git a/packages/widget/public/mockServiceWorker.js b/packages/widget/public/mockServiceWorker.js index 57451a30..0c970efc 100644 --- a/packages/widget/public/mockServiceWorker.js +++ b/packages/widget/public/mockServiceWorker.js @@ -7,114 +7,114 @@ * - Please do NOT modify this file. */ -const PACKAGE_VERSION = "2.12.2"; -const INTEGRITY_CHECKSUM = "4db4a41e972cec1b64cc569c66952d82"; -const IS_MOCKED_RESPONSE = Symbol("isMockedResponse"); -const activeClientIds = new Set(); +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() -addEventListener("install", () => { - self.skipWaiting(); -}); +addEventListener('install', function () { + self.skipWaiting() +}) -addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); -}); +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) -addEventListener("message", async (event) => { - const clientId = Reflect.get(event.source || {}, "id"); +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { - return; + return } - const client = await self.clients.get(clientId); + const client = await self.clients.get(clientId) if (!client) { - return; + return } const allClients = await self.clients.matchAll({ - type: "window", - }); + type: 'window', + }) switch (event.data) { - case "KEEPALIVE_REQUEST": { + case 'KEEPALIVE_REQUEST': { sendToClient(client, { - type: "KEEPALIVE_RESPONSE", - }); - break; + type: 'KEEPALIVE_RESPONSE', + }) + break } - case "INTEGRITY_CHECK_REQUEST": { + case 'INTEGRITY_CHECK_REQUEST': { sendToClient(client, { - type: "INTEGRITY_CHECK_RESPONSE", + type: 'INTEGRITY_CHECK_RESPONSE', payload: { packageVersion: PACKAGE_VERSION, checksum: INTEGRITY_CHECKSUM, }, - }); - break; + }) + break } - case "MOCK_ACTIVATE": { - activeClientIds.add(clientId); + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) sendToClient(client, { - type: "MOCKING_ENABLED", + type: 'MOCKING_ENABLED', payload: { client: { id: client.id, frameType: client.frameType, }, }, - }); - break; + }) + break } - case "CLIENT_CLOSED": { - activeClientIds.delete(clientId); + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) const remainingClients = allClients.filter((client) => { - return client.id !== clientId; - }); + return client.id !== clientId + }) // Unregister itself when there are no more clients if (remainingClients.length === 0) { - self.registration.unregister(); + self.registration.unregister() } - break; + break } } -}); +}) -addEventListener("fetch", (event) => { - const requestInterceptedAt = Date.now(); +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() // Bypass navigation requests. - if (event.request.mode === "navigate") { - return; + if (event.request.mode === 'navigate') { + return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. if ( - event.request.cache === "only-if-cached" && - event.request.mode !== "same-origin" + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' ) { - return; + return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { - return; + return } - const requestId = crypto.randomUUID(); - event.respondWith(handleRequest(event, requestId, requestInterceptedAt)); -}); + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) /** * @param {FetchEvent} event @@ -122,28 +122,38 @@ addEventListener("fetch", (event) => { * @param {number} requestInterceptedAt */ async function handleRequest(event, requestId, requestInterceptedAt) { - const client = await resolveMainClient(event); - const requestCloneForEvents = event.request.clone(); + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() const response = await getResponse( event, client, requestId, requestInterceptedAt, - ); + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - const serializedRequest = await serializeRequest(requestCloneForEvents); + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') // Clone the response so both the client and the library could consume it. - const responseClone = response.clone(); + const responseClone = isEventStreamResponse ? null : response.clone() sendToClient( client, { - type: "RESPONSE", + type: 'RESPONSE', payload: { isMockedResponse: IS_MOCKED_RESPONSE in response, request: { @@ -151,19 +161,21 @@ async function handleRequest(event, requestId, requestInterceptedAt) { ...serializedRequest, }, response: { - type: responseClone.type, - status: responseClone.status, - statusText: responseClone.statusText, - headers: Object.fromEntries(responseClone.headers.entries()), - body: responseClone.body, + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, }, }, }, - responseClone.body ? [serializedRequest.body, responseClone.body] : [], - ); + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) } - return response; + return response } /** @@ -175,30 +187,30 @@ async function handleRequest(event, requestId, requestInterceptedAt) { * @returns {Promise} */ async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId); + const client = await self.clients.get(event.clientId) if (activeClientIds.has(event.clientId)) { - return client; + return client } - if (client?.frameType === "top-level") { - return client; + if (client?.frameType === 'top-level') { + return client } const allClients = await self.clients.matchAll({ - type: "window", - }); + type: 'window', + }) return allClients .filter((client) => { // Get only those clients that are currently visible. - return client.visibilityState === "visible"; + return client.visibilityState === 'visible' }) .find((client) => { // Find the client ID that's recorded in the // set of clients that have registered the worker. - return activeClientIds.has(client.id); - }); + return activeClientIds.has(client.id) + }) } /** @@ -211,36 +223,36 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = event.request.clone(); + const requestClone = event.request.clone() function passthrough() { // Cast the request headers to a new Headers instance // so the headers can be manipulated with. - const headers = new Headers(requestClone.headers); + const headers = new Headers(requestClone.headers) // Remove the "accept" header value that marked this request as passthrough. // This prevents request alteration and also keeps it compliant with the // user-defined CORS policies. - const acceptHeader = headers.get("accept"); + const acceptHeader = headers.get('accept') if (acceptHeader) { - const values = acceptHeader.split(",").map((value) => value.trim()); + const values = acceptHeader.split(',').map((value) => value.trim()) const filteredValues = values.filter( - (value) => value !== "msw/passthrough", - ); + (value) => value !== 'msw/passthrough', + ) if (filteredValues.length > 0) { - headers.set("accept", filteredValues.join(", ")); + headers.set('accept', filteredValues.join(', ')) } else { - headers.delete("accept"); + headers.delete('accept') } } - return fetch(requestClone, { headers }); + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. if (!client) { - return passthrough(); + return passthrough() } // Bypass initial page load requests (i.e. static assets). @@ -248,15 +260,15 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return passthrough(); + return passthrough() } // Notify the client that a request has been intercepted. - const serializedRequest = await serializeRequest(event.request); + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { - type: "REQUEST", + type: 'REQUEST', payload: { id: requestId, interceptedAt: requestInterceptedAt, @@ -264,19 +276,19 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { }, }, [serializedRequest.body], - ); + ) switch (clientMessage.type) { - case "MOCK_RESPONSE": { - return respondWithMock(clientMessage.data); + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) } - case "PASSTHROUGH": { - return passthrough(); + case 'PASSTHROUGH': { + return passthrough() } } - return passthrough(); + return passthrough() } /** @@ -287,21 +299,21 @@ async function getResponse(event, client, requestId, requestInterceptedAt) { */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { - const channel = new MessageChannel(); + const channel = new MessageChannel() channel.port1.onmessage = (event) => { - if (event.data?.error) { - return reject(event.data.error); + if (event.data && event.data.error) { + return reject(event.data.error) } - resolve(event.data); - }; + resolve(event.data) + } client.postMessage(message, [ channel.port2, ...transferrables.filter(Boolean), - ]); - }); + ]) + }) } /** @@ -314,17 +326,17 @@ function respondWithMock(response) { // instance will have status code set to 0. Since it's not possible to create // a Response instance with status code 0, handle that use-case separately. if (response.status === 0) { - return Response.error(); + return Response.error() } - const mockedResponse = new Response(response.body, response); + const mockedResponse = new Response(response.body, response) Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { value: true, enumerable: true, - }); + }) - return mockedResponse; + return mockedResponse } /** @@ -345,5 +357,5 @@ async function serializeRequest(request) { referrerPolicy: request.referrerPolicy, body: await request.arrayBuffer(), keepalive: request.keepalive, - }; + } } diff --git a/packages/widget/scripts/generate-effect-openapi.ts b/packages/widget/scripts/generate-effect-openapi.ts index 0512244a..10b42f22 100644 --- a/packages/widget/scripts/generate-effect-openapi.ts +++ b/packages/widget/scripts/generate-effect-openapi.ts @@ -10,16 +10,32 @@ type JsonPatchOperation = { value: unknown; }; +type SpecOutputConfig = { + format: "httpclient" | "httpclient-type-only"; + outputPath: string; + schemaOnly?: boolean; +}; + type SpecConfig = { name: string; - outputPath: string; + outputs: ReadonlyArray; patches: JsonPatchOperation[]; + prepareSpec?: (contents: string) => string; specFileName: string; url: string; }; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const widgetRoot = path.resolve(scriptDir, ".."); +const workspaceRoot = path.resolve(widgetRoot, "../.."); +const binaryPath = (root: string, name: string) => + path.join( + root, + "node_modules/.bin", + `${name}${process.platform === "win32" ? ".CMD" : ""}` + ); +const openApiGeneratorBin = binaryPath(widgetRoot, "openapigen"); +const biomeBin = binaryPath(workspaceRoot, "biome"); const generatedFileHeader = [ "// @ts-nocheck", "// biome-ignore-all lint: generated by Effect OpenAPI", @@ -31,7 +47,17 @@ const specs: SpecConfig[] = [ name: "LegacyApi", url: process.env.LEGACY_API_SPEC_URL ?? "https://api.stakek.it/docs.yaml", specFileName: "legacy-api.yaml", - outputPath: path.join(widgetRoot, "src/generated/api/legacy.ts"), + outputs: [ + { + format: "httpclient-type-only", + outputPath: path.join(widgetRoot, "src/generated/api/legacy.ts"), + }, + { + format: "httpclient", + outputPath: path.join(widgetRoot, "src/generated/api/legacy-schema.ts"), + schemaOnly: true, + }, + ], patches: [ // Upstream currently declares regionCode as an object, but production // payloads and widget geo-block handling use it as a string. @@ -47,7 +73,17 @@ const specs: SpecConfig[] = [ url: process.env.YIELD_API_SPEC_URL ?? "https://api.stg.yield.xyz/docs.yaml", specFileName: "yield-api.yaml", - outputPath: path.join(widgetRoot, "src/generated/api/yield.ts"), + outputs: [ + { + format: "httpclient-type-only", + outputPath: path.join(widgetRoot, "src/generated/api/yield.ts"), + }, + { + format: "httpclient", + outputPath: path.join(widgetRoot, "src/generated/api/yield-schema.ts"), + schemaOnly: true, + }, + ], patches: [ // The spec marks these date-time fields as nullable, but openapigen beta // currently drops null for nullable date-time strings. Replacing the @@ -72,6 +108,29 @@ const specs: SpecConfig[] = [ }, ], }, + { + name: "BorrowApi", + url: + process.env.BORROW_API_SPEC_URL ?? "https://borrow.yield.xyz/docs.json", + specFileName: "borrow-api.json", + outputs: [ + { + format: "httpclient-type-only", + outputPath: path.join(widgetRoot, "src/generated/api/borrow-client.ts"), + }, + { + format: "httpclient", + outputPath: path.join(widgetRoot, "src/generated/api/borrow.ts"), + schemaOnly: true, + }, + ], + patches: [], + prepareSpec: (contents) => + contents.replaceAll( + '"allOf":[{"$ref":"#/components/schemas/ArgumentSchemaPropertyDto"}]', + '"type":"object"' + ), + }, ]; const run = async ( @@ -135,55 +194,102 @@ const fetchSpec = async (spec: SpecConfig) => { return response.text(); }; +const extractSchemaOnlyOutput = (output: string, specName: string) => { + const clientMarker = "\nexport interface OperationConfig {"; + const clientStart = output.indexOf(clientMarker); + + if (clientStart === -1) { + throw new Error( + `${specName} schema generation did not contain the expected client marker` + ); + } + + return `${output + .slice(0, clientStart) + .replace(/^import \* as Data from "effect\/Data";?\r?\n/m, "") + .replace(/^import \* as Effect from "effect\/Effect";?\r?\n/m, "") + .replace(/^import type \{ SchemaError \} from "effect\/Schema";?\r?\n/m, "") + .replace( + /^import(?: type)? \* as HttpClient[^\n]+"effect\/unstable\/http\/[^\n]+\r?\n/gm, + "" + ) + .trimEnd()}\n`; +}; + const generateSpec = async (spec: SpecConfig, tempDir: string) => { const specPath = path.join(tempDir, spec.specFileName); const patchPath = path.join(tempDir, `${spec.specFileName}.patch.json`); console.log(`Fetching ${spec.name} spec from ${spec.url}`); - await writeFile(specPath, await fetchSpec(spec)); + const specContents = await fetchSpec(spec); + await writeFile(specPath, spec.prepareSpec?.(specContents) ?? specContents); await writeFile(patchPath, `${JSON.stringify(spec.patches, null, 2)}\n`); - await mkdir(path.dirname(spec.outputPath), { recursive: true }); - - console.log(`Generating ${path.relative(widgetRoot, spec.outputPath)}`); - const { stdout, stderr } = await run("pnpm", [ - "exec", - "openapigen", - "--spec", - specPath, - "--name", - spec.name, - "--format", - "httpclient-type-only", - "--patch", - patchPath, - ]); - - if (stderr.trim()) { - console.warn(stderr.trim()); - } - if (!stdout.trim()) { - throw new Error(`${spec.name} generation produced empty output`); - } + for (const output of spec.outputs) { + await mkdir(path.dirname(output.outputPath), { recursive: true }); + + console.log( + `Generating ${path.relative(widgetRoot, output.outputPath)} (${output.format})` + ); + const { stdout, stderr } = await run(openApiGeneratorBin, [ + "--spec", + specPath, + "--name", + spec.name, + "--format", + output.format, + "--patch", + patchPath, + ]); + + if (stderr.trim()) { + console.warn(stderr.trim()); + } - await writeFile(spec.outputPath, `${generatedFileHeader}${stdout}`); + if (!stdout.trim()) { + throw new Error( + `${spec.name} ${output.format} generation produced empty output` + ); + } + + const generatedOutput = output.schemaOnly + ? extractSchemaOnlyOutput(stdout, spec.name) + : stdout; + + await writeFile( + output.outputPath, + `${generatedFileHeader}${generatedOutput}` + ); + } }; const main = async () => { const tempDir = await mkdtemp(path.join(tmpdir(), "stakekit-openapi-")); + const requestedSpecs = new Set(process.argv.slice(2)); + const selectedSpecs = requestedSpecs.size + ? specs.filter((spec) => requestedSpecs.has(spec.name)) + : specs; + + if (!selectedSpecs.length) { + throw new Error( + `No OpenAPI specs matched: ${[...requestedSpecs].join(", ")}` + ); + } try { - for (const spec of specs) { + for (const spec of selectedSpecs) { await generateSpec(spec, tempDir); } - await run("pnpm", [ - "exec", - "biome", + await run(biomeBin, [ "check", "--write", "--unsafe", - ...specs.map((spec) => path.relative(widgetRoot, spec.outputPath)), + ...selectedSpecs.flatMap((spec) => + spec.outputs.map((output) => + path.relative(widgetRoot, output.outputPath) + ) + ), ]); } finally { await rm(tempDir, { force: true, recursive: true }); diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx index 8bc4be7c..e6cc7831 100644 --- a/packages/widget/src/App.tsx +++ b/packages/widget/src/App.tsx @@ -1,29 +1,33 @@ import "@stakekit/rainbowkit/styles.css"; import "./translation"; -import "./utils/extend-purify"; -import "./styles/theme/global.css"; -import type { ComponentProps, RefObject } from "react"; +import "./shared/styles/theme/global.css"; +import type { ComponentProps } from "react"; import { createRef, useImperativeHandle, useState } from "react"; import ReactDOM from "react-dom/client"; import { createMemoryRouter, RouterProvider } from "react-router"; -import { preloadImages } from "./assets/images"; -import { Box } from "./components/atoms/box"; -import { Dashboard } from "./Dashboard"; -import { Providers } from "./providers"; -import { SettingsContextProvider, useSettings } from "./providers/settings"; -import type { SettingsProps, VariantProps } from "./providers/settings/types"; -import { appContainer } from "./style.css"; +import { Providers } from "./app/composition/providers"; +import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime"; +import { normalizeWidgetConfig, useWidgetConfig } from "./app/config"; +import { ClassicRoutes, DashboardRoutes } from "./app/routes"; +import { appContainer } from "./features/widget-shell"; +import type { + BundledSKWidgetProps, + SKAppProps, + VariantProps, +} from "./public-api/types"; +import { isLedgerDappBrowserProvider } from "./services/wallet/browser-environment"; +import { preloadImages } from "./shared/assets/images"; +import { Box } from "./shared/ui/primitives/box"; import { useLoadErrorTranslations } from "./translation"; -import { Widget } from "./Widget"; preloadImages(); const App = () => { useLoadErrorTranslations(); - const { dashboardVariant } = useSettings(); + const dashboardVariant = useWidgetConfig("dashboardVariant"); - return dashboardVariant ? : ; + return dashboardVariant ? : ; }; const Root = () => ( @@ -32,7 +36,7 @@ const Root = () => ( ); -export type SKAppProps = SettingsProps & (VariantProps | { variant?: never }); +export type { BundledSKWidgetProps, SKAppProps } from "./public-api/types"; export const SKApp = (props: SKAppProps) => { const variantProps: VariantProps = @@ -43,24 +47,24 @@ export const SKApp = (props: SKAppProps) => { const [router] = useState(() => createMemoryRouter([{ path: "*", Component: Root }]) ); + const settings = normalizeWidgetConfig( + { ...props, ...variantProps }, + { isLedgerLive: isLedgerDappBrowserProvider() } + ); return ( - + - + ); }; -export type BundledSKWidgetProps = SKAppProps & { - ref?: RefObject<{ rerender: (newProps: BundledSKWidgetProps) => void }>; -}; - const BundledSKWidget = (_props: BundledSKWidgetProps) => { const [props, setProps] = useState(_props); diff --git a/packages/widget/src/Dashboard.tsx b/packages/widget/src/Dashboard.tsx deleted file mode 100644 index 91fb2ca0..00000000 --- a/packages/widget/src/Dashboard.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { Route, Routes } from "react-router"; -import { GlobalModals } from "./components/molecules/global-modals"; -import { ConnectedCheck } from "./navigation/cheks/connected-check"; -// import { RewardsTabPage } from "./pages-dashboard/rewards"; -import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page"; -import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page"; -import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page"; -import { EarnPageContextProvider } from "./pages/details/earn-page/state/earn-page-context"; -import { EarnPageStateUsageBoundaryProvider } from "./pages/details/earn-page/state/earn-page-state-context"; -import { StakeReviewPage } from "./pages/review"; -import { PendingReviewPage } from "./pages/review/pages/pending-review.page"; -import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page"; -import { StakeStepsPage } from "./pages/steps"; -import { ActivityStepsPage } from "./pages/steps/pages/activity-steps.page"; -import { PendingStepsPage } from "./pages/steps/pages/pending-steps.page"; -import { UnstakeStepsPage } from "./pages/steps/pages/unstake-steps.page"; -import { ActivityTabPage } from "./pages-dashboard/activity"; -import { ActivityDetailsPage } from "./pages-dashboard/activity/activity-details.page"; -import { DashboardWrapper } from "./pages-dashboard/common/components/wrapper"; -import { OverviewPage } from "./pages-dashboard/overview"; -import { EarnPageContent } from "./pages-dashboard/overview/earn-page"; -import { ManagePage } from "./pages-dashboard/overview/manage.page"; -import { PositionDetailsPage } from "./pages-dashboard/position-details"; -import { PositionDetailsActions } from "./pages-dashboard/position-details/components/position-details-actions"; -import { PositionDetailsStakeActions } from "./pages-dashboard/position-details/components/position-details-stake-actions"; -import { DashboardProvider } from "./pages-dashboard/providers/dashboard-context"; -import { useSKLocation } from "./providers/location"; - -const positionDetailsStakeFooterPath = - /^\/positions\/[^/]+\/[^/]+(?:\/stake)?$/; - -export const shouldRegisterDashboardEarnFooterButton = (pathname: string) => - pathname === "/" || positionDetailsStakeFooterPath.test(pathname); - -export const Dashboard = () => { - const { current } = useSKLocation(); - const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton( - current.pathname - ); - - return ( - - - - - }> - {/* Earn Tab */} - }> - } /> - - }> - } /> - } /> - } /> - - - - {/* Manage Tab */} - } /> - - {/* Position Details */} - } - > - } /> - - {/* Staking */} - - } /> - } /> - } /> - } /> - - - } - /> - - {/* Unstaking */} - - } /> - } /> - } /> - } /> - - - {/* Pending Actions */} - - } /> - } /> - } /> - - - - {/* Rewards Tab */} - {/* } /> */} - - {/* Activity Tab */} - }> - } /> - } - /> - - - - - - - - - ); -}; diff --git a/packages/widget/src/Widget.tsx b/packages/widget/src/Widget.tsx deleted file mode 100644 index 00dfc49a..00000000 --- a/packages/widget/src/Widget.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { AnimatePresence, LayoutGroup, motion } from "motion/react"; -import { useEffect } from "react"; -import { Navigate, Route, Routes, useNavigate } from "react-router"; -import { GlobalModals } from "./components/molecules/global-modals"; -import { Header } from "./components/molecules/header"; -import { useDetailsMatch } from "./hooks/navigation/use-details-match"; -import { useHandleDeepLinks } from "./hooks/use-handle-deep-links"; -import { useInitParams } from "./hooks/use-init-params"; -import { usePrevious } from "./hooks/use-previous"; -import { useSavedRef } from "./hooks/use-saved-ref"; -import { useUnderMaintenance } from "./hooks/use-under-maintenance"; -import { ConnectedCheck } from "./navigation/cheks/connected-check"; -import { AnimationLayout } from "./navigation/containers/animation-layout"; -import { ActivityCompletePage } from "./pages/complete/pages/activity-complete.page"; -import { PendingCompletePage } from "./pages/complete/pages/pending-complete.page"; -import { StakeCompletePage } from "./pages/complete/pages/stake-complete.page"; -import { UnstakeCompletePage } from "./pages/complete/pages/unstake-complete.page"; -import { Layout } from "./pages/components/layout"; -import { headerContainer } from "./pages/components/layout/styles.css"; -import { PoweredBy } from "./pages/components/powered-by"; -import UnderMaintenance from "./pages/components/under-maintenance"; -import { AnimatedActivityPage } from "./pages/details/activity-page/activity.page"; -import { Details } from "./pages/details/details-page/details.page"; -import { AnimatedEarnPage } from "./pages/details/earn-page/earn.page"; -import { AnimatedPositionsPage } from "./pages/details/positions-page/positions.page"; -import { PositionDetailsPage } from "./pages/position-details"; -import { StakeReviewPage } from "./pages/review"; -import { ActionReviewPage } from "./pages/review/pages/action-review.page"; -import { PendingReviewPage } from "./pages/review/pages/pending-review.page"; -import { UnstakeReviewPage } from "./pages/review/pages/unstake-review.page"; -import { StakeStepsPage } from "./pages/steps"; -import { ActivityStepsPage } from "./pages/steps/pages/activity-steps.page"; -import { PendingStepsPage } from "./pages/steps/pages/pending-steps.page"; -import { UnstakeStepsPage } from "./pages/steps/pages/unstake-steps.page"; -import { useSKLocation } from "./providers/location"; -import { useSKWallet } from "./providers/sk-wallet"; -import { container } from "./style.css"; -import { MaybeWindow } from "./utils/maybe-window"; - -export const Widget = () => { - const underMaintenance = useUnderMaintenance(); - - const { chain, address } = useSKWallet(); - - const prevChain = usePrevious(chain); - const prevAddress = usePrevious(address); - - const { current } = useSKLocation(); - - const pathnameRef = useSavedRef(current.pathname); - const navigateRef = useSavedRef(useNavigate()); - - /** - * On chain change, navigate to home page - */ - useEffect(() => { - if ( - pathnameRef.current !== "/" && - pathnameRef.current !== "/positions" && - pathnameRef.current !== "/activity" && - ((prevChain && chain !== prevChain) || - (prevAddress && address !== prevAddress)) - ) { - MaybeWindow.ifJust((w) => { - const url = new URL(w.location.href); - const newUrl = new URL(w.location.origin); - if (url.searchParams.has("embed")) { - newUrl.searchParams.set("embed", "true"); - } - - w.history.pushState({}, w.document.title, newUrl.href); - navigateRef.current("/", { replace: true }); - }); - } - }, [chain, address, pathnameRef, navigateRef, prevChain, prevAddress]); - - const initTab = useInitParams().data?.tab; - - useEffect(() => { - if (!initTab) return; - - navigateRef.current(initTab === "earn" ? "/" : "/positions"); - }, [initTab, navigateRef]); - - useHandleDeepLinks(); - - const detailsMatch = useDetailsMatch(); - - /** - * Dont unmount details page with tabs - * Handle position details pages in their own Routes - */ - const key = detailsMatch ? "/" : current.key; - - if (underMaintenance) return ; - - return ( - <> - - - -
- - - - - - }> - {/* Home + Tabs */} - }> - } /> - } - /> - } /> - - - }> - {/* Activity flow */} - - } /> - } - /> - } - /> - - - {/* Stake flow */} - - } /> - } /> - } /> - - - {/* Unstake or pending actions flow */} - - } /> - } - /> - - {/* Unstaking */} - - } /> - } /> - } - /> - - - {/* Pending Actions */} - - } /> - } /> - } - /> - - - - - } /> - - - - - - - - - - - - ); -}; diff --git a/packages/widget/src/app/composition/providers/atom-runtime/index.tsx b/packages/widget/src/app/composition/providers/atom-runtime/index.tsx new file mode 100644 index 00000000..c7fa924d --- /dev/null +++ b/packages/widget/src/app/composition/providers/atom-runtime/index.tsx @@ -0,0 +1,71 @@ +import { RegistryProvider, useAtomSet } from "@effect/atom-react"; +import { + useConnection as useSolanaConnection, + useWallet as useSolanaWallet, +} from "@solana/wallet-adapter-react"; +import { type PropsWithChildren, useLayoutEffect, useState } from "react"; +import { config } from "../../../../shared/config/widget-defaults"; +import { + useWidgetConfig, + type WidgetConfig, + widgetConfigAtom, +} from "../../../config"; +import { + dynamicExternalProviderInputAtom, + normalizeDynamicExternalProviderInput, + normalizeSolanaWalletInput, + solanaWalletInputAtom, +} from "../../../runtime/root-inputs"; + +const WidgetConfigBinding = ({ + children, + settings, +}: PropsWithChildren<{ readonly settings: WidgetConfig }>) => { + const setWidgetConfig = useAtomSet(widgetConfigAtom); + + useLayoutEffect(() => { + setWidgetConfig(settings); + }, [setWidgetConfig, settings]); + + return children; +}; + +export const SKAtomRegistryProvider = ({ + children, + settings, +}: PropsWithChildren<{ readonly settings: WidgetConfig }>) => { + const [initialSettings] = useState(settings); + + return ( + + {children} + + ); +}; + +export const SKRootInputProvider = ({ children }: PropsWithChildren) => { + const setDynamicWalletInput = useAtomSet(dynamicExternalProviderInputAtom); + const setSolanaInput = useAtomSet(solanaWalletInputAtom); + const externalProviders = useWidgetConfig("externalProviders"); + const solanaConnection = useSolanaConnection(); + const solanaWallet = useSolanaWallet(); + const dynamicWalletInput = + normalizeDynamicExternalProviderInput(externalProviders); + const solanaInput = normalizeSolanaWalletInput({ + connection: solanaConnection.connection, + wallets: solanaWallet.wallets, + }); + + useLayoutEffect(() => { + setDynamicWalletInput(dynamicWalletInput); + }, [dynamicWalletInput, setDynamicWalletInput]); + + useLayoutEffect(() => { + setSolanaInput(solanaInput); + }, [setSolanaInput, solanaInput]); + + return children; +}; diff --git a/packages/widget/src/app/composition/providers/index.tsx b/packages/widget/src/app/composition/providers/index.tsx new file mode 100644 index 00000000..3707868c --- /dev/null +++ b/packages/widget/src/app/composition/providers/index.tsx @@ -0,0 +1,48 @@ +import type { ComponentProps, PropsWithChildren } from "react"; +import { StrictMode } from "react"; +import { I18nextProvider } from "react-i18next"; +import { WagmiConfigProvider } from "../../../features/wallet"; +import { CurrentLayoutProvider } from "../../../features/widget-shell"; +import { SKLocationProvider } from "../../../shared/react/location-history"; +import { RootElementProvider } from "../../../shared/react/root-element"; +import { i18nInstance } from "../../../translation"; +import { WidgetConfigEffects } from "../../config/widget-config-effects"; +import { SKRootInputProvider } from "./atom-runtime"; +import { MountAnimationEffects } from "./mount-animation"; +import { ThirdPartyQueryClientProvider } from "./query-client"; +import { RainbowProvider } from "./rainbow"; +import { SolanaProvider } from "./solana"; +import { ThemeWrapper } from "./theme-wrapper"; + +export const Providers = ({ + children, +}: PropsWithChildren & ComponentProps) => { + return ( + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + ); +}; diff --git a/packages/widget/src/app/composition/providers/mount-animation/index.tsx b/packages/widget/src/app/composition/providers/mount-animation/index.tsx new file mode 100644 index 00000000..fa8a3098 --- /dev/null +++ b/packages/widget/src/app/composition/providers/mount-animation/index.tsx @@ -0,0 +1,30 @@ +import { useEffect, useRef } from "react"; +import { useMountAnimation } from "../../../../features/mount-animation"; +import { delayAPIRequests } from "../../../../services/api/delay-api-requests"; +import { useSKLocation } from "../../../../shared/react/location-history"; +import { useWidgetConfig } from "../../../config"; + +const removeDelay = delayAPIRequests(); + +export const MountAnimationEffects = () => { + const onMountAnimationComplete = useWidgetConfig("onMountAnimationComplete"); + const callbackRef = useRef(onMountAnimationComplete); + callbackRef.current = onMountAnimationComplete; + const { current } = useSKLocation(); + const { dispatch, state } = useMountAnimation(); + + useEffect(() => { + if (state.layout && state.earnPage) { + removeDelay(); + callbackRef.current?.(); + } + }, [state.earnPage, state.layout]); + + useEffect(() => { + if (current.pathname !== "/") { + dispatch({ type: "all" }); + } + }, [current.pathname, dispatch]); + + return null; +}; diff --git a/packages/widget/src/app/composition/providers/query-client/index.tsx b/packages/widget/src/app/composition/providers/query-client/index.tsx new file mode 100644 index 00000000..6e023ca6 --- /dev/null +++ b/packages/widget/src/app/composition/providers/query-client/index.tsx @@ -0,0 +1,14 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { PropsWithChildren } from "react"; +import { useState } from "react"; + +/** Third-party infrastructure for Wagmi/RainbowKit; widget API state is forbidden here. */ +export const ThirdPartyQueryClientProvider = ({ + children, +}: PropsWithChildren) => { + const [queryClient] = useState(() => new QueryClient()); + + return ( + {children} + ); +}; diff --git a/packages/widget/src/app/composition/providers/rainbow-kit.tsx b/packages/widget/src/app/composition/providers/rainbow-kit.tsx new file mode 100644 index 00000000..8762fa04 --- /dev/null +++ b/packages/widget/src/app/composition/providers/rainbow-kit.tsx @@ -0,0 +1,118 @@ +import type { DisclaimerComponent } from "@stakekit/rainbowkit"; +import { RainbowKitProvider, useChainModal } from "@stakekit/rainbowkit"; +import type { PropsWithChildren } from "react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { shouldShowDisconnect } from "../../../domain/types/connectors"; +import { useTrackEvent } from "../../../features/tracking"; +import { + addLedgerAccountAtom, + useCloseChainModal, + useLedgerDisabledChain, + useSKWallet, +} from "../../../features/wallet"; +import { isLedgerLiveConnector } from "../../../services/wallet/connectors/ledger/ledger-live-connector-meta"; +import { vars } from "../../../shared/styles/theme/contract.css"; +import { id } from "../../../shared/styles/theme/ids"; +import type { ConnectKitTheme } from "../../../shared/styles/tokens/connect-kit"; +import { connectKitTheme } from "../../../shared/styles/tokens/connect-kit"; +import { Text } from "../../../shared/ui/primitives/typography/text"; +import { useWidgetConfig } from "../../config"; + +const finalTheme: ConnectKitTheme = { + ...connectKitTheme.lightMode, + colors: vars.color.connectKit, // ThemeWrapper applies final light/dark colors + radii: vars.borderRadius.connectKit, + fonts: { body: vars.font.body }, +}; + +export const RainbowKitProviderWithTheme = ({ + children, +}: PropsWithChildren) => { + const { connector, connectorChains } = useSKWallet(); + + const portalContainer = useWidgetConfig("portalContainer"); + + const ledgerDisabledChains = useLedgerDisabledChain(); + + const trackEvent = useTrackEvent(); + + const addLedgerAccount = useAtomSet(addLedgerAccountAtom); + const { closeChainModal } = useCloseChainModal(); + + const { t, i18n } = useTranslation(); + + const hideAccountAndChainSelector = useWidgetConfig( + "hideAccountAndChainSelector" + ); + const initialChain = useWidgetConfig("initialChain"); + + const chainIdsToUse = useMemo( + () => new Set(connectorChains.map((c) => c.id)), + [connectorChains] + ); + + const disabledChains = useMemo( + () => + ledgerDisabledChains.map((c) => ({ + ...c, + info: t("chain_modal.disabled_chain_info"), + })), + [ledgerDisabledChains, t] + ); + + const hideDisconnect = useMemo( + () => (connector ? !shouldShowDisconnect(connector) : true), + [connector] + ); + + const locale = useMemo(() => { + if (i18n.language === "fr" || i18n.language === "fr-FR") { + return i18n.language; + } + + return "en"; + }, [i18n.language]); + + return ( + { + trackEvent("addLedgerAccountClicked"); + addLedgerAccount({ + chain: disabledChain, + closeChainModal, + connector: + connector && isLedgerLiveConnector(connector) ? connector : null, + }); + }} + locale={locale} + appInfo={{ disclaimer: Disclamer, appName: t("shared.stake_kit") }} + {...(hideAccountAndChainSelector && { avatar: null })} + showRecentTransactions={false} + initialChain={initialChain} + theme={finalTheme} + hideDisconnect={hideDisconnect} + dialogRoot={portalContainer} + > + + {children} + + ); +}; + +const DisabledChainHandling = () => { + useCloseChainModal().setCloseChainModal = useChainModal().closeChainModal; // haaack + + return null; +}; + +const Disclamer: DisclaimerComponent = () => { + const { t } = useTranslation(); + return {t("chain_modal_disclaimer")}; +}; + +import { useAtomSet } from "@effect/atom-react"; diff --git a/packages/widget/src/app/composition/providers/rainbow/index.tsx b/packages/widget/src/app/composition/providers/rainbow/index.tsx new file mode 100644 index 00000000..04e99cb3 --- /dev/null +++ b/packages/widget/src/app/composition/providers/rainbow/index.tsx @@ -0,0 +1,51 @@ +import { useAtomSet } from "@effect/atom-react"; +import { AccountExtraInfoContext } from "@stakekit/rainbowkit"; +import type { PropsWithChildren } from "react"; +import type { Address } from "viem"; +import { useSKWallet } from "../../../../features/wallet"; +import { + WalletService, + type WalletSwitchAccountInput, +} from "../../../../services/wallet/wallet-service"; +import { formatAddress } from "../../../../shared/lib/general"; +import { appRuntime } from "../../../runtime"; +import { RainbowKitProviderWithTheme } from "../rainbow-kit"; + +const switchLedgerAccountAtom = appRuntime.fn( + (input: WalletSwitchAccountInput) => + WalletService.use((wallet) => wallet.switchAccount(input)) +); + +export const RainbowProvider = ({ children }: PropsWithChildren) => { + const wallet = useSKWallet(); + const switchAccount = useAtomSet(switchLedgerAccountAtom, { + mode: "promise", + }); + + const otherAddresses = + wallet.ledgerAccounts + ?.filter((account) => account.address !== wallet.address) + .map((account) => formatAddress(account.address) as Address) ?? []; + + return ( + { + const account = wallet.ledgerAccounts?.find( + (candidate) => formatAddress(candidate.address) === selectedAddress + ); + + if (account && wallet.isConnected) { + void switchAccount({ + account, + connector: wallet.connector, + }).catch(() => undefined); + } + }, + }} + > + {children} + + ); +}; diff --git a/packages/widget/src/app/composition/providers/solana/index.tsx b/packages/widget/src/app/composition/providers/solana/index.tsx new file mode 100644 index 00000000..ad1b571d --- /dev/null +++ b/packages/widget/src/app/composition/providers/solana/index.tsx @@ -0,0 +1,38 @@ +import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; +import { + ConnectionProvider, + WalletProvider, +} from "@solana/wallet-adapter-react"; +import { + PhantomWalletAdapter, + TrustWalletAdapter, + WalletConnectWalletAdapter, +} from "@solana/wallet-adapter-wallets"; +import { clusterApiUrl } from "@solana/web3.js"; +import { type PropsWithChildren, useMemo } from "react"; +import { config } from "../../../../shared/config/widget-defaults"; + +const network = WalletAdapterNetwork.Mainnet; + +const endpoint = clusterApiUrl(network); + +export const SolanaProvider = ({ children }: PropsWithChildren) => { + const wallets = useMemo(() => { + return config.env.isTestMode + ? [] + : [ + new PhantomWalletAdapter(), + new TrustWalletAdapter(), + new WalletConnectWalletAdapter({ + network: WalletAdapterNetwork.Mainnet, + options: { projectId: config.walletConnectV2.projectId }, + }), + ]; + }, []); + + return ( + + {children} + + ); +}; diff --git a/packages/widget/src/app/composition/providers/theme-wrapper.tsx b/packages/widget/src/app/composition/providers/theme-wrapper.tsx new file mode 100644 index 00000000..6653374b --- /dev/null +++ b/packages/widget/src/app/composition/providers/theme-wrapper.tsx @@ -0,0 +1,62 @@ +import { assignInlineVars } from "@vanilla-extract/dynamic"; +import merge from "lodash.merge"; +import type { PropsWithChildren } from "react"; +import { useMemo } from "react"; +import { vars } from "../../../shared/styles/theme/contract.css"; +import { rootSelector } from "../../../shared/styles/theme/ids"; +import { lightTheme } from "../../../shared/styles/theme/themes"; +import { getFineryThemeOverrides } from "../../../shared/styles/theme/variant-overrides/finery"; +import { portoThemeOverrides } from "../../../shared/styles/theme/variant-overrides/porto"; +import { utilaThemeOverrides } from "../../../shared/styles/theme/variant-overrides/utila"; +import type { RecursivePartial } from "../../../shared/types/utils"; +import type { WidgetConfig } from "../../config"; +import { useWidgetConfig } from "../../config"; + +export const getThemeOverrides = ({ + baseTheme, + variant, +}: { + baseTheme: typeof lightTheme; + variant: WidgetConfig["variant"]; +}): RecursivePartial => { + if (variant === "utila") { + return utilaThemeOverrides; + } + + if (variant === "finery") { + return getFineryThemeOverrides(baseTheme); + } + + if (variant === "porto") { + return portoThemeOverrides; + } + + return {}; +}; + +export const ThemeWrapper = ({ children }: PropsWithChildren) => { + const theme = useWidgetConfig("theme"); + const variant = useWidgetConfig("variant"); + + const finalTheme = useMemo(() => { + const baseTheme = merge(structuredClone(lightTheme), theme); + const overrides = getThemeOverrides({ + baseTheme, + variant, + }); + + return merge(structuredClone(lightTheme), theme, overrides); + }, [theme, variant]); + + return ( + <> +