Sapper: Sapper with fastify-static

Created on 7 Dec 2019  路  10Comments  路  Source: sveltejs/sapper

Describe the bug
When Sapper middleware is active, nothing can be served anymore by fastify

Logs
N/A

To Reproduce
I took the sapper rollup template
Then I installed fastify and fastify-static
npm i fastify fastify-static

Then I changed server.js with the following

const fastify = require('fastify')({ logger: true });
const path = require('path');

// Register the static plugin and serve content from static folder
fastify.register(require('fastify-static'), {
  root: path.join(__dirname, '../../..', 'static')
})

// Run the sapper middleware which will handle server-side rendering
fastify.use(require('@sapper/server').middleware());

fastify.listen(process.env.PORT, (err, address) => {
  if (err) { throw err; }
  fastify.log.info(`Server listening on ${address}`);
});

And ran npm run dev

Actual behavior
http://localhost:3000/favicon.png returns 404

Expected behavior
http://localhost:3000/favicon.png returns the favicon

If I remove the line fastify.use(require('@sapper/server').middleware());, the favicon is served

Information about your Sapper Installation:

  • Your browser and the version: Firefox 70
  • Your operating system: Manjaro 18

  • Your hosting environment: Local

  • Sapper version 0.27.9

  • Svelte version 3.16.0

  • dynamic application.

  • Rollup

Severity
Workaround is to use sirv

question

Most helpful comment

oh cool, thanks for the pointer. fwiw to anyone else who gets to this issue - the following code works with fastify v3 for the starter:

import sirv from 'sirv';
import compression from 'compression';
import * as sapper from '@sapper/server';
import midde from 'middie';
import fastify from 'fastify';

const app = fastify();

const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';

(async () => {
    app.get('/api/health', (req, res) => {
        return { ok: true, timestamp: new Date().toISOString() }
    })

    await app.register(midde); // You can also use Express
    app.use(compression({ threshold: 0 }));
    app.use(sirv('static', { dev }));

    const sap = sapper.middleware();
    app.route({
        method: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'],
        url: '/*',
        handler: async (request, reply) => {
            let next = (err) => {
                if (err) {
                    throw err;
                } else {
                    reply.status(404).send();
                }
            };

            request.originalUrl = request.url;
            reply.end = reply.send.bind(reply);
            reply.setHeader = reply.header.bind(reply);
            sap(request, reply, next);
            return reply;
        },
    });


    app.listen(PORT, err => {
        if (err) console.log('error', err);
    });

})();


EDIT: It doesn't work with the blog route still. I'm not familiar enough with svelte to understand why it needs to sometimes call .writeHead and what all the this.fetch and this.error are doing. That being said, if I go into the blog.js files and everywhere it uses the "express" compatible api change it to use the fastify compatible one, it works. See --
Change [slug].json.js

import posts from './_posts.js';

const lookup = new Map();
posts.forEach(post => {
    lookup.set(post.slug, JSON.stringify(post));
});

export function get(req, res, next) {
    // the `slug` parameter is available because
    // this file is called [slug].json.js
    const { slug } = req.params;

    if (lookup.has(slug)) {
        res.writeHead(200, {
            'Content-Type': 'application/json'
        });

        res.end(lookup.get(slug));
    } else {
        res.writeHead(404, {
            'Content-Type': 'application/json'
        });

        res.end(JSON.stringify({
            message: `Not found`
        }));
    }
}

to

import posts from './_posts.js';

const lookup = new Map();
posts.forEach(post => {
    lookup.set(post.slug, JSON.stringify(post));
});

export function get(req, res, next) {
    // the `slug` parameter is available because
    // this file is called [slug].json.js
    const { slug } = req.params;

    if (lookup.has(slug)) {
        res.header('Content-Type','application/json');

        res.end(lookup.get(slug));
    } else {
        res.header('Content-Type','application/json');


        res.end(JSON.stringify({
            message: `Not found`
        }));
    }
}

All 10 comments

It works well with sirv

fastify.use(require('sirv')('static'))

I dealt with this recently and I'm not sure if there's an easy way around it.

