Architecture Guide
Technical reference for the @picsart/runtime composition model. This guide complements the README's architecture overview with factory-level specifics, interface contracts, and internal wiring details.
Kernel Composition Model
The kernel is a single synchronous construction followed by an asynchronous load cycle. No framework, no plugin registry, no middleware chain — plain constructor injection.
createRuntime Entry Point
src/createRuntime.ts
The public factory is a one-liner that delegates to the Runtime class:
const createRuntime = (config: IRuntimeConfig): IRuntime => new Runtime(config);
IRuntimeConfig declares four required fields and twelve optional service slots:
| Field | Type | Required |
|---|---|---|
container | HTMLElement | Yes |
renderingEngine | IRenderingEngine | Yes |
auth | IAuthService | Yes |
hostName | string | Yes |
analytics | IAnalyticsService | No |
storage | IStorageService | No |
ui | IUIService | No |
upload | IUploadService | No |
loading | ILoadingService | No |
remoteSettings | IRemoteSettingsService | No |
pulse | IPulseService | No |
user | IUserService | No |
project | IProjectService | No |
headers | IHeadersService | No |
deepLink | IDeepLinkService | No |
drag | IDragService | No |
cache | ICacheConfig | No |
Every optional service absent from the config results in a no-op fallback inside the constructor — the kernel never throws on missing services.
The Runtime Orchestrator
src/core/Runtime.ts
The constructor executes a deterministic sequence:
- DOM scaffolding —
setupDOM(container)creates the wrapper/canvas/miniapp container tree - Rendering engine initialization —
renderingEngine.init({ containerId })binds the engine to the canvas container - Container observer —
ContainerObserverwatches for containers to appear in the DOM before loading - Replay initialization —
initReplay()extracts initial replay state and canvas group ID - Runtime state store —
createStore<IRuntimeState>()with six initial fields (isDarkMode, sharedStorage, selectedLayerIds, selectedCanvasId, textSelection, language) - Replay state store —
createStore<IReplayState>()with the initial replay - 15 context factories — each factory receives either a state store, a service, or the rendering engine
- Handler creation — storage handlers and analytics handlers from their respective services
- Cache initialization —
createMiniappCache(config.cache, config.analytics)creates the LRU cache
All nine steps are synchronous. The runtime is fully initialized after new Runtime(config) returns.
DOM Scaffolding
src/dom/setup.ts
setupDOM creates a minimal three-element tree inside the host container:
<hostElement>
<div id="picsart-runtime-wrapper">
<div id="picsart-runtime-canvas" />
<div id="miniapp-container" />
</div>
</hostElement>
The wrapper is positioned relative with 100% width/height. The canvas container hosts the rendering engine. The miniapp container hosts the miniapp iframe. destroy() removes the wrapper from the host element.
MiniappHandle Lifecycle
src/core/MiniappHandle.ts
Each runtime.load() call produces a MiniappHandleImpl — a per-miniapp lifecycle manager. The handle encapsulates:
bootstrap(context?)— passes the assembled bootstrap context to the platform SDK, wires up all 15 observers, fires theonReadycallback. ReturnsIMiniappControlswithclose()anddestroy()methods. Throws if called twice.unload()— unsubscribes all observers, fires theonUnloadcallback (deeplink cleanup), and calls the platform SDK'sunLoad().
The load-to-bootstrap sequence:
runtime.load(miniapp, options)
├── Wait for containers (ContainerObserver)
├── Check cache (tryShowFromCache)
├── Create lifecycle analytics (if analytics service present)
├── Create registry (createRegistry)
├── Build loader options (bridges DI interfaces to platform SDK)
├── Load miniapp via registry (async — platform SDK)
├── Set deeplink URL params (?app=<packageId>&version=<appVersion>)
└── Return MiniappHandleImpl
handle.bootstrap(context?)
├── Assemble context (getAllContext with permission filtering)
├── Call platform SDK bootstrap (async)
├── Wire 15 observers (observeAll)
├── Fire onReady callback
└── Return { close(), destroy() }
Cache System
src/core/cache.ts
LRU cache backed by lru-cache with configurable max size (default: 10) and max age (default: 4 hours). Cache keys are miniapp package IDs, with edit-type miniapps including selected layer IDs in the key.
When a cached miniapp is found and still bootstrapped, the runtime shows it directly (via showMiniapps) and returns a lightweight handle wrapping the cached options. Cache eviction calls unload() on the disposed miniapp and sends an analytics event.
Deeplink System
src/core/deeplink.ts
setMiniappDeeplink(miniapp) writes ?app=<packageId>&version=<appVersion> to the URL via history.replaceState and returns a cleanup function that removes those params. The mapp key variant exists for legacy editor compatibility.
getMiniappDeeplink() reads these params back — the kernel exports this for host applications to detect which miniapp a URL deep-links to.
Lifecycle Analytics
src/core/lifecycleAnalytics.ts
MiniappLifecycleAnalytics tracks four lifecycle phases through the injected IAnalyticsService:
| Event | When |
|---|---|
miniapp_load_time | After bootstrap() completes — includes model download, content load, and total load durations |
miniapp_open | After onReady fires — includes touchpoint metadata |
miniapp_close | On close — includes done or cancel reason |
The analytics class is only instantiated when config.analytics is provided. Timing data is captured via Date.now() at each phase boundary.
Adapter Contract Surfaces
Rendering Engine Contract
src/types/rendering.ts
IRenderingEngine defines the full rendering contract. The kernel calls only the required methods — optional methods extend capabilities without requiring all adapters to implement them.
Required Methods
| Method | Signature | Purpose |
|---|---|---|
init | (config: { containerId: string }) => void | Bind the engine to a DOM container |
destroy | () => void | Release all resources |
getCanvasSizes | () => ICanvasDimensions | Return canvas width/height |
getVisibleBounds | () => IViewportBounds | Return viewport x, y, width, height |
syncLayers | (replay, options?) => Promise<void> | Synchronize layer tree from replay state |
renderLayerToImage | (layerId, options?) => Promise<IPhotoResource> | Rasterize a layer to an image resource |
Optional Methods
| Method | Signature | Purpose |
|---|---|---|
renderToVideo | () => Promise<IVideoResource> | Render project to video |
calculateTextMetrics | (payload) => Promise<ITextMetrics> | Calculate text sizing |
zoomToLayers | (layerIds, options?) => void | Zoom viewport to specific layers |
player | IPlayerAdapter | Media playback adapter |
getStorm | () => IStormAdapter | Event-storm state manager for media player |
getImageLayerResources | (layerId) => unknown | Get image resources for a layer |
onLayerUpdate | (layerId, callback) => VoidFunction | null | Subscribe to layer changes |
getMostVisibleCanvasLayerId | () => string | Identify the dominant canvas in viewport |
Shipped Implementations
| Implementation | Module | Strategy |
|---|---|---|
| web-layering | @picsart/runtime/adapters/web-layering | Full Canvas2D rendering via @picsart/web-layering |
| headless | @picsart/runtime/adapters/headless | No-op — all methods return safe defaults |
Any object satisfying the required methods is a valid rendering engine. The no-op pattern (headless adapter) proves the contract's minimal surface.
Service Provider Contracts
src/types/services.ts
Thirteen service interfaces exist. The kernel consumes them through IRuntimeConfig — each is an optional field except IAuthService.
Required: IAuthService
| Method | Signature |
|---|---|
getHeaders | () => Record<string, string> |
getRefreshedHeaders | () => Promise<Record<string, string>> |
getStatus | () => { token: string; status: string } |
onRedirect | () => void |
getUser (optional) | () => { id?: string; username?: string } |
The kernel calls getHeaders() on every miniapp loader creation and getRefreshedHeaders() when the miniapp requests fresh tokens. getStatus() feeds the user info context when no IUserService is provided.
Optional Services
| Interface | Config field | Fallback |
|---|---|---|
IAnalyticsService | analytics | No-op — analytics events silently dropped |
IStorageService | storage | No-op — storage reads return empty object |
IUIService | ui | No-op — dialogs and notifiers resolve immediately |
IUploadService | upload | No-op — uploads resolve with empty string |
ILoadingService | loading | No-op — loading indicators not shown |
IRemoteSettingsService | remoteSettings | No-op — returns empty settings object |
IPulseService | pulse | No-op — tracking state returns empty JSON |
IUserService | user | Minimal fallback using IAuthService.getUser() and getStatus() |
IProjectService | project | No-op — project reads return undefined |
IHeadersService | headers | Simple headers built from auth.getHeaders() and hostName |
IDeepLinkService | deepLink | Direct window.history.replaceState fallback |
IDragService | drag | Not wired — drag capability absent |
Every fallback is constructed in the Runtime constructor. The pattern is consistent: check if the service exists in config, use it if present, create an inline no-op object if absent.
Permission Model
src/types/permissions.ts
MiniappPermissions is a record of nine capability domains, each with independent read and write booleans:
| Domain | Controls access to |
|---|---|
scene | Rendering scene — layers, replay state, canvas IDs |
theme | Host theme — dark/light mode |
credits | User credits balance |
location | Browser location (pathname, search) |
headers | Custom HTTP headers |
language | User's language preference |
authentication | Auth tokens and user identity |
sharedStorage | Cross-miniapp shared storage |
analytics | Analytics event submission |
Default permissions grant full read/write across all nine domains. Load options can override any domain to restrict a specific miniapp.
Permission Filtering in Context Assembly
Permissions are enforced at two boundaries:
-
getAllContext— when assembling the bootstrap context, each field is populated only if the corresponding domain'sreadpermission istrue. A miniapp withcredits: { read: false }receives no credits data in its context. -
onContextChange— when a miniapp pushes context changes back to the host, each setter is gated by the domain'swritepermission. A miniapp withscene: { write: false }cannot modify selected layer IDs or canvas IDs.
React Provider Adapters
src/providers/react/adapters/
Six adapter modules bridge React context libraries into IRuntimeConfig service objects:
| Adapter | Module | Bridges |
|---|---|---|
| Authentication | adapters/growth-rc | @picsart/growth-rc auth context |
| Analytics | adapters/pulse | @pulse/react tracking context |
| Internationalization | adapters/intl | @picsart/shared-services and @picsart/rc localization |
| Design System | adapters/design-system | Notifier, dialog, and theming from @picsart/design-system |
| Error Boundary | adapters/error-boundary | Sentry error boundary via @picsart/growth-rc |
| Offline Detection | adapters/no-network | Browser online/offline event tracking |
Each adapter exports a factory function (e.g., createAuthenticationAdapter(config)) that returns a provider configuration object. The FullRuntimeProviders component composes all selected adapters into a provider tree and calls onServicesReady with the assembled service objects.
These adapters are entirely optional — non-React hosts construct IRuntimeConfig service objects directly.
Context Factories and Bootstrap Context
The Context Factory Pattern
Every context factory follows the same structure:
const createXxxContext = (dependency) => ({
getXxx: () => value, // synchronous read
setXxx: (next) => void, // write (update state or call service)
observeXxx: (callback) => unsubscribe, // subscribe to changes
});
The dependency is either a state store (IStateProvider<T>), a service interface, or the rendering engine. The returned object is a plain triple of {get, set, observe} — no class, no inheritance, no registration.
State Management: IStateProvider and createStore
src/state/createStore.ts
IStateProvider<T> is the kernel's state abstraction:
| Method | Signature | Purpose |
|---|---|---|
getState | () => T | Synchronous snapshot |
setState | (partial: Partial<T>) => void | Partial update — merges into current state |
subscribe | (listener: (state: T) => void) => VoidFunction | Subscribe to changes — returns unsubscriber |
createStore<T>(initial) wraps event-storm's createStorm() internally. The abstraction hides the event-storm API behind a minimal surface.
Two stores exist per runtime instance:
| Store | Type | Fields |
|---|---|---|
runtimeState | IRuntimeState | isDarkMode, sharedStorage, selectedLayerIds, selectedCanvasId, textSelection, language |
replayState | IReplayState | replay |
The 15 Context Factories
Each factory is categorized by its data source.
State-Backed Contexts
These read from and write to runtimeState via IStateProvider:
| Factory | Source file | State field | What it manages |
|---|---|---|---|
createThemeContext | src/context/theme.ts | isDarkMode | Dark/light theme as "dark" or "light" string |
createSharedStorageContext | src/context/sharedStorage.ts | sharedStorage | Cross-miniapp string storage |
createSelectedLayerIdsContext | src/context/selectedLayerIds.ts | selectedLayerIds | Currently selected layer ID array |
createSelectedCanvasIdContext | src/context/selectedCanvasId.ts | selectedCanvasId | Currently active canvas group ID |
createTextSelectionContext | src/context/textSelection.ts | textSelection | Text selection range (start/end) |
createLanguageContext | src/context/language.ts | language | User's language preference (LanguageCodes) |
Pattern: the factory receives IStateProvider<XxxState>, reads via state.getState().field, writes via state.setState({ field: value }), and observes via state.subscribe().
Service-Backed Contexts
These delegate to an injected service interface:
| Factory | Source file | Service | What it manages |
|---|---|---|---|
createCreditsContext | src/context/credits.ts | IUserService | User credits balance |
createUserInfoContext | src/context/userInfo.ts | IUserService | User identity (id, username, authorization) |
createUserSubscriptionContext | src/context/userSubscription.ts | IUserService | User subscription status |
createHeadersContext | src/context/headers.ts | IHeadersService | HTTP headers for miniapp requests |
createProjectContext | src/context/project.ts | IProjectService | Project identity and metadata |
When the service is absent from config, the Runtime constructor creates inline no-op objects that return safe defaults (empty objects, undefined, no-op observers).
Engine-Backed Contexts
These interact with the rendering engine and/or replay state:
| Factory | Source file | Dependencies | What it manages |
|---|---|---|---|
createMediaPlayerContext | src/context/mediaPlayer.ts | IRenderingEngine | Media playback state |
createActionContext | src/context/action.ts | IStateProvider<IReplayState>, IRenderingEngine | Edit actions — validates actions, syncs layers via the engine, updates replay state |
The action context is the most complex factory. setAction validates the incoming action, calls renderingEngine.syncLayers() to apply it, and updates replayState. observeAction detects new versus undo actions by comparing replay snapshots and dispatches to the appropriate miniapp callback.
Replay-State Contexts
These read from and write to replayState:
| Factory | Source file | What it manages |
|---|---|---|
createReplayContext | src/context/replay.ts | The full replay object (layer tree + history) |
createSettingsContext | src/context/settings.ts | Settings extracted from replay state |
Standalone Observer: location
src/context/location.ts
The location context does not use a state store or a service. It interacts directly with the browser's History API:
getLocation()— returns{ pathname, search }fromwindow.locationsetLocation({ pathname, search })— callswindow.history.pushState()observeLocation(callback)— monkey-patchespushStateandreplaceStateto intercept navigation, returns an unsubscriber that restores the original methods
This is the only context that patches browser globals. All others operate through injected abstractions.
Context Assembly: getAllContext
src/context/getAllContext.ts
getAllContext assembles the full bootstrap context from the 13 context providers (the IContextProviders interface — one getter per context, excluding action and location which are observer-only at bootstrap):
const getAllContext = async (
miniapp: IMiniapp,
permissions: MiniappPermissions,
externalGetters: IExternalGetters,
providers: IContextProviders,
): Promise<Partial<IBootstrapContext>>
The function checks each permission domain before populating its corresponding context field. Fields gated by false permissions are omitted from the returned object — the miniapp receives a partial context reflecting its granted capabilities.
getSceneContext is a separate export that extracts scene-specific fields (replay, selected layers, canvas ID) from the providers. The analytics handler uses this to attach scene state to analytics events.
When scene.read is true, getAllContext also checks the optional getReplayPlayerState provider. If the host supplies a replay player state getter and the player is active, replayMode ('headful' | 'headless') and action (IReplayAction) are populated in the bootstrap context. The getter is optional — hosts without replay player state simply omit it, and the fields remain absent.
Context Change Handler: onContextChange
src/handlers/contextChange.ts
onContextChange is the reverse path — miniapps push context changes back to the host:
const onContextChange = async ({
context, // Partial<ITransferContext> — fields the miniapp wants to change
permissions, // MiniappPermissions — write permissions gate each setter
replayState, // IStateProvider<IReplayState> — for action/replay updates
handlers, // 10 setter/getter functions from context factories
replaceParams, // URL parameter replacement function
}): Promise<ISyncContextResponse>
Each field in the incoming context is routed to the corresponding handler only if the domain's write permission is true. The handler maps:
| Context field | Handler | Permission domain |
|---|---|---|
action | setAction | scene |
credits | setCredits | credits |
sharedStorage | setSharedStorage | sharedStorage |
textSelection | setTextSelection | scene |
selectedCanvasId | setSelectedCanvasId | scene |
selectedLayerIds | setSelectedLayerIds | scene |
mediaPlayerState | setMediaPlayerState | scene |
project | setProject | scene |
The function returns an ISyncContextResponse that can carry error information back to the miniapp.
Execution Target Orthogonality
The Five Targets
Execution targets define where the composed runtime runs. They are orthogonal to adapters — any compatible adapter works in any compatible target.
| Target | Environment | DOM source | Rendering | User interaction |
|---|---|---|---|---|
| Browser CSR | Browser | Native | Any adapter | Direct |
| Browser SSR | Browser + Server | Native (hydrated) | Any adapter | Direct (after hydration) |
| Node | Node.js | jsdom or Puppeteer | Headless adapter | None |
| CLI | Node.js + Chromium | Puppeteer | Any browser adapter | Chromium window |
| Headless | Any | Simulated or none | Headless adapter | None |
Target-Adapter Compatibility Matrix
| Adapter | Browser CSR | Browser SSR | Node | CLI | Headless |
|---|---|---|---|---|---|
| Runtime Kernel | Yes | Yes | Yes | Yes | Yes |
| web-layering | Yes | No | No | Yes | No |
| headless | Yes | Yes | Yes | Yes | Yes |
| React Providers | Yes | Yes | No | No | No |
| CLI Tooling | No | No | Yes | Yes | No |
The kernel runs everywhere. Each adapter declares its constraints — DOM requirements, browser API dependencies, React version needs. The matrix is a consequence of these declarations, not a configuration.
CLI Configuration Resolution
src/cli/
The CLI resolves configuration through four layers, merged in priority order:
defaults → file config → environment variables → CLI flags
| Layer | Source | Priority |
|---|---|---|
| Defaults | Hardcoded in resolve-configuration.ts | Lowest |
| File config | .yml, .yaml, or .json file via --config flag | Low |
| Environment variables | RUNTIME_* env vars | High |
| CLI flags | Command-line arguments | Highest |
The merged configuration is validated against known constraints:
| Field | Constraint |
|---|---|
engine | "none" or "web-layering" |
env | "staging" or "production" |
port | Integer 1-65535 (default: 4173) |
packageId | Required — must be non-empty |
headless | Boolean (default: false, auto-detected when stdin is not a TTY) |
Headless mode is auto-detected: when stdin is not a TTY and no explicit --headless flag is set, the CLI assumes headless execution. In headless mode, output is structured JSON lines (events: server:ready, browser:open, page:loaded, page:error, shutdown). In interactive mode, output uses chalk-formatted text with graceful Ctrl+C shutdown.
Zero Environment Assumptions
The kernel makes no assumptions about:
- DOM — adapters provide DOM access; the kernel accepts a container element
- Browser APIs — history, fetch, URL are only used in specific context factories (location, deeplink) that run exclusively in browser targets
- React — the provider adapters are a separate entry point; the kernel has zero React imports
- Rendering backend — any
IRenderingEngineimplementation works, including a no-op object literal - Environment variables — the kernel reads none; only the React provider adapters and CLI consume environment configuration
Environment coupling is always the adapter's responsibility. The kernel's only hard dependency is a JavaScript runtime with ES2020+ support.