From d30fe94741515795132c550f92e3b4f6cc3967b6 Mon Sep 17 00:00:00 2001 From: James Zetlen Date: Thu, 27 Sep 2018 08:47:08 -0500 Subject: [PATCH] feat(dev): Make PWADevServer host/SSL optional (#175) PWADevServer attempts to create a unique domain name, create local SSL certificates, and tell the OS to trust those certificates, for every new project. It also tries to confirm they exist on every run! This causes many problems for users under some common conditions: - No administrative access to local machine - No OpenSSL installed, or wrong OpenSSL installed - OS cannot be scripted to trust certificates - Developer uses Firefox, which uses its own cert store Additionally, some bugs in the implementation have caused some developers' projects to enter an unusable state. - Adds `provideUniqueHost` flag to PWADevServer configuration. PWADevServer will no longer try to create or retrieve a custom domain name unless `provideUniqueHost` is in its configuration in `webpack.config.js` as either a custom string or `true`. - Adds `provideSSLCert` flag to PWADevServer configuration. PWADevServer will no longer try to create or retrieve a trusted SSL certificate unless `provideSSLCert: true` is in its configuration in `webpack.config.js`. - Modifies custom domain name creation strategy to ensure uniqueness based on a hash of the full local path, rather than using the local flat file database. We created these features for the needs of the developer working on several PWAs at once on their local machine, so that they don't have to set up manual SSL every time, and they have no conflicts with Service Workers. This could be considered "bonus functionality", as it's not critical to the setup of a minimum viable PWA. It was meant to establish our focus on developer experience, and articulate the parts of developer setup that PWA Studio can "own". *However, we soon learned that we could not maintain all scenarios for automated setup and continue to make progress with shopper-facing features*. We still really want to support and automate all of these scenarios, but for now, our implementations are a hindrance and we are turning them off by default. fixup: Documentation edits from PR feedback --- .../src/WebpackTools/PWADevServer.js | 158 +++++++----- .../__tests__/PWADevServer.spec.js | 239 +++++++++++------- .../__snapshots__/PWADevServer.spec.js.snap | 3 + .../reference/pwa-dev-server/index.md | 176 ++++++++++--- 4 files changed, 380 insertions(+), 196 deletions(-) create mode 100644 packages/pwa-buildpack/src/WebpackTools/__tests__/__snapshots__/PWADevServer.spec.js.snap diff --git a/packages/pwa-buildpack/src/WebpackTools/PWADevServer.js b/packages/pwa-buildpack/src/WebpackTools/PWADevServer.js index 34ceb016b1..72339c431b 100644 --- a/packages/pwa-buildpack/src/WebpackTools/PWADevServer.js +++ b/packages/pwa-buildpack/src/WebpackTools/PWADevServer.js @@ -1,5 +1,6 @@ const debug = require('../util/debug').makeFileLogger(__filename); const { join } = require('path'); +const { createHash } = require('crypto'); const url = require('url'); const express = require('express'); const GlobalConfig = require('../util/global-config'); @@ -14,18 +15,15 @@ const { lookup } = require('../util/promisified/dns'); const { find: findPort } = require('../util/promisified/openport'); const runAsRoot = require('../util/run-as-root'); const PWADevServer = { + DEFAULT_NAME: 'my-pwa', + DEV_DOMAIN: 'local.pwadev', validateConfig: optionsValidator('PWADevServer', { - id: 'string', publicPath: 'string', backendDomain: 'string', 'paths.output': 'string', 'paths.assets': 'string', serviceWorkerFileName: 'string' }), - hostnamesById: new GlobalConfig({ - prefix: 'devhostname-byid', - key: x => x - }), portsByHostname: new GlobalConfig({ prefix: 'devport-byhostname', key: x => x @@ -61,12 +59,12 @@ const PWADevServer = { } }, async findFreePort() { - const inUse = await PWADevServer.portsByHostname.values(Number); - debug(`findFreePort(): these ports already in use`, inUse); + const reserved = await PWADevServer.portsByHostname.values(Number); + debug(`findFreePort(): these ports already reserved`, reserved); return findPort({ startingPort: 8000, endingPort: 9999, - avoid: inUse + avoid: reserved }).catch(e => { throw Error( debug.errorMsg( @@ -75,77 +73,87 @@ const PWADevServer = { ); }); }, - async findFreeHostname(identifier, times = 0) { - const maybeHostname = - identifier + (times ? times : '') + '.local.pwadev'; - // if it has a port, it exists - const exists = await PWADevServer.portsByHostname.get(maybeHostname); - if (!exists) { - debug( - `findFreeHostname: ${maybeHostname} unbound to port and available` - ); - return maybeHostname; + getUniqueSubdomain(customName) { + let name = PWADevServer.DEFAULT_NAME; + if (typeof customName === 'string') { + name = customName; } else { - debug(`findFreeHostname: ${maybeHostname} bound to port`, exists); - if (times > 9) { - throw Error( + const pkgLoc = join(process.cwd(), 'package.json'); + try { + // eslint-disable-next-line node/no-missing-require + const pkg = require(pkgLoc); + if (!pkg.name || typeof pkg.name !== 'string') { + throw new Error( + `package.json does not have a usable "name" field!` + ); + } + name = pkg.name; + } catch (e) { + console.warn( debug.errorMsg( - `findFreeHostname: Unable to find a free hostname after 9 tries. You may want to delete your database file at ${GlobalConfig.getDbFilePath()} to clear out old developer hostname entries. (Soon we will make this easier and more automatic.)` - ) + `getUniqueSubdomain(): Using default "${name}" prefix. Could not autodetect theme name from package.json: ` + ), + e ); } - return PWADevServer.findFreeHostname(identifier, times + 1); } + const dirHash = createHash('md4'); + // Using a hash of the current directory is a natural way of preserving + // the same "unique" ID for each project, and changing it only when its + // location on disk has changed. + dirHash.update(process.cwd()); + const digest = dirHash.digest('base64'); + // Base64 truncated to 5 characters, stripped of special characters, + // and lowercased to be a valid domain, is about 36^5 unique values. + // There is therefore a chance of a duplicate ID and host collision, + // specifically a 1 in 60466176 chance. + return `${name}-${digest.slice(0, 5)}` + .toLowerCase() + .replace(/[^a-zA-Z0-9]/g, '-') + .replace(/^-+/, ''); + }, + async provideUniqueHost(prefix) { + debug(`provideUniqueHost ${prefix}`); + return PWADevServer.provideCustomHost( + PWADevServer.getUniqueSubdomain(prefix) + ); }, - async provideDevHost(id) { - debug(`provideDevHost('${id}')`); - let hostname = await PWADevServer.hostnamesById.get(id); - let port; - if (!hostname) { - [hostname, port] = await Promise.all([ - PWADevServer.findFreeHostname(id), - PWADevServer.findFreePort() - ]); + async provideCustomHost(subdomain) { + debug(`provideUniqueHost ${subdomain}`); + const hostname = subdomain + '.' + PWADevServer.DEV_DOMAIN; - await PWADevServer.hostnamesById.set(id, hostname); - await PWADevServer.portsByHostname.set(hostname, port); - } else { - port = await PWADevServer.portsByHostname.get(hostname); - if (!port) { - throw Error( - debug.errorMsg( - `Found no port matching the hostname ${hostname}` - ) - ); - } + const [usualPort, freePort] = await Promise.all([ + PWADevServer.portsByHostname.get(hostname), + PWADevServer.findFreePort() + ]); + const port = usualPort === freePort ? usualPort : freePort; + + if (!usualPort) { + PWADevServer.portsByHostname.set(hostname, port); + } else if (usualPort !== freePort) { + console.warn( + debug.errorMsg( + `This project's dev server normally runs at ${hostname}:${usualPort}, but port ${usualPort} is in use. The dev server will instead run at ${hostname}:${port}, which may cause a blank or unexpected cache and ServiceWorker. Consider fully clearing your browser cache.` + ) + ); } + PWADevServer.setLoopback(hostname); + return { protocol: 'https:', hostname, port }; }, - async configure(config = {}) { + async configure(config) { debug('configure() invoked', config); PWADevServer.validateConfig('.configure(config)', config); - const sanitizedId = config.id - .toLowerCase() - .replace(/[^a-zA-Z0-9]/g, '-') - .replace(/^-+/, ''); - const devHost = await PWADevServer.provideDevHost(sanitizedId); - const https = await SSLCertStore.provide(devHost.hostname); - debug(`https provided:`, https); - return { + const devServerConfig = { contentBase: false, compress: true, hot: true, - https, - host: devHost.hostname, - port: devHost.port, - publicPath: url.format( - Object.assign({}, devHost, { pathname: config.publicPath }) - ), + host: 'localhost', stats: { all: false, builtAt: true, @@ -163,7 +171,10 @@ const PWADevServer = { app.use( middlewares.originSubstitution( new url.URL(config.backendDomain), - devHost + { + hostname: devServerConfig.host, + port: devServerConfig.port + } ) ); } @@ -186,6 +197,33 @@ const PWADevServer = { ); } }; + let devHost; + if (config.id) { + devHost = await PWADevServer.provideCustomHost(config.id); + } else if (config.provideUniqueHost) { + devHost = await PWADevServer.provideUniqueHost( + config.provideUniqueHost + ); + } + if (devHost) { + devServerConfig.host = devHost.hostname; + devServerConfig.port = devHost.port; + } else { + devServerConfig.port = await PWADevServer.findFreePort(); + } + if (config.provideSSLCert) { + devServerConfig.https = await SSLCertStore.provide( + devServerConfig.host + ); + } + devServerConfig.publicPath = url.format({ + protocol: 'https:', + hostname: devServerConfig.host, + port: devServerConfig.port, + pathname: config.publicPath + }); + + return devServerConfig; } }; module.exports = PWADevServer; diff --git a/packages/pwa-buildpack/src/WebpackTools/__tests__/PWADevServer.spec.js b/packages/pwa-buildpack/src/WebpackTools/__tests__/PWADevServer.spec.js index 2054a142c9..fe1d4aa561 100644 --- a/packages/pwa-buildpack/src/WebpackTools/__tests__/PWADevServer.spec.js +++ b/packages/pwa-buildpack/src/WebpackTools/__tests__/PWADevServer.spec.js @@ -17,6 +17,10 @@ const middlewares = { OriginSubstitution: require('../middlewares/OriginSubstitution'), StaticRootRoute: require('../middlewares/StaticRootRoute') }; +// Mocking a variable path requires the `.doMock` +const pkgLocTest = process.cwd() + '/package.json'; +const pkg = jest.fn(); +jest.doMock(pkgLocTest, pkg, { virtual: true }); let PWADevServer; beforeAll(() => { @@ -40,14 +44,6 @@ const simulate = { lookup.mockRejectedValueOnce({ code: 'ENOTFOUND' }); return simulate; }, - hostnameForNextId(name) { - PWADevServer.hostnamesById.get.mockReturnValueOnce(name); - return simulate; - }, - noHostnameForNextId() { - PWADevServer.hostnamesById.get.mockReturnValueOnce(undefined); - return simulate; - }, noPortSavedForNextHostname() { PWADevServer.portsByHostname.get.mockReturnValueOnce(undefined); return simulate; @@ -66,6 +62,20 @@ const simulate = { }, certExistsForNextHostname(pair) { SSLCertStore.provide.mockResolvedValueOnce(pair); + }, + noPackageFound() { + jest.resetModuleRegistry(); + pkg.mockImplementationOnce(() => { + const error = new Error(process.cwd() + '/package.json not found'); + error.code = error.errno = 'ENOTFOUND'; + throw error; + }); + return simulate; + }, + packageNameIs(name) { + jest.resetModuleRegistry(); + pkg.mockImplementationOnce(() => ({ name })); + return simulate; } }; @@ -116,89 +126,95 @@ test('.findFreePort() passes formatted errors from port lookup', async () => { ); }); -test('.findFreeHostname() makes a new hostname for an identifier', async () => { - simulate.noPortSavedForNextHostname(); - const hostname = await PWADevServer.findFreeHostname('bar'); - expect(hostname).toBe('bar.local.pwadev'); +test('.getUniqueSubdomain() makes a new hostname for an identifier', async () => { + const hostname = await PWADevServer.getUniqueSubdomain('bar'); + expect(hostname).toMatch(/bar\-(\w){4,5}/); }); -test('.findFreeHostname() skips past taken hostnames for an identifier', async () => { - const hostname = await PWADevServer.findFreeHostname('foo'); - expect(hostname).toBe('foo.local.pwadev'); - - simulate - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .noPortSavedForNextHostname(); +test('.getUniqueSubdomain() makes a new hostname from the local package name', async () => { + simulate.packageNameIs('lorax'); - const hostname2 = await PWADevServer.findFreeHostname('foo'); - expect(hostname2).toBe('foo3.local.pwadev'); + const hostname = await PWADevServer.getUniqueSubdomain(); + expect(hostname).toMatch(/lorax\-(\w){4,5}/); }); -test('.findFreeHostname() bails after 9 failed attempts', async () => { - const hostname = await PWADevServer.findFreeHostname('foo'); - expect(hostname).toBe('foo.local.pwadev'); +test('.getUniqueSubdomain() logs a warning if it cannot determine a name', async () => { + jest.spyOn(console, 'warn').mockImplementation(); + simulate.packageNameIs(undefined); - simulate - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname() - .portSavedForNextHostname(); - - await expect(PWADevServer.findFreeHostname('foo')).rejects.toThrowError( - `Unable to find a free hostname after` + const hostname = await PWADevServer.getUniqueSubdomain(); + expect(hostname).toMatch(/my\-pwa\-(\w){4,5}/); + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching('Could not autodetect'), + expect.any(Error) + ); + expect(console.warn.mock.calls[0][1].message).toMatchSnapshot(); + + // and even if package cannot be found: + simulate.noPackageFound(); + await PWADevServer.getUniqueSubdomain(); + expect(console.warn).toHaveBeenLastCalledWith( + expect.stringMatching('Could not autodetect'), + expect.any(Error) ); + expect(console.warn.mock.calls[1][1].code).toBe('ENOTFOUND'); + console.warn.mockRestore(); }); -test('.provideDevHost() returns a URL object with a free dev host origin', async () => { +test('.provideUniqueHost() returns a URL object with a free dev host origin and stores a port', async () => { simulate - .noHostnameForNextId() .noPortSavedForNextHostname() .aFreePortWasFound(8765) .hostDoesNotResolve(); - await expect(PWADevServer.provideDevHost('woah')).resolves.toMatchObject({ - protocol: 'https:', - hostname: 'woah.local.pwadev', - port: 8765 - }); + const { protocol, hostname, port } = await PWADevServer.provideUniqueHost( + 'woah' + ); + + expect(protocol).toBe('https:'); + expect(hostname).toMatch(/woah\-(\w){4,5}\.local\.pwadev/); + expect(port).toBe(8765); + + expect(PWADevServer.portsByHostname.get).toHaveBeenCalledWith(hostname); + expect(PWADevServer.portsByHostname.set).toHaveBeenCalledWith( + hostname, + port + ); }); -test('.provideDevHost() returns a URL object with a cached dev host origin', async () => { +test('.provideUniqueHost() returns a cached port for the hostname', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); simulate - .hostnameForNextId('cached-host.local.pwadev') - .portSavedForNextHostname(8765) + .portSavedForNextHostname(8000) + .aFreePortWasFound(8776) .hostResolvesLoopback(); - await expect(PWADevServer.provideDevHost('wat')).resolves.toMatchObject({ - protocol: 'https:', - hostname: 'cached-host.local.pwadev', - port: 8765 - }); + const { port } = await PWADevServer.provideUniqueHost('woah'); + + expect(port).toBe(8776); + expect(console.warn).toHaveBeenCalledWith( + expect.stringMatching( + 'port 8000 is in use. The dev server will instead run' + ) + ); + warn.mockRestore(); }); -test('.provideDevHost() throws if it got a reserved hostname but could not find a port for that hostname', async () => { +test('.provideUniqueHost() warns about reserved port conflict', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); simulate - .hostnameForNextId('doomed-host.local.pwadev') - .noPortSavedForNextHostname(); + .portSavedForNextHostname(8888) + .aFreePortWasFound(8889) + .hostResolvesLoopback(); - await expect(PWADevServer.provideDevHost('dang')).rejects.toThrow( - 'Found no port matching the hostname' - ); + const { port } = await PWADevServer.provideUniqueHost('woah'); + + expect(port).toBe(8889); + + warn.mockRestore(); }); test('.configure() throws errors on missing config', async () => { - await expect(PWADevServer.configure()).rejects.toThrow( - 'id must be of type string' - ); await expect(PWADevServer.configure({ id: 'foo' })).rejects.toThrow( 'publicPath must be of type string' ); @@ -231,24 +247,24 @@ test('.configure() throws errors on missing config', async () => { ).rejects.toThrow('serviceWorkerFileName must be of type string'); }); -test('.configure() gets or creates an SSL cert', async () => { +test('.configure() gets or creates an SSL cert if `provideSSLCert: true`', async () => { simulate - .hostnameForNextId('coolnewhost.local.pwadev') .portSavedForNextHostname(8765) + .aFreePortWasFound(8765) .hostResolvesLoopback() .certExistsForNextHostname({ key: 'fakeKey', cert: 'fakeCert' }); const server = await PWADevServer.configure({ - id: 'heckin', paths: { output: 'good', assets: 'boye' }, publicPath: 'bork', serviceWorkerFileName: 'doin', - backendDomain: 'growe' + backendDomain: 'growe', + provideSSLCert: true }); expect(SSLCertStore.provide).toHaveBeenCalled(); expect(server.https).toHaveProperty('cert', 'fakeCert'); @@ -256,8 +272,8 @@ test('.configure() gets or creates an SSL cert', async () => { test('.configure() returns a configuration object for the `devServer` property of a webpack config', async () => { simulate - .hostnameForNextId('coolnewhost.local.pwadev') .portSavedForNextHostname(8765) + .aFreePortWasFound(8765) .hostResolvesLoopback() .certExistsForNextHostname({ key: 'fakeKey2', @@ -265,7 +281,8 @@ test('.configure() returns a configuration object for the `devServer` property o }); const config = { - id: 'Theme_Unique_Id', + provideUniqueHost: 'horton', + provideSSLCert: true, paths: { output: 'path/to/static', assets: 'path/to/assets' @@ -285,27 +302,64 @@ test('.configure() returns a configuration object for the `devServer` property o key: 'fakeKey2', cert: 'fakeCert2' }, - host: 'coolnewhost.local.pwadev', - port: 8765, - publicPath: - 'https://coolnewhost.local.pwadev:8765/full/path/to/publicPath', - before: expect.any(Function), - after: expect.any(Function) + host: expect.stringMatching(/horton\-(\w){4,5}\.local\.pwadev/), + port: 8765 }); }); -test('.configure() returns a configuration object with before() and after() handlers that add middlewares in order', async () => { +test('.configure() is backwards compatible with `id` param', async () => { simulate - .hostnameForNextId('coolnewhost.local.pwadev') .portSavedForNextHostname(8765) - .hostResolvesLoopback() - .certExistsForNextHostname({ - key: 'fakeKey2', - cert: 'fakeCert2' - }); + .aFreePortWasFound(8765) + .hostResolvesLoopback(); + + const config = { + id: 'samiam', + paths: { + output: 'path/to/static', + assets: 'path/to/assets' + }, + publicPath: 'full/path/to/publicPath', + serviceWorkerFileName: 'swname.js', + backendDomain: 'https://magento.backend.domain' + }; + + const devServer = await PWADevServer.configure(config); + + expect(devServer).toMatchObject({ + host: 'samiam.local.pwadev' + }); +}); + +test('.configure() `id` param overrides `provideUniqueHost` param', async () => { + simulate + .portSavedForNextHostname(8765) + .aFreePortWasFound(8765) + .hostResolvesLoopback(); + + const config = { + id: 'samiam', + provideUniqueHost: 'samiam', + paths: { + output: 'path/to/static', + assets: 'path/to/assets' + }, + publicPath: 'full/path/to/publicPath', + serviceWorkerFileName: 'swname.js', + backendDomain: 'https://magento.backend.domain' + }; + + const devServer = await PWADevServer.configure(config); + + expect(devServer).toMatchObject({ + host: 'samiam.local.pwadev' + }); +}); + +test('.configure() returns a configuration object with before() and after() handlers that add middlewares in order', async () => { + simulate.aFreePortWasFound(); const config = { - id: 'Theme_Unique_Id', paths: { output: 'path/to/static', assets: 'path/to/assets' @@ -352,17 +406,9 @@ test('.configure() returns a configuration object with before() and after() hand }); test('.configure() optionally adds OriginSubstitution middleware', async () => { - simulate - .hostnameForNextId('coolnewhost.local.pwadev') - .portSavedForNextHostname(8765) - .hostResolvesLoopback() - .certExistsForNextHostname({ - key: 'fakeKey2', - cert: 'fakeCert2' - }); + simulate.aFreePortWasFound(8002); const config = { - id: 'Theme_Unique_Id', paths: { output: 'path/to/static', assets: 'path/to/assets' @@ -393,9 +439,8 @@ test('.configure() optionally adds OriginSubstitution middleware', async () => { hostname: 'magento.backend.domain' }), expect.objectContaining({ - protocol: 'https:', - hostname: 'coolnewhost.local.pwadev', - port: 8765 + hostname: 'localhost', + port: 8002 }) ); diff --git a/packages/pwa-buildpack/src/WebpackTools/__tests__/__snapshots__/PWADevServer.spec.js.snap b/packages/pwa-buildpack/src/WebpackTools/__tests__/__snapshots__/PWADevServer.spec.js.snap new file mode 100644 index 0000000000..b7bcf31a47 --- /dev/null +++ b/packages/pwa-buildpack/src/WebpackTools/__tests__/__snapshots__/PWADevServer.spec.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`.getUniqueSubdomain() logs a warning if it cannot determine a name 1`] = `"package.json does not have a usable \\"name\\" field!"`; diff --git a/packages/pwa-devdocs/src/pwa-buildpack/reference/pwa-dev-server/index.md b/packages/pwa-devdocs/src/pwa-buildpack/reference/pwa-dev-server/index.md index b09d2ec4ce..7741c47a09 100644 --- a/packages/pwa-devdocs/src/pwa-buildpack/reference/pwa-dev-server/index.md +++ b/packages/pwa-devdocs/src/pwa-buildpack/reference/pwa-dev-server/index.md @@ -6,25 +6,68 @@ A utility for configuring a development OS and a `webpack-dev-server` for PWA de A typical webpack local development environment uses the [`devServer`] settings in `webpack.config.js` to create a temporary, local HTTP server to show edits in real time. -PWA development requires the following: - -* A *secure* and *trusted* host for ServiceWorker operations -* A *unique* host to prevent ServiceWorker collisions -* A customize-able way to proxy backend request to a Magento 2 backing store - -PWADevServer performs the following during setup: - -* Creates and caches a custom local hostname for the current theme -* Adds the custom local hostname to `/etc/hosts` - *(requires elevated permissions, so you may be asked for a password)* -* Creates and caches an SSL certificate for the custom local hostname -* Adds the certificate to the OS-level keychain for browser trust - *(requires elevated permissions, so you may be asked for a password)* -* Customizes the `webpack-dev-server` instance to: - * Proxy all asset requests not managed by webpack to the Magento store - * Emulate the public path settings of the Magento store - * Automatically switch domain names in HTML attributes - * Debug or disable ServiceWorkers +## Basic Features + +PWADevServer creates an optimized `devServer` for Magento API-backed PWA development. + +The `devServer` provides the following useful features: + +### Hot reload + +The hot reload feature refreshes the page or a relevant subsection of the page +whenever you save a change that affects it. It uses Webpack's +[Hot Module Replacement](https://webpack.js.org/concepts/hot-module-replacement/) +feature to replace components and stylesheets inline. + +### Proxy server + +The `devServer` acts as a proxy server for API and media requests to Magento. It +is configured using environment variables. + +The `MAGENTO_BACKEND_DOMAIN` environment variable configures the proxy server +to accept GraphQL, REST, and media requests and passes them to Magento. + +The `MAGENTO_BACKEND_PUBLIC_PATH` environment variable allows the proxy server +to serve static resources and JavaScript files at the same URL where Magento +would serve them. + +The proxy server also transforms host and referral headers to make them +compatible with Magento settings. + +### Root level ServiceWorker + +The `devServer` serves a JavaScript file at the root path that registers a +ServiceWorker scoped to the whole website. It can also disable that +ServiceWorker when caching would interfere with realtime changes. + +## Optional Features + +The following `devServer` features are optional and are available on the +initial run and confirmed on subsequent runs. They are configured in the +`webpack.config.js` file. + +### Custom hostname + +The custom hostname feature creates a local hostname for the current project. +An entry in the hostfile is added to resolve the hostname to the local +machine. + +_**Note:** Modifying the hostfile requires elevated permissions, so you may +be prompted for a password during the setup process._ + +### SSL certificate configuration + +The `devServer` can be configured to create and cache a 'self-signed' SSL +certificate that allow the use of HTTPS-only features during development. + +_**Note:** Updating the OS security settings to trust the self-signed +certificate requires elevated permissions, so you may be prompted for a +password during the setup process._ + +### Content transformation + +The content transformation feature masks the Magento 2 domain name in all HTML +attributes, replacing it with the development server domain name. ## API @@ -38,12 +81,14 @@ The `PWADevServerOptions` object contains the following properties: | Property: Type | Description | | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `id: string` | **Required.** A [unique ID] for this project. | | `publicPath: string` | **Required.** The public path to the theme assets in the backend server. | | `backendDomain: string` | **Required.** The URL of the backend store. | | `paths:`[`LocalProjectLocation`] | **Required.** Describes the location of the public static assets directory and where to deploy JavaScript files. | | `serviceWorkerFileName: string` | **Required.** The name of the ServiceWorker file this theme creates, such as `sw.js`. | -| `changeOrigin: boolean` | **Experimental.** Toggles the [change origin feature]. Defaults to `false`. | +| `provideSSLCert: boolean` | **Optional.** Toggles the [create SSL certificate] feature. Set `true` to create an SSL certificate for the dev server *and* to configure the OS and browser to trust the certificate if possible. +| `provideUniqueHost: string|boolean` | **Optional.** Toggles the [create custom hostname] feature. Set `true` to create a unique hostname using the `package.json` `"name"` field. Or, set a custom string, e.g. `"my-special-pwa"`, to override the package name. +| `id: string` | **Optional.** Toggles and customizes the [create custom hostname] feature. Create a custom hostname exactly from the ID string, without adding a hash to ensure uniqueness. Overrides `provideUniqueHost`. +| `changeOrigin: boolean` | **Experimental.** Toggles the [change origins in HTML] feature. Defaults to `false`. | {:style="table-layout:auto"} **Return:** @@ -54,7 +99,6 @@ A [Promise] configuration type for webpack. **Note:** `PWADevServer.configure()` is asynchronous. - ## Example In `webpack.config.js`: @@ -76,7 +120,8 @@ module.exports = async env => { output: path.resolve(__dirname, 'web/js'), assets: path.resolve(__dirname, 'web') }, - id: 'magento-venia' + provideUniqueHost: 'magento-venia', + provideSSLCert: true }) }; @@ -88,27 +133,80 @@ module.exports = async env => { {: .bs-callout .bs-callout-info} **Note:** -The example provided uses the newer, cleaner `async/await` syntax instead of using Promises directly +The example provided uses the newer, cleaner `async/await` syntax instead of using Promises directly. {: .bs-callout .bs-callout-info} **Note:** The emitted `devServer` object may have a custom `publicPath`. To get the best performance from the ServiceWorker, set `config.output.publicpath` to the `publicPath` value once the `devServer` is created but before creating a ServiceWorker plugin. - -## Choosing an ID - -The `PWADevServerOptions.id` property is used to create the dev domain name. -We recommend using the theme name for this value, but you can use any domain-name-safe string. - -If you are developing several copies of a theme simultaneously, use this ID to distinguish them in the internal tooling. - -## Change origin feature - -The `PWADevServerOptions.changeOrigin` property toggles an experimental feature that tries to parse HTML responses from the proxied Magento backend and replaces its domain name with the dev server domain name. - -[change origin feature]: #change-origin-feature -[unique ID]: #choosing-an-id +## Creating an SSL Certificate + +PWA features like ServiceWorkers and Push Notifications are only available on +HTTPS secure domains (though some browsers make exceptions for the domain +`localhost`. HTTPS development is becoming the norm, but creating a +self-signed certificate and configuring your server and browser for it can +still be a complex process. The `PWADevServerOptions.provideSSLCert` +configuration flag tells PWADevServer to look for a cached SSL certificate, +or create one for the dev server to use. + +It also attempts to use OS-level security settings to "trust" this certificate, +so you don't have to manually override when the browser tells you the +certificate authority is unknown. Browsers will soon start requiring trust as +well as SSL itself to enable some features. + +**PWADevServer uses OpenSSL to generate these certificates; your operating +system must have an `openssl` command of version 1.0 or above to use this +feature.** + +This feature also requires administrative access, so it may prompt you for +an administrative password at the command line. It does not permanently +elevate permissions for the dev process; instead, it launches a privileged +subprocess to execute one command. + +## Creating a custom hostname + +PWA features like ServiceWorkers use the concept of a 'scope' to separate +installed ServiceWorkers from each other. A scope is a combination of a domain +name, port, and path. If you use `localhost` for development multiple PWAs, +you run the risk of their Service Workers overriding or colliding with each +other. + +One solution to this is to create a custom local hostname for each project. +The `PWADevServerOptions.provideUniqueHost` and `PWADevServerOptions.id` +configuration flags tell PWADevServer to create and route a hostname on first +run, and verify it on subsequent runs. + +Set `provideUniqueHost: true` for the simplest configuration. This option +detects the project name by looking up the `name` property in `package.json`, +and combines it with a short hash string derived from the project directory. +This ensures a consistent domain while working in the same directory, and also +automatically different URLs for projects at different paths. For example, the +`name` field in Venia is `theme-frontend-venia`, so an autogenerated unique +host might look like `https://theme-frontend-venia-a6g2k.local.pwadev`. + +Set `provideUniqueHost` to a string value to partly customize this behavior. +This option uses the provided string instead of looking up the project name in +`package.json`. For example, `provideUniqueHost: "kookaburra"` might produce a +hostname like `https://kookaburra-na87h.local.pwadev`. + +Set `id` to a string value to create a custom domain name, but override both the +automated name lookup and the hashing behavior to ensure uniqueness. +For example, `id: "my-special-pwa"` would produce the hostname +`https://my-special-pwa.local.pwadev`, whereas +`provideUniqueHost: "my-special-pwa"` might produce the hostname +`https://my-special-pwa-c712jb.local.pwadev`. *To ensure no collision of Service +Workers, the `provideUniqueHost` option is recommended.* + +## Change Origins In HTML URLs + +The `PWADevServerOptions.changeOrigin` property toggles an experimental feature +that tries to parse HTML responses from the proxied Magento backend and replaces +its domain name with the dev server domain name. + +[create SSL certificate]: #creating-an-ssl-certificate +[create custom hostname]: #creating-a-custom-hostname +[change origins in HTML]: #change-origins-in-html-urls [`devServer`]: https://webpack.js.org/configuration/dev-server/ [Promise]: https://webpack.js.org/configuration/configuration-types/#exporting-a-promise -[`LocalProjectLocation`]: {{ site.baseurl }}{%link pwa-buildpack/reference/object-types/index.md %}#localprojectlocation \ No newline at end of file +[`LocalProjectLocation`]: {{ site.baseurl }}{%link pwa-buildpack/reference/object-types/index.md %}#localprojectlocation