A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add buildId

+101 -50
+23 -15
packages/cli/commands/build/pwa.ts
··· 5 5 import { logError, logInfo, logSuccess, logWarning } from '../../utils/ui.ts' 6 6 import type { CivilityConfig } from '../../utils/config.ts' 7 7 8 + export interface BuildMeta { 9 + version: string 10 + buildId: string 11 + minCompatVersion?: string 12 + } 13 + 8 14 export interface BuildConfig { 9 15 configPath: string 10 16 config: Record<string, unknown> 11 17 buildOptions: esbuild.BuildOptions 12 18 civilityConfig: CivilityConfig & { entryPoints: string[] } 13 - version: string 19 + meta: BuildMeta 14 20 } 15 21 16 22 export async function createBuildConfig( ··· 20 26 const configPath = resolve('./deno.json') 21 27 const config = JSON.parse(await Deno.readTextFile(configPath)) 22 28 29 + const buildId = crypto.randomUUID() 30 + const meta: BuildMeta = { 31 + version: config.version as string, 32 + buildId, 33 + minCompatVersion: civilityConfig.meta?.minCompatVersion, 34 + } 35 + 23 36 const bannerLines = [ 24 37 `// Generated on ${new Date().toISOString()}`, 25 - `globalThis.__APP_VERSION__ = "${config.version}";`, 38 + `globalThis.__APP_VERSION__ = "${meta.version}";`, 39 + `globalThis.__BUILD_ID__ = "${buildId}";`, 26 40 ] 27 41 if (options.dev) bannerLines.push('globalThis.__DEV__ = true;') 28 42 ··· 40 54 }, 41 55 } 42 56 43 - return { 44 - configPath, 45 - config, 46 - buildOptions, 47 - civilityConfig, 48 - version: config.version as string, 49 - } 57 + return { configPath, config, buildOptions, civilityConfig, meta } 50 58 } 51 59 52 - async function writeMetaJson(outdir: string, version: string): Promise<void> { 60 + async function writeMetaJson(outdir: string, meta: BuildMeta): Promise<void> { 53 61 await Deno.writeTextFile( 54 62 `${outdir}/meta.json`, 55 - JSON.stringify({ version, builtAt: new Date().toISOString() }), 63 + JSON.stringify({ ...meta, builtAt: new Date().toISOString() }), 56 64 ) 57 65 } 58 66 59 67 export async function buildOnce( 60 68 buildOptions: esbuild.BuildOptions, 61 - version: string, 69 + meta: BuildMeta, 62 70 ): Promise<void> { 63 71 try { 64 72 const start = performance.now() 65 73 await esbuild.build(buildOptions) 66 - await writeMetaJson(buildOptions.outdir as string, version) 74 + await writeMetaJson(buildOptions.outdir as string, meta) 67 75 const duration = Math.round(performance.now() - start) 68 76 69 77 logSuccess(`Build completed in ${colors.bold(duration + 'ms')}`) ··· 78 86 79 87 export async function buildWatch( 80 88 buildOptions: esbuild.BuildOptions, 81 - version: string, 89 + meta: BuildMeta, 82 90 ): Promise<esbuild.BuildContext> { 83 91 try { 84 92 const buildWithCallback: esbuild.BuildOptions = { ··· 96 104 if (errors.length > 0) { 97 105 logError(`Build failed with ${errors.length} error(s)`) 98 106 } else { 99 - await writeMetaJson(buildOptions.outdir as string, version) 107 + await writeMetaJson(buildOptions.outdir as string, meta) 100 108 if (warnings.length > 0) { 101 109 logWarning( 102 110 `Build completed with ${warnings.length} warning(s)`,
+2 -2
packages/cli/commands/start.ts
··· 73 73 const buildOutdir = resolvedConfig.outdir || './dist' 74 74 logInfo(`Build output: ${theme.bold(buildOutdir)}`) 75 75 76 - const { buildOptions } = await createBuildConfig(resolvedConfig, { 76 + const { buildOptions, meta } = await createBuildConfig(resolvedConfig, { 77 77 dev: !prod, 78 78 }) 79 - ctx = await pwaBuildWatch(buildOptions) 79 + ctx = await pwaBuildWatch(buildOptions, meta) 80 80 } else if (resolvedConfig.type === 'blog') { 81 81 // Start blog watch in background 82 82 watchBlog({
+1 -1
packages/cli/deno.json
··· 1 1 { 2 2 "name": "@civility/cli", 3 - "version": "0.1.2", 3 + "version": "0.2.0", 4 4 "exports": { 5 5 ".": "./main.ts" 6 6 },
+6 -10
packages/cli/main.ts
··· 113 113 ) 114 114 logInfo(`Output directory: ${theme.bold(outputDir)}`) 115 115 116 - const { buildOptions, version } = await createBuildConfig( 117 - resolvedConfig, 118 - ) 116 + const { buildOptions, meta } = await createBuildConfig(resolvedConfig) 119 117 120 118 if (options.watch) { 121 119 logInfo('Starting build in watch mode...') 122 - const ctx = await pwaBuildWatch(buildOptions, version) 120 + const ctx = await pwaBuildWatch(buildOptions, meta) 123 121 124 122 const cleanup = async () => { 125 123 logInfo('Stopping build watch...') ··· 134 132 await new Promise(() => {}) 135 133 } else { 136 134 logInfo('Building application...') 137 - await pwaBuildOnce(buildOptions, version) 135 + await pwaBuildOnce(buildOptions, meta) 138 136 logSuccess('Build completed successfully') 139 137 } 140 138 } ··· 244 242 ) 245 243 logInfo(`Output directory: ${theme.bold(outputDir)}`) 246 244 247 - const { buildOptions, version } = await createBuildConfig( 248 - resolvedConfig, 249 - ) 245 + const { buildOptions, meta } = await createBuildConfig(resolvedConfig) 250 246 251 247 if (options.watch) { 252 248 logInfo('Starting build in watch mode...') 253 - const ctx = await pwaBuildWatch(buildOptions, version) 249 + const ctx = await pwaBuildWatch(buildOptions, meta) 254 250 255 251 const cleanup = async () => { 256 252 logInfo('Stopping build watch...') ··· 265 261 await new Promise(() => {}) 266 262 } else { 267 263 logInfo('Building application...') 268 - await pwaBuildOnce(buildOptions, version) 264 + await pwaBuildOnce(buildOptions, meta) 269 265 logSuccess('Build completed successfully') 270 266 } 271 267 }
+10
packages/cli/utils/config.ts
··· 33 33 baseUrl?: string 34 34 /** Extension-specific: platforms to build for */ 35 35 platforms?: ('chrome' | 'firefox')[] 36 + /** Build-time metadata included in the generated `meta.json`. */ 37 + meta?: { 38 + /** 39 + * Minimum app version that is data-compatible with the current schema. 40 + * Clients running an older version than this will need a full reset rather 41 + * than just a reload. Surfaced in `meta.json` and the `VERSION_STATUS` 42 + * service worker message. 43 + */ 44 + minCompatVersion?: string 45 + } 36 46 } 37 47 38 48 const DEFAULT_PWA_CONFIG: CivilityConfig = {
+3 -2
packages/store/deno.json
··· 1 1 { 2 2 "name": "@civility/store", 3 - "version": "0.1.2", 3 + "version": "0.2.0", 4 4 "exports": { 5 - ".": "./mod.ts", 5 + "./state": "./state.ts", 6 + "./storage": "./storage.ts", 6 7 "./deno-fs": "./storage/deno_fs_storage.ts", 7 8 "./idb": "./storage/index_db_storage.ts", 8 9 "./local-storage": "./storage/local_storage.ts"
-5
packages/store/mod.ts
··· 1 - export * from './state.ts' 2 - export * from './storage.ts' 3 - 4 - export { default as State } from './state.ts' 5 - export { default as Storage } from './storage.ts'
+1 -1
packages/store/state.ts
··· 24 24 * State Class 25 25 * @example Basic Usage (See more in state.test.ts) 26 26 * ```ts 27 - * import State from '@inro/simple-tools/state' 27 + * import { State } from '@civility/store' 28 28 * 29 29 * class Counter extends State<{ count: number }> { 30 30 * constructor(count: number = 0) {
+1 -1
packages/store/storage.ts
··· 253 253 * 254 254 * @example 255 255 * ```ts 256 - * import LocalStorage from '@inro/simple-tools/storage/local-storage' 256 + * import LocalStorage from '@civility/store/local-storage' 257 257 * 258 258 * const store = new LocalStorage<{ count: number } | null>({ 259 259 * name: 'count',
+1 -1
packages/store/storage/deno_fs_storage.ts
··· 10 10 * @example 11 11 * ```ts 12 12 * import { Todo } from '@inro/simple-tools/todolist' 13 - * import DenoFsStorage from '@inro/simple-tools/storage/deno-fs' 13 + * import DenoFsStorage from '@civility/store/deno-fs' 14 14 * 15 15 * const storage = new DenoFsStorage<Todo[]>({ 16 16 * name: 'todos',
+1 -1
packages/store/storage/index_db_storage.ts
··· 6 6 /** 7 7 * A mechanism for using IndexedDB as a Storage instance. 8 8 * ```ts 9 - * import IndexDBStorage from '@inro/simple-tools/storage/idb' 9 + * import IndexDBStorage from '@civility/store/idb' 10 10 * 11 11 * const storage = new IndexDBStorage<{ data: string[] }>({ 12 12 * name: 'myData',
+14
packages/ui/components/ui-pwa-version.ts
··· 5 5 version: { type: String }, 6 6 _checked: { state: true }, 7 7 _serverVersion: { state: true }, 8 + _minCompatVersion: { state: true }, 8 9 _updateAvailable: { state: true }, 9 10 } 10 11 ··· 12 13 (globalThis as { __APP_VERSION__?: string }).__APP_VERSION__ ?? 'unknown' 13 14 _checked = false 14 15 _serverVersion: string | null = null 16 + _minCompatVersion: string | null = null 15 17 _updateAvailable = false 16 18 17 19 #onSwMessage: ((event: MessageEvent) => void) | null = null ··· 27 29 if (data?.type === 'VERSION_STATUS') { 28 30 this._checked = true 29 31 this._serverVersion = data.serverVersion 32 + this._minCompatVersion = data.minCompatVersion ?? null 30 33 this._updateAvailable = data.updateAvailable 31 34 } 32 35 } ··· 49 52 location.reload() 50 53 } 51 54 55 + get #isBreakingUpdate() { 56 + if (!this._minCompatVersion || !this._updateAvailable) return false 57 + // current version is older than minCompatVersion — data is incompatible 58 + return this.version < this._minCompatVersion 59 + } 60 + 52 61 override render() { 53 62 if (this._updateAvailable) { 54 63 return html` 55 64 <p>Current: <code>${this.version}</code></p> 56 65 <p>New version available: <code>${this._serverVersion}</code></p> 66 + ${this.#isBreakingUpdate 67 + ? html` 68 + <p><strong>This update resets local data.</strong></p> 69 + ` 70 + : ''} 57 71 <button @click="${() => void this.#applyUpdate()}">Update now</button> 58 72 ` 59 73 }
+1 -1
packages/ui/deno.json
··· 1 1 { 2 2 "name": "@civility/ui", 3 - "version": "0.1.2", 3 + "version": "0.2.0", 4 4 "exports": { 5 5 ".": "./mod.ts" 6 6 },
+1 -1
packages/workers/deno.json
··· 1 1 { 2 2 "name": "@civility/workers", 3 - "version": "0.1.2", 3 + "version": "0.2.0", 4 4 "exports": { 5 5 ".": "./mod.ts" 6 6 }
+31 -6
packages/workers/plugins/updates.ts
··· 18 18 * Defaults to 10 seconds. 19 19 */ 20 20 initialDelayMs?: number 21 + /** 22 + * What triggers an `updateAvailable` signal: 23 + * - `'version'` — only when the version string changes (default in prod) 24 + * - `'build'` — when the buildId changes, even if version is the same 25 + * (default in dev; catches redeploys without version bumps) 26 + * 27 + * Defaults to `'build'` when `globalThis.__DEV__` is true, `'version'` otherwise. 28 + */ 29 + updateOn?: 'version' | 'build' 21 30 } 22 31 23 32 /** 24 33 * Plugin: on `activate`, starts a version-polling loop and wires up 25 34 * `SKIP_WAITING` / `CHECK_UPDATE` message handling. 26 35 * 27 - * Fetches `checkUrl` (a JSON file with a `version` field) on each tick and 28 - * broadcasts `VERSION_STATUS` to all clients after every check, whether or not 29 - * an update is available. `serverVersion` will be `null` if the fetch failed 30 - * or the response had no `version` field. 36 + * Fetches `checkUrl` (a JSON file with `version` and `buildId` fields) on each 37 + * tick and broadcasts `VERSION_STATUS` to all clients after every check. 38 + * `serverVersion` and `serverBuildId` will be `null` if the fetch failed. 31 39 * 32 40 * @example 33 41 * ```ts ··· 35 43 * ``` 36 44 */ 37 45 export function withUpdatePolling(options: SwUpdateOptions = {}): WorkerPlugin { 38 - return ({ version }: WorkerContext) => { 46 + return ({ version, buildId }: WorkerContext) => { 47 + const isDev = (globalThis as { __DEV__?: boolean }).__DEV__ === true 39 48 const { 40 49 checkUrl = '/dist/meta.json', 41 50 intervalMs = 5 * 60 * 1000, 42 51 initialDelayMs = 10000, 52 + updateOn = isDev ? 'build' : 'version', 43 53 } = options 44 54 45 55 async function checkForUpdates(): Promise<void> { 46 56 let serverVersion: string | null = null 57 + let serverBuildId: string | null = null 58 + let minCompatVersion: string | null = null 47 59 try { 48 60 const response = await fetch(checkUrl, { 49 61 cache: 'no-cache', ··· 53 65 const data = await response.json() 54 66 serverVersion = typeof data?.version === 'string' 55 67 ? data.version 68 + : null 69 + serverBuildId = typeof data?.buildId === 'string' 70 + ? data.buildId 71 + : null 72 + minCompatVersion = typeof data?.minCompatVersion === 'string' 73 + ? data.minCompatVersion 56 74 : null 57 75 } 58 76 } catch (error) { 59 77 console.log('[SW] Update check failed:', (error as Error).message) 60 78 } 61 79 80 + const updateAvailable = updateOn === 'build' 81 + ? serverBuildId !== null && serverBuildId !== buildId 82 + : serverVersion !== null && serverVersion !== version 83 + 62 84 const clients = await self.clients.matchAll() 63 85 clients.forEach((client) => 64 86 client.postMessage({ 65 87 type: 'VERSION_STATUS', 66 88 currentVersion: version, 89 + currentBuildId: buildId, 67 90 serverVersion, 68 - updateAvailable: serverVersion !== null && serverVersion !== version, 91 + serverBuildId, 92 + minCompatVersion, 93 + updateAvailable, 69 94 }) 70 95 ) 71 96 }
+5 -3
packages/workers/worker.ts
··· 1 1 /** Shared context derived from the app version and cache prefix. */ 2 2 export interface WorkerContext { 3 3 version: string 4 + buildId: string 4 5 cacheName: string 5 6 } 6 7 ··· 26 27 * ``` 27 28 */ 28 29 export function deriveContext(cachePrefix = 'civility_v'): WorkerContext { 29 - const version = ((globalThis as Record<string, unknown>) 30 - .__APP_VERSION__ as string) || '1.0.0' 31 - return { version, cacheName: `${cachePrefix}${version}` } 30 + const g = globalThis as Record<string, unknown> 31 + const version = (g.__APP_VERSION__ as string) || '0.0.0' 32 + const buildId = (g.__BUILD_ID__ as string) || 'unknown' 33 + return { version, buildId, cacheName: `${cachePrefix}${version}` } 32 34 } 33 35 34 36 /**