Unlike some other web frameworks, Fastify always runs all the middleware before figuring out which request handlers should run (see https://www.fastify.io/docs/latest/Lifecycle/). fastify-static doesn't act as a middleware, but instead installs route handlers to serve the static files, so Sapper will always run first regardless of the order in which you enable them. sirv and most other static-file-serving libraries work fine since they function as middleware, and so are able to handle the request before Sapper gets its chance.

I ended up doing the same thing, just using sirv for static file serving. If you do really want to use fastify-static, I can think of a couple options:

  1. Add a wildcard route handler and figure out how to call the sapper middleware from that handler. I haven't tested the below code at all but something like it might work.
let sap = sapper.middleware();
fastify.route({
   method: [ 'GET', 'POST', etc. ],
   url: '/*',
   handler: (request, reply) => {
      return new Promise((resolve, reject) => {
         let next = (err) => {
            if(err) { reject(err) } else { resolve() };
         };

         sap(request, reply, next);
      });
   }
});
  1. If you have a small set of files or directories you want to serve from fastify-static, you can wrap Sapper like so:
function sapperMiddleware() {
  let sap = sapper.middleware();
  return (req, res, next) => {
    if (req.url.startsWith('/the_directory')) {
      return next();
    }
    sap(req, res, next);
  };
}

fastify.use(sapperMiddleware())

I used this approach in my own code since I wanted my Fastify routes to handle the /api prefix. Notably, the Sapper middleware does have an "ignore" option, but it assumes that req.path exists, which is not true on Fastify. Perhaps that would be considered a bug in the middleware, not sure.

One problem with my solution above is that fastify plugins such as fastify-compress will not apply to responses sent from the Sapper middleware. The code block below will install the middleware as a normal fastify route handler. This is the working version of approach 1 from my first post, but I should warn you that it's even more of a hack than my first attempt :)

  let sap = sapper.middleware();
  fastify.route({
    method: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'],
    url: '/*',
    handler: async (request, reply) => {
      let next = (err) => {
        if (err) {
          throw err;
        } else {
          reply.status(404).send();
        }
      };

      request.url = request.raw.url;
      request.originalUrl = request.raw.url;
      reply.end = reply.send.bind(reply);
      reply.setHeader = reply.header.bind(reply);
      sap(request, reply, next);
      return reply;
    },
  });

One problem with my solution above is that fastify plugins such as fastify-compress will not apply to responses sent from the Sapper middleware. The code block below will install the middleware as a normal fastify route handler. This is the working version of approach 1 from my first post, but I should warn you that it's even more of a hack than my first attempt :)

  let sap = sapper.middleware();
  fastify.route({
    method: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'],
    url: '/*',
    handler: async (request, reply) => {
      let next = (err) => {
        if (err) {
          throw err;
        } else {
          reply.status(404).send();
        }
      };

      request.url = request.raw.url;
      request.originalUrl = request.raw.url;
      reply.end = reply.send.bind(reply);
      reply.setHeader = reply.header.bind(reply);
      sap(request, reply, next);
      return reply;
    },
  });

It's not working on the sapper example...
It loads fine and I can go to the "about" tab, but the "blog" won't load and the following error is thrown on the server:
(node:8613) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'toLowerCase' of undefined at handle_route (webpack:///./src/node_modules/@sapper/server.mjs?:28:29) at Array.find_route (webpack:///./src/node_modules/@sapper/server.mjs?:90:5) at nth_handler (webpack:///./src/node_modules/@sapper/server.mjs?:2609:14) at eval (webpack:///./src/node_modules/@sapper/server.mjs?:2609:31) at Array.eval (webpack:///./src/node_modules/@sapper/server.mjs?:2661:4) at nth_handler (webpack:///./src/node_modules/@sapper/server.mjs?:2609:14) at eval (webpack:///./src/node_modules/@sapper/server.mjs?:2609:31) at Array.eval (webpack:///./src/node_modules/@sapper/server.mjs?:2661:4) at nth_handler (webpack:///./src/node_modules/@sapper/server.mjs?:2609:14) at eval (webpack:///./src/node_modules/@sapper/server.mjs?:2609:31) (node:8613) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag--unhandled-rejections=strict(see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1) (node:8613) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

Ok, if you can figure out what needs to be changed, let me know and I'll update the code snippet.

@Dindaleon Hi, have you figured this out?

EDIT:
Ok I figured it out.
You need to add request.method = request.raw.method;

There seems to be in general just a compatibility issue here. Fastify does not use express style req/res. Pretty much everything else does (and so does sapper) at least from my initial impression. I am also interested in getting fastify to work with it, but I don't think it's going to work playing whack-a-mole with basically setting the properties it expects on the reply object. It's just too subject to change - a lot of the above doesn't work as well anymore after fastify v3.
I'd be interested to hear from maintainers their recommended approach for not "hacking it in". Fastify has a large enough presence that I think support isn't too much to ask - a community driven plugin is all it would take. I'd be happy to help. What would be the recommended way to map the data sapper middleware needs from the request / response objects without them being 100% dependent on the express style api. Is there somewhere we could pass a function to just "pull out" the properties sapper is expecting? That way it could be more dynamic. From there it would be really easy to make a plugin of sorts - ie fastify-sapper or something similar.

I haven't look too closely, but perhaps you could use something like https://github.com/fastify/middie ?

I think there's some overlap between this issue and the issue of supporting serverless platforms. We're working on creating a list of the major issues we want to solve and plan to create a tracking issue organizing all the major issues like this in the coming weeks so that we know which problems are related and organize the discussion.

oh cool, thanks for the pointer. fwiw to anyone else who gets to this issue - the following code works with fastify v3 for the starter:

import sirv from 'sirv';
import compression from 'compression';
import * as sapper from '@sapper/server';
import midde from 'middie';
import fastify from 'fastify';

const app = fastify();

const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';

(async () => {
    app.get('/api/health', (req, res) => {
        return { ok: true, timestamp: new Date().toISOString() }
    })

    await app.register(midde); // You can also use Express
    app.use(compression({ threshold: 0 }));
    app.use(sirv('static', { dev }));

    const sap = sapper.middleware();
    app.route({
        method: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'POST', 'DELETE'],
        url: '/*',
        handler: async (request, reply) => {
            let next = (err) => {
                if (err) {
                    throw err;
                } else {
                    reply.status(404).send();
                }
            };

            request.originalUrl = request.url;
            reply.end = reply.send.bind(reply);
            reply.setHeader = reply.header.bind(reply);
            sap(request, reply, next);
            return reply;
        },
    });


    app.listen(PORT, err => {
        if (err) console.log('error', err);
    });

})();


EDIT: It doesn't work with the blog route still. I'm not familiar enough with svelte to understand why it needs to sometimes call .writeHead and what all the this.fetch and this.error are doing. That being said, if I go into the blog.js files and everywhere it uses the "express" compatible api change it to use the fastify compatible one, it works. See --
Change [slug].json.js

import posts from './_posts.js';

const lookup = new Map();
posts.forEach(post => {
    lookup.set(post.slug, JSON.stringify(post));
});

export function get(req, res, next) {
    // the `slug` parameter is available because
    // this file is called [slug].json.js
    const { slug } = req.params;

    if (lookup.has(slug)) {
        res.writeHead(200, {
            'Content-Type': 'application/json'
        });

        res.end(lookup.get(slug));
    } else {
        res.writeHead(404, {
            'Content-Type': 'application/json'
        });

        res.end(JSON.stringify({
            message: `Not found`
        }));
    }
}

to

import posts from './_posts.js';

const lookup = new Map();
posts.forEach(post => {
    lookup.set(post.slug, JSON.stringify(post));
});

export function get(req, res, next) {
    // the `slug` parameter is available because
    // this file is called [slug].json.js
    const { slug } = req.params;

    if (lookup.has(slug)) {
        res.header('Content-Type','application/json');

        res.end(lookup.get(slug));
    } else {
        res.header('Content-Type','application/json');


        res.end(JSON.stringify({
            message: `Not found`
        }));
    }
}

Glad that worked! It sounds like there's a solution for this issue, so I'll go ahead and close it

Was this page helpful?
0 / 5 - 0 ratings

Related issues

keyvan-m-sadeghi picture keyvan-m-sadeghi  路  4Comments

Rich-Harris picture Rich-Harris  路  3Comments

matt3224 picture matt3224  路  4Comments

milosdjakovic picture milosdjakovic  路  3Comments

Rich-Harris picture Rich-Harris  路  3Comments