@pylonsync/functions 0.22.47 → 0.22.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -27,4 +27,4 @@ export type { WorkflowDefinition, WorkflowRun, WorkflowRunRequest, WorkflowRunne
27
27
  export { resetDb, installTestIsolation } from "./testing";
28
28
  export { slugifyName, availableSlug } from "./slugify";
29
29
  export type { SsrResponse, SsrCookieOptions, SsrMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, } from "./ssr-runtime";
30
- export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, Files, ActionFiles, Crdt, StoreFileOptions, StoredFile, } from "./types";
30
+ export type { QueryCtx, MutationCtx, ActionCtx, DbReader, DbWriter, Stream, Scheduler, AuthInfo, AuthMode, AuthRequirement, FnDefinition, Validator, AnyValidator, ValidatorSchema, InferValidator, InferArgs, RequireMember, RequireMemberOptions, Shards, ShardsReader, ShardsWriter, ShardInfo, ShardTransfer, MemberRow, Workflows, WorkflowRunSummary, WorkflowRunStatus, Audit, AuditLog, AuditEntry, Agents, AgentRunOptions, VectorSearchQuery, VectorSearchResult, SearchResult, PaginationResult, Llm, LlmMessage, LlmContentBlock, LlmMediaSource, LlmTool, LlmCompleteRequest, LlmCompleteResponse, LlmStreamEvent, Rooms, Domains, TenantDomainResult, TenantDomainRecord, TenantDomainDns, DomainAvailability, DomainContact, RegisterDomainOptions, RegisteredDomainResult, Files, ActionFiles, Crdt, StoreFileOptions, StoredFile, FileInfo, ReadFile, } from "./types";
@@ -0,0 +1,46 @@
1
+ /** The build-specific values inlined into `/pylon-sw.js`. */
2
+ export interface ServiceWorkerConfig {
3
+ /** Hash of everything below. Cache names carry it. */
4
+ version: string;
5
+ /** The client build's public prefix (`/_pylon/build/`, or a CDN URL). */
6
+ buildPrefix: string;
7
+ /** URLs to keep at install: the offline routes' build files, the web app
8
+ * manifest, the icons. */
9
+ precache: string[];
10
+ /** Offline routes without dynamic segments: their HTML is kept at install. */
11
+ shells: string[];
12
+ /** Every offline route pattern (`/leads/[id]` included). */
13
+ routes: string[];
14
+ /** Path of the start URL; its shell answers any navigation with no
15
+ * better match. */
16
+ startPath: string;
17
+ /** How long a navigation to an offline route waits for the network before
18
+ * the worker answers with the shell. */
19
+ navigationTimeoutMs: number;
20
+ }
21
+ /** What the worker does with one request. */
22
+ export type RequestKind =
23
+ /** A page load: network first, shell when offline. */
24
+ "navigate"
25
+ /** A file of the client build: cache first. */
26
+ | "build"
27
+ /** The web app manifest or an icon: cache first. */
28
+ | "precached"
29
+ /** Not intercepted, never cached. */
30
+ | "network";
31
+ /**
32
+ * Classify one request. Pure; `self.location.origin` is passed in.
33
+ */
34
+ export declare function classifyRequest(request: {
35
+ url: string;
36
+ method: string;
37
+ mode: string;
38
+ }, config: ServiceWorkerConfig, origin: string): RequestKind;
39
+ /**
40
+ * The worker. `sw` is the ServiceWorkerGlobalScope (`self`); passing it in
41
+ * keeps the body free of globals so tests can supply their own. `classify`
42
+ * is `classifyRequest`, passed in for the same reason.
43
+ */
44
+ export declare function pylonServiceWorker(sw: any, config: ServiceWorkerConfig, classify: typeof classifyRequest): void;
45
+ /** The `/pylon-sw.js` source for one build. */
46
+ export declare function renderServiceWorker(config: ServiceWorkerConfig): string;
package/dist/runtime.d.ts CHANGED
@@ -14,8 +14,12 @@
14
14
  * each ctx.db / ctx.scheduler / ctx.runMutation call), so the map never
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
- import type { DbReader, DbWriter, Llm, Rooms, Workflows, Audit, ApiKeys } from "./types";
17
+ import type { DbReader, DbWriter, Files, ActionFiles, Llm, Rooms, Workflows, Audit, ApiKeys } from "./types";
18
18
  export declare function buildDbReader(callId: string, ssrRead?: boolean): DbReader;
19
+ /** `ctx.files` — signed download URLs, minted host-side, and what the host knows of a file. */
20
+ export declare function buildFiles(callId: string): Files;
21
+ /** `ctx.files` in an action: {@link buildFiles} plus store, read and delete. */
22
+ export declare function buildActionFiles(callId: string): ActionFiles;
19
23
  export declare function buildSsrFnCaller(callId: string): (name: string, args?: Record<string, unknown>) => Promise<unknown>;
20
24
  export declare function buildDbWriter(callId: string): DbWriter;
21
25
  /**
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The namespaces a barrel entry re-exports (exported name → module
3
+ * specifier), or null when `source` is not a pure namespace barrel: any
4
+ * statement other than `import * as X from "..."` and `export { ... }` of
5
+ * those names makes it null.
6
+ */
7
+ export declare function parseNamespaceBarrel(source: string): Map<string, string> | null;
8
+ /**
9
+ * Rewrite `import { A, B as C } from "<barrel>"` in `source` to one
10
+ * namespace import per name, from `resolveTarget(spec)` (the specifier the
11
+ * importing file should use for the re-exported module). Type-only imports
12
+ * and names the barrel does not re-export stay on the barrel import.
13
+ * `barrels` maps a bare specifier to its parsed barrel. Returns the source
14
+ * unchanged when nothing applies.
15
+ */
16
+ export declare function rewriteBarrelImports(source: string, barrels: (specifier: string) => Map<string, string> | null, resolveTarget: (barrel: string, spec: string) => string): string;
@@ -1,5 +1,6 @@
1
1
  import { type ManifestFonts } from "./ssr-fonts";
2
2
  import { type ManifestBuildConfig } from "./build-compat";
3
+ import { type BundleOffline } from "./ssr-offline";
3
4
  type Send = (msg: Record<string, unknown>) => void;
