Skip to content

Commit

Permalink
implement offline fallback map fastify plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
achou11 committed Jan 30, 2024
1 parent f5be6af commit a18ecdc
Show file tree
Hide file tree
Showing 9 changed files with 310 additions and 34 deletions.
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"husky": "^8.0.0",
"light-my-request": "^5.10.0",
"lint-staged": "^14.0.1",
"mapeo-offline-map": "^2.0.0",
"math-random-seed": "^2.0.0",
"nanobench": "^3.0.0",
"npm-run-all": "^4.1.5",
Expand Down
58 changes: 35 additions & 23 deletions src/fastify-plugins/maps/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import fp from 'fastify-plugin'

import {
NotFoundError,
createStyleJsonResponseHeaders,
getFastifyServerAddress,
} from '../utils.js'
import { PLUGIN_NAME as MAPEO_STATIC_MAPS } from './static-maps.js'
import { NotFoundError, getFastifyServerAddress } from '../utils.js'
import { PLUGIN_NAME as MAPEO_OFFLINE_FALLBACK } from './offline-fallback-map.js'

export const PLUGIN_NAME = 'mapeo-maps'

export const plugin = fp(mapsPlugin, {
fastify: '4.x',
name: PLUGIN_NAME,
decorators: { fastify: ['mapeoStaticMaps'] },
dependencies: [MAPEO_STATIC_MAPS],
decorators: { fastify: ['mapeoStaticMaps', 'mapeoFallbackMap'] },
dependencies: [MAPEO_STATIC_MAPS, MAPEO_OFFLINE_FALLBACK],
})

/**
Expand All @@ -33,32 +38,39 @@ async function routes(fastify) {
{
const styleId = 'default'

let stats, styleJson
const results = await Promise.all([
fastify.mapeoStaticMaps.getStyleJsonStats(styleId),
fastify.mapeoStaticMaps.getResolvedStyleJson(styleId, serverAddress),
]).catch(() => {
fastify.log.warn('Cannot read default static map')
return null
})

if (results) {
const [stats, styleJson] = results
rep.headers(createStyleJsonResponseHeaders(stats.mtime))
return styleJson
}
}

// TODO: 2. Attempt to get map's style.json from online source

// 3. Provide offline fallback map's style.json
{
let results = null

try {
const results = await Promise.all([
fastify.mapeoStaticMaps.getStyleJsonStats(styleId),
fastify.mapeoStaticMaps.getResolvedStyleJson(styleId, serverAddress),
results = await Promise.all([
fastify.mapeoFallbackMap.getStyleJsonStats(),
fastify.mapeoFallbackMap.getResolvedStyleJson(serverAddress),
])

stats = results[0]
styleJson = results[1]
} catch (err) {
throw new NotFoundError(`id = ${styleId}, style.json`)
throw new NotFoundError(`id = fallback, style.json`)
}

rep.headers({
'Cache-Control': 'max-age=' + 5 * 60, // 5 minutes
'Access-Control-Allow-Headers':
'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since',
'Access-Control-Allow-Origin': '*',
'Last-Modified': new Date(stats.mtime).toUTCString(),
})

const [stats, styleJson] = results
rep.headers(createStyleJsonResponseHeaders(stats.mtime))
return styleJson
}

// TODO: 2. Attempt to get map's style.json from online source

// TODO: 3. Provide offline fallback map's style.json
})
}
117 changes: 117 additions & 0 deletions src/fastify-plugins/maps/offline-fallback-map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import path from 'path'
import fs from 'fs/promises'
import FastifyStatic from '@fastify/static'
import fp from 'fastify-plugin'

import {
NotFoundError,
createStyleJsonResponseHeaders,
getFastifyServerAddress,
} from '../utils.js'

export const PLUGIN_NAME = 'mapeo-static-maps'

export const plugin = fp(offlineFallbackMapPlugin, {
fastify: '4.x',
name: PLUGIN_NAME,
})

/**
* @typedef {object} OfflineFallbackMapPluginOpts
* @property {string} [prefix]
* @property {string} styleJsonPath
* @property {string} sourcesDir
*/

