diff --git a/.changeset/lucky-adults-tan.md b/.changeset/lucky-adults-tan.md new file mode 100644 index 00000000000..92a75073295 --- /dev/null +++ b/.changeset/lucky-adults-tan.md @@ -0,0 +1,6 @@ +--- +'@builder.io/qwik-city': patch +'@builder.io/qwik': patch +--- + +feat(cli): Add check-client command to verify bundle freshness diff --git a/packages/qwik/src/cli/check-client/index.ts b/packages/qwik/src/cli/check-client/index.ts new file mode 100644 index 00000000000..08fe9811d89 --- /dev/null +++ b/packages/qwik/src/cli/check-client/index.ts @@ -0,0 +1,151 @@ +import type { AppCommand } from '../utils/app-command'; +import { intro, log, outro } from '@clack/prompts'; +import { red } from 'kleur/colors'; +import { runInPkg } from '../utils/install-deps'; +import { getPackageManager, panic } from '../utils/utils'; +import fs from 'fs/promises'; +import type { Stats } from 'fs'; +import path from 'path'; + +const getDiskPath = (dist: string) => path.resolve(dist); +const getSrcPath = (src: string) => path.resolve(src); +const getManifestPath = (dist: string) => path.resolve(dist, 'q-manifest.json'); + +export async function runQwikClientCommand(app: AppCommand) { + try { + const src = app.args[1]; + const dist = app.args[2]; + await checkClientCommand(app, src, dist); + } catch (e) { + console.error(`❌ ${red(String(e))}\n`); + process.exit(1); + } +} + +/** + * Handles the core logic for the 'check-client' command. Exports this function so other modules can + * import and call it. + * + * @param {AppCommand} app - Application command context (assuming structure). + */ +async function checkClientCommand(app: AppCommand, src: string, dist: string): Promise { + if (!(await clientDirExists(dist))) { + await goBuild(app); + } else { + const manifest = await getManifestTs(getManifestPath(dist)); + if (manifest === null) { + await goBuild(app); + } else { + if (await hasNewer(getSrcPath(src), manifest)) { + await goBuild(app); + } + } + } +} + +/** + * Builds the application using the appropriate package manager. + * + * @param {AppCommand} app - The application command object containing app details.e path to the + * manifest file (though it's not used in the current function). + * @throws {Error} Throws an error if the build process encounters any issues. + */ + +async function goBuild(app: AppCommand) { + const pkgManager = getPackageManager(); + intro('Building client (manifest missing or outdated)...'); + const { install } = await runInPkg(pkgManager, ['run', 'build.client'], app.rootDir); + if (!(await install)) { + throw new Error('Client build command reported failure.'); + } + outro('Client build complete'); +} + +/** + * Retrieves the last modified timestamp of the manifest file. + * + * @param {string} manifestPath - The path to the manifest file. + * @returns {Promise} Returns the last modified timestamp (in milliseconds) of the + * manifest file, or null if an error occurs. + */ +async function getManifestTs(manifestPath: string) { + try { + // Get stats for the manifest file + const stats: Stats = await fs.stat(manifestPath); + return stats.mtimeMs; + } catch (err: any) { + // Handle errors accessing the manifest file + if (err.code === 'ENOENT') { + log.warn(`q-manifest.json file not found`); + } else { + panic(`Error accessing manifest file ${manifestPath}: ${err.message}`); + } + return null; + } +} + +/** + * Checks if the specified disk directory exists and is accessible. + * + * @returns {Promise} Returns true if the directory exists and can be accessed, returns + * false if it doesn't exist or an error occurs. + */ +export async function clientDirExists(path: string): Promise { + try { + await fs.access(getDiskPath(path)); + return true; // Directory exists + } catch (err: any) { + if (!(err.code === 'ENOENT')) { + panic(`Error accessing disk directory ${path}: ${err.message}`); + } + return false; // Directory doesn't exist or there was an error + } +} + +/** + * Recursively finds the latest modification time (mtime) of any file in the given directory. + * + * @param {string} srcPath - The directory path to search. + * @returns {Promise} Returns the latest mtime (Unix timestamp in milliseconds), or 0 if the + * directory doesn't exist or is empty. + */ +export async function hasNewer(srcPath: string, timestamp: number): Promise { + let returnValue = false; + async function traverse(dir: string): Promise { + if (returnValue) { + return; + } + let items: Array; + try { + items = await fs.readdir(dir, { withFileTypes: true }); + } catch (err: any) { + if (err.code !== 'ENOENT') { + log.warn(`Cannot read directory ${dir}: ${err.message}`); + } + return; + } + + for (const item of items) { + if (returnValue) { + return; + } + const fullPath = path.join(dir, item.name); + try { + if (item.isDirectory()) { + await traverse(fullPath); + } else if (item.isFile()) { + const stats = await fs.stat(fullPath); + if (stats.mtimeMs > timestamp) { + returnValue = true; + return; + } + } + } catch (err: any) { + log.warn(`Cannot access ${fullPath}: ${err.message}`); + } + } + } + + await traverse(srcPath); + return returnValue; +} diff --git a/packages/qwik/src/cli/run.ts b/packages/qwik/src/cli/run.ts index cd4dccd0987..bdd6159bd49 100644 --- a/packages/qwik/src/cli/run.ts +++ b/packages/qwik/src/cli/run.ts @@ -8,6 +8,7 @@ import { note, panic, pmRunCmd, printHeader, bye } from './utils/utils'; import { runBuildCommand } from './utils/run-build-command'; import { intro, isCancel, select, confirm } from '@clack/prompts'; import { runV2Migration } from './migrate-v2/run-migration'; +import { runQwikClientCommand } from './check-client'; const SPACE_TO_HINT = 18; const COMMANDS = [ @@ -53,6 +54,13 @@ const COMMANDS = [ run: (app: AppCommand) => runV2Migration(app), showInHelp: false, }, + { + value: 'check-client', + label: 'check-client', + hint: 'Make sure the client bundle is up-to-date with the source code', + run: (app: AppCommand) => runQwikClientCommand(app), + showInHelp: true, + }, { value: 'help', label: 'help', @@ -110,6 +118,10 @@ async function runCommand(app: AppCommand) { await runV2Migration(app); return; } + case 'check-client': { + await runQwikClientCommand(app); + return; + } case 'version': { printVersion(); return; diff --git a/starters/adapters/aws-lambda/package.json b/starters/adapters/aws-lambda/package.json index 668181564e6..1ffdfa9e252 100755 --- a/starters/adapters/aws-lambda/package.json +++ b/starters/adapters/aws-lambda/package.json @@ -1,7 +1,7 @@ { "description": "AWS Lambda", "scripts": { - "build.server": "vite build -c adapters/aws-lambda/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/aws-lambda/vite.config.ts", "serve": "qwik build && serverless offline", "deploy": "serverless deploy" }, diff --git a/starters/adapters/azure-swa/package.json b/starters/adapters/azure-swa/package.json index f22018583ef..9fbe555e229 100644 --- a/starters/adapters/azure-swa/package.json +++ b/starters/adapters/azure-swa/package.json @@ -1,7 +1,7 @@ { "description": "Azure Static Web Apps", "scripts": { - "build.server": "vite build -c adapters/azure-swa/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/azure-swa/vite.config.ts", "serve": "swa start" }, "devDependencies": { diff --git a/starters/adapters/bun/package.json b/starters/adapters/bun/package.json index bf97dc87e93..118eb8a04ef 100644 --- a/starters/adapters/bun/package.json +++ b/starters/adapters/bun/package.json @@ -1,7 +1,7 @@ { "description": "Bun server", "scripts": { - "build.server": "vite build -c adapters/bun/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/bun/vite.config.ts", "serve": "bun server/entry.bun.js" }, "__qwik__": { diff --git a/starters/adapters/cloud-run/package.json b/starters/adapters/cloud-run/package.json index 818283b12aa..caef0f535ba 100644 --- a/starters/adapters/cloud-run/package.json +++ b/starters/adapters/cloud-run/package.json @@ -1,7 +1,7 @@ { "description": "Google Cloud Run server", "scripts": { - "build.server": "vite build -c adapters/cloud-run/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/cloud-run/vite.config.ts", "deploy": "gcloud run deploy my-cloud-run-app --source ." }, "__qwik__": { diff --git a/starters/adapters/cloudflare-pages/package.json b/starters/adapters/cloudflare-pages/package.json index 4c871e1956f..7a8c5e8ca29 100644 --- a/starters/adapters/cloudflare-pages/package.json +++ b/starters/adapters/cloudflare-pages/package.json @@ -1,7 +1,7 @@ { "description": "Cloudflare Pages", "scripts": { - "build.server": "vite build -c adapters/cloudflare-pages/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/cloudflare-pages/vite.config.ts", "deploy": "wrangler pages deploy ./dist", "serve": "wrangler pages dev ./dist --compatibility-flags=nodejs_als" }, diff --git a/starters/adapters/deno/package.json b/starters/adapters/deno/package.json index 231fe75d9f1..b572cfa3181 100644 --- a/starters/adapters/deno/package.json +++ b/starters/adapters/deno/package.json @@ -1,7 +1,7 @@ { "description": "Deno server", "scripts": { - "build.server": "vite build -c adapters/deno/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/deno/vite.config.ts", "serve": "deno run --allow-net --allow-read --allow-env server/entry.deno.js" }, "__qwik__": { diff --git a/starters/adapters/express/package.json b/starters/adapters/express/package.json index 53e8145b86c..564e917f520 100644 --- a/starters/adapters/express/package.json +++ b/starters/adapters/express/package.json @@ -1,7 +1,7 @@ { "description": "Express.js server", "scripts": { - "build.server": "vite build -c adapters/express/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/express/vite.config.ts", "serve": "node server/entry.express" }, "dependencies": { diff --git a/starters/adapters/fastify/package.json b/starters/adapters/fastify/package.json index 861be8e0ec4..6e97e6da402 100644 --- a/starters/adapters/fastify/package.json +++ b/starters/adapters/fastify/package.json @@ -1,7 +1,7 @@ { "description": "Fastify server", "scripts": { - "build.server": "vite build -c adapters/fastify/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/fastify/vite.config.ts", "serve": "node server/entry.fastify" }, "dependencies": { diff --git a/starters/adapters/firebase/package.json b/starters/adapters/firebase/package.json index b9b3868bcb7..d4212c5beee 100644 --- a/starters/adapters/firebase/package.json +++ b/starters/adapters/firebase/package.json @@ -1,7 +1,7 @@ { "description": "Firebase", "scripts": { - "build.server": "vite build -c adapters/firebase/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/firebase/vite.config.ts", "serve": "qwik build && firebase emulators:start", "deploy": "firebase deploy" }, diff --git a/starters/adapters/netlify-edge/package.json b/starters/adapters/netlify-edge/package.json index e5b68aadb91..2febbffebcf 100644 --- a/starters/adapters/netlify-edge/package.json +++ b/starters/adapters/netlify-edge/package.json @@ -1,7 +1,7 @@ { "description": "Netlify Edge Functions", "scripts": { - "build.server": "vite build -c adapters/netlify-edge/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/netlify-edge/vite.config.ts", "deploy": "netlify deploy --build" }, "devDependencies": { diff --git a/starters/adapters/node-server/package.json b/starters/adapters/node-server/package.json index 8d698f78bf4..2b97a3a85a9 100644 --- a/starters/adapters/node-server/package.json +++ b/starters/adapters/node-server/package.json @@ -1,7 +1,7 @@ { "description": "Vanilla Node server", "scripts": { - "build.server": "vite build -c adapters/node-server/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/node-server/vite.config.ts", "serve": "node server/entry.node-server" }, "__qwik__": { diff --git a/starters/adapters/static/package.json b/starters/adapters/static/package.json index bf70695a465..ebe4f7a3432 100644 --- a/starters/adapters/static/package.json +++ b/starters/adapters/static/package.json @@ -1,7 +1,7 @@ { "description": "Static Site Generator", "scripts": { - "build.server": "vite build -c adapters/static/vite.config.ts" + "build.server": "qwik check-client src dist && vite build -c adapters/static/vite.config.ts" }, "__qwik__": { "priority": 10, diff --git a/starters/adapters/vercel-edge/package.json b/starters/adapters/vercel-edge/package.json index 83144b458c1..fbf007e12fc 100644 --- a/starters/adapters/vercel-edge/package.json +++ b/starters/adapters/vercel-edge/package.json @@ -1,7 +1,7 @@ { "description": "Vercel Edge Functions", "scripts": { - "build.server": "vite build -c adapters/vercel-edge/vite.config.ts", + "build.server": "qwik check-client src dist && vite build -c adapters/vercel-edge/vite.config.ts", "deploy": "vercel deploy" }, "devDependencies": {