diff --git a/lib/middleware.ts b/lib/middleware.ts index d44ecda6..4030631a 100644 --- a/lib/middleware.ts +++ b/lib/middleware.ts @@ -27,6 +27,16 @@ interface MiddlewareOptions { liveReloadPath?: string; } +function findClosestIndexFileForPath(outputPath: string, prefix: string): string | undefined { + const candidates = []; + const parts = prefix.split('/'); + while (parts.length) { + parts.pop(); + candidates.push(resolvePath(outputPath, [...parts, 'index.html'].join(path.sep))); + } + return candidates.find(file => fs.existsSync(file)); +} + // You must call watcher.start() before you call `getMiddleware` // // This middleware is for development use only. It hasn't been reviewed @@ -45,7 +55,7 @@ function handleRequest( // eslint-disable-next-line node/no-deprecated-api const urlObj = url.parse(request.url); const pathname = urlObj.pathname || ''; - let filename: string, stat; + let filename: string, stat!: fs.Stats; try { filename = decodeURIComponent(pathname); @@ -66,9 +76,45 @@ function handleRequest( try { stat = fs.statSync(filename); } catch (e) { - // not found - next(); - return; + const nameStats = path.parse(filename); + const acceptHeaders = request.headers.accept || []; + const hasHTMLHeader = acceptHeaders.indexOf('text/html') !== -1; + const hasCorrectRequestType = ['GET'].includes(request.method); + const hasCorrectPathName = nameStats.ext === ''; + + let maybeIndex; + + if (!filename.substr(1).includes('.')) { + maybeIndex = findClosestIndexFileForPath(outputPath, filename.substr(1)); + } + + const matchSPAconditions = [ + hasCorrectPathName, + hasHTMLHeader, + hasCorrectRequestType, + maybeIndex, + ]; + // if it's looks like an SPA path + if (matchSPAconditions.every(el => el)) { + filename = (maybeIndex as string).replace(path.sep + 'index.html', ''); + try { + stat = fs.statSync(filename); + } catch (e) { + if ((e as Error & { code: string }).code == 'ENOENT') { + // no such file or directory. File really does not exist + // not found + next(); + return; + } else { + // have no idea how to handle it + return; + } + } + } else { + // not found + next(); + return; + } } if (stat.isDirectory()) { diff --git a/package.json b/package.json index 074de50d..04dcbd22 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "watch-detector": "^1.0.0" }, "devDependencies": { + "@types/connect": "^3.4.35", "@types/console-ui": "^2.2.3", "@types/esm": "^3.2.0", "@types/findup-sync": "^2.0.2", diff --git a/test/builder_test.js b/test/builder_test.js index 1634be91..bee7797f 100644 --- a/test/builder_test.js +++ b/test/builder_test.js @@ -17,6 +17,7 @@ import heimdall from 'heimdalljs'; const multidepRequire = MultidepRequire('test/multidep.json'); const Plugin = multidepRequire('broccoli-plugin', '1.3.0'); const broccoliSource = multidepRequire('broccoli-source', '1.1.0'); +const isWin = os.platform() === 'win32'; const Builder = broccoli.Builder; const expect = chai.expect; @@ -1040,7 +1041,8 @@ describe('Builder', function() { // the actual results of process.hrtime() are not // reliable if (process.env.CI !== 'true') { - expect(a).to.be.within(b, b + 10e6); + const delta = isWin ? 15e6 : 10e6; + expect(a).to.be.within(b, b + delta); } }; diff --git a/test/fixtures/spa/index.html b/test/fixtures/spa/index.html new file mode 100644 index 00000000..e0c7e8ee --- /dev/null +++ b/test/fixtures/spa/index.html @@ -0,0 +1,3 @@ + + Hello from SPA + \ No newline at end of file diff --git a/test/server_test.js b/test/server_test.js index d6d5ae16..3a09181b 100644 --- a/test/server_test.js +++ b/test/server_test.js @@ -113,6 +113,60 @@ describe('server', function() { expect(statusCode).to.eql(200); }).timeout(5000); + it('support SPA routing to index.html from child paths', async function() { + const mockUI = new MockUI(); + const builder = new Builder(new broccoliSource.WatchedDir('test/fixtures/spa')); + const watcher = new Watcher(builder, []); + server = new Server.Server(watcher, '127.0.0.1', PORT, undefined, mockUI); + server.start(); + await new Promise(resolve => { + server.instance.on('listening', resolve); + }); + const { statusCode, body } = await got(`http://127.0.0.1:${PORT}/foo/bar/baz`, { + headers: { + Accept: 'text/html', + }, + }); // basic serving + expect(statusCode).to.eql(200); + expect(body).to.contain('Hello from SPA'); + }).timeout(5000); + + it("skip SPA routing to index.html from child path if it's ends with extension", async function() { + const mockUI = new MockUI(); + const builder = new Builder(new broccoliSource.WatchedDir('test/fixtures/spa')); + const watcher = new Watcher(builder, []); + server = new Server.Server(watcher, '127.0.0.1', PORT, undefined, mockUI); + server.start(); + await new Promise(resolve => { + server.instance.on('listening', resolve); + }); + try { + await got(`http://127.0.0.1:${PORT}/foo/bar/baz.png`); + expect.fail('expected rejection'); + } catch (e) { + expect(e.statusCode).to.equal(404); + expect(e.body).to.include(`Cannot GET /foo/bar/baz.png`); + } + }).timeout(5000); + + it('skip SPA routing to index.html from child path contains dot', async function() { + const mockUI = new MockUI(); + const builder = new Builder(new broccoliSource.WatchedDir('test/fixtures/spa')); + const watcher = new Watcher(builder, []); + server = new Server.Server(watcher, '127.0.0.1', PORT, undefined, mockUI); + server.start(); + await new Promise(resolve => { + server.instance.on('listening', resolve); + }); + try { + await got(`http://127.0.0.1:${PORT}/foo/b.ar/baz`); + expect.fail('expected rejection'); + } catch (e) { + expect(e.statusCode).to.equal(404); + expect(e.body).to.include(`Cannot GET /foo/b.ar/baz`); + } + }).timeout(5000); + it('buildSuccess is handled', async function() { const mockUI = new MockUI(); const builder = new Builder(new broccoliSource.WatchedDir('test/fixtures/basic')); diff --git a/yarn.lock b/yarn.lock index a2a7f2b2..b63264fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -113,6 +113,13 @@ dependencies: "@types/node" "*" +"@types/connect@^3.4.35": + version "3.4.35" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" + integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== + dependencies: + "@types/node" "*" + "@types/console-ui@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@types/console-ui/-/console-ui-2.2.3.tgz#14fb18729de6820d07c9b9d81249add14a7c5eba"