- Slim @shared/types/error.ts to the cross-process SerializedError shape - Relocate serializeError (+ toSerializable) to src/main/ai/utils/serializeError.ts; re-point the 4 main/ai consumers - Delete the @shared-side dead AI-SDK subtype family + 23 isSerialized* guards (utils/error.ts) and the unused ProviderSpecificError class (renderer keeps its own live parallel copy) - Move the serializeError test to main/ai/utils; assert discriminant fields directly instead of via the deleted guards - Doc: mark the section 6 error/serializable row resolved
14 KiB
Shared Layer Architecture (src/shared / @shared)
This is the canonical reference for what belongs in @shared, how it is organized, and the rules that keep its top level from sprawling. It owns the @shared-internal rules; Architecture Overview, Renderer Architecture, and Naming Conventions reference it.
@shared is the cross-process primitive layer — Layer 4 in Renderer Architecture §2. It depends on no app code and is importable by main, renderer, and preload.
1. Two Invariants
Everything in @shared must satisfy both, or it does not belong here.
1.1 Cross-process
A module belongs in @shared only if both main and renderer actually use it — types included, with one deliberate carve-out: the Cache schema registry (§1.1.1).
- Why:
@sharedis the single source of truth shared across the process boundary; single-process code already has a process to live in. - Reachable from only one process → it lives in that process's own layer (
src/main/*orsrc/renderer/{utils,hooks,services}). - No speculative placement. If something only might become cross-process, write it in
main/rendererfirst and move it here once it actually crosses. Do not park it in@sharedfor a possibility — the common failure is a type or util added "in case", then never used cross-process and left as cruft.
1.1.1 Carve-out: the Cache schema registry
The Cache subsystem is the one exception to §1.1. Every Cache key schema and its value type lives in @shared/data/cache/ (cacheSchemas.ts + cacheValueTypes.ts) regardless of which process consumes it — including renderer-only types (Tab, ChatScrollAnchor, AgentOpenExternalAppTarget, …). A renderer-only cache value type here is compliant, not a §1.1 violation — do not flag or relocate it. Cache subsystem only; §1.1 holds everywhere else.
1.2 No mutable runtime state
@shared exports types, pure functions, and immutable data only. It exports no class-instance singletons (services / managers / registries) and nothing that holds runtime-mutable state.
- Why:
mainandrendererare separate V8 realms; an@sharedmodule is loaded once per process. A "shared singleton" is a fiction — it silently becomes N per-process instances that diverge. Mutable state has no coherent shared owner; it belongs to the process whose lifecycle and context it reflects. newis not the test — runtime mutability + identity is.newis allowed only to build immutable data that is then frozen and exported (aMap/Set/RegExplookup built once from static data and never mutated, e.g. the privatecommandMapincommand/definitions.ts).- A stateful class ships only its definition (blueprint) from
@shared; its instance is created per-process. Example:ContextKeyServiceis defined cross-process, butnew ContextKeyService()lives in the renderer'sCommandContextKeyProvider.
| Allowed | Banned |
|---|---|
type / interface / enum, schema-derived types |
export const x = new XService() (any exported instance singleton) |
| pure functions, predicates, converters | a registry / manager / service instance |
immutable data — consts, definitions, frozen lookups built via new Map/Set |
any module-level value holding runtime-mutable state |
| stateful-class definitions (blueprints) | a live instance of such a class |
2. The Closed Top-Level Set
The top level is a closed set — this is Naming Conventions §4.8 (top-level closed by default) applied to @shared. Exactly these five, by three principled categories:
| Dir | Category | Why it earns a top-level home |
|---|---|---|
ai |
Core domain | Cherry Studio is an AI product; AI's cross-process contracts and pure logic are first-class (mirrors src/main/ai/). Holds AI's cross-process slice only — not AI UI or per-process services. |
data |
Cross-process infra | The data layer's cross-process contracts: API entity/request types, cache/preference/bootConfig schemas, migration mappings, presets. Framework-like, domain-agnostic. |
ipc |
Cross-process infra | The IpcApi framework: route define helpers, request + event schemas, error model, and shared types (IpcContext, WindowId). Domain-agnostic. |
types |
Shape bucket | Cross-process type declarations with no single owner. |
utils |
Shape bucket | Cross-process pure logic plus its supporting constants and class blueprints. |
Governance rule: a new capability never earns a new top-level dir. It is either (a) the core domain [only ai], (b) genuine cross-process infrastructure, or (c) decomposed by shape into types / utils. Anything else → types / utils.
Naming respects §4.9: ai / data / ipc are singular namespaces, types / utils are plural buckets.
3. Shape: types vs utils
@shared has only two shape buckets. With no UI, no React, and no per-process runtime, the renderer's rich shapes (components / hooks / services / pages) collapse to declarations vs pure logic.
types/ |
utils/ |
|---|---|
| type aliases, interfaces, enums, schema-derived types | pure functions, predicates, converters |
| (plus the small consts a type needs) | plus the constants / static data those functions need, and stateful-class blueprints |
Routing between the two follows the Naming Conventions §5.2 route-by-shape table.
3.1 File vs subdirectory, and barrels
- A single
.tsfile is the default. Most topics are one file —types/<topic>.ts,utils/<topic>.ts, imported directly. Promote to a subdirectory only when the topic actually owns multiple files (Naming Conventions §4.4); never pre-create one. - A topic subdirectory has exactly one
index.tsas its public API —types/<topic>/index.ts,utils/<topic>/index.ts, explicit named exports, noexport *. The import surface is then identical whether the topic is a file or a subdir (@shared/utils/<topic>either way), and the subdir's other files stay private behind it. - The bucket roots
types/andutils/have noindex.ts. A bucket is a category, not a module — a root barrel re-exporting every file buys no aggregate API and only adds churn and import-cycle risk on every addition. Import the specific file or topic, never the bucket. types/has no runtime tests. A declarations bucket has no runtime behavior to test, so a behavioral test undertypes/(expect(fn(...))…) signals the file holds logic — a predicate, type guard, converter, factory, or function — that belongs inutils/(route-by-shape, §3). Move the logic toutils/<topic>.ts(importing the types it needs fromtypes/, the blessedutils → typesdirection) and the test follows it. Type guards (x is T) are runtime predicates too — co-locate them with the logic inutils/, not with the interface intypes/. Schemas built from a validator function (z.custom(isFoo)) follow the function toutils/; a purely declarative schema (z.object({…})) may stay intypes/. The one test that does belong intypes/is a type-level test (expectTypeOf/assertType): it asserts a type contract itself and has no runtime to relocate. Such a test is a transitional guard — it earns its place only while a hand-written type is the source of truth; once a runtime schema (Zod / IpcApi) owns the contract and the type isz.infer-derived, the schema's own validation subsumes it and the type-level test retires with that migration.
3.2 Constants & static data
- Default: a constant lives in its domain/topic single-file, beside the logic it serves (AI model defaults →
ai/; a file-type list →utils/file/). utils/constants.tsis NOT a bucket. It carries only the genuinely global, cross-process residue (KB/MB/GB,APP_NAME). Add to it only when you are 100% certain a constant is app-global and cross-cutting; if it belongs to any domain, it goes in that domain's file. — Why: this is precisely the guardrail the oldconfig/constant.tslacked, which is how it grew into an 82-importer junk drawer (now dissolved — §6).- Single-process constants → leave
@shared(Invariant 1.1). - There is no
config/bucket. A constant is data; a frozen value in its domain file (orutils/) expresses everything aconfig/dir would, without inviting unrelated globals.
3.3 Stateful-class blueprints
A stateful class's definition is pure code, so it rides in its topic module under utils/ — precedent: the stateful MatchPatternMap class in utils/blacklistMatchPattern.ts. @shared has no services/ bucket, because services are per-process (Invariant 1.2).
4. Placement Decision
Two gates, in order, then categorize:
- Cross-process? Reached by both processes — no → it goes to a process layer (
src/main/*orsrc/renderer/*). (Carve-out: a Cache key's schema entry + value type stay in@shared/data/cache/even when single-process — §1.1.1.) - Stateless / immutable? No exported instance, no mutable state — no → only the blueprint and static data stay; the instance goes per-process.
- Categorize: core domain (
ai) / infra (data,ipc) / shape (types,utils). Not one of the first two → decompose by shape intotypes/utils; never open a new top-level dir.
5. Anti-Patterns
- Exported instance singleton —
export const x = new XService(), or any registry / manager / service instance. Violates Invariant 1.2. - Single-process code in
@shared— main-only or renderer-only logic placed here for convenience. Violates Invariant 1.1. (Former epicenter: the now-dissolvedconfig/constant.ts— §6. The Cache schema registry is the one sanctioned exception — §1.1.1.) - Junk-drawer file or dir — a
config/bucket or aconstant.tsaccumulating unrelated globals across domains and processes. Decompose by domain + process; do not relocate as a blob. - A new top-level dir per capability — every capability decomposes by shape; the top level is closed (§2).
- A stateful "service" in
@shared— state has no coherent shared owner; it belongs tomainorrenderer.
6. Migration (target vs current — deferred, tracked)
The structural decomposition is done: command, file, shortcuts, externalApp, and config were dissolved out of the top level — cross-process slices into types/ + utils/ by shape, single-process code back into main/renderer (Invariant 1.1) — and menuRegistry's exported instance was replaced by the pure resolveMenu (Invariant 1.2). config's ~82-importer constant.ts was decomposed by domain + process (file-ext lists → utils/file/, KB/MB/GB/APP_NAME → utils/constants.ts, terminal/update/OAuth/timeout/window-sizing blocks back to their owning main/renderer modules); the actual consumer process was confirmed per item rather than trusted from a directional plan (e.g. API_SERVER_DEFAULTS proved renderer-only, MIN_WINDOW_* cross-process, providers.ts renderer-only). The utils/index.ts bucket-root barrel was later split into topic files (§3.1). A subsequent types/utils audit confirmed the single-process residue in the table below and removed dead code (types/codeTools.ts's unused LoaderReturn, which also dragged a @types renderer import into @shared — a layering violation now gone); keywordSearch and SerializableSchema looked main-only/dead on main but proved cross-process against the feat/chat-page truth branch (renderer GlobalSearch, renderer/types/serialize.ts) and correctly stay. A later pass cleared three of the rows below — searchSnippet.ts and pdf.ts moved to src/main/utils/ (searchSnippet is a generic, DB-free text helper, so it landed in main/utils/ rather than the data/services/utils/ first pencilled in), and externalApp.ts's EXTERNAL_APPS const was inlined into ExternalAppsService — and switched the types/file, utils/file, utils/command, and utils/api topic barrels from export * to explicit named exports (§3.1). The error/serializable cluster was then resolved by routing each part to its real home: the genuinely cross-process SerializedError shape stays in @shared/types/error.ts, the write-side serializeError moved to src/main/ai/utils/, and the AI-SDK subtype/guard family — dead on the @shared side and ~98% duplicated by the renderer's own copy — was deleted along with the unused ProviderSpecificError (the renderer keeps its live parallel copy). Remaining deviations:
| Area | Current | Target |
|---|---|---|
data/types/ converters/guards — coerceSearchRole, deriveRootSpanId, readCherryMeta/withCherryMeta, knowledge.ts string helpers |
logic living in the data type bucket; behavioral tests under data/types/__tests__/ flag it (§3.1, last paragraph) |
open question: route-by-shape would move them to a utils location, but schema-derived guards are conventionally co-located — decision deferred |
IpcChannel.ts |
18 KB v1 channel enum at the root | v1 legacy; folded into ipc/ as the IpcApi migration retires channels — not part of this governance |
7. Related
- Architecture Overview — process model and the
@sharedone-line summary. - Renderer Architecture §2–§3 — the layer model and how the renderer depends on
@shared; §6 owns command's renderer-side cells (this doc owns its@sharedcell). - Naming Conventions §4.8 — top-level closed by default (this doc is its
@sharedapplication); §4.9 singular vs plural; §5.2 route-by-shape.