WeChat Mini Program PWA shell
WeChat Mini Program PWA shell
apps/miniapp is a dependency-free native WeChat Mini Program shell. It owns
WeChat login, secure browser-session handoff, the native page stack (navigation
bar, back button, swipe-back), share cards, and WebView failure recovery. All
product UI is the responsive PWA from apps/web.
Hybrid native architecture
The shell is not a single-page WebView wrapper. Each page-level PWA route is hosted by its own native page so WeChat provides real native navigation:
| Native page | PWA route | Role |
|---|---|---|
pages/home/home | /, /today, /journal, /journal/:entryId | Stack bottom; the authenticated home hub (Trips, Today, Travelogues) and the travelogue reader |
pages/trip/trip | /trips/:id | Trip planner; share-card target |
pages/invite/invite | /invite/:token | Invite acceptance |
Every native page uses the default (native) navigation bar and embeds a
<web-view> that loads https://<web-origin>/miniapp#code=…&path=…. The
target PWA path and the single-use auth code travel in the URL fragment, which
the PWA strips before making any request.
Page-level transitions are driven from the PWA through the official JSSDK
(wx.miniProgram.*, loaded on demand by shared/lib/wechat-bridge.ts in
embedded mode — no wx.config signature is required for this API family):
- opening a trip or invite calls
wx.miniProgram.navigateTowith the PWA path (and an optionaltitleused to pre-label the native bar) in the query; - the home hub surfaces (
/,/today,/journal) and the travelogue reader (/journal/:entryId) all live in the home page's WebView: switching between them navigates via SPA history in place, so the bottom hub navigation does not push native pages; - "back to trips" (returning to any hub surface from a deeper native page such
as a trip) calls
wx.miniProgram.reLaunchto the home page, which is stack-safe from any entry point (including share cards) and avoids the ten-page stack limit; - the native back button and iOS swipe-back pop the stack without any web code.
In-page panels (map / schedule / reservations / budget) remain PWA state inside
one WebView; there is no app-level native tabBar because those panels belong
to a single trip, not to the app shell.
The native navigation bar title mirrors the WebView's document.title, which
the PWA sets per page (useDocumentTitle); the shell also applies the title
query via wx.setNavigationBarTitle before the WebView finishes loading.
Runtime flow
- A native page loads and calls into
miniprogram/lib/session.js. - On first use the shell calls
wx.login()and exchanges the code atPOST /api/auth/wechat-mini-program/sign-infor a Better Auth bearer. The bearer lives only inApp.globalData(memory) — never persisted, never in a URL. - Each native page mints its own hashed, single-use, three-minute bridge code
at
POST /api/mobile-auth/webview/mint. If the bearer expired, the shell retries once with a freshwx.login. - The page opens
https://<web-origin>/miniapp#code=…&path=…. - The PWA removes the fragment and resolves Better Auth through the single
reactive
useSessionowner mounted byAppContent. Only when that initial result is signed out does it exchange the code atPOST /api/mobile-auth/webview/exchangefor the HttpOnly Better Auth cookie, then refetches that same session owner. This keeps multi-page stacks working regardless of whether the WebView shares cookies across native pages, without a second independentgetSessionrequest. - The PWA replaces its location with the target path (an internal redirect — never a native stack push) and renders in embedded mode.
Critical application state remains on the API. wx.miniProgram.postMessage is
used only for share payloads because WeChat delivers it exclusively at selected
lifecycle moments (back navigation, component destroy, share, copy link); it is
never a real-time transport.
Share cards and deep links
- The PWA queues the current share context (
{ type: "share", title, path, imageUrl }) withpostMiniappShareContextwhenever an embedded trip page is active. - The shell collects payloads in
bindmessageand answersonShareAppMessagewith a card pointing at the right native page, carrying the PWApath(andtitle) in its query. - Opening a share card lands on that native page directly;
onLoadsanitizes thepathquery (internal, single-slash-rooted paths only) and boots the WebView at the requested route.
Native shell source
app.js App.globalData.bearer (memory only)
app.json pages/home, pages/trip, pages/invite; native nav bar
app.wxss shared loading/error styles
lib/copy.js single source of shell copy
lib/session.js wx.login + sign-in + per-page mint
lib/webview-page.js shared Page factory (connect, share, deep link)
lib/webview-shell.wxml shared <web-view> + loading/error markup
pages/{home,trip,invite}/There is no Taro, React, duplicated trip model, or native product page. Native UI is limited to the navigation bar plus loading, error, and retry states.
Configuration
Copy apps/miniapp/.env.example to the gitignored .env:
MINIAPP_APP_ID=wx…
MINIAPP_API_BASE_URL=https://api.example.com
MINIAPP_WEB_BASE_URL=https://app.example.comRun make miniapp-sync-config. It generates:
project.private.config.jsonwith the AppID;miniprogram/config.jswith the public API and PWA origins.
Both files are gitignored. The AppSecret stays on the API as
WECHAT_MINI_PROGRAM_APP_SECRET.
Production WeChat configuration requires:
- the PWA origin in 业务域名 for
<web-view>; - the API origin in request 合法域名;
- valid HTTPS certificates;
- a normal Mini Program AppID, not a Mini Game AppID.
Commands
make miniapp-sync-config
make miniapp-open
make miniapp-clear-cache
make miniapp
make dev-miniapp-apiThere is no mini-program build or watcher. WeChat DevTools reads the native
source directly from apps/miniapp/miniprogram.
Embedded PWA behavior
/miniapp is a bootstrap route handled before the regular auth gate. Embedded
mode:
- keeps the auth gate blocked until the initial session result or bridge refetch is definitive, so an empty session atom can never flash the sign-in surface; after that first result, background refetches leave an in-progress OTP or two-factor form mounted;
- shows only a neutral, accessible loading spinner during bridge work rather than presenting repeat page loads as a new login;
- suppresses browser-only install/update prompts, mobile onboarding, and system-notification setup;
- preloads the JSSDK bridge and routes page-level
navigate()calls through the native stack (with an SPA history fallback while the JSSDK loads); - hides the self-drawn back button and title on the mobile planner header and the brand label on the trips header — the native navigation bar owns them;
- keeps the in-trip tab bar, sheets, dialogs, uploads, map, agent, and API caching behavior unchanged — sheet/dialog overlays follow the Visual Viewport keyboard policy documented in mobile-pwa.md.
The Service Worker never caches auth, mutation, or upload requests. Bridge
responses are Cache-Control: private, no-store.
Verification
Test in WeChat DevTools and real iOS/Android WeChat clients:
- first and repeat login;
- forward native-page navigation never renders the sign-in surface between the bridge spinner and the authenticated target route;
- expired and reused bridge codes;
- cookie persistence across native pages (home → trip → back) and logout;
- native back button and swipe-back from the trip page;
- share a trip, open the share card cold (shell must boot straight into the trip), and forward it;
- avatar and trip-media uploads;
- create-trip wizard and other sheet inputs remain visible above the virtual keyboard on iOS and Android WeChat;
- planner, map, and agent behavior;
- offline and upstream failure recovery.