4
5
  interface BundleClientMessage {
5
6
  type: "bundle_client";
@@ -57,6 +58,43 @@ export declare function discoverLoadingModules(fs: any, path: any, cwd: string,
57
58
  * server.
58
59
  */
59
60
  export declare function generateLoadingRegistry(components: string[]): string;
61
+ /**
62
+ * The shared hydration dispatcher + router. ONE module, imported
63
+ * by every per-route entry. Bun's splitter sees N entries reach
64
+ * for it and pulls it (and React via the transitive imports) into
65
+ * a shared chunk.
66
+ *
67
+ * Two responsibilities:
68
+ * 1. `hydrate(component, Page, Layouts)` — called by each route
69
+ * entry. The first call (matching the SSR'd component) calls
70
+ * hydrateRoot; subsequent calls (after a client-side
71
+ * navigation) re-render the cached root with the new tree.
72
+ * Either way, the entry also registers itself in the route
73
+ * cache so future navigations don't need to refetch the chunk.
74
+ * 2. `navigate(href)` — fetches the new SSR HTML, parses out the
75
+ * `__PYLON_DATA__` payload, dynamically loads the new route's
76
+ * entry from the manifest, and re-renders into the existing
77
+ * React root. Layouts that match the previous render survive
78
+ * reconciliation (React keeps their state).
79
+ *
80
+ * Global click handler intercepts `a[data-pylon-link]` clicks for
81
+ * client-side nav. popstate handles back/forward. The runtime
82
+ * exposes `window.__pylon` for the `<Link>` component to call
83
+ * directly (prefetch, navigate).
84
+ */
85
+ export declare const CLIENT_RUNTIME_SOURCE = "// Generated by Pylon SSR (Phase 2 client runtime).\n// DO NOT EDIT \u2014 overwritten on every pylon dev / build.\n\nimport { createElement, Fragment, Suspense } from \"react\";\nimport { hydrateRoot } from \"react-dom/client\";\nimport { configureClient, registerOfflineWorker } from \"@pylonsync/react\";\nimport { createPylonBoundary, nearestBoundaryComponent } from \"./client-boundary\";\nimport { LOADING_MODULES } from \"./loading-registry\";\nimport { createNavPayloadCache } from \"./nav-cache\";\nimport { matchRoute, prefetchTargets } from \"./route-match\";\nimport { clientRouteFor, isOfflinePath } from \"./client-nav\";\nimport { withMetaSlot } from \"./page-tree\";\n\nconst routeCache = Object.create(null);\nlet activeRoot = null;\n// Component path of the route currently mounted (the manifest key). Its cached\n// layout chain is what a pending-navigation skeleton renders inside.\nlet currentComponent = null;\n// Destination of an in-flight client navigation. Read by hydrateRoot's\n// onUncaughtError so a re-render that throws mid-nav degrades to a full page\n// load instead of a white screen. Null when no nav is in flight.\nlet pendingNav = null;\nlet manifestPromise = null;\nconst prefetchedChunks = new Set();\n\nfunction buildTree(Page, Layouts, props, loadingFor) {\n let tree = createElement(Page, props);\n // The server wraps the page of a route with a loading.tsx in ONE Suspense\n // boundary, fallback = that loading module (ssr-runtime.ts, #278). The\n // tree that hydrates the server HTML must have the same boundary, or React\n // finds <Suspense> where it expects the page's first element, throws a\n // hydration mismatch (#418) and re-renders the whole document; clicks in\n // that window are lost. `loadingFor` is the page's component path.\n const Loading = loadingFor ? nearestLoading(loadingFor) : null;\n if (Loading) {\n tree = createElement(Suspense, { fallback: createElement(Loading, props) }, tree);\n }\n // The server renders the page's metadata in this slot; the browser leaves\n // it empty. Same shape on both sides, so useId values match (page-tree.ts).\n tree = withMetaSlot(createElement, Fragment, null, tree);\n for (let i = Layouts.length - 1; i >= 0; i--) {\n const Layout = Layouts[i];\n if (!Layout) continue;\n tree = createElement(Layout, props, tree);\n }\n return tree;\n}\n\n// The loading module the server wraps `component` in: the nearest\n// loading.tsx walking up from the page's directory.\nfunction nearestLoading(component) {\n const key = nearestBoundaryComponent(component, \"loading\", Object.keys(LOADING_MODULES));\n return key ? LOADING_MODULES[key] : null;\n}\n\n// ---------------------------------------------------------------------------\n// Client error / not-found boundary. notFound() (from @pylonsync/react) and\n// any other error thrown DURING a client render \u2014 the normal case when an\n// async lookup 404s after hydration \u2014 would otherwise propagate uncaught and\n// blank the whole document. React error boundaries must be class components;\n// this one catches the throw, resolves the nearest not-found.tsx / error.tsx\n// for the active route from the build manifest (the same nearest-ancestor\n// model the server's findBoundary uses), and renders it in its own layout\n// chain. It sits at the root and is transparent until something throws, so\n// hydration still matches the server HTML exactly. It resets on navigation\n// via a changing navEpoch prop (NOT a key \u2014 a key change would remount every\n// layout on each nav and lose their state).\n// ---------------------------------------------------------------------------\n\nlet navEpoch = 0;\n// The props the active page was rendered with (auth, serverData, params, \u2026).\n// The boundary reuses them so a boundary module AND its layout chain \u2014 e.g. an\n// auth-guarding dashboard layout that reads props.auth \u2014 render with the SAME\n// context the page had. The server renders boundaries with the page props too;\n// a minimal hand-built props object would make such a layout see no auth and\n// (for instance) redirect to /login instead of showing the not-found page.\nlet currentPageProps = {};\n\n// Seed data for the in-flight navigation \u2014 the object handed to <Link seed>,\n// exposed to the destination page via useRouteSeed() so it can paint its\n// Suspense fallback with real content before the SSR fetch lands. Set at the\n// start of every navigation (to the seed, or null), so useRouteSeed() reads a\n// value that's consistent for the whole nav. Null on hard load / seedless nav.\nlet currentSeed = null;\n\n// The not-found / error boundary lives in a real module (./client-boundary,\n// emitted next to this file by the bundler) so its render behavior is\n// unit-testable instead of buried in this string. Wire the runtime's\n// internals in as deps; the returned withBoundary(tree, component, navEpoch)\n// wraps every page tree as the transparent root error boundary.\nconst { withBoundary } = createPylonBoundary({\n loadManifest,\n loadRouteEntry,\n navigate,\n buildTree,\n getPageProps: () => currentPageProps,\n getResetHref: () => location.pathname + location.search,\n});\n\n// Deterministic stringify \u2014 MUST match stableStringify in ssr-runtime.ts so\n// a serverData call's cache key is identical on server and client.\nfunction stableStringify(v) {\n if (v === null || v === undefined || typeof v !== \"object\") {\n return JSON.stringify(v === undefined ? null : v);\n }\n if (Array.isArray(v)) return \"[\" + v.map(stableStringify).join(\",\") + \"]\";\n const keys = Object.keys(v).sort();\n return (\n \"{\" +\n keys.map((k) => JSON.stringify(k) + \":\" + stableStringify(v[k])).join(\",\") +\n \"}\"\n );\n}\n\nconst SERVER_DATA_METHODS = [\n \"get\",\n \"list\",\n \"lookup\",\n \"query\",\n \"queryGraph\",\n \"paginate\",\n \"search\",\n];\n\n// A React-recognized fulfilled thenable: use() reads .value synchronously\n// (status === \"fulfilled\") instead of suspending. Critical for hydration \u2014\n// the server streamed the post-Suspense content, so the client must render\n// that content on the FIRST pass without re-suspending (no fallback flash,\n// no mismatch).\nfunction fulfilledThenable(value) {\n return {\n status: \"fulfilled\",\n value,\n then(onFulfilled) {\n return onFulfilled ? onFulfilled(value) : value;\n },\n };\n}\n\n// Client stand-in for the server's serverData handle. Each method returns a\n// pre-fulfilled thenable (cached per key) sourced from the SSR'd ssrData map,\n// keyed identically to the server. Misses yield undefined \u2014 the page\n// rendered with whatever the server fetched, so a hit is expected.\nfunction makeClientServerData(ssrData) {\n const cache = ssrData || {};\n const pc = new Map();\n const wrap = (prefix) => {\n const out = {};\n for (const m of SERVER_DATA_METHODS) {\n out[m] = (...args) => {\n const key = prefix + m + \":\" + stableStringify(args);\n if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));\n return pc.get(key);\n };\n }\n return out;\n };\n const sd = wrap(\"\");\n sd.unsafe = wrap(\"u:\");\n // serverData.fn(name, args) \u2014 key MUST match the server's makeServerData\n // (\"fn:\" + name + \":\" + stableStringify(args ?? {})) so hydration replays\n // the query function's SSR'd result without a mismatch.\n sd.fn = (name, args) => {\n const key = \"fn:\" + name + \":\" + stableStringify(args ?? {});\n if (!pc.has(key)) pc.set(key, fulfilledThenable(cache[key]));\n return pc.get(key);\n };\n return sd;\n}\n\n// serverData stand-in used during the OPTIMISTIC first paint of a navigation:\n// every method returns a never-resolving thenable, so a page that use()s it\n// suspends and shows its Suspense fallback (which useRouteSeed() renders from\n// the clicked-link seed). The thenable is cached per key so React never sees an\n// \"uncached promise\" if it re-renders the optimistic tree. It never resolves on\n// its own \u2014 navigate() replaces the whole tree with real, fulfilled serverData\n// the moment the SSR fetch lands, which is what actually swaps fallback\u2192content.\nfunction makePendingServerData() {\n const pc = new Map();\n const pending = () => ({ then() {} });\n const wrap = (prefix) => {\n const out = {};\n for (const m of SERVER_DATA_METHODS) {\n out[m] = (...args) => {\n const key = prefix + m + \":\" + stableStringify(args);\n if (!pc.has(key)) pc.set(key, pending());\n return pc.get(key);\n };\n }\n return out;\n };\n const sd = wrap(\"\");\n sd.unsafe = wrap(\"u:\");\n sd.fn = (name, args) => {\n const key = \"fn:\" + name + \":\" + stableStringify(args ?? {});\n if (!pc.has(key)) pc.set(key, pending());\n return pc.get(key);\n };\n return sd;\n}\n\n// Server-only response controller has no meaning on the client (the status/\n// redirect/cookies already shipped). Give pages a no-op so a body that\n// touches props.response during hydration doesn't crash.\nfunction makeNoopResponse() {\n const noop = () => {};\n return {\n setStatus: noop,\n setHeader: noop,\n setCookie: noop,\n redirect: noop,\n notFound: noop,\n };\n}\n\n// Rehydrate the live, server-only props (serverData + response) that were\n// stripped before serialization, so the client tree matches the server's.\n// For a hydrated error boundary (#279), synthesize the reset() the server\n// rendered as a no-op: re-fetch + re-render the current URL (a transient\n// error clears to the page; a deterministic one re-shows the boundary).\n// The current route's dynamic params (e.g. { projectId: \"p_1\" }). Lives here,\n// not in the DOM __PYLON_DATA__ (which navigate() never rewrites), so useParams()\n// in a deep client child has a reactive source. A fresh object per nav \u2192 stable\n// reference between navs (useSyncExternalStore needs that).\nlet currentParams = {};\nfunction setNavParamsRaw(params) {\n currentParams = params || {};\n}\nfunction setNavParams(data) {\n setNavParamsRaw(data && data.props && data.props.params);\n}\n\nfunction withClientProps(data) {\n const props = { ...(data.props || {}) };\n props.serverData = makeClientServerData(data.ssrData);\n props.response = makeNoopResponse();\n if (data.kind === \"error\") {\n props.reset = function () {\n navigate(location.pathname + location.search, { replace: true });\n };\n }\n return props;\n}\n\nfunction readPylonData() {\n const dataEl = document.getElementById(\"__PYLON_DATA__\");\n if (!dataEl) return null;\n try {\n return JSON.parse(dataEl.textContent || \"{}\");\n } catch {\n return null;\n }\n}\n\nasync function loadManifest() {\n if (!manifestPromise) {\n manifestPromise = fetch(\"/_pylon/build/manifest.json\", {\n credentials: \"same-origin\",\n })\n .then((r) => (r.ok ? r.json() : null))\n .catch(() => null);\n }\n return manifestPromise;\n}\n\nfunction preloadChunks(routeInfo) {\n if (!routeInfo) return;\n const prefix = routeInfo.public_prefix || \"/_pylon/build/\";\n const all = [routeInfo.file, ...(routeInfo.imports || [])];\n for (const c of all) {\n // prefetch() passes file: \"\" (HTML-only prefetch has no route entry);\n // an empty name would modulepreload the bare prefix (\"/_pylon/build/\")\n // and the host answers that with a 400 on every page load.\n if (!c) continue;\n if (prefetchedChunks.has(c)) continue;\n prefetchedChunks.add(c);\n const link = document.createElement(\"link\");\n link.rel = \"modulepreload\";\n link.href = prefix + c;\n document.head.appendChild(link);\n }\n}\n\n// Run fn once the page itself has finished loading. modulepreload fetches at\n// high priority, and a sidebar of links warms on first paint (Link's\n// IntersectionObserver), so warming a dozen route chunks inline would compete\n// with the current page's own scripts and data \u2014 buying a faster second\n// navigation with a slower first render.\nfunction whenLoaded(fn) {\n if (document.readyState === \"complete\") {\n fn();\n return;\n }\n window.addEventListener(\"load\", fn, { once: true });\n}\n\n// Run fn when the browser is idle after the page loaded, so warming the next\n// route never competes with this one.\nfunction whenIdle(fn) {\n whenLoaded(() => {\n if (typeof window.requestIdleCallback === \"function\") {\n window.requestIdleCallback(fn, { timeout: 2000 });\n } else {\n setTimeout(fn, 200);\n }\n });\n}\n\n// Import a client-navigable route's entry ahead of the click, so navigate()\n// finds it in routeCache and renders in the same frame. Only after the first\n// hydration: an entry that loads before it would be taken for the page.\nfunction warmRouteEntry(component) {\n if (!activeRoot || routeCache[component]) return;\n loadRouteEntry(component).catch(() => {});\n}\n\n// Marks a request as a client-side NAVIGATION rather than a document load.\n// The server answers with the head metadata + __PYLON_DATA__ and skips the\n// page markup, which this runtime re-renders from that data anyway. The host\n// keys its render cache on the same header, so the two shapes never share an\n// entry. Prefetch and navigate MUST send identical headers, or the prefetched\n// response is the wrong shape for the click that consumes it.\nconst NAV_REQUEST_HEADERS = { Accept: \"text/html\", \"X-Pylon-Nav\": \"1\" };\n\n// Payloads warmed by a hover, consumed by the click that follows. See\n// ./nav-cache for why this is in memory rather than an HTTP-level prefetch.\nconst navPayloads = createNavPayloadCache({\n now: () => Date.now(),\n fetchPage: (target) =>\n fetch(target, {\n credentials: \"same-origin\",\n headers: NAV_REQUEST_HEADERS,\n }).then((res) => {\n // A redirect means the URL navigate() would commit isn't the one that\n // answered; let the real navigation resolve that itself.\n if (!res.ok || res.redirected) return null;\n return res.text();\n }),\n});\n\nasync function prefetch(href, opts) {\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) return;\n const manifest = await loadManifest();\n // A route that renders from the client never fetches its page, so there is\n // no payload to warm. Its entry is warmed instead, below.\n const clientRoute = clientRouteFor(manifest, url.pathname, matchRoute);\n // The page payload, but ONLY on a real intent signal (hover / touch). It\n // costs a full SSR render, so warming a screenful of links on sight would\n // spend a dozen renders per page load to save one.\n if (opts && opts.document && !clientRoute) {\n navPayloads.prefetch(url.pathname + url.search);\n }\n if (!manifest) return;\n // Chunk prefetch. The page payload above is the half that was already\n // cheap: nothing can render until the destination's own entry chunk lands,\n // so warming only the HTML leaves a full round-trip on the click.\n const targets = prefetchTargets(manifest, url.pathname);\n const warm = () => {\n preloadChunks({\n public_prefix: manifest.public_prefix,\n file: targets.file,\n imports: targets.imports,\n });\n if (clientRoute) warmRouteEntry(clientRoute.component);\n };\n // Intent (hover, touch, pointerdown, focus): the tap is coming, fetch now.\n // Otherwise (a link on screen) wait until the page has painted and the\n // browser is idle, so warming routes never counts against this page's\n // first paint, and skip it on a connection the user asked to save.\n if (opts && opts.document) warm();\n else if (!saveData()) whenIdle(warm);\n}\n\n// The user asked for less data (Save-Data), or the connection is 2G.\n// Prefetch on sight costs bytes the user may never use.\nfunction saveData() {\n const c = typeof navigator !== \"undefined\" ? navigator.connection : undefined;\n if (!c) return false;\n return c.saveData === true || /(^|-)2g$/.test(c.effectiveType || \"\");\n}\n\nasync function loadRouteEntry(component) {\n if (routeCache[component]) return routeCache[component];\n const manifest = await loadManifest();\n if (!manifest) throw new Error(\"manifest unavailable\");\n const routeInfo = manifest.routes[component];\n if (!routeInfo) throw new Error(\"unknown component \" + component);\n const prefix = manifest.public_prefix || \"/_pylon/build/\";\n preloadChunks({ ...routeInfo, public_prefix: prefix });\n // Dynamic import the entry. It will call hydrate(...) which\n // populates routeCache[component], then this returns.\n await import(/* @vite-ignore */ prefix + routeInfo.file);\n if (!routeCache[component]) {\n throw new Error(\"route entry did not register: \" + component);\n }\n return routeCache[component];\n}\n\nexport function hydrate(component, Page, Layouts, metadata) {\n // Always cache the route for nav. `metadata` is the page's static\n // `metadata` export: a navigation with no page fetch takes its title.\n routeCache[component] = { Page, Layouts, metadata };\n // Start the manifest fetch NOW rather than on first use. Nothing can resolve\n // a route without it \u2014 the first <Link> reaching the viewport warms its\n // chunks only after it lands \u2014 so leaving it lazy put a round trip in front\n // of all prefetching. In flight from here, it resolves during hydration.\n void loadManifest();\n const data = readPylonData();\n // Namespace client storage by the app's manifest name (injected into\n // __PYLON_DATA__ as `app`) BEFORE any db hook / auth helper reads a token.\n // Without this every app on a shared origin (all localhost:4321 in dev)\n // shares the default `pylon_token` and clobbers each other's sessions.\n // configureClient runs once here; it also migrates a legacy default-keyspace\n // token so an existing session survives the switch.\n if (data && data.app) configureClient({ appName: data.app });\n // First hydrate: the entry's component MATCHES the SSR'd page.\n // Establish the root + install the click + popstate handlers\n // exactly once.\n if (!activeRoot) {\n if (!data || data.component !== component) {\n console.warn(\n \"[pylon ssr] entry/__PYLON_DATA__ mismatch \u2014 initial hydration skipped\",\n );\n return;\n }\n setNavParams(data);\n currentPageProps = withClientProps(data);\n currentComponent = data.component;\n const tree = withBoundary(\n buildTree(Page, Layouts, currentPageProps, data.component),\n data.component,\n navEpoch,\n );\n activeRoot = hydrateRoot(document, tree, {\n // Safety net for client navigation. If a nav re-render throws an uncaught\n // error in React's commit phase, the URL has already changed but the page\n // can't swap \u2014 a white/stale screen. The classic trigger is a page that\n // renders hoisted <title>/<meta>/<link> in its own tree (use the\n // `metadata` export instead); React 19 owns those head nodes on the client\n // and reconciling them across routes can throw. Rather than strand the\n // user, fall back to a full page load of the pending destination, which\n // re-renders it cleanly from SSR. Non-navigation errors keep React's\n // default reporting.\n onUncaughtError(error) {\n if (pendingNav) {\n const dest = pendingNav;\n pendingNav = null;\n console.error(\n \"[pylon ssr] client navigation failed to render; falling back to a full page load:\",\n error,\n );\n window.location.href = dest;\n return;\n }\n console.error(error);\n },\n });\n installNavHandlers();\n // Registers the offline service worker for an app that opted in, and\n // removes one left on this origin by an earlier build otherwise.\n // `build` is the build this page runs, for useAppUpdate. An option\n // rather than its own import, so an app on an older @pylonsync/react\n // still builds.\n registerOfflineWorker({\n optedIn: !!data.offline,\n production: !(data.offline && data.offline.dev),\n signedIn: !!(data.props && data.props.session && data.props.session.exists),\n build:\n data.offline && data.offline.version\n ? { version: data.offline.version, builtAt: data.offline.builtAt || null }\n : undefined,\n });\n if (data.offline) {\n // The service worker answered this load with the page it kept for\n // another URL: a dynamic offline route, or no network. The page on\n // screen is that URL's; render the address bar's URL from the replica.\n const shellPath = data.props && data.props.pathname;\n if (typeof shellPath === \"string\" && shellPath !== location.pathname) {\n navigate(location.pathname + location.search + location.hash, {\n replace: true,\n shellPath,\n });\n }\n }\n return;\n }\n // Subsequent hydrate calls fire from dynamic-loaded entries\n // during navigation. The render is driven by navigate(), so we\n // only need to populate the cache (already done above).\n}\n\n// Swap the page's SEO/social <head> tags on a client-side navigation.\n// The SSR runtime marks every page-metadata <meta>/<link> with\n// data-pylon-meta; we drop the current set and import the incoming page's\n// set, so description / canonical / og:* / twitter:* / icons track the new\n// route. The layout's charset/viewport and Pylon's injected stylesheet\n// links carry no marker, so they survive untouched (no FOUC). The page\n// component never renders these tags on the client, so React doesn't own\n// them \u2014 this manual swap can't fight hydration.\nfunction syncHeadMeta(doc) {\n const head = document.head;\n if (!head) return;\n const current = head.querySelectorAll(\"[data-pylon-meta]\");\n for (let i = 0; i < current.length; i++) current[i].remove();\n const incoming = doc.head\n ? doc.head.querySelectorAll(\"[data-pylon-meta]\")\n : [];\n for (let i = 0; i < incoming.length; i++) {\n head.appendChild(document.importNode(incoming[i], true));\n }\n}\n\n// How long a navigation may run before the destination's loading.tsx takes\n// over the page area. Below this, the transition finishes on its own and\n// swapping to a skeleton would read as a flicker rather than as feedback.\nconst PENDING_PLACEHOLDER_MS = 100;\n\n// Paint the destination's nearest loading.tsx in place of the current page,\n// so a click that can't complete immediately is still acknowledged. Returns\n// whether anything was rendered \u2014 apps without a loading.tsx keep the old\n// behavior (previous page stays put until the new one is ready).\n//\n// Rendered inside the CURRENTLY MOUNTED layout chain, not the destination's:\n// the destination's layouts live in the chunk this skeleton exists to cover,\n// so waiting for them would defeat the point. Sibling routes (the common case\n// for a nav that's slow enough to reach here) share that chain anyway, and\n// React keeps those layouts mounted across the swap \u2014 only the page area\n// changes.\n//\n// Props are the current page's, verbatim. Layouts re-render with the data they\n// already had, so one that read serverData or params can't suspend on a\n// never-resolving handle or crash on a cache miss halfway through a\n// transition. A skeleton has no data of its own to show.\nfunction renderPendingPlaceholder(pathname, manifest, myEpoch) {\n if (!activeRoot || !currentComponent) return false;\n const matched = matchRoute(manifest, pathname);\n if (!matched) return false;\n const key = nearestBoundaryComponent(\n matched.component,\n \"loading\",\n Object.keys(LOADING_MODULES),\n );\n const Loading = key ? LOADING_MODULES[key] : null;\n if (!Loading) return false;\n const current = routeCache[currentComponent];\n const tree = withBoundary(\n buildTree(Loading, (current && current.Layouts) || [], currentPageProps),\n currentComponent,\n myEpoch,\n );\n activeRoot.render(tree);\n return true;\n}\n\n// The path and query the page last rendered. A popstate that keeps them and\n// only changes the hash is a same-page jump (an anchor link, a back press\n// between anchors), not a navigation: re-rendering for it would reset the\n// scroll to the top.\nlet renderedPath = \"\";\n\nasync function navigate(href, opts) {\n const push = !opts || opts.push !== false;\n const url = new URL(href, location.href);\n if (url.origin !== location.origin) {\n window.location.href = href;\n return;\n }\n const target = url.pathname + url.search;\n\n // Claim an epoch for this navigation. A later navigate() bumps navEpoch, so\n // every await below can bail once it's been superseded by a newer nav \u2014\n // otherwise a slow fetch could render a stale destination over a newer one.\n const myEpoch = ++navEpoch;\n currentSeed = opts && opts.seed != null ? opts.seed : null;\n\n // Flipped the moment this nav commits or gives up, so a pending-placeholder\n // timer that fires late can never paint a skeleton over real content.\n const navState = { settled: false };\n\n // ---- Instant navigation --------------------------------------------------\n // A route whose page exports `navigation = \"client\"`, or whose URL is an\n // offline route, renders from the client with no page fetch: its data comes\n // from the local replica. The manifest is in hand after hydration, and a\n // Link warms the entry ahead of the click, so this paints in the same frame.\n if (activeRoot) {\n const manifest = await loadManifest();\n if (myEpoch !== navEpoch) return;\n const matched = clientRouteFor(manifest, url.pathname, matchRoute);\n if (matched) {\n let route = null;\n try {\n route = await loadRouteEntry(matched.component);\n } catch (e) {\n // Fall through to the fetch below, which reloads the page when the\n // entry cannot load.\n console.warn(\"[pylon ssr] client navigation fell back to a page fetch:\", e);\n }\n if (myEpoch !== navEpoch) return;\n if (route) {\n renderFromClient(url, matched, route, myEpoch, opts, push);\n return;\n }\n }\n }\n const fullLoad = () => {\n navState.settled = true;\n window.location.href = href;\n };\n\n // ---- Optimistic first paint --------------------------------------------\n // With a seed AND a client-resolvable route, render the destination NOW \u2014\n // before the SSR fetch \u2014 with a pending serverData, so the page shows its\n // Suspense fallback painted from the seed (via useRouteSeed). The real render\n // below swaps in full data in place. Any failure (no route match, chunk load\n // error) falls through to the normal blocking fetch-then-render path, so a\n // seed can never break navigation. Requires the page to keep its serverData\n // reads inside a <Suspense> (the store detail page does) \u2014 a bare top-level\n // use() would suspend the whole page with no seeded fallback to show.\n let didOptimistic = false;\n if (currentSeed != null && activeRoot) {\n try {\n const manifest = await loadManifest();\n if (myEpoch !== navEpoch) return;\n const matched = matchRoute(manifest, url.pathname);\n if (matched) {\n const route = await loadRouteEntry(matched.component);\n if (myEpoch !== navEpoch) return;\n setNavParamsRaw(matched.params);\n currentPageProps = {\n params: matched.params,\n serverData: makePendingServerData(),\n response: makeNoopResponse(),\n };\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n matched.component,\n myEpoch,\n );\n pendingNav = target;\n currentComponent = matched.component;\n activeRoot.render(tree);\n if (opts && opts.replace) {\n history.replaceState({ component: matched.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: matched.component }, \"\", target);\n }\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n didOptimistic = true;\n }\n } catch (e) {\n // Fall through to the normal fetch-then-render path below.\n }\n }\n\n // ---- Pending-state placeholder -----------------------------------------\n // Nothing below paints until the SSR payload AND the destination's chunk are\n // both in hand, which on a cold route is long enough for the click to look\n // ignored. Hand the page area to the destination's loading.tsx once the nav\n // outlives the flicker threshold. Skipped when a seed already painted the\n // real destination \u2014 that's strictly better than a skeleton.\n let placeholderTimer = null;\n if (!didOptimistic && activeRoot) {\n placeholderTimer = setTimeout(() => {\n placeholderTimer = null;\n // loadManifest resolves instantly once warm (Link prefetch primes it on\n // first paint); the settled check covers the cold first navigation,\n // where the fetch can win the race with it.\n loadManifest().then((manifest) => {\n if (navState.settled || myEpoch !== navEpoch) return;\n if (renderPendingPlaceholder(url.pathname, manifest, myEpoch)) {\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n }\n });\n }, PENDING_PLACEHOLDER_MS);\n }\n\n // ---- Real fetch + render -----------------------------------------------\n let html;\n try {\n // A hover-prefetch usually has this in hand, or in flight \u2014 either way the\n // click joins it instead of opening a second request for the same page.\n const prefetched = navPayloads.take(target);\n html = prefetched ? await prefetched : null;\n if (html == null) {\n const res = await fetch(target, {\n credentials: \"same-origin\",\n headers: NAV_REQUEST_HEADERS,\n });\n if (!res.ok) {\n fullLoad();\n return;\n }\n html = await res.text();\n }\n } catch {\n // No network. An offline route renders from the replica; anything else\n // needs the server.\n if (myEpoch !== navEpoch) return;\n if (await renderOffline(url, myEpoch, opts, push)) {\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n return;\n }\n if (opts && opts.shellPath) {\n // This load already came from a kept page and the URL cannot open\n // offline. Reloading would get the same kept page again; show its URL.\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n history.replaceState({ component: currentComponent }, \"\", opts.shellPath);\n renderedPath = location.pathname + location.search;\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n return;\n }\n fullLoad();\n return;\n }\n if (myEpoch !== navEpoch) return;\n const doc = new DOMParser().parseFromString(html, \"text/html\");\n const dataEl = doc.getElementById(\"__PYLON_DATA__\");\n if (!dataEl) {\n fullLoad();\n return;\n }\n let data;\n try {\n data = JSON.parse(dataEl.textContent || \"{}\");\n } catch {\n fullLoad();\n return;\n }\n let route;\n try {\n route = await loadRouteEntry(data.component);\n } catch (e) {\n console.warn(\"[pylon ssr] nav fallback (entry load failed):\", e);\n fullLoad();\n return;\n }\n if (myEpoch !== navEpoch) return;\n navState.settled = true;\n if (placeholderTimer) clearTimeout(placeholderTimer);\n document.title = doc.title || document.title;\n syncHeadMeta(doc);\n setNavParams(data);\n // Keep currentSeed set for the rest of this nav: useRouteData renders the seed\n // as CONTENT (not a Suspense fallback) and upgrades it to real data in place,\n // so the seed must stay readable across the optimistic\u2192real render. It's reset\n // at the START of the next navigation. (Clearing it here would degrade the\n // page to its skeleton for a frame during the swap \u2014 a visible flash.)\n currentPageProps = withClientProps(data);\n // Reuse myEpoch (do NOT bump) so the optimistic tree and the real tree share\n // a boundary identity: React resolves the Suspense fallback into the real\n // content in place instead of remounting the whole subtree (no flash).\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n data.component,\n myEpoch,\n );\n // Track the in-flight destination so hydrateRoot's onUncaughtError can fall\n // back to a full page load if this re-render throws in React's commit phase\n // (instead of leaving the URL changed but the page unswapped). Cleared on the\n // next macrotask once the commit has settled with no error.\n pendingNav = target;\n currentComponent = data.component;\n // Drop every other prefetched payload once a navigation commits: they were\n // rendered against the previous page's state, and whatever the user does\n // here can invalidate them. Hovering on the new page re-warms in one request.\n navPayloads.clear();\n activeRoot.render(tree);\n setTimeout(() => {\n if (pendingNav === target) pendingNav = null;\n }, 0);\n if (didOptimistic) {\n // URL + scroll already handled during the optimistic paint; only keep the\n // history entry's component in sync with the authoritative SSR component.\n history.replaceState({ component: data.component }, \"\", target);\n } else {\n if (opts && opts.replace) {\n history.replaceState({ component: data.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: data.component }, \"\", target);\n }\n // Notify the router hooks (useSearchParams / usePathname) so deep children\n // re-read location after a Link click or a router.push(). popstate already\n // covers back/forward, but pushState/replaceState fire no event.\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n // After a successful nav, scroll to top (Next.js default).\n window.scrollTo(0, 0);\n }\n}\n\n// Render an offline route with no server, after the page fetch failed: the\n// page reads its data from the local replica (db.useQuery), so it needs only\n// its code, its params and the identity-free props the last page had. Returns\n// false when the URL is not an offline route, or its code is not on the\n// device.\nasync function renderOffline(url, myEpoch, opts, push) {\n const manifest = await loadManifest();\n if (!manifest || !activeRoot) return false;\n if (!isOfflinePath(manifest, url.pathname, matchRoute)) return false;\n const matched = matchRoute(manifest, url.pathname);\n if (!matched) return false;\n let route;\n try {\n route = await loadRouteEntry(matched.component);\n } catch (e) {\n return false;\n }\n if (myEpoch !== navEpoch) return true;\n renderFromClient(url, matched, route, myEpoch, opts, push);\n return true;\n}\n\n// Commit a navigation rendered from the client (instant navigation, or an\n// offline route with no network). History, the navigation event and the\n// scroll reset are the same as a fetched navigation's.\nfunction renderFromClient(url, matched, route, myEpoch, opts, push) {\n const prev = currentPageProps || {};\n const searchParams = {};\n url.searchParams.forEach((v, k) => {\n searchParams[k] = v;\n });\n const data = {\n component: matched.component,\n props: {\n pathname: url.pathname,\n url: url.pathname,\n host: prev.host || \"\",\n params: matched.params,\n searchParams,\n auth: prev.auth,\n session: prev.session,\n headers: {},\n cookies: {},\n },\n ssrData: {},\n };\n setNavParams(data);\n currentPageProps = withClientProps(data);\n const tree = withBoundary(\n buildTree(route.Page, route.Layouts, currentPageProps),\n data.component,\n myEpoch,\n );\n const target = url.pathname + url.search + url.hash;\n // See the fetched path: a commit that throws falls back to a page load.\n pendingNav = target;\n currentComponent = data.component;\n navPayloads.clear();\n const title = route.metadata && route.metadata.title;\n if (typeof title === \"string\" && title) document.title = title;\n activeRoot.render(tree);\n setTimeout(() => {\n if (pendingNav === target) pendingNav = null;\n }, 0);\n if (opts && opts.replace) {\n history.replaceState({ component: data.component }, \"\", target);\n } else if (push) {\n history.pushState({ component: data.component }, \"\", target);\n }\n window.dispatchEvent(new Event(\"pylon:navigation\"));\n renderedPath = location.pathname + location.search;\n window.scrollTo(0, 0);\n}\n\nfunction installNavHandlers() {\n document.addEventListener(\"click\", (e) => {\n if (e.defaultPrevented) return;\n if (e.button !== 0) return;\n if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;\n const target = e.target;\n if (!target || !target.closest) return;\n const link = target.closest(\"a[data-pylon-link]\");\n if (!link) return;\n const href = link.getAttribute(\"href\");\n if (!href) return;\n if (link.target && link.target !== \"\" && link.target !== \"_self\") return;\n if (href.startsWith(\"http://\") || href.startsWith(\"https://\") || href.startsWith(\"//\")) {\n // External \u2014 let the browser handle.\n return;\n }\n e.preventDefault();\n // A <Link seed> registers its seed in window.__pylonLinkSeeds keyed by the\n // anchor element; pass it through so navigate() can paint optimistically.\n const seeds =\n typeof window !== \"undefined\" ? window.__pylonLinkSeeds : undefined;\n const seed = seeds ? seeds.get(link) : undefined;\n navigate(href, seed != null ? { seed } : undefined);\n });\n renderedPath = location.pathname + location.search;\n window.addEventListener(\"popstate\", () => {\n const next = location.pathname + location.search;\n if (next === renderedPath) return;\n navigate(next, { push: false });\n });\n // Progressive-enhancement form submit (#276): intercept <form data-pylon-form>\n // so the page doesn't full-reload. fetch the same route.ts endpoint, follow\n // the handler's redirect, and swap the destination in via navigate(). Without\n // JS this listener never runs and the browser submits natively (POST \u2192\n // handler \u2192 303 \u2192 GET) \u2014 identical result, just a full navigation.\n document.addEventListener(\"submit\", (e) => {\n if (e.defaultPrevented) return;\n const form = e.target;\n if (!form || form.tagName !== \"FORM\") return;\n if (!form.hasAttribute(\"data-pylon-form\")) return;\n const action = form.getAttribute(\"action\");\n if (!action) return;\n // Off-origin actions / new-tab targets \u2192 let the browser submit.\n if (action.startsWith(\"http://\") || action.startsWith(\"https://\") || action.startsWith(\"//\")) return;\n const tgt = form.getAttribute(\"target\");\n if (tgt && tgt !== \"\" && tgt !== \"_self\") return;\n const method = (form.getAttribute(\"method\") || \"post\").toUpperCase();\n e.preventDefault();\n // urlencoded body (matches the server's parser; files use the native path).\n const body = new URLSearchParams();\n const fd = new FormData(form);\n fd.forEach((v, k) => {\n if (typeof v === \"string\") body.append(k, v);\n });\n fetch(action, {\n method,\n body,\n credentials: \"same-origin\",\n headers: { Accept: \"text/html\" },\n })\n .then((res) => {\n // fetch followed the handler's 303 \u2192 res.url is the destination page.\n // Drive a client navigation to it (re-renders without a full reload).\n const dest = new URL(res.url || action, location.href);\n if (dest.origin === location.origin) {\n navigate(dest.pathname + dest.search);\n } else {\n window.location.href = dest.href;\n }\n })\n .catch(() => {\n // Network/abort \u2014 fall back to a native submit so the user isn't stuck.\n form.removeAttribute(\"data-pylon-form\");\n form.submit();\n });\n });\n}\n\n// Expose for <Link> component prefetch.\nconst pylonGlobal = {\n prefetch,\n navigate,\n // Read by useParams(); a getter so it always reflects the latest nav.\n get params() {\n return currentParams;\n },\n // Read by useRouteSeed(); the seed the active navigation was started with\n // (from <Link seed>), or null. A getter so it always reflects the latest nav.\n get seed() {\n return currentSeed;\n },\n};\nif (typeof window !== \"undefined\") {\n window.__pylon = pylonGlobal;\n}\n";
86
+ /**
87
+ * Write the client runtime (`client-runtime.ts`) and the sibling modules it
88
+ * imports into `stageDir`. Each sibling's source of truth is this package's
89
+ * real, unit-tested module (ssr-client-boundary.ts and so on), not an inline
90
+ * string. The app-specific `loading-registry.ts` and route entries are
91
+ * written by the caller.
92
+ */
93
+ export declare function writeClientRuntimeModules(fs: any, path: any, stageDir: string): Promise<void>;
94
+ /** Whether a page exports a static `metadata` object. The route entry passes
95
+ * it to the runtime, which takes the title from it on a navigation with no
96
+ * page fetch. A static scan, so a page without one imports nothing extra. */
97
+ export declare function exportsStaticMetadata(source: string): boolean;
60
98
  /**
61
99
  * Manifest schema. One entry per route, indexed by the same
62
100
  * project-relative component path the SSR side passes through.
@@ -90,12 +128,20 @@ export interface PylonBundleManifest {
90
128
  /** URL pattern (e.g. `/p/[slug]`) — page routes only. Lets the client
91
129
  * matcher resolve an href to this route for optimistic navigation. */
92
130
  path?: string;
131
+ /** `"client"` when the page exports \`navigation = "client"\`: the
132
+ * runtime renders it with no page fetch (ssr-client-nav.ts). */
133
+ navigation?: "client";
93
134
  }>;
94
135
  /** Self-hosted fonts (next/font parity): structured `@font-face`s + the
95
136
  * `:root` CSS variables + the woff2 files to preload. Global (route-
96
137
  * independent); rendered into every SSR `<head>` against `public_prefix`.
97
138
  * Absent when the app declares no `font({...})`. */
98
139
  fonts?: ManifestFonts;
140
+ /** `buildManifest({ offline })`: the install tags for every page's head,
141
+ * the routes the client may render with no network, and the service
142
+ * worker version. The worker is `pylon-sw.js` in the outdir. Absent when
143
+ * the app did not opt in. */
144
+ offline?: BundleOffline;
99
145
  /** Set when the Tailwind compile failed for this build: the pages are
100
146
  * serving WITHOUT styles. Dev surfaces it as an on-page banner (see
101
147
  * pylonDevHud) — a silent unstyled page reads as a CSS bug, not a
@@ -133,6 +179,10 @@ export interface ClientBuildOptions {
133
179
  /** The app manifest (fonts, build settings). Default
134
180
  * `<cwd>/pylon.manifest.json`. */
135
181
  manifestPath?: string;
182
+ /** Write brotli and gzip copies of the build's text files for the
183
+ * runtime to send. Default: on, except in dev (`NODE_ENV=development`),
184
+ * where every edit rebuilds and the browser is local. */
185
+ precompress?: boolean;
136
186
  }
