@liiift-studio/deploy-vercel-from-sanity 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -37
- package/dist/index.d.mts +54 -4
- package/dist/index.d.ts +54 -4
- package/dist/index.js +527 -394
- package/dist/index.mjs +352 -219
- package/package.json +5 -4
- package/proxy/.env.example +40 -0
- package/proxy/README.md +228 -0
- package/proxy/core.ts +283 -0
- package/proxy/nextjs-app-router/route.ts +129 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Deploy proxy route for Next.js App Router — drop in at app/api/vercel-deploy/[...path]/route.ts
|
|
2
|
+
//
|
|
3
|
+
// Requires `next-sanity` for webhook signature verification:
|
|
4
|
+
// npm i next-sanity
|
|
5
|
+
//
|
|
6
|
+
// The Sanity plugin is configured with:
|
|
7
|
+
// vercelDeploy({ mode: 'proxy', proxyUrl: 'https://your-site.com/api/vercel-deploy', statusKey: '…' })
|
|
8
|
+
|
|
9
|
+
import { parseBody } from 'next-sanity/webhook'
|
|
10
|
+
import {
|
|
11
|
+
envFromProcess,
|
|
12
|
+
handleCancel,
|
|
13
|
+
handleDeployRequest,
|
|
14
|
+
handleDeployments,
|
|
15
|
+
handleEvents,
|
|
16
|
+
type DeployRequestPayload,
|
|
17
|
+
} from '../core'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Status key the Studio sends.
|
|
21
|
+
*
|
|
22
|
+
* Header only — deliberately no query-string fallback, so the key cannot end up in
|
|
23
|
+
* access logs, Referer headers or CDN cache keys.
|
|
24
|
+
*/
|
|
25
|
+
function statusKeyOf(request: Request): string | null {
|
|
26
|
+
return request.headers.get('x-deploy-status-key')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Last path segment, which selects the operation. */
|
|
30
|
+
function operation(request: Request): string {
|
|
31
|
+
const { pathname } = new URL(request.url)
|
|
32
|
+
return pathname.split('/').filter(Boolean).pop() ?? ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Origins allowed to call the status endpoints, comma-separated —
|
|
37
|
+
* e.g. `https://acme.sanity.studio,http://localhost:3333`.
|
|
38
|
+
*
|
|
39
|
+
* The Studio is served from a different origin than this route and sends a custom
|
|
40
|
+
* `x-deploy-status-key` header, which forces a CORS preflight. Without this the
|
|
41
|
+
* browser blocks every status, log and cancel call.
|
|
42
|
+
*/
|
|
43
|
+
const ALLOWED_ORIGINS = (process.env.VERCEL_DEPLOY_ALLOWED_ORIGINS ?? '')
|
|
44
|
+
.split(',')
|
|
45
|
+
.map(o => o.trim())
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
|
|
48
|
+
/** CORS headers for an allowed origin, or none when the origin is not permitted. */
|
|
49
|
+
function corsHeaders(request: Request): Record<string, string> {
|
|
50
|
+
const origin = request.headers.get('origin')
|
|
51
|
+
if (!origin || !ALLOWED_ORIGINS.includes(origin)) return {}
|
|
52
|
+
return {
|
|
53
|
+
'Access-Control-Allow-Origin': origin,
|
|
54
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
55
|
+
'Access-Control-Allow-Headers': 'content-type, x-deploy-status-key',
|
|
56
|
+
'Access-Control-Max-Age': '86400',
|
|
57
|
+
Vary: 'Origin',
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Preflight. Required because of the custom status-key header. */
|
|
62
|
+
export async function OPTIONS(request: Request): Promise<Response> {
|
|
63
|
+
return new Response(null, { status: 204, headers: corsHeaders(request) })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function json(result: { status: number; body: unknown }, request: Request): Response {
|
|
67
|
+
return Response.json(result.body, { status: result.status, headers: corsHeaders(request) })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function GET(request: Request): Promise<Response> {
|
|
71
|
+
try {
|
|
72
|
+
return await handleGet(request)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// Must go through json(): an uncaught throw returns a 500 with no CORS
|
|
75
|
+
// headers, which the browser refuses to expose, so the Studio shows
|
|
76
|
+
// "Failed to fetch" rather than the message the transport wrote for this.
|
|
77
|
+
return json({ status: 500, body: { error: err instanceof Error ? err.message : 'Proxy error' } }, request)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function handleGet(request: Request): Promise<Response> {
|
|
82
|
+
const env = envFromProcess(process.env)
|
|
83
|
+
const url = new URL(request.url)
|
|
84
|
+
const key = url.searchParams.get('key')
|
|
85
|
+
const statusKey = statusKeyOf(request)
|
|
86
|
+
|
|
87
|
+
switch (operation(request)) {
|
|
88
|
+
case 'deployments': {
|
|
89
|
+
const limit = Number(url.searchParams.get('limit')) || undefined
|
|
90
|
+
return json(await handleDeployments({ key, limit, statusKey }, env), request)
|
|
91
|
+
}
|
|
92
|
+
case 'events':
|
|
93
|
+
return json(await handleEvents({ key, deploymentId: url.searchParams.get('deploymentId'), statusKey }, env), request)
|
|
94
|
+
default:
|
|
95
|
+
return json({ status: 404, body: { error: 'Unknown operation' } }, request)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function POST(request: Request): Promise<Response> {
|
|
100
|
+
try {
|
|
101
|
+
return await handlePost(request)
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return json({ status: 500, body: { error: err instanceof Error ? err.message : 'Proxy error' } }, request)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function handlePost(request: Request): Promise<Response> {
|
|
108
|
+
const env = envFromProcess(process.env)
|
|
109
|
+
|
|
110
|
+
// The deploy path is driven by a signed Sanity webhook, never by a direct call.
|
|
111
|
+
// Signature verification is what makes this endpoint safe to expose publicly.
|
|
112
|
+
if (operation(request) === 'deploy') {
|
|
113
|
+
const secret = process.env.SANITY_WEBHOOK_SECRET
|
|
114
|
+
if (!secret) return json({ status: 500, body: { error: 'Missing SANITY_WEBHOOK_SECRET' } }, request)
|
|
115
|
+
|
|
116
|
+
const { isValidSignature, body } = await parseBody<DeployRequestPayload>(request, secret)
|
|
117
|
+
if (!isValidSignature) return json({ status: 401, body: { error: 'Invalid signature' } }, request)
|
|
118
|
+
if (!body) return json({ status: 400, body: { error: 'Empty payload' } }, request)
|
|
119
|
+
|
|
120
|
+
return json(await handleDeployRequest(body, env), request)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (operation(request) === 'cancel') {
|
|
124
|
+
const payload = (await request.json()) as { key?: string; deploymentId?: string }
|
|
125
|
+
return json(await handleCancel({ ...payload, statusKey: statusKeyOf(request) }, env), request)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return json({ status: 404, body: { error: 'Unknown operation' } }, request)
|
|
129
|
+
}
|