Skip to content

Commit beaf872

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 #9869
1 parent 091ff40 commit beaf872

File tree

5 files changed

+268
-41
lines changed

5 files changed

+268
-41
lines changed

packages/angular_devkit/build_angular/src/builders/browser/specs/service-worker_spec.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ describe('Browser Builder service worker', () => {
121121
],
122122
dataGroups: [],
123123
hashTable: {
124-
'/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
124+
'/favicon.ico': '460fcbd48b20fcc32b184388606af1238c890dba',
125125
'/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
126126
'/index.html': '8964a35a8b850942f8d18ba919f248762ff3154d',
127-
'/spectrum.png': '8d048ece46c0f3af4b598a95fd8e4709b631c3c0',
127+
'/spectrum.png': '39e7beae24a1099266e5f085d8b815c1b9a23938',
128128
},
129129
}),
130130
);
@@ -238,7 +238,7 @@ describe('Browser Builder service worker', () => {
238238
],
239239
dataGroups: [],
240240
hashTable: {
241-
'/foo/bar/favicon.ico': '84161b857f5c547e3699ddfbffc6d8d737542e01',
241+
'/foo/bar/favicon.ico': '460fcbd48b20fcc32b184388606af1238c890dba',
242242
'/foo/bar/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
243243
'/foo/bar/index.html': '5c99755c1e7cfd1c8aba34ad1155afc72a288fec',
244244
},

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': '460fcbd48b20fcc32b184388606af1238c890dba',
101+
'/assets/folder-asset.txt': '617f202968a6a81050aa617c2e28e1dca11ce8d4',
102+
'/index.html': 'cb8ad8c81cd422699d6d831b6f25ad4481f2c90a',
103+
'/spectrum.png': '39e7beae24a1099266e5f085d8b815c1b9a23938',
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

+39-37
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,30 @@
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+
const content = await this.read(file);
29+
30+
return crypto.createHash('sha1').update(content).digest('hex');
3531
}
3632

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

4137
private _resolve(file: string): string {
@@ -44,12 +40,15 @@ class CliFilesystem implements Filesystem {
4440

4541
private async _recursiveList(dir: string, items: string[]): Promise<string[]> {
4642
const subdirectories = [];
47-
for await (const entry of await fs.opendir(dir)) {
48-
if (entry.isFile()) {
43+
for (const entry of await this.fs.readdir(dir)) {
44+
const entryPath = path.join(dir, entry);
45+
const stats = await this.fs.stat(entryPath);
46+
47+
if (stats.isFile()) {
4948
// 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));
49+
items.push('/' + path.relative(this.base, entryPath).replace(/\\/g, '/'));
50+
} else if (stats.isDirectory()) {
51+
subdirectories.push(entryPath);
5352
}
5453
}
5554

@@ -67,6 +66,8 @@ export async function augmentAppWithServiceWorker(
6766
outputPath: string,
6867
baseHref: string,
6968
ngswConfigPath?: string,
69+
inputputFileSystem = fsPromises,
70+
outputFileSystem = fsPromises,
7071
): Promise<void> {
7172
// Determine the configuration file path
7273
const configPath = ngswConfigPath
@@ -76,7 +77,7 @@ export async function augmentAppWithServiceWorker(
7677
// Read the configuration file
7778
let config: Config | undefined;
7879
try {
79-
const configurationData = await fs.readFile(configPath, 'utf-8');
80+
const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8');
8081
config = JSON.parse(configurationData) as Config;
8182
} catch (error) {
8283
assertIsError(error);
@@ -101,36 +102,37 @@ export async function augmentAppWithServiceWorker(
101102
).Generator;
102103

103104
// Generate the manifest
104-
const generator = new GeneratorConstructor(new CliFilesystem(outputPath), baseHref);
105+
const generator = new GeneratorConstructor(
106+
new CliFilesystem(outputFileSystem, outputPath),
107+
baseHref,
108+
);
105109
const output = await generator.process(config);
106110

107111
// Write the manifest
108112
const manifest = JSON.stringify(output, null, 2);
109-
await fs.writeFile(path.join(outputPath, 'ngsw.json'), manifest);
113+
await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), manifest);
110114

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

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

121131
// If present, write the safety worker code
122-
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
123132
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-
);
133+
const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js');
134+
await copy(safetyPath, 'worker-basic.min.js');
135+
await copy(safetyPath, 'safety-worker.js');
134136
} catch (error) {
135137
assertIsError(error);
136138
if (error.code !== 'ENOENT') {

0 commit comments

Comments
 (0)