sholladay / pogo

Server framework for Deno

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Serve a directory of static files

nalaka opened this issue · comments

Hi, thanks for doing this @sholladay - is there a way to serve a directory of static files at a particular path with the correct response type set automatically?

The use case is to serve the web UI- e.g. the production build of a create-react-app project

In Hapi, I can do this with @hapi/inert and the directory handler it gives.

  server.route({
    method: "GET",
    path: "/app/{path*}",
    handler: {
      directory: {
        path: Path.join(__dirname, "../../client/build/"),
        listing: false,
        index: true
      }
    }
  });

Thank you! Not yet implemented, but very much wanted.

For now, you should follow the tips for serving files and combine it with a dynamic route and maybe a React template for rendering the directory listing (for those who want it):
https://github.com/sholladay/pogo/blob/master/README.md#serve-static-files

I am thinking that this is such a common use case that I may want to implement it in core rather than as a plugin. I'm not entirely sure why it was removed from hapi core - I've needed inert in almost every hapi project I've done.

PRs related to this are welcome. It's among the top priorities for Pogo at the moment. I want to make it easy and spec compliant.

I am thinking that this is such a common use case that I may want to implement it in core rather than as a plugin.

@sholladay - Makes total sense. If you haven't already started on it, I can have a look. Mind you, I am new to Deno- actually only first heard about it about about a week back. :)

That would be great, go ahead. As for being new to Deno, I'm sure you'll pick it up pretty quickly, as it shares much in common with Node, at least in terms of user-facing functionality.

Some resources to help you:

  • There are some useful utility modules in deno/std, which we may or may not need, but good to know about regardless.
  • The Deno chat is on DIscord (you can find me there): https://discord.gg/TGMHGv6

If you need any help with the implementation, feel free to reach out or open a draft PR for discussion.