/**
* @typedef {object} FallbackMapPluginDecorator
* @property {(serverAddress: string) => Promise<any>} getResolvedStyleJson
* @property {() => Promise<import('node:fs').Stats>} getStyleJsonStats
*/

/** @type {import('fastify').FastifyPluginAsync<OfflineFallbackMapPluginOpts>} */
async function offlineFallbackMapPlugin(fastify, opts) {
const { styleJsonPath, sourcesDir } = opts

fastify.decorate(
'mapeoFallbackMap',
/** @type {FallbackMapPluginDecorator} */
({
async getResolvedStyleJson(serverAddress) {
const rawStyleJson = await fs.readFile(styleJsonPath, 'utf-8')
const styleJson = JSON.parse(rawStyleJson)

const sources = styleJson.sources || {}

const sourcesDirFiles = await fs.readdir(sourcesDir, {
withFileTypes: true,
})

for (const file of sourcesDirFiles) {
// Only work with files
if (file.isDirectory()) continue

// Ignore the style.json file if it exists
if (file.name === 'style.json') continue

// Only work with json or geojson files
const extension = path.extname(file.name)
if (!(extension === '.json' || extension === '.geojson')) continue

const sourceName = path.basename(file.name, extension) + '-source'

sources[sourceName] = {
type: 'geojson',
data: new URL(`${opts.prefix || ''}/${file.name}`, serverAddress)
.href,
}
}

styleJson.sources = sources

return styleJson
},
async getStyleJsonStats() {
return fs.stat(styleJsonPath)
},
})
)

fastify.register(routes, {
prefix: opts.prefix,
styleJsonPath: opts.styleJsonPath,
sourcesDir: opts.sourcesDir,
})
}

/** @type {import('fastify').FastifyPluginAsync<OfflineFallbackMapPluginOpts, import('fastify').RawServerDefault, import('@fastify/type-provider-typebox').TypeBoxTypeProvider>} */
async function routes(fastify, opts) {
const { sourcesDir } = opts

fastify.register(FastifyStatic, {
root: sourcesDir,
decorateReply: false,
})

fastify.get('/style.json', async (req, rep) => {
const serverAddress = await getFastifyServerAddress(req.server.server)

let stats, styleJson

try {
const results = await Promise.all([
fastify.mapeoFallbackMap.getStyleJsonStats(),
fastify.mapeoFallbackMap.getResolvedStyleJson(serverAddress),
])

stats = results[0]
styleJson = results[1]
} catch (err) {
throw new NotFoundError(`id = fallback, style.json`)
}

rep.headers(createStyleJsonResponseHeaders(stats.mtime))

return styleJson
})
}
16 changes: 6 additions & 10 deletions src/fastify-plugins/maps/static-maps.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import asar from '@electron/asar'
import { Mime } from 'mime/lite'
import standardTypes from 'mime/types/standard.js'

import { NotFoundError, getFastifyServerAddress } from '../utils.js'
import {
NotFoundError,
createStyleJsonResponseHeaders,
getFastifyServerAddress,
} from '../utils.js'

export const PLUGIN_NAME = 'mapeo-static-maps'

Expand Down Expand Up @@ -192,15 +196,7 @@ async function routes(fastify, opts) {
throw new NotFoundError(`id = ${styleId}, style.json`)
}

rep.headers({
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'max-age=' + 5 * 60, // 5 minutes
'Access-Control-Allow-Headers':
'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since',
'Access-Control-Allow-Origin': '*',
'Last-Modified': new Date(stats.mtime).toUTCString(),
'Content-Length': Buffer.from(styleJson).length,
})
rep.headers(createStyleJsonResponseHeaders(stats.mtime))

