Skip to content

Support for other service-workers #26857

Description

@OysteinAmundsen

Command

serve

Description

I want to leverage todays file-watch when using ng serve. I also want to use WorkBox and not angular-serviceworker. This conflicts, for in order to compile my WorkBox template and inject built files into it it requires a second build step to be performed after angular has done emitting files to the output directory. I managed to get something like this working using a custom webpack configuration, but with angular 17 we are moving away from webpack and over to esbuild, and this customization is no longer available.

Describe the solution you'd like

In the angular.json config, a serviceWorker is either missing or declared as a pointer to an angular-serviceworker json config.
I haven't thought this fully through yet, but something like the following config would be nice:

"targets": {
  "build": {
    "options": {
      "serviceWorker": {
        "path": "./sw.ts",
        "builder": "./path/to/buildScript.js"
      }
    }
  }
}

Describe alternatives you've considered

The alternative is to give up HMR and ng serve completely, build angular first, then build and injectManifest into service worker and then run the entire project through a local http-server. This greatly reduces productivity.

Activity

  1. alan-agius4 commented on Jan 12, 2024

    @alan-agius4
    Collaborator

    Hi, this can be achieved by a custom esbuilder where you can add additional plugins and run post build tasks.

  2. OysteinAmundsen commented on Feb 5, 2024

    @OysteinAmundsen
    Author

    As far as I understand it, the only way to add a custom esbuild plugin is through nx where they override your @angular-devkit/build-angular with their custom @nx/angular builder. Then you have access to a plugin property where you can point to an esbuild plugin.
    But plugging ekstra code into the build process introduces at least two different problems:

    1. you cannot specify an additional file to include as module in the build output. If you override the build options and include an ekstra entryPoint, this will be bundled in a way that makes it impossible to run as standalone code. You have to call an additional esbuild process to build out your service-worker template and overwrite the built code into the build process onEnd.
    2. In the onEnd hook, the builders result.outputFiles does not include all files nescessary to inject into the workbox manifest. Only javascript chunks are present, not html, css or assets.

    I could wait until output is written to disk and call workbox-build.injectManifest from disk content, but that would exclude in-memory builds which is used when spinning up local dev-server, which makes debugging the service-worker cumbersome to say the least.

    Code example of such a plugin is presented in the following stackoverflow issue: https://stackoverflow.com/questions/77887040/building-workbox-into-angular-using-custom-esbuilder

    import * as esbuild from 'esbuild';
    import { readFileSync } from 'fs';
    import * as path from 'path';
    
    const MATCH_FILES = /^.+\.(js|css|svg|img|html|txt|webmanifest)$/i;
    const manifest: { url: string; revision: string }[] = [];
    const projectRoot = 'apps/myApp/src';
    
    const wbInject: esbuild.Plugin = {
      name: 'wbInject',
      setup(build: esbuild.PluginBuild) {
        // Append ngsw-worker to entry points
        // This is done so that it is discoverable in the onEnd hook
        const options = build.initialOptions;
        Object.assign(options.entryPoints || {}, {
          'ngsw-worker': path.resolve(projectRoot, 'ngsw-worker.ts'),
        });
        let workerCode = '';
    
        // Pre-build the worker, so that we get a single module for this
        // as angular would chunk it otherwise
        build.onStart(async () => {
          console.log('Building service-worker');
          const result = await esbuild.build({
            entryPoints: [projectRoot + '/ngsw-worker.ts'],
            bundle: true,
            write: false,
            platform: 'browser',
            target: 'es2017',
          });
          workerCode = result.outputFiles[0].text;
        });
    
        // Inject manifest into worker
        build.onEnd(async result => {
          if (result.errors.length !== 0) return;
    
          // Add build output to manifest
          const files = result.outputFiles?.filter(f => f.path.match(MATCH_FILES) && !f.path.match(/ngsw-worker/)) || [];
          for (const file of files) await addToManifest(file.path, file.contents);
    
          const workerFile = result.outputFiles?.find(file => file.path.match(/ngsw-worker.*\.js$/));
          const workerSourceMap = result.outputFiles?.find(file => file.path.match(/ngsw-worker.*\.js.map$/));
          if (!workerFile) return;
    
          // Remove cache busting from worker file
          [workerFile, ...(workerSourceMap != null ? [workerSourceMap] : [])].forEach(
            file => (file.path = file.path.replace(/\-[A-Z0-9]{8}\./, '.')),
          );
    
          // Inject manifest into worker
          const updatedWorkerCode = workerCode.replace('self.__WB_MANIFEST', JSON.stringify(manifest));
    
          // Update the worker file in the output
          workerFile.contents = new TextEncoder().encode(updatedWorkerCode);
        });
      },
    };
    
    async function addToManifest(filePath: string, contents?: Uint8Array) {
      const relPath = path.relative(process.cwd(), filePath);
      if (manifest.findIndex(i => i.url === relPath) !== -1) return;
    
      const content = contents || (await readFileSync(path.resolve(projectRoot, relPath)));
      const hash = await createHash(content);
      manifest.push({ url: relPath, revision: hash });
    }
    
    async function createHash(content: BufferSource) {
      const hash: ArrayBuffer = await crypto.subtle.digest('SHA-256', content);
      return Array.from(new Uint8Array(hash))
        .map(b => b.toString(16).padStart(2, '0'))
        .join('');
    }
    
    export default wbInject;

    It would be nice if angular would consider a better support for plugins, and documentation for how a normal use-case like this could be implemented.

  3. angular-automatic-lock-bot commented on Mar 8, 2024

    @angular-automatic-lock-bot

    This issue has been automatically locked due to inactivity.
    Please file a new issue if you are encountering a similar or related problem.

    Read more about our automatic conversation locking policy.

    This action has been performed automatically by a bot.

  4. locked and limited conversation to collaborators on Mar 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions