Self-hostingThe envelope

The envelope

A pwapp is two things: an envelope (the app as data — plain JSON files under pwpack/) and a runtime that runs it (the runner, the renderer, and the block bundles the pages use). The runner never compiles the envelope in; it fetches the files at load time from a folder path or an absolute URL, so the same package works at a host root, under a sub-path, or on a CDN.

Every claim here is taken from the engine and platform source; each section names its file. What the code leaves open is under Unverified. Repo-relative paths below are under pwfabric-core/packages/runner/src/ unless a longer path is given.

Two downloads, one envelope

ButtonFileContentsRuns by itself?
Export<slug>.zipenvelope under pwpack/ plus index.html, assets/, shims/, blocks/, engines/yes — unzip and serve the folder
Download<slug>.pwpack.tgzenvelope only: project.json, navigation.json, connections.json, appearance.json, surfaces/*.receipt.json, auth.json when the app has a doorno — you supply the runtime and a block manifest

The tgz has no package/ prefix and no pwfabric.manifest.json; paths are relative to the archive root. The zip places the same files under pwpack/.

Source: PhiWebs API (closed source): phico-projects.ts (POST /:id/export, POST /:id/download-pwpack), PhiCo publish panel (closed source), PhiWebs API (closed source): phico-project-service.ts (buildPwappEnvelope).

Layout of an exported pwapp

index.html                     the single shell; imports the runner, calls runApp()
assets/embed.js                the renderer (defines window.PhiWebs.render)
assets/runner.js               @phimajor-solutions/pwfabric-runner, ES module
assets/theme.css               the app's token set as CSS custom properties
shims/*.mjs                    import-map targets for react, zod, @phimajor-solutions/*
blocks/manifest.json           block type -> bundle file + SHA-256
blocks/<type>.<hash8>.mjs      one ESM bundle per block type the pages use
engines/manifest.json          connector/provider engines (bundled or declared)
engines/<atomId>.<hash8>.mjs   bundled engines only
pwpack/project.json            pages, routes, identity              (required)
pwpack/navigation.json         navbar links                         (optional)
pwpack/appearance.json         appearance config                    (optional)
pwpack/connections.json        data connections                     (optional)
pwpack/auth.json               who the visitor is                   (optional)
pwpack/surfaces/<key>.receipt.json   one receipt per page
pwpack/media/<file>            platform media rehosted into the envelope

There is no per-page HTML. The runner fetches project.json and blocks/manifest.json (both required), then navigation.json, appearance.json, auth.json, connections.json and engines/manifest.json, each of which may be absent.

Source: PhiWebs API (closed source): web-app-assembly-service.ts (assembleWebApp, buildShellHtml), pwfabric-core/packages/runner/src/run-app.ts (the Promise.all at the top of runApp).

project.json

The runner’s read contract is PwappProject. Two fields are required.

FieldTypeRequiredMeaning
entrySurfaceIdstringyespage key of the entry page (/ and #/ resolve here)
surfaceOrderstring[]yesevery page key, in order; this is the route table
surfaceReceiptFilesRecord<key, path>nopage key → receipt path relative to source; absent ⇒ surfaces/<key>.receipt.json
schemaVersionstring | numbernothe platform writes "1.0.0"
idstringnothe platform writes the project slug; keys the app session in session storage
titlestringnodocument title, and the prefix of every page title
identity{ required, topology?, methods? }norequired: boolean; topology is 'platform' or 'external'; methods only when topology === 'platform'

The platform also writes worldId and projectId. The runner does not read them; they pass through for the app’s own record API. Despite the names, entrySurfaceId and surfaceOrder hold page keys (short route names such as home, people), not surface ids.

Source: pwfabric-core/packages/runner/src/types.ts (PwappProject), pwfabric-core/packages/runner/src/helpers.ts (receiptFilesOf), phico-project-service.ts (projectJson in buildPwappEnvelope).

A page is a key in surfaceOrder; its receipt is surfaceReceiptFiles[key]. Routing is one rule in both modes: the location either names a key in surfaceOrder, or it names nothing and the runner renders its not-found page. It never falls back to the entry page.

  • hash mode (default): #/<key>; #/ and no hash mean the entry page. #section without a slash is an in-page anchor and is left to the browser.
  • path mode: <base>/<key>, where <base> is the shell’s <base href>. Needs an SPA fallback on the host and <meta name="pw-router" content="path">.

Links inside receipts are written root-relative to the app: /people, /people/, or / for the entry page. At render time the runner rewrites every internal string under a href prop or any prop ending in Href (ctaHref, linkHref) into the active router’s form. Absolute-scheme URLs (https:, mailto:) and fragment links (#…) are left alone. An unknown key is still rewritten, so the click lands on the in-app not-found page.

Source: pwfabric-core/packages/runner/src/router.ts, pwfabric-core/packages/runner/src/helpers.ts (pageKeyOf, isHrefKey, rewriteHrefs).

surfaces/<key>.receipt.json

A receipt is the render input for one page. The runner reads:

FieldTypeNotes
blocksBlockNode[]required; each node has type, props, optional children
titlestringoptional; composed with project.title unless it already starts with it
appearance{ config: {...} }optional; a ref object with the page’s resolved theme
capabilities{ capability, config? }[]optional; passed to the renderer as-is (data-bound blocks derive their sources from it)

The platform additionally writes description, readme, tags and intent; the runner ignores them. Blocks are referenced only by type; that string is the join key into blocks/manifest.json.

Source: types.ts (SurfaceReceipt, SurfaceBlockNode), run-app.ts (route()), phico-project-service.ts (collectFrozenPages).

Atoms: how blocks reach the page

A pwapp ships no atom source. Of the nine atom kinds the contracts define (block, surface, capability, ui-appearance, pattern, icon-set, connector, provider, auth-provider), two travel in an exported app, both as compiled ESM: blocks (one bundle per block type any page uses, listed in blocks/manifest.json) and connector/provider engines (one per declared connection, listed in engines/manifest.json). Surfaces travel as receipts; appearance travels as appearance.json.

Each blocks/manifest.json entry is a DynamicBlockDescriptor:

{ "blockType": "navbar", "moduleUrl": "blocks/navbar.3f9c1a2b.mjs",
  "integrity": "<sha256 hex>", "publisher": "phiwebs",
  "publisherVerified": true, "definition": { "name": "navbar" } }

Before evaluating a bundle the renderer fetches moduleUrl, hashes the bytes with SHA-256 and compares them to integrity (hex, or SRI sha256-<base64>). A missing or mismatching hash is a hard failure. Bundles keep react, zod and @phimajor-solutions/* external; the shell’s import map points those specifiers at ./shims/*.mjs.

engines/manifest.json entries are either bundled (moduleUrl + integrity, same rule as blocks) or declared (an engine object and no module — the runtime interprets it as data). Never both.

Source: pwfabric-core/packages/contracts/src/fabric/atom.ts (ATOM_KINDS, classifyAtom), pwfabric-core/packages/contracts/src/fabric/dynamic-artifact.ts (DynamicBlockDescriptor), pwfabric-core/packages/runtime/src/render/dynamic-block-loader.ts (verifyIntegrity), types.ts (PwappEngineDescriptor), web-app-assembly-service.ts (BlockManifestEntry, EngineManifestEntry).

The other envelope files

  • navigation.json — { navbar: { links: [{ label, href }] } }. A navbar block whose props.links is empty is filled from here (hrefs rewritten); one that already has links is left as authored. A project with no authored navigation gets one derived link per page, href: "/<key>".
  • appearance.json — the appearance config ({ theme, styleVariables, darkTheme, … }). The runner wraps a bare config as { config } and passes it as the renderer’s appearance override; an object that already has config passes through; anything else (including the legacy { use: 'default' }) is ignored and each receipt’s own appearance applies.
  • connections.json — { connections: [{ name, connector?, config? }] }. name is what engine entries bind to. Credential-like keys are never written.
  • auth.json — only when the app has a door. provider is oauth2-pkce (issuer, clientId, scopes) or password (loginUrl, registerUrl, logoutUrl, sessionUrl, optional providers[]). See Visitor sign-in.

Source: helpers.ts (assembleNav), run-app.ts (projectAppearanceRef), types.ts (PwappNavigation, PwappConnections, PwappAuth), web-app-assembly-service.ts (deriveNavigation, CREDENTIAL_KEY_RE), phico-project-service.ts (buildAuthJson).

From the download to runApp

The exported zip contains its shell; serving the unzipped folder is the whole self-host. index.html is, in essence:

<link rel="stylesheet" href="./assets/theme.css" />
<script type="importmap">{ "imports": { "react": "./shims/react.mjs", "...": "..." } }</script>
<script src="./assets/embed.js"></script>
<div id="root"></div>
<script type="module">
  import { runApp } from './assets/runner.js'
  runApp({ source: './pwpack', blocks: './blocks/', router: 'hash' })
</script>

runApp reads the envelope from source, mounts into mount, resolves the router, and per route: fetches the receipt, rewrites hrefs, fills the navbar, sets the title, and calls window.PhiWebs.render(...) in fat mode with the block manifest and appearance. The renderer is embed.js; if window.PhiWebs is missing the runner throws. When the app declares sign-in, receipts are fetched with an x-app-session header; a plain static host ignores it.

Starting from the .pwpack.tgz instead, you must supply what the zip ships: embed.js, runner.js, the shims and import map, and a blocks/manifest.json pointing at bundles you host. No engine package produces those from a bare tgz.

Source: run-app.ts (runApp), helpers.ts (fetchJson), web-app-assembly-service.ts (buildShellHtml, buildImportMapJson).

Versioning

  • project.json.schemaVersion is "1.0.0" in every envelope the platform writes today. The runner types it string | number and does not branch on it.
  • There is no contracts-version field in the envelope. Compatibility is carried by the copies inside the zip: assets/runner.js and assets/embed.js are whatever @phimajor-solutions/pwfabric-runner / pwfabric-embed the exporting server has installed (both 4.4.1 in current engine source), and block bundles are pinned by hash.
  • The one compatibility rule in code is the legacy fallback: no surfaceReceiptFiles ⇒ surfaces/<key>.receipt.json for every key.

Source: types.ts (PwappProject.schemaVersion), helpers.ts (receiptFilesOf), pwfabric-core/packages/runner/package.json, pwfabric-core/packages/embed/package.json, web-app-assembly-service.ts and phico-project-service.ts (schemaVersion: '1.0.0').

Worked example

A two-page app. pwpack/project.json:

{
  "schemaVersion": "1.0.0",
  "id": "cadence",
  "title": "Cadence",
  "entrySurfaceId": "home",
  "surfaceOrder": ["home", "people"],
  "surfaceReceiptFiles": { "home": "surfaces/home.receipt.json", "people": "surfaces/people.receipt.json" },
  "identity": { "required": false, "topology": "platform", "methods": [] }
}

pwpack/navigation.json is { "navbar": { "links": [ { "label": "Home", "href": "/" }, { "label": "People", "href": "/people" } ] } }. pwpack/surfaces/home.receipt.json:

{
  "title": "Home",
  "blocks": [
    { "id": "nav",  "type": "navbar", "props": { "links": [], "logoText": "Cadence", "showCTA": false } },
    { "id": "hero", "type": "hero",   "props": { "title": "Cadence", "ctaHref": "/people" } }
  ],
  "appearance": { "config": { "theme": { } } },
  "capabilities": []
}

blocks/manifest.json lists navbar and hero as in the descriptor above. Served in hash mode: #/people renders people.receipt.json; the navbar’s empty links are filled as #/ and #/people; the hero’s ctaHref becomes #/people; #/nowhere renders the not-found page.

Not the same thing: the pack source tgz

the phiwebs-basepack'sscripts/export-tgz.mjs“ (the pack’s pnpm build) produces a pwpack source archive: atom source trees (blocks/, capabilities/, appearance/, patterns/, surfaces/, icons/, connectors/, providers/, auth/) plus an identity-only package.json, no root manifest. It is the input to marketplace publishing, where the server classifies each path into an atom kind (classifyAtom in atom.ts) and bundles it. It is not an app envelope; the runner cannot run it.

Unverified

  • The exported index.html is written with <html lang="tr"> regardless of the app’s locale; whether that is intended is not stated in code.
  • The token keys inside appearance.config.theme are SurfaceTheme in contracts; not enumerated here, and the example leaves the object empty.
  • Whether a .pwpack.tgz can be re-imported into PhiWebs was not traced.
  • On PhiWebs the runner host is /api/installed/<world>/<project>/, which serves the zip contents with an injected <base href>; that injection was not read. The PhiCo /s/ route renders one surface with the platform runtime and does not use the runner or the envelope.
  • How the runner absolutizes media/<file> refs against source was not traced.
  • No build_pwapps script exists in any workspace repository (searched); a local-runner recipe by that name is not part of the published engine.