Skip to content

Design: Architecture

This page describes Cloudpack's technical architecture for contributors and advanced users who want to understand how the system works internally.

Overview

Cloudpack uses a multi-server architecture to separate concerns and enable features like cross-repo development. The key components are:

            ┌───────────────────────────────────────┐
            │               Browser                 │
            │  ┌─────────────────────────────────┐  │
            │  │ App code + Import map + Overlay │  │
            │  └────────┬──────────────┬─────────┘  │
            └───────────┼──────────────┼────────────┘
                        │              │
    Requests:           │              │          Requests:
    HTML/Routes ────────┘              └───────── Bundles
                        │              │
            ┌───────────▼──────────────▼────────────┐
            │          Development Servers          │
            │   ┌────────────┐  ┌───────────────┐   │
            │   │ App Server │  │ Bundle Server │   │
            │   │  :5000     │  │  :5500        │   │
            │   └─────┬──────┘  └───────┬───────┘   │
            │         │                 │           │
            │         └────────┬────────┘           │
            │                  │                    │
            │         ┌────────▼────────┐           │
            │         │   API Server    │           │
            │         │ (orchestration) │           │
            │         └────────┬────────┘           │
            └──────────────────┼────────────────────┘

            ┌──────────────────▼────────────────────┐
            │       Context (Session, Cache)        │
            └───────────────────────────────────────┘

Multi-server architecture

When you run cloudpack start, up to three Express servers are started to handle different responsibilities.

App server

The app server hosts your application's routes and serves HTML pages.

Responsibilities:

  • Serve HTML pages with embedded import maps
  • Handle application routes (including custom rendering if configured)
  • Inject the Cloudpack overlay script for status and error display
  • Optionally serve bundles (if useSingleWebServer feature is enabled)

Default ports: 5000-5003 (configurable via server.port in Cloudpack config or --app-server-port)

Key files: startServers and createRoutes

Bundle server

The bundle server handles requests for package bundles. It's the workhorse of Cloudpack's library mode. (If the useSingleWebServer feature is enabled, the bundle requests are handled by the app server.)

Responsibilities:

  • Parse bundle request URLs (format: /{packageName}@{version}/h-{hash}/v{refreshVersion}/{bundled|unbundled}/{file})
  • Call ensurePackageBundled to either bundle the package or get the cached version
  • Serve bundles with appropriate caching headers
  • Handle linked package redirects
  • Handle worker requests

Default ports: 5500-5503 (configurable via --bundle-server-port)

Key files: startBundleServer and handleBundleRequest

API server

The API server provides internal orchestration and real-time communication with the browser overlay.

Responsibilities:

  • Provide a tRPC server which exposes a WebSocket with API endpoints for clients, such as the browser overlay UI or link/unlink CLI
  • Provide a data bus for internal communication
  • Publish bundling status updates, errors, and notifications to the WebSocket via the data bus

Default ports: 9890-9893 (configurable via --api-server-port)

Key files: createCloudpackServer to create the server, createCloudpackClient to connect to the server, and list of APIs

Other contents of the api-server package

The @ms-cloudpack/api-server package also contains a lot of core logic for bundling, as well as Context creation. However, bundling is not currently orchestrated with API server requests.

The main entry point for bundling is ensurePackageBundled, and requests are managed and de-duplicated by the TaskRunner.

Single web server mode

For environments where cross-origin requests cause issues, enabling the useSingleWebServer feature routes bundle requests through the app server instead of a separate bundle server. (There will still be a separate API server.)

Request flow

Startup

When cloudpack start runs (source):

  1. Load configuration - Read cloudpack.config.json and merge with defaults
  2. Create context - Initialize context and session state: package definitions cache, task runner, data bus, cache helpers, etc
  3. Resolve dependencies - During context initialization, build the app's resolve map (dependency graph) from node_modules
  4. Start servers - Launch API server, app server, bundle server
  5. Pre-bundle the app package - Call ensurePackageBundled for the app package
  6. Open browser - Navigate to the app URL

Page load

When the browser loads a page:

  1. Browser requests HTML from app server
  2. App server returns HTML page with:
    • Content as specified in the routes config
    • Import map (<script type="importmap">) containing package to URL mappings generated by createImportMap
    • Bootstrap script that creates a Cloudpack client connected to the API server WebSocket
    • Overlay component for status display
  3. Browser executes app code and encounters imports
  4. Import map resolves bare specifiers (e.g. react) to bundle URLs
  5. Browser requests bundles from bundle server

Bundle request handling

