Skip to content

Commit bfbb034

Browse files
committed
feat(@angular-devkit/build-angular): add ability to serve service worker 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 angular#9869
1 parent 091ff40 commit bfbb034

File tree

4 files changed

+266
-38
lines changed

4 files changed

+266
-38
lines changed

packages/angular_devkit/build_angular/src/builders/dev-server/index.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
getStylesConfig,
4444
} from '../../webpack/configs';
4545
import { IndexHtmlWebpackPlugin } from '../../webpack/plugins/index-html-webpack-plugin';
46+
import { ServiceWorkerPlugin } from '../../webpack/plugins/service-worker-plugin';
4647
import { createWebpackLoggingCallback } from '../../webpack/utils/stats';
4748
import { Schema as BrowserBuilderSchema, OutputHashing } from '../browser/schema';
4849
import { Schema } from './schema';
@@ -205,6 +206,8 @@ export function serveWebpackBrowser(
205206
webpackConfig = await transforms.webpackConfiguration(webpackConfig);
206207
}
207208

209+
webpackConfig.plugins ??= [];
210+
208211
if (browserOptions.index) {
209212
const { scripts = [], styles = [], baseHref } = browserOptions;
210213
const entrypoints = generateEntryPoints({
@@ -216,7 +219,6 @@ export function serveWebpackBrowser(
216219
isHMREnabled: !!webpackConfig.devServer?.hot,
217220
});
218221

219-
webpackConfig.plugins ??= [];
220222
webpackConfig.plugins.push(
221223
new IndexHtmlWebpackPlugin({
222224
indexPath: path.resolve(workspaceRoot, getIndexInputFile(browserOptions.index)),
@@ -234,6 +236,18 @@ export function serveWebpackBrowser(
234236
);
235237
}
236238

239+
if (browserOptions.serviceWorker) {
240+
webpackConfig.plugins.push(
241+
new ServiceWorkerPlugin({
242+
baseHref: browserOptions.baseHref,
243+
root: context.workspaceRoot,
244+
projectRoot,
245+
outputPath: path.join(context.workspaceRoot, browserOptions.outputPath),
246+
ngswConfigPath: browserOptions.ngswConfigPath,
247+
}),
248+
);
249+
}
250+
237251
return {
238252
browserOptions,
239253
webpackConfig,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// eslint-disable-next-line import/no-extraneous-dependencies
10+
import fetch from 'node-fetch';
11+
import { concatMap, count, take, timeout } from 'rxjs/operators';
12+
import { serveWebpackBrowser } from '../../index';
13+
import { executeOnceAndFetch } from '../execute-fetch';
14+
import {
15+
BASE_OPTIONS,
16+
BUILD_TIMEOUT,
17+
DEV_SERVER_BUILDER_INFO,
18+
describeBuilder,
19+
setupBrowserTarget,
20+
} from '../setup';
21+
22+
describeBuilder(serveWebpackBrowser, DEV_SERVER_BUILDER_INFO, (harness) => {
23+
const manifest = {
24+
index: '/index.html',
25+
assetGroups: [
26+
{
27+
name: 'app',
28+
installMode: 'prefetch',
29+
resources: {
30+
files: ['/favicon.ico', '/index.html'],
31+
},
32+
},
33+
{
34+
name: 'assets',
35+
installMode: 'lazy',
36+
updateMode: 'prefetch',
37+
resources: {
38+
files: ['/assets/**', '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)'],
39+
},
40+
},
41+
],
42+
};
43+
44+
describe('Behavior: "dev-server builder serves service worker"', () => {
45+
it('works with service worker', async () => {
46+
setupBrowserTarget(harness, {
47+
serviceWorker: true,
48+
assets: ['src/favicon.ico', 'src/assets'],
49+
styles: ['src/styles.css'],
50+
});
51+
52+
await harness.writeFiles({
53+
'ngsw-config.json': JSON.stringify(manifest),
54+
'src/assets/folder-asset.txt': 'folder-asset.txt',
55+
'src/styles.css': `body { background: url(./spectrum.png); }`,
56+
});
57+
58+
harness.useTarget('serve', {
59+
...BASE_OPTIONS,
60+
});
61+
62+
const { result, response } = await executeOnceAndFetch(harness, '/ngsw.json');
63+
64+
expect(result?.success).toBeTrue();
65+
66+
expect(await response?.json()).toEqual(
67+
jasmine.objectContaining({
68+
configVersion: 1,
69+
index: '/index.html',
70+
navigationUrls: [
71+
{ positive: true, regex: '^\\/.*$' },
72+
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*\\.[^/]*$' },
73+
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*$' },
74+
{ positive: false, regex: '^\\/(?:.+\\/)?[^/]*__[^/]*\\/.*$' },
75+
],
76+
assetGroups: [
77+
{
78+
name: 'app',
79+
installMode: 'prefetch',
80+
updateMode: 'prefetch',
81+
urls: ['/favicon.ico', '/index.html'],
82+
cacheQueryOptions: {
83+
ignoreVary: true,
84+
},
85+
patterns: [],
86+
},
87+
{
88+
name: 'assets',
89+
installMode: 'lazy',
90+
updateMode: 'prefetch',
91+
urls: ['/assets/folder-asset.txt', '/spectrum.png'],
92+
cacheQueryOptions: {
93+
ignoreVary: true,
94+
},
95+
patterns: [],
96+
},
97+
],
98+
dataGroups: [],
99+
hashTable: {
100+
'/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
101+
'/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
102+
'/index.html': 'cb8ad8c81cd422699d6d831b6f25ad4481f2c90a',
103+
'/spectrum.png': '8d048ece46c0f3af4b598a95fd8e4709b631c3c0',
104+
},
105+
}),
106+
);
107+
});
108+
109+
it('works in watch mode', async () => {
110+
setupBrowserTarget(harness, {
111+
serviceWorker: true,
112+
watch: true,
113+
assets: ['src/favicon.ico', 'src/assets'],
114+
styles: ['src/styles.css'],
115+
});
116+
117+
await harness.writeFiles({
118+
'ngsw-config.json': JSON.stringify(manifest),
119+
'src/assets/folder-asset.txt': 'folder-asset.txt',
120+
'src/styles.css': `body { background: url(./spectrum.png); }`,
121+
});
122+
123+
harness.useTarget('serve', {
124+
...BASE_OPTIONS,
125+
});
126+
127+
const buildCount = await harness
128+
.execute()
129+
.pipe(
130+
timeout(BUILD_TIMEOUT),
131+
concatMap(async ({ result }, index) => {
132+
expect(result?.success).toBeTrue();
133+
const response = await fetch(new URL('ngsw.json', `${result?.baseUrl}`));
134+
const { hashTable } = await response.json();
135+
const hashTableEntries = Object.keys(hashTable);
136+
137+
switch (index) {
138+
case 0:
139+
expect(hashTableEntries).toEqual([
140+
'/assets/folder-asset.txt',
141+
'/favicon.ico',
142+
'/index.html',
143+
'/spectrum.png',
144+
]);
145+
146+
await harness.writeFile(
147+
'src/assets/folder-new-asset.txt',
148+
harness.readFile('src/assets/folder-asset.txt'),
149+
);
150+
break;
151+
152+
case 1:
153+
expect(hashTableEntries).toEqual([
154+
'/assets/folder-asset.txt',
155+
'/assets/folder-new-asset.txt',
156+
'/favicon.ico',
157+
'/index.html',
158+
'/spectrum.png',
159+
]);
160+
break;
161+
}
162+
}),
163+
take(2),
164+
count(),
165+
)
166+
.toPromise();
167+
168+
expect(buildCount).toBe(2);
169+
});
170+
});
171+
});

packages/angular_devkit/build_angular/src/utils/service-worker.ts

+40-37
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,31 @@
88

99
import type { Config, Filesystem } from '@angular/service-worker/config';
1010
import * as crypto from 'crypto';
11-
import { createReadStream, promises as fs, constants as fsConstants } from 'fs';
11+
import { constants as fsConstants, promises as fsPromises } from 'fs';
1212
import * as path from 'path';
13-
import { pipeline } from 'stream';
1413
import { assertIsError } from './error';
1514
import { loadEsmModule } from './load-esm';
1615

1716
class CliFilesystem implements Filesystem {
18-
constructor(private base: string) {}
17+
constructor(private fs: typeof fsPromises, private base: string) {}
1918

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

2423
read(file: string): Promise<string> {
25-
return fs.readFile(this._resolve(file), 'utf-8');
24+
return this.fs.readFile(this._resolve(file), 'utf-8');
2625
}
2726

28-
hash(file: string): Promise<string> {
29-
return new Promise((resolve, reject) => {
30-
const hash = crypto.createHash('sha1').setEncoding('hex');
31-
pipeline(createReadStream(this._resolve(file)), hash, (error) =>
32-
error ? reject(error) : resolve(hash.read()),
33-
);
34-
});
27+
async hash(file: string): Promise<string> {
28+
return crypto
29+
.createHash('sha1')
30+
.update(await this.fs.readFile(this._resolve(file)))
31+
.digest('hex');
3532
}
3633

37-
write(file: string, content: string): Promise<void> {
38-
return fs.writeFile(this._resolve(file), content);
34+
write(_file: string, _content: string): never {
35+
throw new Error('This should never happen.');
3936
}
4037

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

4542
private async _recursiveList(dir: string, items: string[]): Promise<string[]> {
4643
const subdirectories = [];
47-
for await (const entry of await fs.opendir(dir)) {
48-
if (entry.isFile()) {
44+
for (const entry of await this.fs.readdir(dir)) {
45+
const entryPath = path.join(dir, entry);
46+
const stats = await this.fs.stat(entryPath);
47+
48+
if (stats.isFile()) {
4949
// Uses posix paths since the service worker expects URLs
50-
items.push('/' + path.relative(this.base, path.join(dir, entry.name)).replace(/\\/g, '/'));
51-
} else if (entry.isDirectory()) {
52-
subdirectories.push(path.join(dir, entry.name));
50+
items.push('/' + path.relative(this.base, entryPath).replace(/\\/g, '/'));
51+
} else if (stats.isDirectory()) {
52+
subdirectories.push(entryPath);
5353
}
5454
}
5555

@@ -67,6 +67,8 @@ export async function augmentAppWithServiceWorker(
6767
outputPath: string,
6868
baseHref: string,
6969
ngswConfigPath?: string,
70+
inputputFileSystem = fsPromises,
71+
outputFileSystem = fsPromises,
7072
): Promise<void> {
7173
// Determine the configuration file path
7274
const configPath = ngswConfigPath
@@ -76,7 +78,7 @@ export async function augmentAppWithServiceWorker(
7678
// Read the configuration file
7779
let config: Config | undefined;
7880
try {
79-
const configurationData = await fs.readFile(configPath, 'utf-8');
81+
const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8');
8082
config = JSON.parse(configurationData) as Config;
8183
} catch (error) {
8284
assertIsError(error);
@@ -101,36 +103,37 @@ export async function augmentAppWithServiceWorker(
101103
).Generator;
102104

103105
// Generate the manifest
104-
const generator = new GeneratorConstructor(new CliFilesystem(outputPath), baseHref);
106+
const generator = new GeneratorConstructor(
107+
new CliFilesystem(outputFileSystem, outputPath),
108+
baseHref,
109+
);
105110
const output = await generator.process(config);
106111

107112
// Write the manifest
108113
const manifest = JSON.stringify(output, null, 2);
109-
await fs.writeFile(path.join(outputPath, 'ngsw.json'), manifest);
114+
await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), manifest);
110115

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

119+
const copy = async (src: string, dest: string): Promise<void> => {
120+
const resolvedDest = path.join(outputPath, dest);
121+
122+
return inputputFileSystem === outputFileSystem
123+
? // Native FS (Builder).
124+
inputputFileSystem.copyFile(workerPath, resolvedDest, fsConstants.COPYFILE_FICLONE)
125+
: // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory).
126+
outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src));
127+
};
128+
114129
// Write the worker code
115-
await fs.copyFile(
116-
workerPath,
117-
path.join(outputPath, 'ngsw-worker.js'),
118-
fsConstants.COPYFILE_FICLONE,
119-
);
130+
await copy(workerPath, 'ngsw-worker.js');
120131

121132
// If present, write the safety worker code
122-
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
123133
try {
124-
await fs.copyFile(
125-
safetyPath,
126-
path.join(outputPath, 'worker-basic.min.js'),
127-
fsConstants.COPYFILE_FICLONE,
128-
);
129-
await fs.copyFile(
130-
safetyPath,
131-
path.join(outputPath, 'safety-worker.js'),
132-
fsConstants.COPYFILE_FICLONE,
133-
);
134+
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
135+
await copy(safetyPath, 'worker-basic.min.js');
136+
await copy(safetyPath, 'safety-worker.js');
134137
} catch (error) {
135138
assertIsError(error);
136139
if (error.code !== 'ENOENT') {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import type { Compiler } from 'webpack';
10+
import { augmentAppWithServiceWorker } from '../../utils/service-worker';
11+
12+
export interface ServiceWorkerPluginOptions {
13+
projectRoot: string;
14+
root: string;
15+
outputPath: string;
16+
baseHref?: string;
17+
ngswConfigPath?: string;
18+
}
19+
20+
export class ServiceWorkerPlugin {
21+
constructor(private readonly options: ServiceWorkerPluginOptions) {}
22+
23+
apply(compiler: Compiler) {
24+
compiler.hooks.done.tapPromise('angular-service-worker', async (_compilation) => {
25+
const { projectRoot, root, baseHref = '', ngswConfigPath, outputPath } = this.options;
26+
27+
await augmentAppWithServiceWorker(
28+
projectRoot,
29+
root,
30+
outputPath,
31+
baseHref,
32+
ngswConfigPath,
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
(compiler.inputFileSystem as any).promises,
35+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
36+
(compiler.outputFileSystem as any).promises,
37+
);
38+
});
39+
}
40+
}

0 commit comments

Comments
 (0)