return styleJson
}
Expand Down
13 changes: 13 additions & 0 deletions src/fastify-plugins/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,16 @@ export async function getFastifyServerAddress(server, { timeout } = {}) {

return 'http://' + addr
}

/**
* @param {Date} lastModified
*/
export function createStyleJsonResponseHeaders(lastModified) {
return {
'Cache-Control': 'max-age=' + 5 * 60, // 5 minutes
'Access-Control-Allow-Headers':
'Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since',
'Access-Control-Allow-Origin': '*',
'Last-Modified': new Date(lastModified).toUTCString(),
}
}
46 changes: 45 additions & 1 deletion tests/fastify-plugins/maps.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { test } from 'brittle'
import path from 'node:path'
import Fastify from 'fastify'

import { plugin as MapsPlugin } from '../../src/fastify-plugins/maps/index.js'
import { plugin as StaticMapsPlugin } from '../../src/fastify-plugins/maps/static-maps.js'
import { plugin as OfflineFallbackMapPlugin } from '../../src/fastify-plugins/maps/offline-fallback-map.js'

const MAP_FIXTURES_PATH = new URL('../fixtures/maps', import.meta.url).pathname

const MAPEO_FALLBACK_MAP_PATH = new URL(
'../../node_modules/mapeo-offline-map',
import.meta.url
).pathname

test('fails to register when dependent plugins are not registered', async (t) => {
const server = setup(t)

Expand All @@ -21,6 +28,11 @@ test('prefix opt is handled correctly', async (t) => {
prefix: 'static',
staticRootDir: MAP_FIXTURES_PATH,
})
server.register(OfflineFallbackMapPlugin, {
prefix: 'fallback',
styleJsonPath: path.join(MAPEO_FALLBACK_MAP_PATH, 'style.json'),
sourcesDir: path.join(MAPEO_FALLBACK_MAP_PATH, 'dist'),
})

server.register(MapsPlugin, { prefix: 'maps' })

Expand All @@ -47,14 +59,43 @@ test('prefix opt is handled correctly', async (t) => {
}
})

// TODO: Add similar tests/fixtures for proxied online style and offline fallback style
test('/style.json resolves style.json of local "default" static map when available', async (t) => {
const server = setup(t)

server.register(StaticMapsPlugin, {
prefix: 'static',
staticRootDir: MAP_FIXTURES_PATH,
})
server.register(OfflineFallbackMapPlugin, {
prefix: 'fallback',
styleJsonPath: path.join(MAPEO_FALLBACK_MAP_PATH, 'style.json'),
sourcesDir: path.join(MAPEO_FALLBACK_MAP_PATH, 'dist'),
})
server.register(MapsPlugin)

await server.listen()

const response = await server.inject({
method: 'GET',
url: '/style.json',
})

t.is(response.statusCode, 200)
})

test('/style.json resolves style.json of offline fallback map when static and online are not available', async (t) => {
const server = setup(t)

server.register(StaticMapsPlugin, {
prefix: 'static',
// Need to choose a directory that doesn't have any map fixtures
staticRootDir: path.resolve(MAP_FIXTURES_PATH, '../does-not-exist'),
})
server.register(OfflineFallbackMapPlugin, {
prefix: 'fallback',
styleJsonPath: path.join(MAPEO_FALLBACK_MAP_PATH, 'style.json'),
sourcesDir: path.join(MAPEO_FALLBACK_MAP_PATH, 'dist'),
})
server.register(MapsPlugin)

await server.listen()
Expand All @@ -64,9 +105,12 @@ test('/style.json resolves style.json of local "default" static map when availab
url: '/style.json',
})

t.is(response.json().id, 'blank', 'gets fallback style.json')
t.is(response.statusCode, 200)
})

// TODO: add test for proxying online map style.json

/**
* @param {import('brittle').TestInstance} t
*/
Expand Down
Loading

0 comments on commit a18ecdc

Please sign in to comment.