首页
/ 使用 Supabase Storage 与 Edge Functions 自托管 Protomaps 静态地图

使用 Supabase Storage 与 Edge Functions 自托管 Protomaps 静态地图

2026-09-06 18:57:59作者:尤辰城Agatha

在本文示例仓库中,Supabase 官方提供了一个完整的"自托管地图"参考实现(位于 examples/storage/protomaps):先把地图数据切片成 PMTiles 静态文件上传到 Supabase Storage 私有桶,再通过 Edge Functions 代理完成细粒度访问控制,最后由浏览器里的 MapLibre GL 直接渲染。读完本文,你将掌握 PMTiles 文件的生成与上传、maps-private 代理函数的每一行原理、Supabase 本地 config.toml 对存储与函数的声明式配置,以及如何仅用几行静态 HTML 就在本地看到可缩放的地图。

一、整体架构与数据链路

整套方案只依赖四类资产,全部可以在仓库中直接找到:

环节 仓库内文件 作用
地图切片数据 需要按 Protomaps 指南自行生成的 my_area.pmtiles 矢量瓦片与元数据打包成的单文件
私有存储桶 运行期在 Dashboard 创建的 maps-private 存放 PMTiles 文件,不对公网直接暴露
代理 Edge Function supabase/functions/maps-private/index.ts 转发请求到 authenticated 对象路径,实现白名单/鉴权
前端渲染页 index.html 用 MapLibre GL + PMTiles 协议从代理地址拉瓦片渲染

链路可概括为一条单向数据流:

浏览器 MapLibre GL
   │  pmtiles:// 协议请求瓦片
   ▼
Supabase Edge Function(maps-private,verify_jwt=false)
   │  校验 Origin 白名单,注入 service role 级别密钥作为 Authorization
   ▼
Supabase Storage(/storage/v1/object/authenticated/...)
   ▼
返回 PMTiles 二进制瓦片 → 浏览器解码并绘制

关键在于:PMTiles 是一种"按字节范围随机读取"的归档格式,Edge Function 需要把 Range 等请求头原样透传给 Storage,浏览器才能只拉取需要的瓦片块——这决定了代理必须是"透传式"的,下文源码剖析会看到这一点。

二、步骤一:生成静态 PMTiles 地图文件

原文档的第一步是遵循 Protomaps 官方入门指南,从目标区域数据中提取出 my_area.pmtiles。这里不重复切片工具链,只强调三点与后续上传直接相关的实践注意点:

  1. 输出物是一个单文件:PMTiles 设计目标就是把成千上万个瓦片合并进一个文件,既便于复制分发,也天然契合 Supabase Storage 的对象模型(一个对象 = 一个文件)。
  2. 文件名即 URL 路径:本示例前端访问地址最终是 …/maps-private/my_area.pmtiles,文件名(含 .pmtiles 后缀)会直接出现在 URL 与 Storage 对象路径中,生成后不要随意改名,否则需要同步修改代理后的前端 URL。
  3. 区域越小、加载越快:PMTiles 按需读取的特性意味着你的切片范围决定了首屏与缩放时的数据量,示例仅用于演示时可先截取一个城市甚至更小的区域。

三、步骤二:上传到 Supabase Storage

原文档给出了两个操作要点:

  1. 新建一个私有桶 maps-private
  2. my_area.pmtiles 上传进去;
  3. 同时注意文件大小上限与套餐档位的关系。

在本地开发场景中,这个"建私有桶"的动作还可以用仓库里已经写好的声明式配置完成。查看 supabase/config.toml,其中已包含与 Storage 有关的完整定义:

project_id = "protomaps"

[api]
# 本示例不通过 PostgREST 客户端读取数据,因此关闭数据 API。
enabled = false

[storage]
# 项目内所有桶允许的最大文件大小。
file_size_limit = "50MiB"

[storage.buckets.maps-private]
public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["application/vnd.pmtiles"]
# 取消注释可指定一个本地目录,把其中的对象批量上传进该桶。
# objects_path = "./buckets/maps-private"

逐项说明配置含义:

  • [api].enabled = false:本示例不需要 PostgREST 数据 API,本地 CLI 启动时会跳过 Data API 服务。
  • [storage].file_size_limit = "50MiB":全局文件大小上限。这里取 50 MiB 正好对齐 Free 套餐的上限。
  • [storage.buckets.maps-private]:声明式创建一个名为 maps-private 的桶。
    • public = false关键配置。桶不公开,所有对象只能通过带鉴权信息的请求读取,这正是后面要引入 Edge Function 代理的原因。
    • 被注释的 allowed_mime_types = ["application/vnd.pmtiles"] 提示我们可以把桶约束成只接受 PMTiles 这一种 MIME 类型,属于可选的加固项。
    • 被注释的 objects_path = "./buckets/maps-private" 提示本地开发时可以把 PMTiles 文件放进该目录,由 CLI 自动同步为桶内对象。

