From b139ab72f9f5dd7ddc2ee744effc68deb489d395 Mon Sep 17 00:00:00 2001 From: Luis Almeida Date: Thu, 10 Sep 2026 18:26:54 +0200 Subject: [PATCH] feat(themes): serve the whole component dist tree in preview A component's release tree is more than its entry: lazy chunks and assets fetched relative to it must resolve the same way locally as in production. Only the entry is rewritten for livereload; everything else is served verbatim. Metadata now comes from the built component.json, so preview registers the version the bundle was actually built with. --- .../src/commands/themes/preview.ts | 43 +++++++----- .../zcli-themes/src/lib/getComponent.test.ts | 18 ++--- packages/zcli-themes/src/lib/getComponent.ts | 5 +- .../mocks/base_component/component.json | 1 - .../tests/functional/preview.test.ts | 65 +++++++++++-------- 5 files changed, 75 insertions(+), 57 deletions(-) diff --git a/packages/zcli-themes/src/commands/themes/preview.ts b/packages/zcli-themes/src/commands/themes/preview.ts index 287dcfe8..0b453175 100644 --- a/packages/zcli-themes/src/commands/themes/preview.ts +++ b/packages/zcli-themes/src/commands/themes/preview.ts @@ -151,25 +151,18 @@ export default class Preview extends Command { private async previewComponent (componentPath: string, flags: PreviewFlags) { const { logs: tailLogs } = flags - let component = getComponent(componentPath) - if (!fs.existsSync(`${componentPath}/dist/index.js`)) { this.error(`Couldn't find a bundle at path: "${componentPath}/dist/index.js" — build the component first`) } - const { app, server, wss } = this.createServer(flags) + let component = getComponent(componentPath) - app.get('/theme_components/:name/:version/index.js', (req, res) => { - const bundle = path.resolve(`${componentPath}/dist/index.js`) + const { app, server, wss } = this.createServer(flags) - // The version segment is ignored on purpose: the bundle on disk is the - // one being developed, whatever version a cached page may still request. - if (req.params.name !== component.name || !fs.existsSync(bundle)) { - res.sendStatus(404) - return - } + const componentRoutes = express.Router() - const source = fs.readFileSync(bundle, 'utf8') + componentRoutes.get('/index.js', (req, res) => { + const source = fs.readFileSync(path.resolve(`${componentPath}/dist/index.js`), 'utf8') const label = `${component.name}@${component.version}` res.header('Content-Type', 'text/javascript') @@ -177,6 +170,22 @@ export default class Preview extends Command { res.send(flags.livereload ? appendLivereloadSnippet(source, getLocalServerBaseUrl(flags, true), label) : source) }) + // Everything else in the release tree (lazy chunks, fetched assets) is served + // verbatim: only the entry is a bundle we can safely rewrite for livereload. + componentRoutes.use(express.static(`${componentPath}/dist`, { + setHeaders: (res) => res.header('Cache-Control', 'no-cache') + })) + + // The version segment is ignored on purpose: the tree on disk is the one being + // developed, whatever version a cached page may still request. + app.use('/theme_components/:name/:version', (req, res, next) => { + if (req.params.name !== component.name) { + res.sendStatus(404) + return + } + next() + }, componentRoutes) + // Listen before registering so a failed start leaves no registration // pointing at a server that is not ours. await this.listen(server, wss, flags) @@ -195,14 +204,12 @@ export default class Preview extends Command { this.log(`You can exit preview mode in the UI or by visiting ${baseUrl}/hc/admin/local_preview/stop`) tailLogs && this.log(chalk.bold('Tailing logs')) - const monitoredPaths = [ - `${componentPath}/component.json`, - `${componentPath}/dist` - ] + const metadataPath = path.join(componentPath, 'dist/component.json') const handleComponentChange = async (changedPath: string) => { this.log(chalk.bold('Change'), changedPath) - if (changedPath === path.join(componentPath, 'component.json')) { + // Re-register from the built metadata, which is what HC is told to serve. + if (changedPath === metadataPath) { try { const next = getComponent(componentPath) await previewComponent(componentPath, flags) @@ -215,7 +222,7 @@ export default class Preview extends Command { this.broadcastReload(wss) } - const watcher = chokidar.watch(monitoredPaths, { ignoreInitial: true }) + const watcher = chokidar.watch(`${componentPath}/dist`, { ignoreInitial: true }) .on('add', handleComponentChange) .on('change', handleComponentChange) .on('unlink', handleComponentChange) diff --git a/packages/zcli-themes/src/lib/getComponent.test.ts b/packages/zcli-themes/src/lib/getComponent.test.ts index 579e9497..98327637 100644 --- a/packages/zcli-themes/src/lib/getComponent.test.ts +++ b/packages/zcli-themes/src/lib/getComponent.test.ts @@ -20,11 +20,11 @@ describe('getComponent', () => { } existsSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns(true) readFileSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns(JSON.stringify(component)) expect(getComponent('component/path')).to.deep.equal(component) @@ -34,12 +34,12 @@ describe('getComponent', () => { const existsSyncStub = sinon.stub(fs, 'existsSync') existsSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns(false) expect(() => { getComponent('component/path') - }).to.throw('Couldn\'t find a component.json file at path: "component/path/component.json"') + }).to.throw('Couldn\'t find a component.json file at path: "component/path/dist/component.json"') }) it('throws an error when the component.json file is malformed', () => { @@ -47,16 +47,16 @@ describe('getComponent', () => { const readFileSyncStub = sinon.stub(fs, 'readFileSync') existsSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns(true) readFileSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns('{"name": "request_list",,, }') expect(() => { getComponent('component/path') - }).to.throw('component.json file was malformed at path: "component/path/component.json"') + }).to.throw('component.json file was malformed at path: "component/path/dist/component.json"') }) it('throws an error when name or version are missing', () => { @@ -64,11 +64,11 @@ describe('getComponent', () => { const readFileSyncStub = sinon.stub(fs, 'readFileSync') existsSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns(true) readFileSyncStub - .withArgs('component/path/component.json') + .withArgs('component/path/dist/component.json') .returns(JSON.stringify({ name: 'request_list' })) expect(() => { diff --git a/packages/zcli-themes/src/lib/getComponent.ts b/packages/zcli-themes/src/lib/getComponent.ts index 3fed7994..3ece10cb 100644 --- a/packages/zcli-themes/src/lib/getComponent.ts +++ b/packages/zcli-themes/src/lib/getComponent.ts @@ -4,10 +4,11 @@ import * as fs from 'fs' import * as chalk from 'chalk' export default function getComponent (componentPath: string): Component { - const componentFilePath = `${componentPath}/component.json` + // The built metadata, not the source file: `version` only exists after a build. + const componentFilePath = `${componentPath}/dist/component.json` if (!fs.existsSync(componentFilePath)) { - throw new CLIError(chalk.red(`Couldn't find a component.json file at path: "${componentFilePath}"`)) + throw new CLIError(chalk.red(`Couldn't find a component.json file at path: "${componentFilePath}" — build the component first`)) } let component: Component diff --git a/packages/zcli-themes/tests/functional/mocks/base_component/component.json b/packages/zcli-themes/tests/functional/mocks/base_component/component.json index b051848e..7ddeaec6 100644 --- a/packages/zcli-themes/tests/functional/mocks/base_component/component.json +++ b/packages/zcli-themes/tests/functional/mocks/base_component/component.json @@ -1,6 +1,5 @@ { "name": "request_list", - "version": "1.0.0", "settings": [ { "label": "request_list_group_label", diff --git a/packages/zcli-themes/tests/functional/preview.test.ts b/packages/zcli-themes/tests/functional/preview.test.ts index 31c8cba3..b0928f97 100644 --- a/packages/zcli-themes/tests/functional/preview.test.ts +++ b/packages/zcli-themes/tests/functional/preview.test.ts @@ -11,9 +11,37 @@ import env from './env' describe('themes:preview', function () { const baseThemePath = path.join(__dirname, 'mocks/base_theme') + const baseComponentPath = path.join(__dirname, 'mocks/base_component') + const bundlePath = path.join(baseComponentPath, 'dist/index.js') + const bundle = 'export function mount (container, props) {\n container.textContent = props.settings.heading_text\n}\n' + const chunkPath = path.join(baseComponentPath, 'dist/chunks/extra-chunk.js') + const chunk = 'export function extra () {\n return true\n}\n' + const localePath = path.join(baseComponentPath, 'dist/locales/en-us.json') + const locale = '{"greeting":"hi"}' + const metadataPath = path.join(baseComponentPath, 'dist/component.json') + // The version exists only in built metadata, never in source, so these tests + // fail if anything reads the source component.json instead. + const metadata = JSON.stringify({ + ...JSON.parse(fs.readFileSync(path.join(baseComponentPath, 'component.json'), 'utf8')), + version: '1.0.0' + }) let fetchStub: sinon.SinonStub + // dist/ is gitignored build output and one test deletes it, so every test + // starts from the whole tree a build would emit. beforeEach(() => { + const files: Array<[string, string]> = [ + [bundlePath, bundle], + [chunkPath, chunk], + [localePath, locale], + [metadataPath, metadata] + ] + + for (const [file, contents] of files) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, contents) + } + fetchStub = sinon.stub(global, 'fetch') }) @@ -84,16 +112,6 @@ describe('themes:preview', function () { }) describe('component preview', function () { - const baseComponentPath = path.join(__dirname, 'mocks/base_component') - const bundlePath = path.join(baseComponentPath, 'dist/index.js') - const bundle = 'export function mount (container, props) {\n container.textContent = props.settings.heading_text\n}\n' - - // dist/ is gitignored build output, so the fixture writes its own bundle - before(() => { - fs.mkdirSync(path.dirname(bundlePath), { recursive: true }) - fs.writeFileSync(bundlePath, bundle) - }) - describe('with live-reload', () => { let server: { close: () => void } @@ -153,6 +171,16 @@ describe('themes:preview', function () { expect((e as AxiosError).response?.status).to.eq(404) } }) + + preview + .it('should serve sibling dist/ assets verbatim', async () => { + const chunkResponse = await axios.get('http://0.0.0.0:9998/theme_components/request_list/1.0.0/chunks/extra-chunk.js') + expect(chunkResponse.data).to.eq(chunk) + expect(chunkResponse.data).not.to.contain('WebSocket') + + const localeResponse = await axios.get('http://0.0.0.0:9998/theme_components/request_list/1.0.0/locales/en-us.json') + expect(localeResponse.data).to.deep.eq(JSON.parse(locale)) + }) }) describe('with --no-livereload', () => { @@ -206,14 +234,6 @@ describe('themes:preview', function () { }) describe('when component registration fails after listening', () => { - const baseComponentPath = path.join(__dirname, 'mocks/base_component') - const bundlePath = path.join(baseComponentPath, 'dist/index.js') - - before(() => { - fs.mkdirSync(path.dirname(bundlePath), { recursive: true }) - fs.writeFileSync(bundlePath, 'export function mount () {}\n') - }) - test .stdout() .env(env) @@ -242,14 +262,6 @@ describe('themes:preview', function () { }) describe('when the component bundle has not been built', () => { - const baseComponentPath = path.join(__dirname, 'mocks/base_component') - const bundlePath = path.join(baseComponentPath, 'dist/index.js') - - afterEach(() => { - fs.mkdirSync(path.dirname(bundlePath), { recursive: true }) - fs.writeFileSync(bundlePath, 'export function mount () {}\n') - }) - test .stdout() .env(env) @@ -269,7 +281,6 @@ describe('themes:preview', function () { }) describe('when the port is already in use', () => { - const baseComponentPath = path.join(__dirname, 'mocks/base_component') let blocker: http.Server before(async () => {