Skip to content

Commit

Permalink
Add live reload delay (#998)
Browse files Browse the repository at this point in the history
* Add live reload delay

* Address PR feedback

Co-authored-by: Jared Ramirez <[email protected]>
Co-authored-by: Fred K. Schott <[email protected]>
  • Loading branch information
3 people authored Sep 16, 2020
1 parent 9b7ad5c commit 11c0eb5
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 49 deletions.
82 changes: 51 additions & 31 deletions snowpack/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
Expand Down Expand Up @@ -216,7 +216,8 @@ export async function command(commandOptions: CommandOptions) {

const messageBus = new EventEmitter();

// note: this would cause an infinite loop if not for the logger.on(…) in `paint.ts`.
// note: this would cause an infinite loop if not for the logger.on(…) in
// `paint.ts`.
console.log = (...args: [any, ...any[]]) => {
logger.info(util.format(...args));
};
Expand Down Expand Up @@ -460,9 +461,10 @@ export async function command(commandOptions: CommandOptions) {
}

/**
* Given a file, build it. Building a file sends it through our internal file builder
* pipeline, and outputs a build map representing the final build. A Build Map is used
* because one source file can result in multiple built files (Example: .svelte -> .js & .css).
* Given a file, build it. Building a file sends it through our internal
* file builder pipeline, and outputs a build map representing the final
* build. A Build Map is used because one source file can result in multiple
* built files (Example: .svelte -> .js & .css).
*/
async function buildFile(fileLoc: string): Promise<SnowpackBuildMap> {
const existingBuilderPromise = filesBeingBuilt.get(fileLoc);
Expand Down Expand Up @@ -491,17 +493,22 @@ export async function command(commandOptions: CommandOptions) {
}

/**
* Wrap Response: The same build result can be expressed in different ways based on
* the URL. For example, "App.css" should return CSS but "App.css.proxy.js" should
* return a JS representation of that CSS. This is handled in the wrap step.
* Wrap Response: The same build result can be expressed in different ways
* based on the URL. For example, "App.css" should return CSS but
* "App.css.proxy.js" should return a JS representation of that CSS. This is
* handled in the wrap step.
*/
async function wrapResponse(
code: string | Buffer,
{
hasCssResource,
sourceMap,
sourceMappingURL,
}: {hasCssResource: boolean; sourceMap?: string; sourceMappingURL: string},
}: {
hasCssResource: boolean;
sourceMap?: string;
sourceMappingURL: string;
},
) {
// transform special requests
if (isRoute) {
Expand Down Expand Up @@ -548,8 +555,8 @@ export async function command(commandOptions: CommandOptions) {
}

/**
* Resolve Imports: Resolved imports are based on the state of the file system, so
* they can't be cached long-term with the build.
* Resolve Imports: Resolved imports are based on the state of the file
* system, so they can't be cached long-term with the build.
*/
async function resolveResponseImports(
fileLoc: string,
Expand Down Expand Up @@ -627,8 +634,9 @@ If Snowpack is having trouble detecting the import, add ${colors.bold(
}

/**
* Given a build, finalize it for the response. This involves running individual steps
* needed to go from build result to sever response, including:
* Given a build, finalize it for the response. This involves running
* individual steps needed to go from build result to sever response,
* including:
* - wrapResponse(): Wrap responses
* - resolveResponseImports(): Resolve all ESM imports
*/
Expand Down Expand Up @@ -681,32 +689,40 @@ If Snowpack is having trouble detecting the import, add ${colors.bold(
sendFile(req, res, responseContent, fileLoc, responseFileExt);
return;
}

// 2. Load the file from disk. We'll need it to check the cold cache or build from scratch.
const fileContents = await readFile(fileLoc);

// 3. Send dependencies directly, since they were already build & resolved at install time.
// 3. Send dependencies directly, since they were already build & resolved
// at install time.
if (reqPath.startsWith(config.buildOptions.webModulesUrl) && !isProxyModule) {
sendFile(req, res, fileContents, fileLoc, responseFileExt);
return;
}

// 4. Check the persistent cache. If found, serve it via a "trust-but-verify" strategy.
// Build it after sending, and if it no longer matches then assume the entire cache is suspect.
// In that case, clear the persistent cache and then force a live-reload of the page.
// 4. Check the persistent cache. If found, serve it via a
// "trust-but-verify" strategy. Build it after sending, and if it no longer
// matches then assume the entire cache is suspect. In that case, clear the
// persistent cache and then force a live-reload of the page.
const cachedBuildData =
!filesBeingDeleted.has(fileLoc) &&
(await cacache.get(BUILD_CACHE, fileLoc).catch(() => null));
if (cachedBuildData) {
const {originalFileHash} = cachedBuildData.metadata;
const newFileHash = etag(fileContents);
if (originalFileHash === newFileHash) {
// IF THIS FAILS TS CHECK: If you are changing the structure of SnowpackBuildMap, be sure to
// also update `BUILD_CACHE` in util.ts to a new unique name, to guarantee a clean cache for
// our users.
// IF THIS FAILS TS CHECK: If you are changing the structure of
// SnowpackBuildMap, be sure to also update `BUILD_CACHE` in util.ts to
// a new unique name, to guarantee a clean cache for our users.
const coldCachedResponse: SnowpackBuildMap = JSON.parse(
cachedBuildData.data.toString(),
) as Record<string, {code: string; map?: string}>;
) as Record<
string,
{
code: string;
map?: string;
}
>;
inMemoryBuildCache.set(fileLoc, coldCachedResponse);
// Trust...
const wrappedResponse = await finalizeResponse(
Expand Down Expand Up @@ -812,7 +828,8 @@ ${err}`);
})
.listen(port);

const hmrEngine = new EsmHmrEngine({server});
const {hmrDelay} = config.devOptions;
const hmrEngine = new EsmHmrEngine({server, delay: hmrDelay});
onProcessExit(() => {
hmrEngine.disconnectAllClients();
});
Expand Down Expand Up @@ -850,14 +867,16 @@ ${err}`);
return;
}

// Append ".proxy.js" to Non-JS files to match their registered URL in the client app.
// Append ".proxy.js" to Non-JS files to match their registered URL in the
// client app.
if (!updateUrl.endsWith('.js')) {
updateUrl += '.proxy.js';
}
// Check if a virtual file exists in the resource cache (ex: CSS from a Svelte file)
// If it does, mark it for HMR replacement but DONT trigger a separate HMR update event.
// This is because a virtual resource doesn't actually exist on disk, so we need the main
// resource (the JS) to load first. Only after that happens will the CSS exist.
// Check if a virtual file exists in the resource cache (ex: CSS from a
// Svelte file) If it does, mark it for HMR replacement but DONT trigger a
// separate HMR update event. This is because a virtual resource doesn't
// actually exist on disk, so we need the main resource (the JS) to load
// first. Only after that happens will the CSS exist.
const virtualCssFileUrl = updateUrl.replace(/.js$/, '.css');
const virtualNode = hmrEngine.getEntry(`${virtualCssFileUrl}.proxy.js`);
if (virtualNode) {
Expand All @@ -869,8 +888,9 @@ ${err}`);
return;
}

// Otherwise, reload the page if the file exists in our hot cache (which means that the
// file likely exists on the current page, but is not supported by HMR (HTML, image, etc)).
// Otherwise, reload the page if the file exists in our hot cache (which
// means that the file likely exists on the current page, but is not
// supported by HMR (HTML, image, etc)).
if (inMemoryBuildCache.has(fileLoc)) {
hmrEngine.broadcastMessage({type: 'reload'});
return;
Expand Down
24 changes: 13 additions & 11 deletions snowpack/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,21 @@ import http from 'http';
import {validate, ValidatorResult} from 'jsonschema';
import path from 'path';
import yargs from 'yargs-parser';
import {logger} from './logger';

import srcFileExtensionMapping from './build/src-file-extension-mapping';
import {logger} from './logger';
import {esbuildPlugin} from './plugins/plugin-esbuild';
import {
CLIFlags,
DeepPartial,
LegacySnowpackPlugin,
PluginLoadOptions,
PluginLoadResult,
PluginOptimizeOptions,
Proxy,
ProxyOptions,
SnowpackConfig,
SnowpackPlugin,
LegacySnowpackPlugin,
PluginLoadResult,
} from './types/snowpack';
import {
addLeadingSlash,
Expand Down Expand Up @@ -57,6 +58,7 @@ const DEFAULT_CONFIG: Partial<SnowpackConfig> = {
open: 'default',
out: 'build',
fallback: 'index.html',
hmrDelay: 0,
},
buildOptions: {
baseUrl: '/',
Expand Down Expand Up @@ -231,10 +233,7 @@ function parseScript(script: string): {scriptType: string; input: string[]; outp
/** load and normalize plugins from config */
function loadPlugins(
config: SnowpackConfig,
): {
plugins: SnowpackPlugin[];
extensionMap: Record<string, string>;
} {
): {plugins: SnowpackPlugin[]; extensionMap: Record<string, string>} {
const plugins: SnowpackPlugin[] = [];

function execPluginFactory(pluginFactory: any, pluginOptions?: any): SnowpackPlugin {
Expand All @@ -246,7 +245,8 @@ function loadPlugins(
function loadPluginFromScript(specifier: string): SnowpackPlugin | undefined {
try {
const pluginLoc = require.resolve(specifier, {paths: [process.cwd()]});
return execPluginFactory(require(pluginLoc)); // no plugin options to load because we’re loading from a string
return execPluginFactory(require(pluginLoc)); // no plugin options to load because we’re
// loading from a string
} catch (err) {
// ignore
}
Expand Down Expand Up @@ -291,16 +291,18 @@ function loadPlugins(
return result.result;
};
}
// Legacy support: Map the new optimize() interface to the old bundle() interface
// Legacy support: Map the new optimize() interface to the old bundle()
// interface
if (bundle) {
plugin.optimize = async (options: PluginOptimizeOptions) => {
return bundle({
srcDirectory: options.buildDirectory,
destDirectory: options.buildDirectory,
// @ts-ignore internal API only
log: options.log,
// It turns out, this was more or less broken (included all files, not just JS).
// Confirmed no plugins are using this now, so safe to use an empty array.
// It turns out, this was more or less broken (included all
// files, not just JS). Confirmed no plugins are using this
// now, so safe to use an empty array.
jsFilePaths: [],
}).catch((err) => {
logger.error(
Expand Down
58 changes: 55 additions & 3 deletions snowpack/src/hmr-server-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ interface Dependency {
needsReplacementCount: number;
}

type HMRMessage = {type: 'reload'} | {type: 'update', url: string};
const DEFAULT_PORT = 12321;

export class EsmHmrEngine {
clients: Set<WebSocket> = new Set();
dependencyTree = new Map<string, Dependency>();

private delay: number = 0;
private currentBatch: HMRMessage[] = [];
private currentBatchTimeout: NodeJS.Timer | null = null;
wsUrl = `ws://localhost:${DEFAULT_PORT}`;

constructor(options: {server?: http.Server | http2.Http2Server} = {}) {
constructor(
options: {server?: http.Server | http2.Http2Server; delay?: number} = {},
) {
const wss = options.server
? new WebSocket.Server({noServer: true})
: new WebSocket.Server({port: DEFAULT_PORT});
Expand All @@ -37,6 +44,9 @@ export class EsmHmrEngine {
this.connectClient(client);
this.registerListener(client);
});
if (options.delay) {
this.delay = options.delay;
}
}

registerListener(client: WebSocket) {
Expand Down Expand Up @@ -112,10 +122,52 @@ export class EsmHmrEngine {
entry.needsReplacement = !!entry.needsReplacementCount;
}

broadcastMessage(data: object) {
broadcastMessage(data: HMRMessage) {
if (this.delay > 0) {
if (this.currentBatchTimeout) {
clearTimeout(this.currentBatchTimeout);
}
this.currentBatch.push(data);
this.currentBatchTimeout = setTimeout(() => this.broadcastBatch(), this.delay);
} else {
this.dispatchMessage([data]);
}
}

broadcastBatch() {
if (this.currentBatchTimeout) {
clearTimeout(this.currentBatchTimeout);
}
if (this.currentBatch.length > 0) {
this.dispatchMessage(this.currentBatch);
this.currentBatch = [];
}
}

/**
* This is shared logic to dispatch messages to the clients. The public methods
* `broadcastMessage` and `broadcastBatch` manage the delay then use this,
* internally when it's time to actually send the data.
*/
private dispatchMessage(messageBatch: HMRMessage[]) {
if (messageBatch.length === 0) {
return;
}

let singleReloadMessage =
messageBatch.every(message => message.type === 'reload')
? messageBatch[0]
: null;

this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
if (singleReloadMessage) {
client.send(JSON.stringify(singleReloadMessage));
} else {
messageBatch.forEach((data) => {
client.send(JSON.stringify(data));
});
}
} else {
this.disconnectClient(client);
}
Expand Down
18 changes: 14 additions & 4 deletions snowpack/src/types/snowpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export type DeepPartial<T> = {

export type EnvVarReplacements = Record<string, string | number | true>;

export type SnowpackBuiltFile = {code: string | Buffer; map?: string};
export type SnowpackBuiltFile = {
code: string | Buffer;
map?: string;
};
export type SnowpackBuildMap = Record<string, SnowpackBuiltFile>;

/** Standard file interface */
Expand Down Expand Up @@ -58,9 +61,14 @@ export interface SnowpackPlugin {
name: string;
/** Tell Snowpack how the load() function will resolve files. */
resolve?: {
/** file extensions that this load function takes as input (e.g. [".jsx", ".js", …]) */
/**
file extensions that this load function takes as input (e.g. [".jsx",
".js", …])
*/
input: string[];
/** file extensions that this load function outputs (e.g. [".js", ".css"]) */
/**
file extensions that this load function outputs (e.g. [".js", ".css"])
*/
output: string[];
};
/** load a file that matches resolve.input */
Expand Down Expand Up @@ -121,6 +129,7 @@ export interface SnowpackConfig {
fallback: string;
open: string;
hmr?: boolean;
hmrDelay: number;
};
installOptions: {
dest: string;
Expand All @@ -132,7 +141,8 @@ export interface SnowpackConfig {
externalPackage: string[];
namedExports: string[];
rollup: {
plugins: RollupPlugin[]; // for simplicity, only Rollup plugins are supported for now
plugins: RollupPlugin[]; // for simplicity, only Rollup plugins are
// supported for now
dedupe?: string[];
};
};
Expand Down

1 comment on commit 11c0eb5

@vercel
Copy link

@vercel vercel bot commented on 11c0eb5 Sep 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.