@supuwoerc/masonry
    Preparing search index...

    Worker Communication Protocol & Messaging

    This document details the communication protocol design between main thread and Worker, message types, payload structures, and Transferable object transfer mechanisms.

    The communication protocol is the bridge connecting the main thread and Worker. It defines a type-safe message format ensuring accurate and efficient data exchange between threads.

    File Responsibility
    src/core/worker/protocol.ts Protocol definitions (Message, MessageType, Payload)
    src/core/worker/constant.ts Worker constants

    interface Message<T = MessagePayload> {
    id: string // Unique identifier generated by nanoid
    from?: string // Source message ID (for request-response pairing)
    type: MessageType // Message type enum
    payload: T // Generic payload
    timestamp: number // Send timestamp
    }
    Field Purpose
    id Uniquely identifies each message for debugging and tracing
    from Marks which request a response corresponds to (mainly for error tracking)
    type Determines how the message is processed (switch-case dispatch)
    payload Carries specific data, type determined by type
    timestamp Records send time, useful for performance analysis

    Both main thread and Worker have their own #sendMessage method — same core logic but different calling interfaces:

    // ─── Main thread side (src/core/masonry.ts) ───
    #sendMessage(type: MessageType, payload: MessagePayload, transfer?: Transferable[]) {
    const message: Message<MessagePayload> = {
    id: nanoid(), // Independent ID for each message
    type,
    payload,
    timestamp: Date.now(), // Record send time
    }
    // postMessage's second parameter is the Transferable list
    // Defaults to empty array when no transfer needed (regular structured clone)
    this.#worker?.postMessage(message, transfer ?? [])
    }

    // ─── Worker side (src/core/worker/offscreen-canvas.ts) ───
    #sendMessage(type: MessageType, payload: RequestPayload | ResponsePayload, from?: string): void {
    const message: Message<RequestPayload | ResponsePayload> = {
    id: nanoid(),
    from, // Marks which request this message responds to
    type,
    payload,
    timestamp: Date.now(),
    }
    // Worker uses globalThis.postMessage to send to main thread
    // Note: Worker side has no transfer parameter because Worker→Main direction
    // currently has no objects needing ownership transfer (ClickResult/LayoutUpdated are pure data)
    globalThis.postMessage(message)
    }

    Design Notes:

    • Main thread has transfer parameter: Because Main→Worker direction needs to transfer OffscreenCanvas and ImageBitmap (zero-copy ownership transfer).
    • Worker side has from parameter: Used for error tracing. When Worker encounters an error processing a request, the from field records which request caused it, helping the main thread pinpoint issues.
    • nanoid() for ID generation: Lightweight unique ID generator (21 chars, URL-safe) with better browser compatibility than crypto.randomUUID().

    enum MessageType {
    Setup, // Initialize setup
    SetupResponse, // Setup complete response
    LoadMore, // Request to load more data
    LoadMoreResponse, // Load more data response
    Render, // Trigger render
    RenderLoading, // Request placeholder rendering
    RenderLoadingResponse,// Placeholder render complete
    Resize, // Container resize
    RemoveLoading, // Remove loading placeholder
    Error, // Error message
    Scroll, // Scroll offset update
    LayoutUpdated, // Layout update notification
    ImageLoaded, // Image loaded
    Click, // Click event
    ClickResult, // Click result response
    }
    MainWorker (8 types):
    Setup, Render, Resize, Scroll, ImageLoaded, Click,
    RenderLoadingResponse, LoadMoreResponse

    WorkerMain (7 types):
    SetupResponse, LoadMore, RenderLoading, RemoveLoading,
    LayoutUpdated, ClickResult, Error
    Request Response Direction
    Setup SetupResponse Main→Worker→Main
    Click ClickResult Main→Worker→Main
    RenderLoading RenderLoadingResponse Worker→Main→Worker
    LoadMore LoadMoreResponse Worker→Main→Worker

    When the Worker receives a message, it dispatches it to the appropriate handler via #handleMessage's switch-case:

    // src/core/worker/offscreen-canvas.ts
    #setupMessageHandler(): void {
    globalThis.onmessage = (event: MessageEvent<Message>) => {
    try {
    this.#handleMessage(event.data)
    } catch (error) {
    // Top-level error boundary: any uncaught exception won't crash the Worker
    // Instead it notifies the main thread via an Error message
    this.#sendError(error)
    }
    }
    }

    #handleMessage(message: Message) {
    const { type, payload } = message
    switch (type) {
    case MessageType.Setup:
    this.#handleSetup(payload as SetupPayload)
    break
    case MessageType.Render:
    // Render triggers three actions: start animation loop + first frame render + check if load more needed
    this.#startAnimationLoop()
    this.#handleRerender()
    this.#checkLoadMore()
    break
    case MessageType.Resize:
    this.#handleResize(payload as ResizePayload)
    break
    case MessageType.Scroll:
    this.#handleScroll(payload as ScrollPayload)
    break
    case MessageType.RenderLoadingResponse:
    this.#handleRenderLoading(payload as RenderLoadingResponsePayload)
    break
    case MessageType.LoadMoreResponse:
    this.#handleLoadMoreResponse(payload as LoadMoreResponsePayload)
    break
    case MessageType.ImageLoaded:
    this.#handleImageLoaded(payload as ImageLoadedPayload)
    break
    case MessageType.Click:
    this.#handleClick(payload as ClickPayload)
    break
    default:
    // Exhaustiveness guard: immediately errors on unknown message types
    // Ensures protocol changes don't silently ignore new message types
    throw new MasonryError(`unknown message type: ${type}`)
    }
    }

    Design Notes:

    • Top-level try/catch error boundary: Uncaught exceptions in a Worker thread don't have global error handling like the main thread. Without this boundary, an exception in one handler would break the entire Worker's onmessage, making all subsequent messages unprocessable.
    • default throws error: During protocol evolution, if the main thread sends a new message type the Worker doesn't support yet, throwing immediately makes version mismatch issues easier to discover than silently ignoring.
    • Render triggers three actions: This is the "start" signal after initialization completes — it atomically starts the render loop, draws the first frame, and performs the first loadMore check.

    // Main → Worker request payloads
    type RequestPayload =
    | SetupPayload | ResizePayload | ScrollPayload
    | ImageLoadedPayload | ClickPayload | Array<string> | string

    // Worker → Main response payloads
    type ResponsePayload =
    | RenderLoadingResponsePayload | LayoutUpdatedPayload
    | ClickResultPayload | LoadMoreResponsePayload | Error | null
    interface SetupPayload {
    offscreenCanvas: OffscreenCanvas // Offscreen canvas [Transferable]
    clientWidth: number // Container CSS width
    clientHeight: number // Container CSS height
    config: WorkerConfiguration // Worker configuration
    dpr: number // Device pixel ratio
    }
    interface ScrollPayload {
    deltaX: number // Horizontal scroll delta (px)
    deltaY: number // Vertical scroll delta (px)
    }
    interface ImageLoadedPayload {
    index: number // Image index in data source
    bitmap: ImageBitmap // Loaded bitmap [Transferable]
    width: number // Original width
    height: number // Original height
    }
    interface LoadMoreResponsePayload {
    page: number // Current page number
    hasMore: boolean // Whether more data exists
    data: Array<ImageBitmap> // Loaded image bitmap array
    }
    interface ResizePayload {
    clientWidth: number // New CSS width
    clientHeight: number // New CSS height
    dpr: number // New device pixel ratio
    }
    interface RenderLoadingResponsePayload {
    id: string // Placeholder ID
    bitmap: ImageBitmap // Rendered bitmap [Transferable]
    }
    interface ClickPayload {
    x: number // Click CSS X coordinate (relative to canvas)
    y: number // Click CSS Y coordinate
    }

    type ClickResultPayload = {
    item: GridItem // Hit grid item
    index: number // Data source index
    row: number // Row number
    column: number // Column number
    } | null // Returns null on miss
    interface LayoutUpdatedPayload {
    contentWidth: number // Total content width
    contentHeight: number // Total content height
    }

    interface GridItem {
    id: string // nanoid unique identifier
    image: ImageBitmap | null // Image data (null when loading)
    status: 'loading' | 'loaded' // Loading status
    x: number // Layout X coordinate
    y: number // Layout Y coordinate
    width?: number // Render width (variable in masonry)
    height?: number // Render height
    itemIndex: number // Index in data source
    }

    GridItem is the core data structure for managing elements within the Worker, with x/y/width/height populated by the layout strategy.


    The second parameter of postMessage can specify a list of Transferable objects. After transfer:

    • Zero-copy: Ownership is transferred, no serialization/deserialization
    • Original reference invalidated: Sender can no longer access the object
    • Performance benefit: Large ImageBitmap transfer has near-zero overhead
    Object Type Transfer Timing Direction
    OffscreenCanvas Initialization (once) Main → Worker
    ImageBitmap Image load complete Main → Worker
    ImageBitmap Placeholder render complete Main → Worker
    // Transfer OffscreenCanvas (initialization)
    this.#sendMessage(MessageType.Setup, payload, [offscreenCanvas])

    // Transfer ImageBitmap (image loaded)
    this.#sendMessage(MessageType.ImageLoaded, payload, [bitmap])

    // Transfer ImageBitmap (placeholder)
    this.#sendMessage(MessageType.RenderLoadingResponse, { bitmap, id }, [bitmap])
    • OffscreenCanvas transfer is irreversible; main thread can never regain control
    • After ImageBitmap transfer, sender's reference becomes an empty object with 0 width/height
    • Objects in the transfer list must be referenced in the payload

    Worker cannot and does not need certain main-thread-only objects:

    interface WorkerConfiguration extends Omit<
    MasonryConfiguration,
    'core' | 'interaction' | 'loader' | 'placeholderRenderer' | 'events' | 'imageLoad'
    > {
    core: Omit<Core, 'canvas' | 'items'> & {
    items?: ImageBitmap[]
    itemCount?: number
    itemSizes?: Array<{ width?: number; height?: number }>
    }
    interaction?: Omit<Interaction, 'onClick'>
    loader?: Omit<LoadMoreConfig, 'loadMore'>
    }

    Here is the complete code for how the main thread builds the SetupPayload, showing configuration stripping and data normalization logic:

    // src/core/masonry.ts - payload assembly in #initWorker()
    const payload: SetupPayload = {
    offscreenCanvas,
    clientWidth: canvas.clientWidth,
    clientHeight: canvas.clientHeight,
    config: {
    core: {
    // Only pass pure data fields; exclude canvas (DOM) and items (may contain URL strings)
    backgroundColor: this.#config.core.backgroundColor,
    style: this.#config.core.style,
    layout: this.#config.core.layout,
    limit: this.#config.core.limit,
    timeout: this.#config.core.timeout,
    },
    },
    dpr: window.devicePixelRatio || 1,
    }

    // ─── Items normalization: different handling paths for three input formats ───
    const items = this.#config.core.items
    if (items?.length) {
    if (items[0] instanceof ImageBitmap) {
    // Path 1: Pre-loaded ImageBitmap array
    // Pass directly to Worker — Worker can render immediately
    payload.config.core.items = items as ImageBitmap[]
    } else {
    // Path 2: URL strings or ItemDescriptor objects
    // URLs cannot be sent to Worker (fetch needs main thread for cookies/auth)
    // So only send count and size info; Worker uses placeholders first
    const descriptors = this.#normalizeItems(items as string[] | ItemDescriptor[])
    payload.config.core.itemCount = descriptors.length // Worker creates placeholder items
    payload.config.core.itemSizes = descriptors.map((d) => ({
    width: d.width,
    height: d.height,
    }))
    // URLs stay on main thread, loaded later by ImageLoader
    this.#pendingUrls = descriptors
    }
    }

    // ─── Interaction config stripping: exclude non-serializable onClick function ───
    if (this.#config.interaction) {
    payload.config.interaction = {
    scroll: this.#config.interaction?.scroll, // scroll is a pure data object
    }
    }

    // ─── Loader config stripping: exclude non-serializable loadMore function ───
    if (this.#config.loader) {
    payload.config.loader = {
    pageSize: this.#config.loader.pageSize, // Only pass the numeric value
    }
    }

    // Send message with OffscreenCanvas as Transferable for zero-copy transfer
    this.#sendMessage(MessageType.Setup, payload, [offscreenCanvas])

    Design Notes:

    • Why URLs aren't sent to the Worker: Image loading (fetch) typically requires cookies, auth headers, CORS configuration — capabilities more easily managed on the main thread. The Worker only handles rendering pre-decoded ImageBitmaps.
    • itemCount + itemSizes design: Worker needs to know "how many items exist" to pre-create placeholder GridItems and perform layout calculation. Size information (when available) lets masonry layout produce accurate heights before images load.
    • #pendingUrls purpose: Stores the list of URLs to load, which #loadImages() uses to start async loading after receiving SetupResponse.

    Main Thread                              Worker Thread
    │ │
    │──── Setup [OffscreenCanvas] ──────────→│
    │ │ handleSetup()
    │ │ performLayout()
    │←──── SetupResponse ───────────────────│
    │ │
    │──── Render ───────────────────────────→│
    │ │ startAnimationLoop()
    │ │ detect loading items
    │←──── RenderLoading [ids] ─────────────│
    render placeholders
    │──── RenderLoadingResponse [bitmap] ───→│
    │ │ draw placeholder
    │ │
    │──── ImageLoaded [bitmap] ─────────────→│
    │ │ handleImageLoaded()
    │ │ performLayout()
    │←──── RemoveLoading [id] ──────────────│
    │←──── LayoutUpdated ───────────────────│
    │ │
    │──── Scroll {deltaX, deltaY} ─────────→│
    │ │ handleScroll()
    │ │ tickInertia()
    │ │ checkLoadMore()
    │←──── LoadMore ────────────────────────│
    loadMore() │
    │──── LoadMoreResponse [bitmaps] ───────→│
    │ │ handleLoadMoreResponse()
    │ │
    │──── Click {x, y} ────────────────────→│
    │ │ handleClick()
    │←──── ClickResult {item, row, col} ────│
    │ │
    │──── Resize {w, h, dpr} ──────────────→│
    │ │ handleResize()
    │←──── LayoutUpdated ───────────────────│