文件大小上限:需要对照套餐档位

原文档提醒我们依据项目套餐查阅文件大小限制,对应到仓库文档 apps/docs/content/guides/storage/uploads/file-limits.mdx 有明确数据:

套餐 全局最大文件大小上限
Free 50 MB
Pro 500 GB
Team 500 GB
Enterprise 可自定义

需要理解的三层含义:

  1. 全局上限可在 Storage Settings 中调整,但 Free 项目不能超过 50 MB,Pro 及以上最高可设 500 GB;
  2. 全局上限会作用于所有桶,推荐把全局值设为应用能接受的最高值,再用 [storage.buckets.*] 中更小的 file_size_limit 做单桶收窄;
  3. 标准上传方式最大支持约 5 GB 文件,若 PMTiles 切片超过 6 MB 并需要更高可靠性,仓库文档 standard-uploads.mdx 建议改用 TUS 断点续传。

因此一个典型判断是:Free 项目下整包切片若接近 50 MB,应当缩小导出区域或调低瓦片层级,否则会直接上传失败。

四、步骤三:用 Edge Function 代理私有切片

私有桶的对象无法被浏览器匿名读取,直接把前端 URL 指向 Storage 会得到 401。Supabase 给出的解法是把 Edge Function 当作"看门代理":函数本身可以 verify_jwt = false(免 JWT 公开访问),在函数内部自行校验来源并注入 Storage 所需的鉴权头。

原文档给出两条部署命令与一条修改要求:

  1. 部署函数(不校验 JWT):
supabase functions deploy maps-private --no-verify-jwt
  1. 修改 index.html 中的 protomaps.url,把占位的 <project_ref> 换成你自己的项目引用(Project Ref)。

逐行拆解代理函数源码

代理实现位于 supabase/functions/maps-private/index.ts,全文很短,逻辑却很完整:

import { withSupabase } from 'npm:@supabase/server@^1'

const ALLOWED_ORIGINS = ['http://localhost:8000']

// 公开的瓦片代理,因此以 verify_jwt = false 部署。
export default {
  fetch: withSupabase({ auth: 'none' }, (req) => {
    // 限制哪些来源可以读取私有瓦片。
    const origin = req.headers.get('Origin')
    if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
      return new Response('Not Allowed', { status: 405 })
    }

    const reqUrl = new URL(req.url)
    const url = `${Deno.env.get('SUPABASE_URL')}/storage/v1/object/authenticated${reqUrl.pathname}`

    const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
    const { method, headers } = req
    // 追加 Auth 头,使 Storage 提供私有对象。
    const modHeaders = new Headers(headers)
    modHeaders.append('authorization', `Bearer ${SUPABASE_SECRET_KEYS['default']!}`)
    return fetch(url, { method, headers: modHeaders })
  }),
}

