Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 25 additions & 18 deletions packages/zcli-themes/src/commands/themes/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,32 +151,41 @@
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')
res.header('Cache-Control', 'no-cache')
res.send(flags.livereload ? appendLivereloadSnippet(source, getLocalServerBaseUrl(flags, true), label) : source)
})

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a file system access
, but is not rate-limited.

// 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)
Expand All @@ -195,14 +204,12 @@
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)
Expand All @@ -215,7 +222,7 @@
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)
Expand Down
18 changes: 9 additions & 9 deletions packages/zcli-themes/src/lib/getComponent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -34,41 +34,41 @@ 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', () => {
const existsSyncStub = sinon.stub(fs, 'existsSync')
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', () => {
const existsSyncStub = sinon.stub(fs, 'existsSync')
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(() => {
Expand Down
5 changes: 3 additions & 2 deletions packages/zcli-themes/src/lib/getComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"name": "request_list",
"version": "1.0.0",
"settings": [
{
"label": "request_list_group_label",
Expand Down
65 changes: 38 additions & 27 deletions packages/zcli-themes/tests/functional/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,37 @@

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')
})

Expand Down Expand Up @@ -84,16 +112,6 @@
})

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 }

Expand Down Expand Up @@ -153,6 +171,16 @@
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', () => {
Expand Down Expand Up @@ -206,14 +234,6 @@
})

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)
Expand Down Expand Up @@ -242,14 +262,6 @@
})

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)
Expand All @@ -269,7 +281,6 @@
})

describe('when the port is already in use', () => {
const baseComponentPath = path.join(__dirname, 'mocks/base_component')
let blocker: http.Server

before(async () => {
Expand Down
Loading