feat(waiter): venue servers publish the native UI bundle (manifest + zip)
- scripts/pack-bundle.mjs zips dist-native deterministically (sorted paths,
fixed mtimes) into dist/downloads/waiter-bundle-<hash>.zip and writes
waiter-bundle.json {format, version "<APP_VERSION>-<hash12>", file,
sha256 (hex), size, min_shell_build, app_version, built_at}
- src/native/shell.js: MIN_SHELL_BUILD - lowest APK versionCode a bundle
runs in (bump with native changes)
- Dockerfile: node 22; builds web + native bundle + pack (APP_VERSION build
arg, from ${VERSION} in docker-compose.dev.yml)
- vite native mode strips public/downloads from dist-native - the 10.8 MB
APK was being copied into the native build (and would have ended up inside
the next APK); bundle 15 MB → 5 MB
- nginx /downloads/: json/zip types, CORS * (the app reads the manifest from
origin http://localhost), no-cache
- .dockerignore: android/, releases/, dist-native/ out of the build context
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ services:
|
||||
waiter_pwa:
|
||||
build:
|
||||
context: ./waiter_pwa
|
||||
args:
|
||||
APP_VERSION: ${VERSION:-dev}
|
||||
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
|
||||
ports:
|
||||
- "5173:80"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
# Local-only build outputs — never part of the image build context
|
||||
android
|
||||
releases
|
||||
dist-native
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
# Shown in the UI bundle manifest (plan step 8); the bundle's identity is its content hash
|
||||
ARG APP_VERSION=0.0.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,7 +9,9 @@ COPY package.json package-lock.json* ./
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
# Web build (served to browsers) + native UI bundle that phones download from
|
||||
# this server: dist/downloads/waiter-bundle.json + waiter-bundle-<hash>.zip
|
||||
RUN npm run build && npx vite build --mode native && APP_VERSION=$APP_VERSION node scripts/pack-bundle.mjs
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
|
||||
@@ -27,9 +27,17 @@ server {
|
||||
|
||||
# Native app APK for sideloading (bundled by scripts/build-apk.mjs release).
|
||||
# Real 404 when absent - never fall through to index.html.
|
||||
# Also the per-venue UI bundle for the native app (plan step 8):
|
||||
# waiter-bundle.json (manifest, never cached) + waiter-bundle-<hash>.zip.
|
||||
location /downloads/ {
|
||||
types { application/vnd.android.package-archive apk; }
|
||||
types {
|
||||
application/vnd.android.package-archive apk;
|
||||
application/json json;
|
||||
application/zip zip;
|
||||
}
|
||||
add_header Cache-Control "no-cache";
|
||||
# The native app (origin http://localhost) reads the manifest via fetch
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Pack the native UI bundle that venue servers hand to phones (plan step 8).
|
||||
//
|
||||
// node scripts/pack-bundle.mjs (after `vite build --mode native`)
|
||||
//
|
||||
// Zips dist-native/ into dist/downloads/waiter-bundle-<hash>.zip and writes
|
||||
// dist/downloads/waiter-bundle.json:
|
||||
// { format, version, file, sha256, size, min_shell_build, app_version, built_at }
|
||||
// The app downloads the zip only if its SHA-256 (hex, lowercase) matches the
|
||||
// manifest, which it fetches over the pinned TLS link. The version is derived
|
||||
// from the content hash, so identical code always yields the same version and
|
||||
// any change yields a new one — no manual version bumping.
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { zipSync } from 'fflate'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const src = join(root, 'dist-native')
|
||||
const outDir = join(root, 'dist', 'downloads')
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir).flatMap(name => {
|
||||
const p = join(dir, name)
|
||||
return statSync(p).isDirectory() ? walk(p) : [p]
|
||||
})
|
||||
}
|
||||
|
||||
const files = walk(src).filter(f => !relative(src, f).startsWith('downloads')).sort()
|
||||
if (!files.some(f => relative(src, f) === 'index.html')) {
|
||||
console.error('dist-native/index.html missing — run `vite build --mode native` first')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Deterministic zip: sorted paths, fixed timestamps → same code, same bytes, same hash
|
||||
const entries = {}
|
||||
for (const f of files) {
|
||||
entries[relative(src, f).split('\\').join('/')] = [readFileSync(f), { mtime: new Date('2000-01-01T00:00:00Z') }]
|
||||
}
|
||||
const zip = zipSync(entries, { level: 9 })
|
||||
const sha256 = createHash('sha256').update(zip).digest('hex')
|
||||
|
||||
const minShell = Number(readFileSync(join(root, 'src', 'native', 'shell.js'), 'utf8')
|
||||
.match(/MIN_SHELL_BUILD\s*=\s*(\d+)/)?.[1])
|
||||
if (!minShell) {
|
||||
console.error('MIN_SHELL_BUILD not found in src/native/shell.js')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const appVersion = process.env.APP_VERSION || JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version
|
||||
const version = `${appVersion}-${sha256.slice(0, 12)}`
|
||||
const file = `waiter-bundle-${sha256.slice(0, 12)}.zip`
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
for (const old of readdirSync(outDir)) {
|
||||
if (/^waiter-bundle-.*\.zip$/.test(old)) unlinkSync(join(outDir, old))
|
||||
}
|
||||
writeFileSync(join(outDir, file), zip)
|
||||
const manifest = {
|
||||
format: 1,
|
||||
version,
|
||||
file,
|
||||
sha256,
|
||||
size: zip.length,
|
||||
min_shell_build: minShell,
|
||||
app_version: appVersion,
|
||||
built_at: new Date().toISOString(),
|
||||
}
|
||||
writeFileSync(join(outDir, 'waiter-bundle.json'), JSON.stringify(manifest, null, 2) + '\n')
|
||||
console.log(`bundle ${version}: ${files.length} files, ${(zip.length / 1024).toFixed(0)} KiB → dist/downloads/${file}`)
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Native shell compatibility (plan step 8).
|
||||
*
|
||||
* Venue servers hand phones their own copy of this UI (a "bundle"). A bundle
|
||||
* can only run inside an APK that has the native pieces it calls (plugins,
|
||||
* TrustStore, …). MIN_SHELL_BUILD is the lowest APK versionCode this bundle
|
||||
* works with — scripts/pack-bundle.mjs copies it into the bundle manifest, and
|
||||
* phones with an older APK skip the bundle and show "update the app" instead.
|
||||
*
|
||||
* Bump it ONLY when this web code starts depending on something new in the
|
||||
* native project (android/…), in the same commit that bumps versionCode.
|
||||
*/
|
||||
export const MIN_SHELL_BUILD = 1
|
||||
@@ -1,3 +1,5 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
@@ -30,6 +32,14 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
// public/downloads/ holds the APK + UI bundle that venue servers hand out.
|
||||
// It belongs in the web build only — never inside the app or its bundle.
|
||||
native && {
|
||||
name: 'xenia-strip-downloads',
|
||||
closeBundle() {
|
||||
rmSync(fileURLToPath(new URL('./dist-native/downloads', import.meta.url)), { recursive: true, force: true })
|
||||
},
|
||||
},
|
||||
VitePWA({
|
||||
disable: native,
|
||||
registerType: 'prompt',
|
||||
|
||||
Reference in New Issue
Block a user