emotion icon indicating copy to clipboard operation
emotion copied to clipboard

@emotion/server crash on import in Worker environment

Open frandiox opened this issue 3 years ago • 22 comments

Current behavior:

Looks like @emotion/server always assumes a Node.js environment. However, it is now possible to do SSR in Workers environment (such as Cloudflare Workers). This package breaks on import because some dependencies (html-tokenize) try to access Buffer, Stream, and other Node-only APIs.

I've tried to get some of the problems fixed: https://github.com/LinusU/buffer-from/issues/13 However, html-tokenize have other issues.

To reproduce:

Run import createEmotionServer from '@emotion/server/create-instance' in a worker or a browser environment.

It cannot be reproduced in Codesandbox because looks like they add some Node API polyfills (Buffer, etc).

Expected behavior:

It should not crash on import. More specifically, it would be great if there was an ESM build or a way to import only the necessary functions. For example, importing createExtractCriticalToChunks and createConstructStyleTagsFromChunks, and leaving out createRenderStylesToStream, which is the one causing problems and not needed for this use case.

Right now I have this code, which works in Node but not in workers.

frandiox avatar Jul 29 '21 17:07 frandiox

With https://github.com/emotion-js/emotion/pull/2819 we are allowing ourselves to add a special entrypoint for workers. So we are able to fix this if somebody would be willing to provide a fix for this. That being said I think that @nicksrandall has already been testing this branch with Cloudflare Workers and he said that it's all working. So perhaps there isn't anything to be done here once this lands. On the other hand, this PR doesn't really remove dependency on the packages mentioned in this issue so maybe there is still something to be worked on here.

Andarist avatar Jul 31 '22 08:07 Andarist

It's still not possible to use @emotion/server/create-instance in a worker environment.

@nicksrandall, I see #2819 did not add a worker condition to the server package. Would that be a big change, or is there a reason this was left out?

In our case, it's the only apparent blocker to using Emotion + SSR in a worker environment.

aaronadamsCA avatar Nov 15 '22 12:11 aaronadamsCA

@aaronadamsCA it might be an oversight - could you tell me how does it crash right now in the worker env? There is a chance that we depend on node-specific stuff there. In such a case, we'd have to provide an alternative implementation for workers

Andarist avatar Nov 15 '22 14:11 Andarist

@Andarist You guessed right! html-tokenize is heavily dependent on Node.js. I've tried polyfilling its various needs to get it to run in a worker environment, without success; it gets upset with the absence of Buffer, and doesn't seem to tolerate a polyfill.

html-tokenize appears to be a dead project; the last major release was in 2016 (with one dependency update in 2020). I think the only way forward here would be to get rid of that dependency; the discussion in #2781 seems to be on the right track.

aaronadamsCA avatar Nov 15 '22 15:11 aaronadamsCA

Actually - I wonder if the scope of this issue could be to fix Emotion Server in worker environments, and then the scope of #2781 (blocked by this issue) could be to add any specific worker environment capabilities.

My understanding is that @emotion/server is essentially a progressive enhancement; Emotion works fine without it by injecting styles into the client-side <head>, but if you want styles in your SSR pages during the initial load, then you need to get those tags from @emotion/server and inject them into the first render yourself.

What seems odd, then, is that @emotion/server has unique external dependencies to make this happen. Obviously Emotion already knows how to generate style tags; so why isn't @emotion/server just getting those tags from other parts of Emotion?

I think this would be well worth fixing soon, as I haven't found a viable workaround. Right now we get a massive FOUC (flash of unstyled content) on landing/reload; we can solve that with a loading indicator, but a big part of the value of SSR workers is a correct first render, and I'd really like to get that working.

aaronadamsCA avatar Nov 15 '22 16:11 aaronadamsCA

This patch applied to html-tokenize helped get me up and running in a worker environment:

diff --git a/index.js b/index.js
index 162eef7a0a5b8a2852496368bac5b5865861b95c..0fd321fa9ace582d84f5d7f88cea87be5e48da95 100644
--- a/index.js
+++ b/index.js
@@ -1,4 +1,3 @@
-var bufferFrom = require('buffer-from')
 var Transform = require('readable-stream').Transform;
 var inherits = require('inherits');
 
