Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add functionality to serve offline fallback map #450

Merged
merged 7 commits into from
Mar 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -91,6 +91,7 @@
"husky": "^8.0.0",
"light-my-request": "^5.10.0",
"lint-staged": "^14.0.1",
"mapeo-offline-map": "^2.0.0",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added as a dev dependency but if we want to be more strict about using this specifically, can change to direct or peer dep.

wasn't too keen on the offline map being bundled as of the npm package so i figured it would make sense to let the consumer decide to add the map as a dependency in its own project

"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
})
}
114 changes: 114 additions & 0 deletions src/fastify-plugins/maps/offline-fallback-map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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) {
if (!file.isFile()) continue

if (file.name === 'style.json') continue

const extension = path.extname(file.name).toLowerCase()
if (!(extension === '.json' || extension === '.geojson')) continue
EvanHahn marked this conversation as resolved.
Show resolved Hide resolved

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
},
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
})
}
20 changes: 7 additions & 13 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 @@ -186,19 +190,9 @@ async function routes(fastify, opts) {
throw new NotFoundError(`id = ${styleId}, style.json`)
}

const styleJsonBytes = Buffer.from(styleJson)
rep.headers(createStyleJsonResponseHeaders(stats.mtime))

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': styleJsonBytes.length,
})

return styleJsonBytes
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 {Readonly<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': lastModified.toUTCString(),
}
}
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { plugin as MapeoStaticMapsFastifyPlugin } from './fastify-plugins/maps/static-maps.js'
export { plugin as MapeoOfflineFallbackMapFastifyPlugin } from './fastify-plugins/maps/offline-fallback-map.js'
export { plugin as MapeoMapsFastifyPlugin } from './fastify-plugins/maps/index.js'
export { FastifyController } from './fastify-controller.js'
export { MapeoManager } from './mapeo-manager.js'
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 MapServerPlugin } 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(MapServerPlugin, { 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(MapServerPlugin)

await server.listen()

EvanHahn marked this conversation as resolved.
Show resolved Hide resolved
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(MapServerPlugin)

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
Loading