This feature should be easier to implement now because of the new router (see #29).

Previously, you would've had to read the filesystem and map all of the desired file names to a list of paths and then construct a list of routes based on that, which would be annoying.

Now, we have support for wildcard paths, such as /{foo*}, which will match any request that starts with /. Or /bar/{foo*}, which will match any request that starts with /bar/. You'll probably want to use that in any route that serves a directory so that it can look up arbitrary file names from within the handler.

Hi @sholladay - sorry I was tied up with some other work, will get back to this over the next weekend.

More progress towards this. I have opened PR #32 which adds h.file() to create a response from a filepath. To fully support directories in a convenient way, pogo.directory() could return a route whose handler uses h.file().

h.file() was merged and is now tested, documented, and fully supported. I think we're ready for someone to work on pogo.directory(). Should be relatively straightforward. Any takers?

Here's a rough sketch to get started....

interface NamedStat extends Deno.FileInfo {
    name: Deno.DirEntry['name']
}

const renderFileList = (basePath: string, files: Array<Deno.FileInfo & { name : string }>) => {
    return (
        <html>
            <body>
                <ul>
                    {files.map((file) => {
                        return <li key={file.name}><a href={basePath + '/' + file.name}>{file.name}</a></li>;
                    })}
                </ul>
            </body>
        </html>
    );
};
const readDirStats = async (dir: string): Promise<Array<NamedStat>> => {
    const stats: Array<Promise<NamedStat>> = [];
    const statEntry = async (file: Deno.DirEntry): Promise<NamedStat> => {
        const status = await Deno.stat(path.join(dir, file.name));
        return {
            ...file,
            ...status
        };
    };
    for await (const child of Deno.readDir(dir)) {
        stats.push(statEntry(child));
    }
    return Promise.all(stats);
};
const directory = (dir: string, options?: { listing: boolean }): RouteHandler => {
    return async (request, h) => {
        const fp = Object.values(request.params).find((value) => {
            return request.url.pathname.endsWith(value);
        });
        const filePath = path.join(dir, fp ?? '.');
        if (filePath !== dir && !(await isPathInside.fs(filePath, dir))) {
            throw bang.forbidden();
        }
        const status = await Deno.stat(filePath);
        if (status.isFile) {
            return h.file(filePath, { confine : dir });
        }
        if (!options?.listing) {
            throw bang.forbidden();
        }
        const files = await readDirStats(filePath);
        return renderFileList(path.relative(dir, filePath), files);
    };
};

@sholladay Let me know if you have some suggestions on how this should be added to pogo, and I can put together a PR. I was able to get the file to run, and return the bytes of the file, but I haven't tried the file listing portion yet.

Update: Added a test to check the file listing option.

// Implemenation deps
import { join, relative } from 'https://deno.land/std/path/mod.ts'
import { RouteHandler } from 'https://deno.land/x/pogo/lib/types.ts'
import isPathInside from 'https://deno.land/x/pogo/lib/util/is-path-inside.ts'
import * as bang from 'https://deno.land/x/pogo/lib/bang.ts'

// Testing deps
import { assert } from 'https://deno.land/std@v0.56.0/testing/asserts.ts'
import pogo from 'https://deno.land/x/pogo/main.ts'
import Toolkit from 'https://deno.land/x/pogo/lib/toolkit.ts'
import Response from 'https://deno.land/x/pogo/lib/response.ts'
import Request from 'https://deno.land/x/pogo/lib/request.ts'
import * as http from 'https://deno.land/std@v0.56.0/http/server.ts'
import Server from 'https://deno.land/x/pogo/lib/server.ts'
const { cwd } = Deno

// Implementation
interface NamedStat extends Deno.FileInfo { name: Deno.DirEntry['name'] }

const fileLi = (basePath: string, file: Deno.FileInfo & { name: string }) => {
    return `
      <li key='${ file.name}'>
          <a href='${ basePath + '/' + file.name}'>${file.name}</a>
      </li>
  `
}

const renderFileList = (
    basePath: string,
    files: Array<Deno.FileInfo & { name: string }>
) => `
    <html>
        <body>
            <ul>${ files.map(fileLi.bind(null, basePath)).join('')}</ul>
        </body>
    </html>
`

const readDirStats = async (dir: string): Promise<Array<NamedStat>> => {
    const stats: Array<Promise<NamedStat>> = []

    const statEntry = async (file: Deno.DirEntry): Promise<NamedStat> => {
        const status = await Deno.stat(join(dir, file.name));
        return {
            ...file,
            ...status
        }
    }

    for await (const child of Deno.readDir(dir)) {
        stats.push(statEntry(child))
    }

    return Promise.all(stats)
}

const directory = (dir: string, options?: { listing: boolean }): RouteHandler => {
    return async (request, h) => {

        const fp = Object.values(request.params).find(value => {
            return request.url.pathname.endsWith(value)
        })

        const filePath = join(dir, fp ?? '.')

        if (filePath !== dir && !(await isPathInside.fs(filePath, dir))) {
            throw bang.forbidden()
        }

        const status = await Deno.stat(filePath)

        if (status.isFile) {
            return h.file(filePath, { confine: dir })
        }

        if (!options?.listing) {
            throw bang.forbidden()
        }

        const files = await readDirStats(filePath)
        return renderFileList(relative(dir, filePath), files);
    }
}

// Testing
const server = pogo.server({ port: 3000 })

server.route({
    method: 'GET',
    path: '/',
    handler: directory(cwd(), { listing: true })
})

server.route({
    method: 'GET',
    path: '/{*}',
    handler: directory(cwd())
})

let serverRequest, response

// Test listing response
serverRequest = new http.ServerRequest()
serverRequest.method = 'GET'
serverRequest.url = '/'

response = await server.inject(serverRequest)

console.log(response.body)

// Test file response
serverRequest = new http.ServerRequest()
serverRequest.method = 'GET'
serverRequest.url = '/file1.txt'

response = await server.inject(serverRequest)

console.log(response.body)

// Example
//$ echo 'hi' > file1.txt
//$ echo 'hello' > file2.txt
//$ deno run --allow-read index.ts
//Check file:///Users/user/projects/project/index.ts
//    <html>
//        <body>
//            <ul>
//      <li key='file2.txt'>
//          <a href='/file2.txt'>file2.txt</a>
//      </li>
//      <li key='file1.txt'>
//          <a href='/file1.txt'>file1.txt</a>
//      </li>
//      <li key='index.ts'>
//          <a href='/index.ts'>index.ts</a>
//      </li>
//      <li key='update.ts'>
//          <a href='/update.ts'>update.ts</a>
//      </li>
//  </ul>
//        </body>
//    </html>
//Uint8Array(3) [ 104, 105, 10 ]

Thanks, @afaur. I'll be working on this tonight. Should have a PR in the next day or two. Will ping the people in this thread to help review it.

@sholladay Glad I could help. Let me know if there is anything else that might be good to take a look at.

@sholladay Does deno support automatic jsx to html or React.createElement when you have a .jsx file?

I wondered why I needed to change your react code to backticks to get it to work, and now realize that I probably should have made the file .jsx when I was testing on my system.

Update:

  • I tried changing the example to use .jsx and seemed to get an error about:
    • interface NamedStat extends Deno.FileInfo { name: Deno.DirEntry['name'] }
    • error: Unexpected token Some(Word(interface))
  • So then I tried .tsx and seemed to get an error about the <li> tag:
    • error: Unexpected token Some(Word(li))
  • I am probably doing something wrong in my setup, but not sure what.

Deno (via TypeScript) compiles the JSX syntax to React.createElement() calls, so long as a .jsx or .tsx file extension is used. However, Deno does not provide an implementation of React, so we need to import React anywhere that JSX is used. Pogo is responsible for rendering those React calls to static HTML markup via ReactDOMServer. Or you can render them yourself and return the markup as a string, Pogo will still treat it as HTML.

I hope that clears things up! We could probably use an FAQ section with that in it. I also just noticed that the README mentions the Pika CDN, which I had to stop using due to some bugs. That needs to be updated.

Good news! This is now available on the master branch. It would be great if everyone here could give it a try over the next few days to find any issues, before I tag a release.

To do so, import Pogo without a version (or alternatively, use a recent commit hash) and then use h.directory().

Documentation here:
https://github.com/sholladay/pogo#using-hdirectory-recommended