对应每一段的职责:

  • withSupabase({ auth: 'none' }, ...):来自 npm:@supabase/server 的封装。auth: 'none' 表示该函数不要求用户携带 Supabase Auth JWT,配合 --no-verify-jwt 部署,允许匿名请求到达函数本体。
  • Origin 白名单(L3、L9-L12:读取请求头 Origin,只有命中 ALLOWED_ORIGINS(默认仅 http://localhost:8000)才放行,否则返回 405。这是"仅允许你的网页调用代理"的廉价防线——MapLibre 发起瓦片请求时会携带页面 Origin。
  • 路径拼接(L14-L15reqUrl.pathname 形如 /maps-private/my_area.pmtiles,被拼到 …/storage/v1/object/authenticated 之后,构成 Storage 私有对象的标准 REST 路径。
  • 密钥注入(L17-L21:从环境变量 SUPABASE_SECRET_KEYS 解析 JSON 后取出 default 键,以 Bearer 形式追加进 authorization 头。这相当于用服务端凭据"代读" authenticated 权限下的私有对象。
  • 透传代理(L22:原样转发 method 与头集合。Range 请求头因此能抵达 Storage,PMTiles 的按范围随机读取机制才得以生效——这是整个代理设计中最容易被忽视却最关键的一行。

延伸:如果改成"仅登录用户可见"

原文档还提到一种常见变体——用 Supabase Auth JWT 控制地图可见性。对比仓库中另一个示例 read-storage/index.ts,只需把封装参数换成 withSupabase({ auth: 'user' }, async (req, ctx) => {...}),即可拿到 ctx.supabase 客户端,进而把 Origin 白名单校验替换成"当前请求必须携带合法用户 JWT"的判断。更细的策略还可叠加行级安全(RLS)查询,实现"按用户裁剪数据源"。

五、前端渲染:MapLibre GL + PMTiles 协议

修改好 URL 后,index.html 负责把切片画出来。它通过 CDN 引入了三类库并做了粘合:

  1. MapLibre GL JS 4.1.2:矢量地图渲染引擎;
  2. pmtiles.js 3.0.6:PMTiles 协议的浏览器实现;
  3. protomaps-themes-base 2.0.0-alpha.5:Protomaps 官方基础样式主题。

渲染逻辑的核心片段如下:

<script src="https://unpkg.com/pmtiles@3.0.6/dist/pmtiles.js"></script>
...
<script type="text/javascript">
  let protocol = new pmtiles.Protocol()
  maplibregl.addProtocol('pmtiles', protocol.tile)

  const map = new maplibregl.Map({
    hash: true,
    container: 'map',
    style: {
      version: 8,
      glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
      sources: {
        protomaps: {
          attribution: 'Protomaps © OpenStreetMap',
          type: 'vector',
          url: 'pmtiles://https://<project_ref>.supabase.co/functions/v1/maps-private/my_area.pmtiles',
        },
      },
      layers: protomaps_themes_base.default('protomaps', 'dark'),
    },
  })
</script>

要点:

  • maplibregl.addProtocol('pmtiles', protocol.tile)pmtiles:// 自定义 URL 协议注册进 MapLibre;
  • source 的 urlpmtiles:// 前缀 + 你的 Edge Function 完整地址。其中 <project_ref> 即第三步要求替换的占位符,域名固定为 <project_ref>.supabase.co
  • 字体字形由 Protomaps 的公共 PBF 字体服务提供;
  • 图层样式直接复用 protomaps_themes_base.default('protomaps', 'dark'),一行即得到完整的暗色底图图层栈,也可以按需改为 'light' 等其他主题。

六、步骤四:本地起一个静态服务器

由于瓦片请求依赖浏览器发送 Origin 请求头,且代理白名单默认只放行 http://localhost:8000,直接双击打开 index.htmlfile:// 协议)会因 Origin 为空而被拒绝。原文档给出的做法是用 Python 起静态服务:

python3 -m http.server

默认监听 8000 端口,然后浏览器访问:

http://localhost:8000/

看到地图即代表整条链路打通。若你希望换端口或换域名,记得同时修改两处:

  1. 代理函数里的 ALLOWED_ORIGINS 数组;
  2. index.html 中 source 的 url

七、安全边界与注意事项总结

综合原文档与源码,这套自托管方案的边界值得明确:

  • 私有桶是第一道闸public = false(见 config.toml)保证对象不直接暴露,即使有人猜到 Storage URL 也无法匿名读取。
  • Edge Function 是第二道闸:默认通过 Origin 白名单限制调用方页面;可升级为 auth: 'user' 校验 Supabase Auth JWT 实现登录墙。
  • 密钥只应存在于服务端:代理函数从 SUPABASE_SECRET_KEYS 环境变量读取 default 键,该环境变量由平台自动注入,切勿复制到前端代码。
  • 验证部署生效前先 supabase start 或确认函数已上传:本地调试时建议先用本地 CLI 启动,再执行部署命令核对函数日志,避免 URL 已改但函数未部署导致的 404/405。

八、文件清单与后续探索

相对路径 说明
examples/storage/protomaps/README.md 本示例原始操作文档(四步指南)
examples/storage/protomaps/supabase/functions/maps-private/index.ts 代理 Edge Function 完整实现
examples/storage/protomaps/supabase/config.toml 本地项目声明式配置(桶、函数、大小限制)
examples/storage/protomaps/index.html MapLibre GL 前端渲染页
apps/docs/content/guides/storage/uploads/file-limits.mdx 官方文件大小限制说明
apps/docs/content/guides/storage/uploads/standard-uploads.mdx 大文件上传方式说明
examples/edge-functions/supabase/functions/read-storage/index.ts auth: 'user' 读取 Storage 的对照示例

想进一步深化,可从两条路线切入:一是把 Origin 白名单换成 Auth JWT 校验,让地图只对登录用户开放;二是引入 Overture Places 等公开数据集配合 Protomaps 构建更大范围的自有底图服务。需要强调,这套方案的存储带宽与函数调用都会计入项目用量,生产化前请根据实际瓦片命中量评估成本与文件上限配置。

登录后查看全文
热门项目推荐
相关项目推荐