Skip to content

Commit

Permalink
feat(dev): Make PWADevServer host/SSL optional (#175)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
zetlen authored Sep 27, 2018
1 parent a6106a2 commit d30fe94
Show file tree
Hide file tree
Showing 4 changed files with 380 additions and 196 deletions.
158 changes: 98 additions & 60 deletions packages/pwa-buildpack/src/WebpackTools/PWADevServer.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -163,7 +171,10 @@ const PWADevServer = {
app.use(
middlewares.originSubstitution(
new url.URL(config.backendDomain),
devHost
{
hostname: devServerConfig.host,
port: devServerConfig.port
}
)
);
}
Expand All @@ -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;
Loading

0 comments on commit d30fe94

Please sign in to comment.