Search code examples
reactjstypescriptserver-side-rendering

"SyntaxError: Invalid or unexpected token" - How to SSR images/css with react


I'm using SSR to render a react component which should import an image. I've added:

declare module '*.ico' {
  const value: any
  export default value
}

Which works. Then when I try to do:

import Favicon from '/path/to/favicon.ico'

I get an error "SyntaxError: Invalid or unexpected token". I get this error even if I don't even attempt to use it, it breaks on the import itself:

SyntaxError: Invalid or unexpected token
    at wrapSafe (internal/modules/cjs/loader.js:979:16)
    at Module._compile (internal/modules/cjs/loader.js:1027:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Module.require (internal/modules/cjs/loader.js:952:19)
    at require (internal/modules/cjs/helpers.js:88:18)

I'd ask how I can then use it but I'm first stuck on just even importing it before I'll attempt to use it.

Note, I don't want to rerun webpack or anything like that when rendering a single component server side, so how can I use an image with an import without using webpack.

I'm starting to think that only way to achieve this is to manually use fs.writeFile and to assign the static URL manually to the image source, but I was hoping there's an easier solution than that, I assume webpack is doing all that under the hood.

EDIT: I thought I'd found a solution to this but still no luck.

You can have something like this, which can render a page:

export const component = async (
  request: Request,
  response: Response,
): Promise<void> => {
  console.log(request.url)

  const stream = renderToNodeStream(<Component />)

  stream.pipe(response)

  stream.on('end', () => {
    response.end()
  })
}

Just about every blog post and example on the internet stops there and says "ay okay, job done", but it's not, that's not even nearly half the story.

When you use the above along with:

import Favicon from '/path/to/favicon.ico'

Inside that component, then it will break because typescript can't parse any images, it makes sense. It will also break on any CSS or anything else that isn't react. What that means is that you can't reuse any client side components and rerender them on the server, not if they include any images or any CSS or anything that isn't pure typescript/react.

You can use:

import webpack from 'webpack'

import config.js from './config'

...

const compiler = webpack(config('production'))

compiler.run((error, stats) => {
  compiler.close()
})

And using that you can compile and bundle specific files with webpack and invoke it programmatically, but that doesn't really solve the problem. I don't get why every example out there just shows renderToStaticMarkup or renderToString when not a single one of them tells you "oh by the way using that on anything you have will break." It's not possible to run or use or utilize react-dom/server in any useful way if you don't precompile it with webpack and run a webpack compiler on every prerendered HTML before fs.readFile'ing that *.html and feeding it to react's render.

You also can't just manually use <img src='/path/to/image'/> because then your server and client code will not be the same so hydrate will complain, since webpack is rendering the images in a different way than you're manually doing, not to mention that you can't reuse the same components with something like import Component from './component', you'll have to duplicate your entire code base, once for webpack client and once for manually writing image paths.

So how can you simply render an image using renderToString, I don't get how nobody out there and no blog post and no discussion forum or no nothing has any information on this?! More than that why does everyone simply say "oh just use renderToString and voila", when it clearly doesn't work for images/css or anything else.

What's the puzzle piece I'm missing, am I missing something really stupid or obvious?


Solution

  • I finally managed to figure this out, seems like I had the puzzle pieces but I was putting them together in all the wrong ways. I'll post the complete solution in case anyone ever wants it.

    The first thing to mention is that using any react server functions without some sort of a bundler like webpack isn't possible, or it's possible but it wouldn't make sense because you will have images and css and stuff that typescript can't parse.

    The first thing you'll need is the normal webpack config that you're used to:

    const config = {
      output: {
        path: path.join(__dirname, 'public'),
        filename: '[contenthash].js',
        assetModuleFilename: '[contenthash].[ext]',
        publicPath: '/',
        libraryTarget: 'umd',
        clean: false,
      },
      target: 'web',
      resolve: {
        extensions: ['.js', '.ts', '.jsx', '.tsx'],
      },
      module: {
        rules: [
          {
            test: /\.tsx?$/,
            use: 'ts-loader',
            exclude: [/node_modules/],
          },
          {
            test: /\.(jpg|png|ico)$/,
            type: 'asset/resource',
            generator: {
              filename: '[contenthash].[ext]',
            },
          },
        ],
      },
      ...
    

    It's not the complete config but it will give a general idea. There you can use all the normal plugins and stuff you're used to, i.e. file-loader or style-loader or whatever.

    After that assume that you have a directory structure like this:

    - webpack.config.js
    - ui/route // we want all files here to be SSR and client side rendered
    

    Then you can bundle all of that like you normally do and serve it client side using your normal webpack setup. However in addition to that you'll need some kind of a compiler to write your .js files in order to be consumed by react server functions. This is the key part.

    import fs from 'fs'
    import path from 'path'
    import webpack from 'webpack'
    
    import config from '../webpack.config.js'
    
    const configuration = config({production: true})
    
    const files = fs.readdirSync(path.join(__dirname, 'ui/route'))
    
    for (const file of files) {
      if (file.indexOf('.map') === -1) {
        configuration.target = 'node'
        configuration.entry = path.join(__dirname, 'ui/route', file)
        configuration.output.path = path.join(__dirname, '../public')
        configuration.output.filename = file // [contenthash].js
    
        const compiler = webpack(configuration)
        compiler.run((error) => {
          console.log(error)
    
          compiler.close((error) => {
            console.log(error)
          })
        })
      }
    }
    

    That code is very raw and can still be optimized in many different ways, but it shows you what you can do. You can either do each file individually, or you can create some sort of a glob to read everything off a specific directory (or search it recursively), then you programmatically invoke the webpack compiler and output each file (page (route)) separately to their directories as .js files, each with their own CSS/Images/whatever already compiled with webpack. Something to note here is don't go and create different webpack configs for no reason, all you need to change is the target to node, the entry and the output, you can use the original webpack config to do that.

    Then you can include this compiler in your build step, so each time you start the server using something like tsc && node lib/compiler.js && lib/server.js. This will build all your stuff, after which it will invoke webpack on all of it and output those files, then you start the server. You can also do it dynamically, that's up to you. What's important here is that typescript should only compile your server side files while webpack (with ts-loader invoking typescript) should compile your UI files.

    After that you can use:

    // api/example.tsx
    
    import HTML from './html' // the html.js file we just compiled with webpack
    
    export const index = async (
      request: Request,
      response: Response,
    ): Promise<void> => {
      console.log(request.url)
    
      const stream = renderToNodeStream(<HTML />)
    
      stream.pipe(response)
    
      stream.on('end', () => {
        response.end()
      })
    }
    

    To render the webpack output files with react and send them as a stream to the requesting client. Note that in the above case HTML should be a .js file that was output using webpack's compiler, this is important, don't try to include the typescript/original javascript components here. So you want to compile it with webpack, then feed that javascript output to react's server functions in your API. Note that the above is an express server.

    Here's the original HTML typescript component before it was compiled with webpack:

    import React from 'react'
    
    const HTML = (): JSX.Element => {
      return (
        <>
          <html>
            <head>
              <meta charSet='utf-8' />
              <meta name='robots' content='index, follow' />
              <meta name='viewport' content='width=device-width, initial-scale=1' />
              <title>Title</title>
              <script src='/script.js' defer></script>
            </head>
            <body>
              <div id='ui'></div>
            </body>
          </html>
        </>
      )
    }
    
    export default HTML
    

    Then the only thing left to to is to hydrate the original client side webpack UI.

    import React from 'react'
    import {hydrate} from 'react-dom'
    
    import HTML from './html'
    
    const UI = () => {
      return <HTML/>
    }
    
    hydrate(<UI />, document.getElementById('ui'))
    

    Or you can do:

    import React from 'react'
    import {render} from 'react-dom'
    
    const UI = () => {
      return <>Hello World!</>
    }
    
    render(<UI />, document.getElementById('ui'))
    

    Now when I request '/' the server will start streaming the HTML string to the UI, after which it will fetch the script.js file, which is the client side webpack bundle with the hydrate and once that hits the client's browser it will hydrate the markup that was previously streamed.

    Congratulations, now you have an API serving HTML without any prerendered html file!

    The key here is to create the custom compiler with webpack and to override the entry and output files, then to consume those files in the react server functions after they've been built.

    EDIT: Slight improvement on compiler performance. You can run multiple compilers like this, each with their own config.

    var webpack = require('webpack');
    
    var config1 = {
      entry: './index1.js',
      output: {filename: 'bundle1.js'}
    }
    var config2 = {
      entry: './index2.js',
      output: {filename:'bundle2.js'}
    }
    
    webpack([config1, config2], (err, stats) => {
      process.stdout.write(stats.toString() + "\n");
    })
    

    And if you wanna go the extra mile you can write files to memory instead of disk using https://www.npmjs.com/package/memfs:

    import {fs} from 'memfs'
    
    ...
    
    compiler.outputFileSystem = fs
    
    ...
    
    const content = fs.readFileSync('...')
    

    https://webpack-v3.jsx.app/api/compiler/#watching

    Which is what webpack-dev-server uses under the hood.

    If I weren't previously a webpack enthusiast, I am now... This has opened up a whole new can of worms of what webpack is really capable of.