@@ -15,13 +14,13 @@ var codes = {
 };
 
 var strings = {
-    endScript: bufferFrom('</script'),
-    endStyle: bufferFrom('</style'),
-    endTitle: bufferFrom('</title'),
-    comment: bufferFrom('<!--'),
-    endComment: bufferFrom('-->'),
-    cdata: bufferFrom('<![CDATA['),
-    endCdata: bufferFrom(']]>')
+    endScript: Uint8Array.from('</script'),
+    endStyle: Uint8Array.from('</style'),
+    endTitle: Uint8Array.from('</title'),
+    comment: Uint8Array.from('<!--'),
+    endComment: Uint8Array.from('-->'),
+    cdata: Uint8Array.from('<![CDATA['),
+    endCdata: Uint8Array.from(']]>')
 };
 
 var states = {
@@ -48,7 +47,7 @@ Tokenize.prototype._transform = function (buf, enc, next) {
     var offset = 0;
     
     if (this._prev) {
-        buf = Buffer.concat([ this._prev, buf ]);
+        buf = new Uint8Array([ ...this._prev, ...buf ]);
         i = this._prev.length - 1;
         offset = this._offset;
         this._prev = null;
@@ -193,7 +192,7 @@ Tokenize.prototype._flush = function (next) {
 
 Tokenize.prototype._pushState = function (ev) {
     if (this.buffers.length === 0) return;
-    var buf = Buffer.concat(this.buffers);
+    var buf = new Uint8Array(this.buffers.reduce((p, c) => [...p, ...c], []));
     this.buffers = [];
     this.push([ ev, buf ]);
 };
@@ -231,7 +230,7 @@ Tokenize.prototype._testRaw = function (buf, offset, index) {
     if (!compare(last, raw)) return;
     
     this.buffers.push(buf.slice(offset, index + 1));
-    var buf = Buffer.concat(this.buffers);
+    var buf = new Uint8Array(this.buffers.reduce((p, c) => [...p, ...c], []));
     var k = buf.length - raw.length;
     return [ buf.slice(0, k), buf.slice(k) ];
 };

If Emotion were to adopt this abandoned code, I think it could easily be adjusted to use Buffer or Uint8Array depending on the runtime, which could at least fix the issue for now with minimum work.

aaronadamsCA avatar Nov 15 '22 20:11 aaronadamsCA

The current implementation shouldn't be reused in workers as-is as conceptually it returns a node stream: https://github.com/emotion-js/emotion/blob/92be52d894c7d81d013285e9dfe90820e6b178f8/packages/server/types/create-instance.d.ts#L22

Even the method's name mention this - it's renderStylesToNodeStream after all.

I'm all for providing an alternative implementation of this that would work with web streams. For the time being, we could just have an additional renderStylesToWebStream that wouldn't depend on this html-tokenize (and other node-specific deps) at all.

Note that this whole thing is mostly needed only if you are using @emotion/css (and not @emotion/react/@emotion/styled). With React-specific APIs we could have something that wouldn't rely on any kind of extraction from the stream. We could just grab "inserted" things more directly from the per-request cache.

Andarist avatar Nov 24 '22 08:11 Andarist

The current implementation shouldn't be reused in workers as-is as conceptually it returns a node stream:

https://github.com/emotion-js/emotion/blob/92be52d894c7d81d013285e9dfe90820e6b178f8/packages/server/types/create-instance.d.ts#L22

Even the method's name mention this - it's renderStylesToNodeStream after all.

Agreed; which is why we're not using renderStylesToNodeStream. Worker runtimes support streaming, but they don't require it. Here's our implementation:

const cache = createEmotionCache({ key: "css" });
const html = renderToString(
  <CacheProvider value={cache}>
    <RemixServer context={remixContext} url={request.url} />
  </CacheProvider>
);
const server = createEmotionServer(cache);
const criticalData = server.extractCriticalToChunks(html);
const styleTags = server.constructStyleTagsFromChunks(criticalData);
const markup = html.replace("</head>", styleTags + "</head>");

Even though we only use constructStyleTagsFromChunks and extractCriticalToChunks, we are still affected by the Node.js-only import of html-tokenize. That problem will need to be solved before any other improvements can be made.

Maybe the simplest solution would be to convert these imports to dynamic import() expressions inside createRenderStylesToNodeStream():

https://github.com/emotion-js/emotion/blob/92be52d894c7d81d013285e9dfe90820e6b178f8/packages/server/src/create-instance/stream.js#L3-L5

Note that this whole thing is mostly needed only if you are using @emotion/css (and not @emotion/react/@emotion/styled). With React-specific APIs we could have something that wouldn't rely on any kind of extraction from the stream. We could just grab "inserted" things more directly from the per-request cache.

Yep, this is what I meant by "why isn't @emotion/server just getting those tags from other parts of Emotion". We'd love this.

aaronadamsCA avatar Nov 24 '22 10:11 aaronadamsCA

Even though we only use constructStyleTagsFromChunks and extractCriticalToChunks, we are still affected by the Node.js-only import of html-tokenize. That problem will need to be solved before any other improvements can be made.

Ah, ye - I can see that. We need to restructure our package a little bit or provide different bundles for specific environments. This might require some changes in our bundling solution (https://github.com/preconstruct/preconstruct/) to make it all work.

Yep, this is what I meant by "why isn't @emotion/server just getting those tags from other parts of Emotion". We'd love this.

It's on the "roadmap". However, lately, I don't get as much free time for OSS as I used to have and I'm not certain when I will be able to actually work on this.

Andarist avatar Nov 24 '22 10:11 Andarist

Maybe the simplest solution would be to convert these imports to dynamic import() expressions inside createRenderStylesToNodeStream():

We probably can't do that because this would taint the whole function - it would have to become async and that would be a breaking change.

Andarist avatar Nov 24 '22 10:11 Andarist

Good point. require()?

aaronadamsCA avatar Nov 24 '22 11:11 aaronadamsCA

I'd prefer not to mix ESM with require - it's a can of worms that I prefer closed ;p

Andarist avatar Nov 24 '22 11:11 Andarist

Our eventual solution was to replace @emotion/server with a "vendored" package in our monorepo.

package.json
{
  "name": "emotion-server",
  "sideEffects": false
}
index.d.ts
import type { EmotionCache } from "@emotion/utils";

interface EmotionCriticalToChunks {
  html: string;
  styles: { key: string; ids: string[]; css: string }[];
}

interface EmotionServer {
  constructStyleTagsFromChunks: (
    criticalData: EmotionCriticalToChunks
  ) => string;
  extractCriticalToChunks: (html: string) => EmotionCriticalToChunks;
}

export function createEmotionServer(cache: EmotionCache): EmotionServer;
index.js
function createExtractCriticalToChunks(cache) {
  return function (html) {
    const RGX = new RegExp(`${cache.key}-([a-zA-Z0-9-_]+)`, "gm");

    const o = { html, styles: [] };
    let match;
    const ids = {};
    while ((match = RGX.exec(html)) !== null) {
      if (ids[match[1]] === undefined) {
        ids[match[1]] = true;
      }
    }

    const regularCssIds = [];
    let regularCss = "";

    Object.keys(cache.inserted).forEach((id) => {
      if (
        (ids[id] !== undefined ||
          cache.registered[`${cache.key}-${id}`] === undefined) &&
        cache.inserted[id] !== true
      ) {
        if (cache.registered[`${cache.key}-${id}`]) {
          regularCssIds.push(id);
          regularCss += cache.inserted[id];
        } else {
          o.styles.push({
            key: `${cache.key}-global`,
            ids: [id],
            css: cache.inserted[id],
          });
        }
      }
    });

    o.styles.push({ key: cache.key, ids: regularCssIds, css: regularCss });

    return o;
  };
}

function generateStyleTag(cssKey, ids, styles, nonceString) {
  return `<style data-emotion="${cssKey} ${ids}"${nonceString}>${styles}</style>`;
}

function createConstructStyleTagsFromChunks(cache, nonceString) {
  return function (criticalData) {
    let styleTagsString = "";

    criticalData.styles.forEach((item) => {
      styleTagsString += generateStyleTag(
        item.key,
        item.ids.join(" "),
        item.css,
        nonceString
      );
    });

    return styleTagsString;
  };
}

export function createEmotionServer(cache) {
  if (cache.compat !== true) {
    cache.compat = true;
  }
  const nonceString =
    cache.nonce !== undefined ? ` nonce="${cache.nonce}"` : "";
  return {
    extractCriticalToChunks: createExtractCriticalToChunks(cache),
    constructStyleTagsFromChunks: createConstructStyleTagsFromChunks(
      cache,
      nonceString
    ),
  };
}

This exports a createEmotionServer that is a drop-in replacement for @emotion/server/create-instance in a web worker environment. It's functionally identical to the actual package, just without the Node.js-only parts we don't use.

aaronadamsCA avatar Jan 05 '23 16:01 aaronadamsCA

@aaronadamsCA thanks! This works like charm!

Nipsuli avatar Jan 06 '23 11:01 Nipsuli

@aaronadamsCA Thanks! I'm using Chakra-UI in a Remix project (specifically a Hydrogen Shopify app) and had the same issue. The workaround is very helpful.

maheshsundaram avatar Feb 19 '23 04:02 maheshsundaram

@aaronadamsCA great job. Only thing I could get to work after messing around for hours w/ Miniflare (CloudFlare local dev tool), Chakra-UI, and Remix configurations and examples. This has fixed it for local dev at least. Will let you know if it has problems with deployng.

MHarris021 avatar Apr 19 '23 01:04 MHarris021

For those using Typescript, setting up the paths object to look like below allows for additional dropins for situations like this:

paths": { "~/*": ["./app/*"], "@lib/*": ["./lib/*"] },

Anything in the root level lib directory can be imported this way. Just make sure to include an index w/ an export.

MHarris021 avatar Apr 19 '23 01:04 MHarris021

For others who might need a little extra guidance and building on the work of @aaronadamsCA

  1. Create new directory /app/vendor/@emotion/server

  2. Add index.ts in this directory

/app/vendor/@emotion/server/index.ts
import type { EmotionCache } from "@emotion/utils";

function createExtractCriticalToChunks(cache: EmotionCache) {
  return function (html: string) {
    const RGX = new RegExp(`${cache.key}-([a-zA-Z0-9-_]+)`, "gm");

    const o: {
      html: string;
      styles: { key: string; ids: string[]; css: string | boolean }[];
    } = { html, styles: [] };
    let match;
    const ids: { [key: string]: boolean} = {};
    while ((match = RGX.exec(html)) !== null) {
      if (ids[match[1]] === undefined) {
        ids[match[1]] = true;
      }
    }

    const regularCssIds: string[] = [];
    let regularCss = "";

    Object.keys(cache.inserted).forEach((id) => {
      if (
        (ids[id] !== undefined ||
          cache.registered[`${cache.key}-${id}`] === undefined) &&
        cache.inserted[id] !== true
      ) {
        if (cache.registered[`${cache.key}-${id}`]) {
          regularCssIds.push(id);
          regularCss += cache.inserted[id];
        } else {
          o.styles.push({
            key: `${cache.key}-global`,
            ids: [id],
            css: cache.inserted[id],
          });
        }
      }
    });

    o.styles.push({ key: cache.key, ids: regularCssIds, css: regularCss });

    return o;
  };
}

function generateStyleTag(cssKey: string, ids: string, styles: string | boolean, nonceString: string) {
  return `<style data-emotion="${cssKey} ${ids}"${nonceString}>${styles}</style>`;
}

function createConstructStyleTagsFromChunks(cache: EmotionCache, nonceString: string) {
  return function (criticalData: ReturnType<ReturnType<typeof createExtractCriticalToChunks>>) {
    let styleTagsString = "";

    criticalData.styles.forEach((item) => {
      styleTagsString += generateStyleTag(
        item.key,
        item.ids.join(" "),
        item.css,
        nonceString
      );
    });

    return styleTagsString;
  };
}

export function createEmotionServer(cache: EmotionCache) {
  if (cache.compat !== true) {
    cache.compat = true;
  }
  const nonceString =
    cache.nonce !== undefined ? ` nonce="${cache.nonce}"` : "";
  return {
    extractCriticalToChunks: createExtractCriticalToChunks(cache),
    constructStyleTagsFromChunks: createConstructStyleTagsFromChunks(
      cache,
      nonceString
    ),
  };
}
  1. Update /app/entry.server.tsx to use the new Emotion server
/app.entry.server.tsx
//... existing imports

import createEmotionCache from '@emotion/cache';
import { createEmotionServer } from '~/vendor/@emotion/server';

export default function handleRequest(
) {
  const cache = createEmotionCache({ key: 'css' });
  const { extractCriticalToChunks } = createEmotionServer(cache);

  //... if using MUI boilerplate you might have MuiRemixServer function
  const html = ReactDOMServer.renderToString(/* existing component */);

  const { styles } = extractCriticalToChunks(html);

  //... injecting styles
  //... returns
}

jrschumacher avatar Sep 26 '23 03:09 jrschumacher

So from my understanding currently the only workaround is by serving the content as a string with renderToString and add the emotion styles in the string. There are no workarounds if we need to use renderToReadableStream, correct?

lorenzo-del-rosario avatar Jan 29 '24 22:01 lorenzo-del-rosario