When the bundle server receives a request:

GET /react@18.0.0/h-abc123/v1/bundled/index.js
  1. Parse URL - Extract package name, version, hash, and file path
  2. Validate - Check if package exists in resolve map
  3. Check cache - Look for existing bundle matching the hash
  4. Bundle if needed - If no valid cache, run the bundler
  5. Cache result - Save bundle to disk cache
  6. Serve response - Return bundle with caching headers

File change (watch mode)

When you edit a file:

  1. Watcher detects change - File watcher notifies the system
  2. Invalidate cache - Mark affected package bundles as stale
  3. Re-bundle - TaskRunner queues a new bundle task
  4. Notify browser - Publish update via data bus
  5. Update page - Overlay receives notification via reloadCountSource and either:
    • Applies Hot Module Replacement (if enabled and supported)
    • Triggers a full page reload

If the Cloudpack config is edited, the entire start command will restart.

Context object

The Context object is the central state container passed to all server operations. It's created by createApiContext and contains:

PropertyDescription
sessionCurrent session state (config, resolve map, import map, URLs)
reporterConsole output and task logging
packagesPackage definitions (package.json) cache
taskRunnerManages bundling and other async tasks
packageHashesCached package hash calculations
busData bus for pub/sub messaging with client
watcherFile system watcher
telemetryClientManages telemetry tracking if enabled
remoteCacheClientManages the remote cache connection and auth if enabled

Session state

The Session object holds runtime state that can change during a dev session. Partial contents:

PropertyDescription
configMerged configuration from files and defaults
resolveMapDependency graph with resolved package locations and versions
importMapImport map for the browser
urlsServer URLs (app, bundle, API)
linkedPathsExternally linked package paths (from cloudpack link)
cachePathLocation of the local bundle cache
sessionVersionUsed to force refreshing all bundles on the client
targetVersionsUsed to force refreshing one package (target) on the client
sequenceUsed to force a refresh on the client via reloadCountSource

Key file: createSession

Resolve map

The ResolveMap is the source of truth for the dependency graph. It maps package specifiers to their resolved locations and metadata, using exports maps or main/module settings if available, or various heuristics if not.

The import map is generated from the resolve map, converting package paths to bundle server URLs.

Expand for examples

Partial resolve map:

json
{
  "my-app": {
    "version": "1.0.0",
    "path": "/repo/packages/my-app",
    "dependencies": {
      "react": "17.0.0",
      "library-a": "1.0.0"
    }
  },
  "library-a": {
    "version": "1.0.0",
    "path": "/repo/packages/library-a",
    "dependencies": {
      "react": "18.0.0"
    },
    "requiredBy": {
      "my-app@1.0.0": "^1.0.0"
    }
  },
  "react": {
    "version": "17.0.0",
    "path": "/repo/node_modules/react",
    "isExternal": true,
    "dependencies": {
      "loose-envify": "1.4.0"
    },
    "requiredBy": {
      "test-app@1.0.0": "^17.0.0"
    },
    "scopedVersions": {
      "18.0.0": {
        "version": "18.0.0",
        "path": "/repo/node_modules/library-a/node_modules/react",
        "isExternal": true,
        "dependencies": {
          "loose-envify": "1.4.0"
        },
        "requiredBy": {
          "library-a@1.0.0": "^18.0.0"
        }
      }
    }
  }
}

Import map

An import map is a modern browser feature that tells the browser how to resolve module specifiers in ESM import statements. It goes inside a <script type="importmap"> tag and looks like this (from the resolve map example above):

json
{
  "imports": {
    "my-app/lib/index.js": "/my-app@1.0.0/h-pending/v0.15/bundled/lib/index.js",
    "library-a": "/library-a@1.0.0/h-pending/v0.15/bundled/lib/index.js",
    "loose-envify": "/loose-envify@1.4.0/h-v12-abc123/v0/bundled/index.js",
    "react": "/react@17.0.0/h-v12-def123/v0/bundled/index.js",
    "react/jsx-runtime": "/react@17.0.0/h-v12-def123/v0/bundled/jsx-runtime.js"
  },
  "scopes": {
    "/library-a@1.0.0": {
      "react": "/react@18.0.0/h-v12-abc456/v0/bundled/index.js",
      "react/jsx-runtime": "/react@18.0.0/h-v12-abc456/v0/bundled/jsx-runtime.js"
    }
  }
}

How import maps work at runtime

See the bundling page for how the import map is created by Cloudpack and used by the browser.

URL format

