Skip to main content

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:

FieldTypeRequired
containerHTMLElementYes
renderingEngineIRenderingEngineYes
authIAuthServiceYes
hostNamestringYes
analyticsIAnalyticsServiceNo
storageIStorageServiceNo
uiIUIServiceNo
uploadIUploadServiceNo
loadingILoadingServiceNo
remoteSettingsIRemoteSettingsServiceNo
pulseIPulseServiceNo
userIUserServiceNo
projectIProjectServiceNo
headersIHeadersServiceNo
deepLinkIDeepLinkServiceNo
dragIDragServiceNo
cacheICacheConfigNo

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:

  1. DOM scaffoldingsetupDOM(container) creates the wrapper/canvas/miniapp container tree
  2. Rendering engine initializationrenderingEngine.init({ containerId }) binds the engine to the canvas container
  3. Container observerContainerObserver watches for containers to appear in the DOM before loading
  4. Replay initializationinitReplay() extracts initial replay state and canvas group ID
  5. Runtime state storecreateStore<IRuntimeState>() with six initial fields (isDarkMode, sharedStorage, selectedLayerIds, selectedCanvasId, textSelection, language)
  6. Replay state storecreateStore<IReplayState>() with the initial replay
  7. 15 context factories — each factory receives either a state store, a service, or the rendering engine
  8. Handler creation — storage handlers and analytics handlers from their respective services
  9. Cache initializationcreateMiniappCache(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 the onReady callback. Returns IMiniappControls with close() and destroy() methods. Throws if called twice.
  • unload() — unsubscribes all observers, fires the onUnload callback (deeplink cleanup), and calls the platform SDK's unLoad().

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.

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:

EventWhen
miniapp_load_timeAfter bootstrap() completes — includes model download, content load, and total load durations
miniapp_openAfter onReady fires — includes touchpoint metadata
miniapp_closeOn 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

MethodSignaturePurpose
init(config: { containerId: string }) => voidBind the engine to a DOM container
destroy() => voidRelease all resources
getCanvasSizes() => ICanvasDimensionsReturn canvas width/height
getVisibleBounds() => IViewportBoundsReturn 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

MethodSignaturePurpose
renderToVideo() => Promise<IVideoResource>Render project to video
calculateTextMetrics(payload) => Promise<ITextMetrics>Calculate text sizing
zoomToLayers(layerIds, options?) => voidZoom viewport to specific layers
playerIPlayerAdapterMedia playback adapter
getStorm() => IStormAdapterEvent-storm state manager for media player
getImageLayerResources(layerId) => unknownGet image resources for a layer
onLayerUpdate(layerId, callback) => VoidFunction | nullSubscribe to layer changes
getMostVisibleCanvasLayerId() => stringIdentify the dominant canvas in viewport

Shipped Implementations

ImplementationModuleStrategy
web-layering@picsart/runtime/adapters/web-layeringFull Canvas2D rendering via @picsart/web-layering
headless@picsart/runtime/adapters/headlessNo-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

MethodSignature
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

InterfaceConfig fieldFallback
IAnalyticsServiceanalyticsNo-op — analytics events silently dropped
IStorageServicestorageNo-op — storage reads return empty object
IUIServiceuiNo-op — dialogs and notifiers resolve immediately
IUploadServiceuploadNo-op — uploads resolve with empty string
ILoadingServiceloadingNo-op — loading indicators not shown
IRemoteSettingsServiceremoteSettingsNo-op — returns empty settings object
IPulseServicepulseNo-op — tracking state returns empty JSON
IUserServiceuserMinimal fallback using IAuthService.getUser() and getStatus()
IProjectServiceprojectNo-op — project reads return undefined
IHeadersServiceheadersSimple headers built from auth.getHeaders() and hostName
IDeepLinkServicedeepLinkDirect window.history.replaceState fallback
IDragServicedragNot 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:

DomainControls access to
sceneRendering scene — layers, replay state, canvas IDs
themeHost theme — dark/light mode
creditsUser credits balance
locationBrowser location (pathname, search)
headersCustom HTTP headers
languageUser's language preference
authenticationAuth tokens and user identity
sharedStorageCross-miniapp shared storage
analyticsAnalytics 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:

  1. getAllContext — when assembling the bootstrap context, each field is populated only if the corresponding domain's read permission is true. A miniapp with credits: { read: false } receives no credits data in its context.

  2. onContextChange — when a miniapp pushes context changes back to the host, each setter is gated by the domain's write permission. A miniapp with scene: { 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:

AdapterModuleBridges
Authenticationadapters/growth-rc@picsart/growth-rc auth context
Analyticsadapters/pulse@pulse/react tracking context
Internationalizationadapters/intl@picsart/shared-services and @picsart/rc localization
Design Systemadapters/design-systemNotifier, dialog, and theming from @picsart/design-system
Error Boundaryadapters/error-boundarySentry error boundary via @picsart/growth-rc
Offline Detectionadapters/no-networkBrowser 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:

MethodSignaturePurpose
getState() => TSynchronous snapshot
setState(partial: Partial<T>) => voidPartial update — merges into current state
subscribe(listener: (state: T) => void) => VoidFunctionSubscribe 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:

StoreTypeFields
runtimeStateIRuntimeStateisDarkMode, sharedStorage, selectedLayerIds, selectedCanvasId, textSelection, language
replayStateIReplayStatereplay

The 15 Context Factories

Each factory is categorized by its data source.

State-Backed Contexts

These read from and write to runtimeState via IStateProvider:

FactorySource fileState fieldWhat it manages
createThemeContextsrc/context/theme.tsisDarkModeDark/light theme as "dark" or "light" string
createSharedStorageContextsrc/context/sharedStorage.tssharedStorageCross-miniapp string storage
createSelectedLayerIdsContextsrc/context/selectedLayerIds.tsselectedLayerIdsCurrently selected layer ID array
createSelectedCanvasIdContextsrc/context/selectedCanvasId.tsselectedCanvasIdCurrently active canvas group ID
createTextSelectionContextsrc/context/textSelection.tstextSelectionText selection range (start/end)
createLanguageContextsrc/context/language.tslanguageUser'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:

FactorySource fileServiceWhat it manages
createCreditsContextsrc/context/credits.tsIUserServiceUser credits balance
createUserInfoContextsrc/context/userInfo.tsIUserServiceUser identity (id, username, authorization)
createUserSubscriptionContextsrc/context/userSubscription.tsIUserServiceUser subscription status
createHeadersContextsrc/context/headers.tsIHeadersServiceHTTP headers for miniapp requests
createProjectContextsrc/context/project.tsIProjectServiceProject 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:

FactorySource fileDependenciesWhat it manages
createMediaPlayerContextsrc/context/mediaPlayer.tsIRenderingEngineMedia playback state
createActionContextsrc/context/action.tsIStateProvider<IReplayState>, IRenderingEngineEdit 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:

FactorySource fileWhat it manages
createReplayContextsrc/context/replay.tsThe full replay object (layer tree + history)
createSettingsContextsrc/context/settings.tsSettings 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 } from window.location
  • setLocation({ pathname, search }) — calls window.history.pushState()
  • observeLocation(callback) — monkey-patches pushState and replaceState to 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 fieldHandlerPermission domain
