使用 Supabase Storage 与 Edge Functions 自托管 Protomaps 静态地图
在本文示例仓库中,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。这里不重复切片工具链,只强调三点与后续上传直接相关的实践注意点:
- 输出物是一个单文件:PMTiles 设计目标就是把成千上万个瓦片合并进一个文件,既便于复制分发,也天然契合 Supabase Storage 的对象模型(一个对象 = 一个文件)。
- 文件名即 URL 路径:本示例前端访问地址最终是
…/maps-private/my_area.pmtiles,文件名(含.pmtiles后缀)会直接出现在 URL 与 Storage 对象路径中,生成后不要随意改名,否则需要同步修改代理后的前端 URL。 - 区域越小、加载越快:PMTiles 按需读取的特性意味着你的切片范围决定了首屏与缩放时的数据量,示例仅用于演示时可先截取一个城市甚至更小的区域。
三、步骤二:上传到 Supabase Storage
原文档给出了两个操作要点:
- 新建一个私有桶
maps-private; - 把
my_area.pmtiles上传进去; - 同时注意文件大小上限与套餐档位的关系。
在本地开发场景中,这个"建私有桶"的动作还可以用仓库里已经写好的声明式配置完成。查看 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 | 可自定义 |
需要理解的三层含义:
- 全局上限可在 Storage Settings 中调整,但 Free 项目不能超过 50 MB,Pro 及以上最高可设 500 GB;
- 全局上限会作用于所有桶,推荐把全局值设为应用能接受的最高值,再用
[storage.buckets.*]中更小的file_size_limit做单桶收窄; - 标准上传方式最大支持约 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 所需的鉴权头。
原文档给出两条部署命令与一条修改要求:
- 部署函数(不校验 JWT):
supabase functions deploy maps-private --no-verify-jwt
- 修改 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-L15):
reqUrl.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 引入了三类库并做了粘合:
- MapLibre GL JS 4.1.2:矢量地图渲染引擎;
- pmtiles.js 3.0.6:PMTiles 协议的浏览器实现;
- 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 的
url是pmtiles://前缀 + 你的 Edge Function 完整地址。其中<project_ref>即第三步要求替换的占位符,域名固定为<project_ref>.supabase.co; - 字体字形由 Protomaps 的公共 PBF 字体服务提供;
- 图层样式直接复用
protomaps_themes_base.default('protomaps', 'dark'),一行即得到完整的暗色底图图层栈,也可以按需改为'light'等其他主题。
六、步骤四:本地起一个静态服务器
由于瓦片请求依赖浏览器发送 Origin 请求头,且代理白名单默认只放行 http://localhost:8000,直接双击打开 index.html(file:// 协议)会因 Origin 为空而被拒绝。原文档给出的做法是用 Python 起静态服务:
python3 -m http.server
默认监听 8000 端口,然后浏览器访问:
http://localhost:8000/
看到地图即代表整条链路打通。若你希望换端口或换域名,记得同时修改两处:
- 代理函数里的
ALLOWED_ORIGINS数组; 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 构建更大范围的自有底图服务。需要强调,这套方案的存储带宽与函数调用都会计入项目用量,生产化前请根据实际瓦片命中量评估成本与文件上限配置。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00