Bundle URLs generated by createImportMap follow this pattern:

/{packageName}@{version}/h-{hash}/v{sessionVersion}{.targetVersion}/{bundleType}/{entryPath}
  • hash - Ensures browser fetches new bundles when package changes
    • For internal packages it will start as "pending". After each re-bundle (such as on watched file change), addImportMapHash is called to update the hash in the server's copy of the import map, which is sent to the browser on page reload.
  • sessionVersion - Forces refresh when session state changes (e.g., after linking or restarting all tasks)
  • .targetVersion - Optional segment specific to the package (target), based on number of times it's been forced to re-bundle for reasons other than content changes
  • bundleType - "bundled" or "unbundled" depending on original module type
  • entryPath - Path to the package entry file used at runtime, as detected by getRuntimeEntryPaths

Variants:

  • If features.useSingleWebServer is enabled, all URLs are prefixed with /__bundled__/
  • If features.relativeImportMapPaths is disabled, the paths are full URLs

Caching strategy

Cloudpack's speed comes from aggressive caching at multiple levels.

Package hashing

Each package is hashed before bundling. This ensures the cached bundle output is invalidated when anything that will affect it changes.

The hash includes the following (see hashPackage and getPackageHashEntries for full list and implementation):

  • Metadata: name, version, exports map, and dependencies
  • Source file contents for internal/linked packages only (for external packages, the contents would only change when the version changes)
  • Patch file contents for external packages with patches
  • Package settings and bundler capabilities
  • Target environment and HMR settings

Cache levels

  1. Browser cache - Bundles served with max-age=31536000 (1 year) when the URL includes a hash. Changed packages get new hashes, so old cached versions don't conflict.
  2. Local disk cache - Bundled output stored in ~/.cloudpack/{name}-${version}-{hash}/. Before bundling, Cloudpack checks if a valid cached bundle exists.
  3. Remote cache - Azure Blob Storage can store pre-built bundles. Teams can share caches via cloudpack sync:
    • Upload during CI: cloudpack sync --upload
    • Download on dev machines: cloudpack sync (often in postinstall)

Cache invalidation

  • On package change: When a package's hash changes, its bundle output is invalidated. During start, the file watcher will notify about these changes to internal packages, invalidate the cache, and notify the API server to refresh the browser.
  • On browser interaction: Certain user interactions with the browser or overlay UI during start can force cache invalidation using the Session properties sessionVersion, targetVersions, and sequence.
  • On cloudpack link changes: Uses the same mechanism as above
  • On internal changes to Cloudpack: There's a hardcoded global hashVersion in the Cloudpack source which can be used to invalidate all cache output in case of breaking internal changes within Cloudpack, such as default settings passed through to the underlying bundlers.

Data bus

The data bus stores data in a tree of named paths and provides pub/sub over that tree: publishers set the value at a path, and subscribers to any branch get notified when data underneath it changes. (It can also use providers to fetch data on demand, though Cloudpack only uses direct publishing.)

Publishers emit events to named sources. In Cloudpack, these are defined in api-server busSources.ts.

ts
const reloadCountSource = createDataPath({
  path: ['session', 'version'] as const,
  type: zod.number(),
})

bus.publish(reloadCountSource, 2)

Subscribers receive updates:

ts
bus.subscribe(reloadCountSource, (count) => {
  context.reporter.reset()
})

The API server allows the client to subscribe to data bus updates via CloudpackClient onDataChanged:

ts
client.onDataChanged.subscribe(reloadCountSource, {
  onData: (data) => {
    if (Number(data) > currentSequence) {
      window.location.reload()
    }
  },
})

For more details, see the data-bus readme

Hot module replacement

When HMR is enabled (features.hmr: true), Cloudpack can update code in the browser without a full page reload. This feature is still experimental and currently has some significant limitations; see the HMR GitHub issue for details.

Flow

  1. File change detected - Watcher notifies the system
  2. Re-bundle package - Only the changed package is rebuilt
  3. Publish update - notifyHost emits affected entry points to the data bus packageUpdateSource
  4. Browser receives - Overlay client gets the update via WebSocket
  5. Apply update - handlePackageUpdate uses react-refresh to apply changes
  6. Fallback - If HMR fails, falls back to full page reload

Notes and limitations

  • Only supported with Rspack (will be selected automatically for internal packages when HMR is enabled)
  • Changes to eagerly-loaded React components work best; non-component changes or changes to lazy-loaded components typically require full reload
  • Component state is preserved across updates
  • Automatic fallback to full page reload on errors