Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add ability to serve service wor…
Browse files Browse the repository at this point in the history
…ker when using dev-server

With this change we add the ability for the dev-server to serve service workers when configured in the browser builder.

Closes #9869
  • Loading branch information
alan-agius4 authored and dgp1130 committed Aug 4, 2022
1 parent 3e3dc69 commit 44c2551
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 38 deletions.
Expand Up @@ -43,6 +43,7 @@ import {
getStylesConfig,
} from '../../webpack/configs';
import { IndexHtmlWebpackPlugin } from '../../webpack/plugins/index-html-webpack-plugin';
import { ServiceWorkerPlugin } from '../../webpack/plugins/service-worker-plugin';
import { createWebpackLoggingCallback } from '../../webpack/utils/stats';
import { Schema as BrowserBuilderSchema, OutputHashing } from '../browser/schema';
import { Schema } from './schema';
Expand Down Expand Up @@ -205,6 +206,8 @@ export function serveWebpackBrowser(
webpackConfig = await transforms.webpackConfiguration(webpackConfig);
}

webpackConfig.plugins ??= [];

if (browserOptions.index) {
const { scripts = [], styles = [], baseHref } = browserOptions;
const entrypoints = generateEntryPoints({
Expand All @@ -216,7 +219,6 @@ export function serveWebpackBrowser(
isHMREnabled: !!webpackConfig.devServer?.hot,
});

webpackConfig.plugins ??= [];
webpackConfig.plugins.push(
new IndexHtmlWebpackPlugin({
indexPath: path.resolve(workspaceRoot, getIndexInputFile(browserOptions.index)),
Expand All @@ -234,6 +236,18 @@ export function serveWebpackBrowser(
);
}

if (browserOptions.serviceWorker) {
webpackConfig.plugins.push(
new ServiceWorkerPlugin({
baseHref: browserOptions.baseHref,
root: context.workspaceRoot,
projectRoot,
outputPath: path.join(context.workspaceRoot, browserOptions.outputPath),
ngswConfigPath: browserOptions.ngswConfigPath,
}),
);
}

return {
browserOptions,
webpackConfig,
Expand Down
@@ -0,0 +1,171 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

// eslint-disable-next-line import/no-extraneous-dependencies
import fetch from 'node-fetch';
import { concatMap, count, take, timeout } from 'rxjs/operators';
import { serveWebpackBrowser } from '../../index';
import { executeOnceAndFetch } from '../execute-fetch';
import {
BASE_OPTIONS,
BUILD_TIMEOUT,
DEV_SERVER_BUILDER_INFO,
describeBuilder,
setupBrowserTarget,
} from '../setup';

describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
const manifest = {
index: '/index.html',
assetGroups: [
{
name: 'app',
installMode: 'prefetch',
resources: {
files: ['/favicon.ico', '/index.html'],
},
},
{
name: 'assets',
installMode: 'lazy',
updateMode: 'prefetch',
resources: {
files: ['/assets/**', '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)'],
},
},
],
};

describe('Behavior: "dev-server builder serves service worker"', () => {
it('works with service worker', async () => {
setupBrowserTarget(harness, {
serviceWorker: true,
assets: ['src/favicon.ico', 'src/assets'],
styles: ['src/styles.css'],
});

await harness.writeFiles({
'ngsw-config.json': JSON.stringify(manifest),
'src/assets/folder-asset.txt': 'folder-asset.txt',
'src/styles.css': `body { background: url(./spectrum.png); }`,
});

harness.useTarget('serve', {
...BASE_OPTIONS,
});

const { result, response } = await executeOnceAndFetch(harness, '/ngsw.json');

expect(result?.success).toBeTrue();

expect(await response?.json()).toEqual(
jasmine.objectContaining({
configVersion: 1,
index: '/index.html',
navigationUrls: [
{ positive: true, regex: '^\\/.*$' },
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$' },
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$' },
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$' },
],
assetGroups: [
{
name: 'app',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: ['/favicon.ico', '/index.html'],
cacheQueryOptions: {
ignoreVary: true,
},
patterns: [],
},
{
name: 'assets',
installMode: 'lazy',
updateMode: 'prefetch',
urls: ['/assets/folder-asset.txt', '/spectrum.png'],
cacheQueryOptions: {
ignoreVary: true,
},
patterns: [],
},
],
dataGroups: [],
hashTable: {
'/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
'/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
'/index.html': 'cb8ad8c81cd422699d6d831b6f25ad4481f2c90a',
'/spectrum.png': '8d048ece46c0f3af4b598a95fd8e4709b631c3c0',
},
}),
);
});

it('works in watch mode', async () => {
setupBrowserTarget(harness, {
serviceWorker: true,
watch: true,
assets: ['src/favicon.ico', 'src/assets'],
styles: ['src/styles.css'],
});

await harness.writeFiles({
'ngsw-config.json': JSON.stringify(manifest),
'src/assets/folder-asset.txt': 'folder-asset.txt',
'src/styles.css': `body { background: url(./spectrum.png); }`,
});

harness.useTarget('serve', {
...BASE_OPTIONS,
});

const buildCount = await harness
.execute()
.pipe(
timeout(BUILD_TIMEOUT),
concatMap(async ({ result }, index) => {
expect(result?.success).toBeTrue();
const response = await fetch(new URL('ngsw.json', `${result?.baseUrl}`));
const { hashTable } = await response.json();
const hashTableEntries = Object.keys(hashTable);

switch (index) {
case 0:
expect(hashTableEntries).toEqual([
'/assets/folder-asset.txt',
'/favicon.ico',
'/index.html',
'/spectrum.png',
]);

await harness.writeFile(
'src/assets/folder-new-asset.txt',
harness.readFile('src/assets/folder-asset.txt'),
);
break;

case 1:
expect(hashTableEntries).toEqual([
'/assets/folder-asset.txt',
'/assets/folder-new-asset.txt',
'/favicon.ico',
'/index.html',
'/spectrum.png',
]);
break;
}
}),
take(2),
count(),
)
.toPromise();

expect(buildCount).toBe(2);
});
});
});
77 changes: 40 additions & 37 deletions packages/angular_devkit/build_angular/src/utils/service-worker.ts
Expand Up @@ -8,34 +8,31 @@

import type { Config, Filesystem } from '@angular/service-worker/config';
import * as crypto from 'crypto';
import { createReadStream, promises as fs, constants as fsConstants } from 'fs';
import { constants as fsConstants, promises as fsPromises } from 'fs';
import * as path from 'path';
import { pipeline } from 'stream';
import { assertIsError } from './error';
import { loadEsmModule } from './load-esm';

class CliFilesystem implements Filesystem {
constructor(private base: string) {}
constructor(private fs: typeof fsPromises, private base: string) {}

list(dir: string): Promise<string[]> {
return this._recursiveList(this._resolve(dir), []);
}

read(file: string): Promise<string> {
return fs.readFile(this._resolve(file), 'utf-8');
return this.fs.readFile(this._resolve(file), 'utf-8');
}

hash(file: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1').setEncoding('hex');
pipeline(createReadStream(this._resolve(file)), hash, (error) =>
error ? reject(error) : resolve(hash.read()),
);
});
async hash(file: string): Promise<string> {
return crypto
.createHash('sha1')
.update(await this.fs.readFile(this._resolve(file)))
.digest('hex');
}

write(file: string, content: string): Promise<void> {
return fs.writeFile(this._resolve(file), content);
write(_file: string, _content: string): never {
throw new Error('This should never happen.');
}

private _resolve(file: string): string {
Expand All @@ -44,12 +41,15 @@ class CliFilesystem implements Filesystem {

private async _recursiveList(dir: string, items: string[]): Promise<string[]> {
const subdirectories = [];
for await (const entry of await fs.opendir(dir)) {
if (entry.isFile()) {
for (const entry of await this.fs.readdir(dir)) {
const entryPath = path.join(dir, entry);
const stats = await this.fs.stat(entryPath);

if (stats.isFile()) {
// Uses posix paths since the service worker expects URLs
items.push('/' + path.relative(this.base, path.join(dir, entry.name)).replace(/\\/g, '/'));
} else if (entry.isDirectory()) {
subdirectories.push(path.join(dir, entry.name));
items.push('/' + path.relative(this.base, entryPath).replace(/\\/g, '/'));
} else if (stats.isDirectory()) {
subdirectories.push(entryPath);
}
}

Expand All @@ -67,6 +67,8 @@ export async function augmentAppWithServiceWorker(
outputPath: string,
baseHref: string,
ngswConfigPath?: string,
inputputFileSystem = fsPromises,
outputFileSystem = fsPromises,
): Promise<void> {
// Determine the configuration file path
const configPath = ngswConfigPath
Expand All @@ -76,7 +78,7 @@ export async function augmentAppWithServiceWorker(
// Read the configuration file
let config: Config | undefined;
try {
const configurationData = await fs.readFile(configPath, 'utf-8');
const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8');
config = JSON.parse(configurationData) as Config;
} catch (error) {
assertIsError(error);
Expand All @@ -101,36 +103,37 @@ export async function augmentAppWithServiceWorker(
).Generator;

// Generate the manifest
const generator = new GeneratorConstructor(new CliFilesystem(outputPath), baseHref);
const generator = new GeneratorConstructor(
new CliFilesystem(outputFileSystem, outputPath),
baseHref,
);
const output = await generator.process(config);

// Write the manifest
const manifest = JSON.stringify(output, null, 2);
await fs.writeFile(path.join(outputPath, 'ngsw.json'), manifest);
await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), manifest);

// Find the service worker package
const workerPath = require.resolve('@angular/service-worker/ngsw-worker.js');

const copy = async (src: string, dest: string): Promise<void> => {
const resolvedDest = path.join(outputPath, dest);

return inputputFileSystem === outputFileSystem
? // Native FS (Builder).
inputputFileSystem.copyFile(workerPath, resolvedDest, fsConstants.COPYFILE_FICLONE)
: // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory).
outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src));
};

// Write the worker code
await fs.copyFile(
workerPath,
path.join(outputPath, 'ngsw-worker.js'),
fsConstants.COPYFILE_FICLONE,
);
await copy(workerPath, 'ngsw-worker.js');

// If present, write the safety worker code
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
try {
await fs.copyFile(
safetyPath,
path.join(outputPath, 'worker-basic.min.js'),
fsConstants.COPYFILE_FICLONE,
);
await fs.copyFile(
safetyPath,
path.join(outputPath, 'safety-worker.js'),
fsConstants.COPYFILE_FICLONE,
);
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
await copy(safetyPath, 'worker-basic.min.js');
await copy(safetyPath, 'safety-worker.js');
} catch (error) {
assertIsError(error);
if (error.code !== 'ENOENT') {
Expand Down
@@ -0,0 +1,40 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import type { Compiler } from 'webpack';
import { augmentAppWithServiceWorker } from '../../utils/service-worker';

export interface ServiceWorkerPluginOptions {
projectRoot: string;
root: string;
outputPath: string;
baseHref?: string;
ngswConfigPath?: string;
}

export class ServiceWorkerPlugin {
constructor(private readonly options: ServiceWorkerPluginOptions) {}

apply(compiler: Compiler) {
compiler.hooks.done.tapPromise('angular-service-worker', async (_compilation) => {
const { projectRoot, root, baseHref = '', ngswConfigPath, outputPath } = this.options;

await augmentAppWithServiceWorker(
projectRoot,
root,
outputPath,
baseHref,
ngswConfigPath,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(compiler.inputFileSystem as any).promises,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(compiler.outputFileSystem as any).promises,
);
});
}
}

0 comments on commit 44c2551

Please sign in to comment.