Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(@angular-devkit/build-angular): add ability to serve service worker when using dev-server #23679

Merged
merged 1 commit into from
Aug 4, 2022
Merged
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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,
);
});
}
}