actionsetActionscene
creditssetCreditscredits
sharedStoragesetSharedStoragesharedStorage
textSelectionsetTextSelectionscene
selectedCanvasIdsetSelectedCanvasIdscene
selectedLayerIdssetSelectedLayerIdsscene
mediaPlayerStatesetMediaPlayerStatescene
projectsetProjectscene

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.

TargetEnvironmentDOM sourceRenderingUser interaction
Browser CSRBrowserNativeAny adapterDirect
Browser SSRBrowser + ServerNative (hydrated)Any adapterDirect (after hydration)
NodeNode.jsjsdom or PuppeteerHeadless adapterNone
CLINode.js + ChromiumPuppeteerAny browser adapterChromium window
HeadlessAnySimulated or noneHeadless adapterNone

Target-Adapter Compatibility Matrix

AdapterBrowser CSRBrowser SSRNodeCLIHeadless
Runtime KernelYesYesYesYesYes
web-layeringYesNoNoYesNo
headlessYesYesYesYesYes
React ProvidersYesYesNoNoNo
CLI ToolingNoNoYesYesNo

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
LayerSourcePriority
DefaultsHardcoded in resolve-configuration.tsLowest
File config.yml, .yaml, or .json file via --config flagLow
Environment variablesRUNTIME_* env varsHigh
CLI flagsCommand-line argumentsHighest

The merged configuration is validated against known constraints:

FieldConstraint
engine"none" or "web-layering"
env"staging" or "production"
portInteger 1-65535 (default: 4173)
packageIdRequired — must be non-empty
headlessBoolean (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 IRenderingEngine implementation 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.