137
187
  /** The `build` block of `<cwd>/pylon.manifest.json`, if any. */
138
188
  export declare function readManifestBuildConfig(fs: any, path: any, cwd: string): ManifestBuildConfig | undefined;
@@ -0,0 +1,30 @@
1
+ /** A route match, as `matchRoute` in ssr-route-match.ts returns it. */
2
+ export interface ClientRouteMatch {
3
+ component: string;
4
+ params: Record<string, string | string[]>;
5
+ }
6
+ /** The bundle manifest fields this module reads. */
7
+ export interface ClientNavManifest {
8
+ routes: Record<string, {
9
+ path?: string;
10
+ navigation?: string;
11
+ }>;
12
+ offline?: {
13
+ routes?: string[];
14
+ };
15
+ }
16
+ type MatchRoute = (manifest: any, pathname: string) => ClientRouteMatch | null;
17
+ /**
18
+ * Whether page source declares `export const navigation = "client"` (single,
19
+ * double or backtick quotes, with an optional type annotation or `as const`).
20
+ * A static scan: the bundler reads the page file without running it.
21
+ */
22
+ export declare function declaresClientNavigation(source: string): boolean;
23
+ /** Whether `pathname` is one of the app's offline routes. */
24
+ export declare function isOfflinePath(manifest: ClientNavManifest | null, pathname: string, matchRoute: MatchRoute): boolean;
25
+ /**
26
+ * The page to render from the client for `pathname`, or null when the
27
+ * navigation must fetch the page (no match, or the page did not opt in).
28
+ */
29
+ export declare function clientRouteFor(manifest: ClientNavManifest | null, pathname: string, matchRoute: MatchRoute): ClientRouteMatch | null;
30
+ export {};
@@ -0,0 +1,93 @@
1
+ import { type ServiceWorkerConfig } from "./offline-sw";
2
+ import { type RouteAssetManifest } from "./ssr-route-assets";
3
+ /** `buildManifest({ offline })` as the SDK writes it to pylon.manifest.json. */
4
+ export interface ManifestOfflineInput {
5
+ name: string;
6
+ shortName?: string;
7
+ description?: string;
8
+ icons?: Array<{
9
+ src: string;
10
+ sizes: string;
11
+ type?: string;
12
+ purpose?: string;
13
+ }>;
14
+ themeColor?: string;
15
+ backgroundColor?: string;
16
+ startUrl: string;
17
+ display: string;
18
+ routes: string[];
19
+ }
20
+ /** What the bundle manifest keeps for the render and the client runtime. */
21
+ export interface BundleOffline {
22
+ name: string;
23
+ shortName?: string;
24
+ themeColor?: string;
25
+ icons?: Array<{
26
+ src: string;
27
+ sizes: string;
28
+ type?: string;
29
+ purpose?: string;
30
+ }>;
31
+ startUrl: string;
32
+ routes: string[];
33
+ /** The service worker's version (its cache names carry it). */
34
+ version: string;
35
+ /** When this build was made (ISO 8601). Not part of `version`: two
36
+ * machines that build the same app get the same version. */
37
+ builtAt: string;
38
+ }
39
+ /** `pylon-version.json` in the client build, served at `/_pylon/version`.
40
+ * `useAppUpdate` in @pylonsync/react polls it. */
41
+ export interface BuildVersionFile {
42
+ version: string;
43
+ builtAt: string;
44
+ /** Hash of what a home-screen app reads only when it is added (see
45
+ * `installIdentity`). */
46
+ install: string;
47
+ }
48
+ /** The file the runtime serves at `/_pylon/version`. */
49
+ export declare const VERSION_FILE = "pylon-version.json";
50
+ /** How long a navigation to an offline route waits for the network before
51
+ * the worker shows the kept page. Long enough for a slow but working
52
+ * connection to win; short enough that a dead one does not leave a blank
53
+ * screen. */
54
+ export declare const NAVIGATION_TIMEOUT_MS = 3000;
55
+ /** The `offline` block of `<cwd>/pylon.manifest.json`, or null. */
56
+ export declare function readManifestOffline(fs: any, path: any, cwd: string, manifestPath?: string): ManifestOfflineInput | null;
57
+ /** Every route pattern in `routes` that names a page in this build, with
58
+ * the page's component. A pattern with no page is reported, not dropped
59
+ * silently. */
60
+ export declare function resolveOfflineRoutes(manifest: RouteAssetManifest, routes: string[]): {
61
+ matched: Array<{
62
+ pattern: string;
63
+ component: string;
64
+ }>;
65
+ unknown: string[];
66
+ };
67
+ /** True for a pattern with a dynamic segment: one shell cannot serve it. */
68
+ export declare function isDynamicPattern(pattern: string): boolean;
69
+ /**
70
+ * Hash of what iOS reads from the web app manifest and the head tags only
71
+ * when the app is added to the home screen: the name, the short name, the
72
+ * display mode, the start URL and the icons (with the bytes of each icon
73
+ * file in `public/`). When it changes, an installed app keeps the old
74
+ * values until it is removed and added again.
75
+ *
76
+ * The manifest is the one the runtime serves: `public/manifest.webmanifest`,
77
+ * then `<appDir>/manifest.webmanifest`, else the `offline` config (see
78
+ * `web_manifest_source` in crates/runtime/src/frontend/offline.rs).
79
+ */
80
+ export declare function installIdentity(fs: any, path: any, cwd: string, appDirRel: string, offline: ManifestOfflineInput): Promise<string>;
81
+ /** The service worker config for this build. `install` is
82
+ * `installIdentity`; it is part of the version, so a manifest change
83
+ * installs a new worker (which precaches the new manifest). */
84
+ export declare function offlineWorkerConfig(manifest: RouteAssetManifest, offline: ManifestOfflineInput, install?: string): Promise<{
85
+ config: ServiceWorkerConfig;
86
+ unknownRoutes: string[];
87
+ }>;
88
+ /** Write `pylon-sw.js` and `pylon-version.json` into `outdir` and return
89
+ * the bundle manifest's `offline` block. `cwd` and `appDirRel` locate the
90
+ * app's own manifest file and its icons. */
91
+ export declare function buildOfflineWorker(fs: any, path: any, outdir: string, manifest: RouteAssetManifest, offline: ManifestOfflineInput, cwd: string, appDirRel: string): Promise<BundleOffline>;
92
+ /** `<head>` tags that make the app installable. */
93
+ export declare function offlineHeadTags(offline: BundleOffline): string;
@@ -0,0 +1,16 @@
1
+ /** Smaller files gain less than the bytes of the encoding headers. */
2
+ export declare const MIN_COMPRESS_BYTES = 1024;
3
+ export interface PrecompressResult {
4
+ /** Files that got at least one compressed copy. */
5
+ files: number;
6
+ /** Total bytes of those files, and of their brotli copies. */
7
+ bytes: number;
8
+ brotliBytes: number;
9
+ }
10
+ /**
11
+ * Write `<file>.br` (brotli, quality 11) and `<file>.gz` (gzip, level 9)
12
+ * next to every compressible file in `outdir` of at least
13
+ * `MIN_COMPRESS_BYTES`. A copy that is not smaller than the file is not
14
+ * written. Copies left by an earlier build are replaced.
15
+ */
16
+ export declare function precompressBuild(fs: any, path: any, zlib: any, outdir: string): PrecompressResult;
@@ -0,0 +1,35 @@
1
+ /** The bundle-manifest fields this module reads. */
2
+ export interface RouteAssetManifest {
3
+ public_prefix?: string;
4
+ polyfills?: string;
5
+ routes: Record<string, {
6
+ file: string;
7
+ imports?: string[];
8
+ css?: string[];
9
+ path?: string;
10
+ } | undefined>;
11
+ fonts?: {
12
+ preload?: string[];
13
+ } | null;
14
+ }
15
+ /** Outdir-relative files for one route, in the order the page loads them. */
16
+ export interface RouteAssetFiles {
17
+ /** `<link rel="stylesheet">` (or inlined `<style>`) in the head. */
18
+ styles: string[];
19
+ /** `<link rel="modulepreload">` in the head. */
20
+ preloads: string[];
21
+ /** `<link rel="preload" as="font">` in the head. */
22
+ fonts: string[];
23
+ /** `<script type="module">` in the body tail: the polyfills, then the
24
+ * route entry. */
25
+ scripts: string[];
26
+ }
27
+ /** The scripts the hydration tail loads for a route entry. */
28
+ export declare function routeScriptFiles(route: {
29
+ file: string;
30
+ }, polyfills: string | undefined): string[];
31
+ /** Every client-build file the page for `component` loads, or null when the
32
+ * build has no entry for it. */
33
+ export declare function routeAssetFiles(manifest: RouteAssetManifest, component: string): RouteAssetFiles | null;
34
+ /** All of a route's files as URLs under the build's public prefix. */
35
+ export declare function routeAssetUrls(manifest: RouteAssetManifest, component: string): string[];
@@ -490,6 +490,13 @@ export declare function buildHydrationTail(args: {
490
490
  bucketAuth?: {
491
491
  signedIn: boolean;
492
492
  };
493
+ /** The app opted into `buildManifest({ offline })`: the client registers
494
+ * the service worker (production only), and `useAppUpdate` compares this
495
+ * build's version with `/_pylon/version`. */
496
+ offline?: {
497
+ version: string;
498
+ builtAt?: string;
499
+ };
493
500
  /** Client-side NAVIGATION payload: emit the `__PYLON_DATA__` blob and
494
501
  * nothing else. The client already has the runtime and resolves the route
495
502
  * entry from the build manifest, so the entry `<script>`, the preload tags
@@ -529,7 +536,7 @@ export declare function errorDigest(err: any): string;
529
536
  * can be serialized into `__PYLON_DATA__.ssrData` and replayed on the client
530
537
  * — keeping hydration free of mismatches.
531
538
  */
532
- export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onFnCall?: () => void): any;
539
+ export declare function makeServerData(reader: any, valueCache: Record<string, any>, callFn?: (name: string, args?: Record<string, unknown>) => Promise<unknown>, onIdentityRead?: () => void, onRead?: () => void): any;
533
540
  /**
534
541
  * #278: does this route STREAM (vs buffer the whole document)? Streaming is
535
542
  * opt-in: a `loading.tsx` (route-level Suspense) or `export const streaming =
@@ -576,11 +583,11 @@ export declare function computeCacheVerdict(args: {
576
583
  * `authTouched` proves a bucketable body depends on nothing but the binary
577
584
  * session bit.
578
585
  * - `dynamicTouched` — reading request headers/cookies makes it request-specific.
579
- * - `strictPolicies` — in strict mode serverData reads are auth-FILTERED, so the
580
- * body/ssrData would vary by identity even without the page reading auth. In
581
- * the default (non-strict) mode serverData reads bypass the policy gate and
582
- * return identity-independent rows, so the cached body is safe to share within
583
- * the bucket. Strict mode therefore must NOT bucket.
586
+ * - `strictPolicies` — kept as a veto. Safe serverData reads are filtered by
587
+ * the caller's read policies in EVERY mode (not only strict), so an
588
+ * identity-carrying render that reads serverData sets `authTouched` itself
589
+ * (makeServerData's onIdentityRead); that, not this flag, is what keeps a
590
+ * signed-in render with policy-filtered rows out of the bucket.
584
591
  * - cookieCount / forceDynamic / wantsStream / non-200 — same reasons as the
585
592
  * anon verdict (a Set-Cookie, a streaming head, or an error/redirect can't be
586
593
  * safely shared).
@@ -600,6 +607,36 @@ export declare function computeBucketVerdict(args: {
600
607
  wantsStream: boolean;
601
608
  status: number;
602
609
  }): boolean;
610
+ /** Whether the host forwarded the service worker's offline-shell request
611
+ * header (`X-Pylon-Offline-Shell: 1`). */
612
+ export declare function isOfflineShellRequest(headers: Record<string, unknown> | undefined): boolean;
613
+ /**
614
+ * May the offline service worker keep this render as the route's shell? The
615
+ * shell is stored on the device and shown, with no network, to whoever is
616
+ * signed in at the time, so its HTML must not depend on who rendered it:
617
+ *
618
+ * - `authTouched`: reading `props.auth` (or a `serverData.fn` call with an
619
+ * identity) makes the HTML one user's. Read `props.session.exists` for a
620
+ * signed-in check; the shell keeps that bit and nothing else.
621
+ * - `dynamicTouched`: request headers and cookies are one request's.
622
+ * - `serverDataTouched`: offline there is no server; an offline route
623
+ * renders its data from the replica with `db.useQuery`.
624
+ * - a Set-Cookie, or any status but 200 (a redirect to sign-in, an error).
625
+ *
626
+ * Pure and exported for the leak-class tests.
627
+ */
628
+ export declare function computeOfflineShellVerdict(args: {
629
+ authTouched: boolean;
630
+ dynamicTouched: boolean;
631
+ serverDataTouched: boolean;
632
+ cookieCount: number;
633
+ status: number;
634
+ }): {
635
+ ok: true;
636
+ } | {
637
+ ok: false;
638
+ reason: string;
639
+ };
603
640
  /**
604
641
  * Dev HUD: explain the cache verdict for a finished render in human terms — the
605
642
  * verdict, its TTL, and (when dynamic) the SINGLE reason it isn't cached. Pure so
package/dist/types.d.ts CHANGED
@@ -752,7 +752,27 @@ export type LlmContentBlock = {
752
752
  tool_use_id: string;
753
753
  content: string;
754
754
  is_error?: boolean;
755
+ }
756
+ /** An image the model reads: png, jpeg, gif or webp, as base64. */
757
+ | {
758
+ type: "image";
759
+ source: LlmMediaSource;
760
+ }
761
+ /**
762
+ * A document the model reads: a PDF or plain text, as base64. Anthropic
763
+ * only: the OpenAI provider refuses it (LLM_UNSUPPORTED_CONTENT).
764
+ */
765
+ | {
766
+ type: "document";
767
+ source: LlmMediaSource;
768
+ title?: string;
755
769
  };
770
+ /** The bytes of an image or document block. */
771
+ export interface LlmMediaSource {
772
+ type: "base64";
773
+ media_type: string;
774
+ data: string;
775
+ }
756
776
  export interface LlmTool {
757
777
  name: string;
758
778
  description?: string;
@@ -981,6 +1001,33 @@ export interface Files {
981
1001
  signedUrl(fileId: string, opts?: {
982
1002
  ttlSecs?: number;
983
1003
  }): Promise<string>;
1004
+ /**
1005
+ * What the host knows of a stored file, in any function. On a backend
1006
+ * that tracks owners (local disk, a private S3 bucket) `ownerId` is who
1007
+ * uploaded or stored it, and a file that does not exist is null. A
1008
+ * backend that does not (`ownerTracked: false`, the Stack0 CDN) knows no
1009
+ * owner: there a file id is a capability, so check who may use it
1010
+ * yourself, for example by recording uploads in your own rows.
1011
+ */
1012
+ info(fileId: string): Promise<FileInfo | null>;
1013
+ }
1014
+ /** {@link Files.info}. */
1015
+ export interface FileInfo {
1016
+ id: string;
1017
+ /** Recorded at upload, when the backend keeps it. */
1018
+ contentType: string | null;
1019
+ /** The uploader, or null when the backend does not track it or the system stored it. */
1020
+ ownerId: string | null;
1021
+ public: boolean;
1022
+ /** The backend records owners, so `ownerId` and a null answer can be trusted. */
1023
+ ownerTracked: boolean;
1024
+ }
1025
+ /** {@link ActionFiles.read}. */
1026
+ export interface ReadFile {
1027
+ id: string;
1028
+ contentType: string | null;
1029
+ size: number;
1030
+ data: Uint8Array;
984
1031
  }
985
1032
  /** Options for {@link ActionFiles.store}. */
986
1033
  export interface StoreFileOptions {
@@ -1019,6 +1066,12 @@ export interface ActionFiles extends Files {
1019
1066
  delete(fileId: string): Promise<{
1020
1067
  deleted: boolean;
1021
1068
  }>;
1069
+ /**
1070
+ * A stored file's bytes, with server trust: the function decides who may
1071
+ * read it. At most the host's upload limit (25 MiB, or
1072
+ * `PYLON_MAX_UPLOAD_BYTES`).
1073
+ */
1074
+ read(fileId: string): Promise<ReadFile>;
1022
1075
  }
1023
1076
  /**
1024
1077
  * Shard tickets: short-lived, signed permission to join one realtime shard,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.22.47",
3
+ "version": "0.22.58",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -58,7 +58,7 @@
58
58
  "@types/react-dom": "^19.0.0",
59
59
  "browserslist": "^4.29.1",
60
60
  "core-js": "^3.50.0",
61
- "happy-dom": "^15.0.0",
61
+ "happy-dom": "^20.8.9",
62
62
  "lightningcss": "~1.32.0",
63
63
  "react": "^19.0.0",
64
64
  "react-dom": "^19.0.0"
package/src/index.ts CHANGED
@@ -99,6 +99,7 @@ export type {
99
99
  Llm,
100
100
  LlmMessage,
101
101
  LlmContentBlock,
102
+ LlmMediaSource,
102
103
  LlmTool,
103
104
  LlmCompleteRequest,
104
105
  LlmCompleteResponse,
@@ -120,4 +121,6 @@ export type {
120
121
  Crdt,
121
122
  StoreFileOptions,
122
123
  StoredFile,
124
+ FileInfo,
125
+ ReadFile,
123
126
  } from "./types";