From a885f4098a3bc44eea065c67d5264c815bf99f67 Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Wed, 3 May 2023 23:58:22 +0800 Subject: [PATCH 1/2] implement metadata and TileJSON-like responses for Cloudflare Workers [#169] --- serverless/cloudflare/src/index.test.ts | 22 ++++-- serverless/cloudflare/src/index.ts | 100 ++++++++++++++++++++---- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/serverless/cloudflare/src/index.test.ts b/serverless/cloudflare/src/index.test.ts index 64fe012..5e9865e 100644 --- a/serverless/cloudflare/src/index.test.ts +++ b/serverless/cloudflare/src/index.test.ts @@ -31,9 +31,9 @@ test("parse tile default", () => { ({ name, tile } = tile_path("/foo/11/22/33.pbf")); assert.strictEqual(name, "foo"); - assert.strictEqual(tile[0], 11); - assert.strictEqual(tile[1], 22); - assert.strictEqual(tile[2], 33); + assert.strictEqual(tile![0], 11); + assert.strictEqual(tile![1], 22); + assert.strictEqual(tile![2], 33); }); test("parse tile path setting", () => { @@ -41,17 +41,17 @@ test("parse tile path setting", () => { "/foo/11/22/33.pbf", "/{name}/{z}/{y}/{x}.{ext}" ); - assert.strictEqual(tile[1], 33); - assert.strictEqual(tile[2], 22); + assert.strictEqual(tile![1], 33); + assert.strictEqual(tile![2], 22); assert.strictEqual(ext, "pbf"); ({ ok, name, tile, ext } = tile_path( "/tiles/foo/4/2/3.mvt", "/tiles/{name}/{z}/{x}/{y}.{ext}" )); assert.strictEqual(name, "foo"); - assert.strictEqual(tile[0], 4); - assert.strictEqual(tile[1], 2); - assert.strictEqual(tile[2], 3); + assert.strictEqual(tile![0], 4); + assert.strictEqual(tile![1], 2); + assert.strictEqual(tile![2], 3); assert.strictEqual(ext, "mvt"); }); @@ -70,3 +70,9 @@ test("parse tile path setting slash", () => { ); assert.strictEqual(name, "foo/bar"); }); + +test("parse tileset default", () => { + let { ok, name, tile, ext } = tile_path("/abcd.json"); + assert.strictEqual(ok, true); + assert.strictEqual("abcd", name); +}); \ No newline at end of file diff --git a/serverless/cloudflare/src/index.ts b/serverless/cloudflare/src/index.ts index 2a33f45..b67b60e 100644 --- a/serverless/cloudflare/src/index.ts +++ b/serverless/cloudflare/src/index.ts @@ -18,6 +18,7 @@ interface Env { ALLOWED_ORIGINS?: string; PMTILES_PATH?: string; TILE_PATH?: string; + TILESET_PATH?: string; CACHE_MAX_AGE?: number; } @@ -37,33 +38,57 @@ export const pmtiles_path = (name: string, setting?: string): string => { const TILE = /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+)\/(?\d+)\/(?\d+)\/(?\d+).(?[a-z]+)$/; +const TILESET = /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+).json$/; + export const tile_path = ( path: string, - setting?: string + tile_setting?: string, + tileset_setting?: string ): { ok: boolean; name: string; - tile: [number, number, number]; + tile?: [number, number, number]; ext: string; } => { - let pattern = TILE; - if (setting) { + let tile_pattern = TILE; + if (tile_setting) { // escape regex - setting = setting.replace(/[.*+?^$()|[\]\\]/g, "\\$&"); - setting = setting.replace("{name}", "(?[0-9a-zA-Z/!-_.*'()]+)"); - setting = setting.replace("{z}", "(?\\d+)"); - setting = setting.replace("{x}", "(?\\d+)"); - setting = setting.replace("{y}", "(?\\d+)"); - setting = setting.replace("{ext}", "(?[a-z]+)"); - pattern = new RegExp(setting); + tile_setting = tile_setting.replace(/[.*+?^$()|[\]\\]/g, "\\$&"); + tile_setting = tile_setting.replace( + "{name}", + "(?[0-9a-zA-Z/!-_.*'()]+)" + ); + tile_setting = tile_setting.replace("{z}", "(?\\d+)"); + tile_setting = tile_setting.replace("{x}", "(?\\d+)"); + tile_setting = tile_setting.replace("{y}", "(?\\d+)"); + tile_setting = tile_setting.replace("{ext}", "(?[a-z]+)"); + tile_pattern = new RegExp(tile_setting); } - let match = path.match(pattern); + let tile_match = path.match(tile_pattern); - if (match) { - const g = match.groups!; + if (tile_match) { + const g = tile_match.groups!; return { ok: true, name: g.NAME, tile: [+g.Z, +g.X, +g.Y], ext: g.EXT }; } + + let tileset_pattern = TILESET; + if (tileset_setting) { + tileset_setting = tileset_setting.replace(/[.*+?^$()|[\]\\]/g, "\\$&"); + tileset_setting = tileset_setting.replace( + "{name}", + "(?[0-9a-zA-Z/!-_.*'()]+)" + ); + tileset_pattern = new RegExp(tileset_setting); + } + + let tileset_match = path.match(tileset_pattern); + + if (tileset_match) { + const g = tileset_match.groups!; + return { ok: true, name: g.NAME, ext: "json" }; + } + return { ok: false, name: "", tile: [0, 0, 0], ext: "" }; }; @@ -184,6 +209,53 @@ export default { const p = new PMTiles(source, CACHE, nativeDecompress); try { const p_header = await p.getHeader(); + + if (!tile) { + const metadata = await p.getMetadata(); + cacheable_headers.set("Content-Type", "application/json"); + + let ext = ""; + if (p_header.tileType === TileType.Mvt) { + ext = ".mvt"; + } else if (p_header.tileType === TileType.Png) { + ext = ".png"; + } else if (p_header.tileType === TileType.Jpeg) { + ext = ".jpg"; + } else if (p_header.tileType === TileType.Webp) { + ext = ".webp"; + } + + // TODO: this needs to be based on the TILE_PATH setting + metadata.tiles = [ + url.protocol + + "//" + + url.hostname + + "/" + + name + + "/{z}/{x}/{y}" + + ext, + ]; + metadata.bounds = [ + p_header.minLon, + p_header.minLat, + p_header.maxLon, + p_header.maxLat, + ]; + metadata.center = [ + p_header.centerLon, + p_header.centerLat, + p_header.centerZoom, + ]; + metadata.maxzoom = p_header.maxZoom; + metadata.minzoom = p_header.minZoom; + metadata.scheme = "xyz"; + return cacheableResponse( + JSON.stringify(metadata), + cacheable_headers, + 200 + ); + } + if (tile[0] < p_header.minZoom || tile[0] > p_header.maxZoom) { return cacheableResponse(undefined, cacheable_headers, 404); } From dc8eb7345896b61e33bd930e2a139c036c40a1ba Mon Sep 17 00:00:00 2001 From: Brandon Liu Date: Wed, 21 Jun 2023 18:19:10 +0800 Subject: [PATCH 2/2] TileJSON support for Cloudflare and AWS [#169] * Remove TILE_PATH configuration as this makes supporting non-tile paths difficult * create shared/ dir in serverless for common code * linting fixes --- serverless/aws/package-lock.json | 88 +++++++++++++ serverless/aws/package.json | 4 +- serverless/aws/src/index.ts | 79 +++++------- serverless/cloudflare/package.json | 2 +- serverless/cloudflare/src/index.test.ts | 78 ------------ serverless/cloudflare/src/index.ts | 160 +++--------------------- serverless/shared/index.test.ts | 27 ++++ serverless/shared/index.ts | 71 +++++++++++ 8 files changed, 239 insertions(+), 270 deletions(-) delete mode 100644 serverless/cloudflare/src/index.test.ts create mode 100644 serverless/shared/index.test.ts create mode 100644 serverless/shared/index.ts diff --git a/serverless/aws/package-lock.json b/serverless/aws/package-lock.json index f038f61..2990a60 100644 --- a/serverless/aws/package-lock.json +++ b/serverless/aws/package-lock.json @@ -15,6 +15,7 @@ "@types/aws-lambda": "^8.10.108", "@types/node": "^18.11.2", "esbuild": "^0.15.11", + "esbuild-runner": "^2.2.2", "typescript": "^4.8.4" } }, @@ -1351,6 +1352,12 @@ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/esbuild": { "version": "0.15.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.11.tgz", @@ -1644,6 +1651,28 @@ "node": ">=12" } }, + "node_modules/esbuild-runner": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", + "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", + "dev": true, + "dependencies": { + "source-map-support": "0.5.21", + "tslib": "2.4.0" + }, + "bin": { + "esr": "bin/esr.js" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/esbuild-runner/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + }, "node_modules/esbuild-sunos-64": { "version": "0.15.11", "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.11.tgz", @@ -1723,6 +1752,25 @@ "url": "https://paypal.me/naturalintelligence" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -2870,6 +2918,12 @@ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "esbuild": { "version": "0.15.11", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.11.tgz", @@ -3012,6 +3066,24 @@ "dev": true, "optional": true }, + "esbuild-runner": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esbuild-runner/-/esbuild-runner-2.2.2.tgz", + "integrity": "sha512-fRFVXcmYVmSmtYm2mL8RlUASt2TDkGh3uRcvHFOKNr/T58VrfVeKD9uT9nlgxk96u0LS0ehS/GY7Da/bXWKkhw==", + "dev": true, + "requires": { + "source-map-support": "0.5.21", + "tslib": "2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, "esbuild-sunos-64": { "version": "0.15.11", "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.11.tgz", @@ -3048,6 +3120,22 @@ "strnum": "^1.0.5" } }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", diff --git a/serverless/aws/package.json b/serverless/aws/package.json index 1436dd0..ae1e104 100644 --- a/serverless/aws/package.json +++ b/serverless/aws/package.json @@ -5,12 +5,14 @@ "@types/aws-lambda": "^8.10.108", "@types/node": "^18.11.2", "esbuild": "^0.15.11", + "esbuild-runner": "^2.2.2", "typescript": "^4.8.4" }, "private": true, "scripts": { "tsc": "tsc --noEmit --watch", - "build": "esbuild src/index.ts --target=es2020 --outfile=dist/index.mjs --format=esm --bundle --platform=node --target=node18 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//$(git describe --always) && cd dist && zip lambda_function.zip index.mjs" + "build": "esbuild src/index.ts --target=es2020 --outfile=dist/index.mjs --format=esm --bundle --platform=node --target=node18 --external:@aws-sdk/client-s3 --external:@aws-sdk/node-http-handler --banner:js=//$(git describe --always) && cd dist && zip lambda_function.zip index.mjs", + "test": "node -r esbuild-runner/register ../shared/index.test.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.213.0", diff --git a/serverless/aws/src/index.ts b/serverless/aws/src/index.ts index 1d6629f..2130b4c 100644 --- a/serverless/aws/src/index.ts +++ b/serverless/aws/src/index.ts @@ -1,4 +1,3 @@ -import { Readable } from "stream"; import { Context, APIGatewayProxyResult, @@ -12,8 +11,8 @@ import { Compression, TileType, } from "../../../js/index"; +import { pmtiles_path, tile_path, tileJSON } from "../../shared/index"; -import https from "https"; import zlib from "zlib"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; @@ -43,47 +42,6 @@ async function nativeDecompress( // Lambda needs to run with 512MB, empty function takes about 70 const CACHE = new ResolvedValueCache(undefined, undefined, nativeDecompress); -// duplicated code below -export const pmtiles_path = (name: string, setting?: string): string => { - if (setting) { - return setting.replaceAll("{name}", name); - } - return name + ".pmtiles"; -}; - -const TILE = - /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+)\/(?\d+)\/(?\d+)\/(?\d+).(?[a-z]+)$/; - -export const tile_path = ( - path: string, - setting?: string -): { - ok: boolean; - name: string; - tile: [number, number, number]; - ext: string; -} => { - let pattern = TILE; - if (setting) { - // escape regex - setting = setting.replace(/[.*+?^$()|[\]\\]/g, "\\$&"); - setting = setting.replace("{name}", "(?[0-9a-zA-Z/!-_.*'()]+)"); - setting = setting.replace("{z}", "(?\\d+)"); - setting = setting.replace("{x}", "(?\\d+)"); - setting = setting.replace("{y}", "(?\\d+)"); - setting = setting.replace("{ext}", "(?[a-z]+)"); - pattern = new RegExp(setting); - } - - let match = path.match(pattern); - - if (match) { - const g = match.groups!; - return { ok: true, name: g.NAME, tile: [+g.Z, +g.X, +g.Y], ext: g.EXT }; - } - return { ok: false, name: "", tile: [0, 0, 0], ext: "" }; -}; - class S3Source implements Source { archive_name: string; @@ -138,11 +96,11 @@ const apiResp = ( // Does not work with CloudFront events/Lambda@Edge; see README export const handlerRaw = async ( event: APIGatewayProxyEventV2, - context: Context, + _context: Context, tilePostprocess?: (a: ArrayBuffer, t: TileType) => ArrayBuffer ): Promise => { - var path; - var is_api_gateway; + let path; + let is_api_gateway; if (event.pathParameters) { is_api_gateway = true; if (event.pathParameters.proxy) { @@ -158,14 +116,13 @@ export const handlerRaw = async ( return apiResp(500, "Invalid event configuration"); } - var headers: Headers = {}; - // TODO: metadata and TileJSON + const headers: Headers = {}; if (process.env.CORS) { headers["Access-Control-Allow-Origin"] = process.env.CORS; } - const { ok, name, tile, ext } = tile_path(path, process.env.TILE_PATH); + const { ok, name, tile, ext } = tile_path(path); if (!ok) { return apiResp(400, "Invalid tile URL", false, headers); @@ -175,6 +132,28 @@ export const handlerRaw = async ( const p = new PMTiles(source, CACHE, nativeDecompress); try { const header = await p.getHeader(); + + if (!tile) { + if (!process.env.PUBLIC_HOSTNAME) { + return apiResp( + 400, + "PUBLIC_HOSTNAME must be set for TileJSON", + false, + headers + ); + } + headers["Content-Type"] = "application/json"; + + const t = tileJSON( + header, + await p.getMetadata(), + process.env.PUBLIC_HOSTNAME, + name + ); + + return apiResp(200, JSON.stringify(t), false, headers); + } + if (tile[0] < header.minZoom || tile[0] > header.maxZoom) { return apiResp(404, "", false, headers); } @@ -259,5 +238,5 @@ export const handler = async ( event: APIGatewayProxyEventV2, context: Context ): Promise => { - return handlerRaw(event, context); + return await handlerRaw(event, context); }; diff --git a/serverless/cloudflare/package.json b/serverless/cloudflare/package.json index 221e2be..fa2f315 100644 --- a/serverless/cloudflare/package.json +++ b/serverless/cloudflare/package.json @@ -11,7 +11,7 @@ "scripts": { "start": "wrangler dev", "deploy": "wrangler publish", - "test": "node -r esbuild-runner/register src/index.test.ts", + "test": "node -r esbuild-runner/register ../shared/index.test.ts", "tsc": "tsc --watch", "build": "wrangler publish --outdir dist --dry-run" } diff --git a/serverless/cloudflare/src/index.test.ts b/serverless/cloudflare/src/index.test.ts deleted file mode 100644 index 5e9865e..0000000 --- a/serverless/cloudflare/src/index.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert"; - -import { pmtiles_path, tile_path } from "./index"; - -test("pmtiles path", () => { - let result = pmtiles_path("foo", undefined); - assert.strictEqual(result, "foo.pmtiles"); -}); - -test("pmtiles path", () => { - let result = pmtiles_path("foo", "folder/{name}/file.pmtiles"); - assert.strictEqual(result, "folder/foo/file.pmtiles"); -}); - -test("pmtiles path with slash", () => { - let result = pmtiles_path("foo/bar", "folder/{name}/file.pmtiles"); - assert.strictEqual(result, "folder/foo/bar/file.pmtiles"); -}); - -test("pmtiles path with multiple names", () => { - let result = pmtiles_path("slug", "folder/{name}/{name}.pmtiles"); - assert.strictEqual(result, "folder/slug/slug.pmtiles"); - result = pmtiles_path("foo/bar", "folder/{name}/{name}.pmtiles"); - assert.strictEqual(result, "folder/foo/bar/foo/bar.pmtiles"); -}); - -test("parse tile default", () => { - let { ok, name, tile, ext } = tile_path("abcd"); - assert.strictEqual(ok, false); - - ({ name, tile } = tile_path("/foo/11/22/33.pbf")); - assert.strictEqual(name, "foo"); - assert.strictEqual(tile![0], 11); - assert.strictEqual(tile![1], 22); - assert.strictEqual(tile![2], 33); -}); - -test("parse tile path setting", () => { - let { ok, name, tile, ext } = tile_path( - "/foo/11/22/33.pbf", - "/{name}/{z}/{y}/{x}.{ext}" - ); - assert.strictEqual(tile![1], 33); - assert.strictEqual(tile![2], 22); - assert.strictEqual(ext, "pbf"); - ({ ok, name, tile, ext } = tile_path( - "/tiles/foo/4/2/3.mvt", - "/tiles/{name}/{z}/{x}/{y}.{ext}" - )); - assert.strictEqual(name, "foo"); - assert.strictEqual(tile![0], 4); - assert.strictEqual(tile![1], 2); - assert.strictEqual(tile![2], 3); - assert.strictEqual(ext, "mvt"); -}); - -test("parse tile path setting special chars", () => { - let { ok, name, tile, ext } = tile_path( - "/folder(new/foo/11/22/33.pbf", - "/folder(new/{name}/{z}/{y}/{x}.{ext}" - ); - assert.strictEqual(name, "foo"); -}); - -test("parse tile path setting slash", () => { - let { ok, name, tile, ext } = tile_path( - "/foo/bar/11/22/33.pbf", - "/{name}/{z}/{y}/{x}.{ext}" - ); - assert.strictEqual(name, "foo/bar"); -}); - -test("parse tileset default", () => { - let { ok, name, tile, ext } = tile_path("/abcd.json"); - assert.strictEqual(ok, true); - assert.strictEqual("abcd", name); -}); \ No newline at end of file diff --git a/serverless/cloudflare/src/index.ts b/serverless/cloudflare/src/index.ts index b67b60e..7a0f1d4 100644 --- a/serverless/cloudflare/src/index.ts +++ b/serverless/cloudflare/src/index.ts @@ -1,25 +1,18 @@ -/** - * - Run `wrangler dev src/index.ts` in your terminal to start a development server - * - Open a browser tab at http://localhost:8787/ to see your worker in action - * - Run `wrangler publish src/index.ts --name my-worker` to publish your worker - */ - import { PMTiles, Source, RangeResponse, ResolvedValueCache, TileType, - Compression, } from "../../../js/index"; +import { pmtiles_path, tile_path, tileJSON } from "../../shared/index"; interface Env { - BUCKET: R2Bucket; ALLOWED_ORIGINS?: string; - PMTILES_PATH?: string; - TILE_PATH?: string; - TILESET_PATH?: string; + BUCKET: R2Bucket; CACHE_MAX_AGE?: number; + PMTILES_PATH?: string; + PUBLIC_HOSTNAME?: string; } class KeyNotFoundError extends Error { @@ -28,86 +21,7 @@ class KeyNotFoundError extends Error { } } -export const pmtiles_path = (name: string, setting?: string): string => { - if (setting) { - return setting.replaceAll("{name}", name); - } - return name + ".pmtiles"; -}; - -const TILE = - /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+)\/(?\d+)\/(?\d+)\/(?\d+).(?[a-z]+)$/; - -const TILESET = /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+).json$/; - -export const tile_path = ( - path: string, - tile_setting?: string, - tileset_setting?: string -): { - ok: boolean; - name: string; - tile?: [number, number, number]; - ext: string; -} => { - let tile_pattern = TILE; - if (tile_setting) { - // escape regex - tile_setting = tile_setting.replace(/[.*+?^$()|[\]\\]/g, "\\$&"); - tile_setting = tile_setting.replace( - "{name}", - "(?[0-9a-zA-Z/!-_.*'()]+)" - ); - tile_setting = tile_setting.replace("{z}", "(?\\d+)"); - tile_setting = tile_setting.replace("{x}", "(?\\d+)"); - tile_setting = tile_setting.replace("{y}", "(?\\d+)"); - tile_setting = tile_setting.replace("{ext}", "(?[a-z]+)"); - tile_pattern = new RegExp(tile_setting); - } - - let tile_match = path.match(tile_pattern); - - if (tile_match) { - const g = tile_match.groups!; - return { ok: true, name: g.NAME, tile: [+g.Z, +g.X, +g.Y], ext: g.EXT }; - } - - let tileset_pattern = TILESET; - if (tileset_setting) { - tileset_setting = tileset_setting.replace(/[.*+?^$()|[\]\\]/g, "\\$&"); - tileset_setting = tileset_setting.replace( - "{name}", - "(?[0-9a-zA-Z/!-_.*'()]+)" - ); - tileset_pattern = new RegExp(tileset_setting); - } - - let tileset_match = path.match(tileset_pattern); - - if (tileset_match) { - const g = tileset_match.groups!; - return { ok: true, name: g.NAME, ext: "json" }; - } - - return { ok: false, name: "", tile: [0, 0, 0], ext: "" }; -}; - -async function nativeDecompress( - buf: ArrayBuffer, - compression: Compression -): Promise { - if (compression === Compression.None || compression === Compression.Unknown) { - return buf; - } else if (compression === Compression.Gzip) { - let stream = new Response(buf).body!; - let result = stream.pipeThrough(new DecompressionStream("gzip")); - return new Response(result).arrayBuffer(); - } else { - throw Error("Compression method not supported"); - } -} - -const CACHE = new ResolvedValueCache(25, undefined, nativeDecompress); +const CACHE = new ResolvedValueCache(25, undefined); class R2Source implements Source { env: Env; @@ -153,23 +67,23 @@ export default { return new Response(undefined, { status: 405 }); const url = new URL(request.url); - const { ok, name, tile, ext } = tile_path(url.pathname, env.TILE_PATH); + const { ok, name, tile, ext } = tile_path(url.pathname); const cache = caches.default; if (ok) { let allowed_origin = ""; if (typeof env.ALLOWED_ORIGINS !== "undefined") { - for (let o of env.ALLOWED_ORIGINS.split(",")) { + for (const o of env.ALLOWED_ORIGINS.split(",")) { if (o === request.headers.get("Origin") || o === "*") { allowed_origin = o; } } } - let cached = await cache.match(request.url); + const cached = await cache.match(request.url); if (cached) { - let resp_headers = new Headers(cached.headers); + const resp_headers = new Headers(cached.headers); if (allowed_origin) resp_headers.set("Access-Control-Allow-Origin", allowed_origin); resp_headers.set("Vary", "Origin"); @@ -187,9 +101,9 @@ export default { ) => { cacheable_headers.set( "Cache-Control", - "max-age=" + (env.CACHE_MAX_AGE | 86400) + "max-age=" + (env.CACHE_MAX_AGE || 86400) ); - let cacheable = new Response(body, { + const cacheable = new Response(body, { headers: cacheable_headers, status: status, }); @@ -197,7 +111,7 @@ export default { // normalize HEAD requests ctx.waitUntil(cache.put(request.url, cacheable)); - let resp_headers = new Headers(cacheable_headers); + const resp_headers = new Headers(cacheable_headers); if (allowed_origin) resp_headers.set("Access-Control-Allow-Origin", allowed_origin); resp_headers.set("Vary", "Origin"); @@ -206,54 +120,21 @@ export default { const cacheable_headers = new Headers(); const source = new R2Source(env, name); - const p = new PMTiles(source, CACHE, nativeDecompress); + const p = new PMTiles(source, CACHE); try { const p_header = await p.getHeader(); if (!tile) { - const metadata = await p.getMetadata(); cacheable_headers.set("Content-Type", "application/json"); - let ext = ""; - if (p_header.tileType === TileType.Mvt) { - ext = ".mvt"; - } else if (p_header.tileType === TileType.Png) { - ext = ".png"; - } else if (p_header.tileType === TileType.Jpeg) { - ext = ".jpg"; - } else if (p_header.tileType === TileType.Webp) { - ext = ".webp"; - } - - // TODO: this needs to be based on the TILE_PATH setting - metadata.tiles = [ - url.protocol + - "//" + - url.hostname + - "/" + - name + - "/{z}/{x}/{y}" + - ext, - ]; - metadata.bounds = [ - p_header.minLon, - p_header.minLat, - p_header.maxLon, - p_header.maxLat, - ]; - metadata.center = [ - p_header.centerLon, - p_header.centerLat, - p_header.centerZoom, - ]; - metadata.maxzoom = p_header.maxZoom; - metadata.minzoom = p_header.minZoom; - metadata.scheme = "xyz"; - return cacheableResponse( - JSON.stringify(metadata), - cacheable_headers, - 200 + const t = tileJSON( + p_header, + await p.getMetadata(), + env.PUBLIC_HOSTNAME || url.hostname, + name ); + + return cacheableResponse(JSON.stringify(t), cacheable_headers, 200); } if (tile[0] < p_header.minZoom || tile[0] > p_header.maxZoom) { @@ -310,7 +191,6 @@ export default { } } - // TODO: metadata responses, tileJSON return new Response("Invalid URL", { status: 404 }); }, }; diff --git a/serverless/shared/index.test.ts b/serverless/shared/index.test.ts new file mode 100644 index 0000000..1427f6d --- /dev/null +++ b/serverless/shared/index.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert"; + +import { pmtiles_path, tile_path } from "./index"; + +test("pmtiles path", () => { + let result = pmtiles_path("foo", undefined); + assert.strictEqual(result, "foo.pmtiles"); +}); + +test("pmtiles path", () => { + let result = pmtiles_path("foo", "folder/{name}/file.pmtiles"); + assert.strictEqual(result, "folder/foo/file.pmtiles"); +}); + +test("pmtiles path with slash", () => { + let result = pmtiles_path("foo/bar", "folder/{name}/file.pmtiles"); + assert.strictEqual(result, "folder/foo/bar/file.pmtiles"); +}); + +test("pmtiles path with multiple names", () => { + let result = pmtiles_path("slug", "folder/{name}/{name}.pmtiles"); + assert.strictEqual(result, "folder/slug/slug.pmtiles"); + result = pmtiles_path("foo/bar", "folder/{name}/{name}.pmtiles"); + assert.strictEqual(result, "folder/foo/bar/foo/bar.pmtiles"); +}); + diff --git a/serverless/shared/index.ts b/serverless/shared/index.ts new file mode 100644 index 0000000..ad03d3d --- /dev/null +++ b/serverless/shared/index.ts @@ -0,0 +1,71 @@ +import { Header, TileType } from "../../js/index"; + +export const pmtiles_path = (name: string, setting?: string): string => { + if (setting) { + return setting.replaceAll("{name}", name); + } + return name + ".pmtiles"; +}; + +const TILE = + /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+)\/(?\d+)\/(?\d+)\/(?\d+).(?[a-z]+)$/; + +const TILESET = /^\/(?[0-9a-zA-Z\/!\-_\.\*\'\(\)]+).json$/; + +export const tile_path = ( + path: string +): { + ok: boolean; + name: string; + tile?: [number, number, number]; + ext: string; +} => { + const tile_match = path.match(TILE); + + if (tile_match) { + const g = tile_match.groups!; + return { ok: true, name: g.NAME, tile: [+g.Z, +g.X, +g.Y], ext: g.EXT }; + } + + const tileset_match = path.match(TILESET); + + if (tileset_match) { + const g = tileset_match.groups!; + return { ok: true, name: g.NAME, ext: "json" }; + } + + return { ok: false, name: "", tile: [0, 0, 0], ext: "" }; +}; + +export const tileJSON = ( + header: Header, + metadata: unknown, + hostname: string, + tileset_name: string +) => { + let ext = ""; + if (header.tileType === TileType.Mvt) { + ext = ".mvt"; + } else if (header.tileType === TileType.Png) { + ext = ".png"; + } else if (header.tileType === TileType.Jpeg) { + ext = ".jpg"; + } else if (header.tileType === TileType.Webp) { + ext = ".webp"; + } + + return { + tilejson: "3.0.0", + scheme: "xyz", + tiles: ["https://" + hostname + "/" + tileset_name + "/{z}/{x}/{y}" + ext], + vector_layers: metadata.vector_layers, + attribution: metadata.attribution, + description: metadata.description, + name: metadata.name, + version: metadata.version, + bounds: [header.minLon, header.minLat, header.maxLon, header.maxLat], + center: [header.centerLon, header.centerLat, header.centerZoom], + minzoom: header.minZoom, + maxzoom: header.maxZoom, + }; +};