Skip to content

Commit 51bc7b8

Browse files
authored
Add Go Vercel API parity foundation (#119)
* Add Go Vercel API parity foundation - Ports the current Vercel REST emulator surface to a dependency-free Go service while leaving npm packages and TypeScript tests in place. - Wires native Vercel seeding, runtime mounting, and Vercel Go Function preview routing. - Adds Go coverage for user auth, projects, deployments, domains, env vars, API keys, OAuth, and adapter forwarding. * Fix native Vercel parity edge cases * Fix native Vercel auth scope and OAuth persistence * Fix native Vercel OAuth redirect matching * Fix Vercel scaffold test formatting * Fix native Vercel OAuth and file uploads * Fix native Vercel review findings
1 parent 4e51210 commit 51bc7b8

25 files changed

Lines changed: 4391 additions & 19 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ microsoft:
409409

410410
## Vercel API
411411

412-
Every endpoint below is fully stateful with Vercel-style JSON responses and cursor-based pagination.
412+
Every endpoint below is fully stateful with Vercel-style JSON responses and cursor-based pagination. The native Go runtime implements this same Vercel REST surface for local CLI runs and Vercel Go Function previews.
413413

414414
### User & Teams
415415
- `GET /v2/user` - authenticated user
@@ -736,7 +736,7 @@ This creates:
736736
- `vercel.json`, with `/emulate/:path*` rewritten to `/api/emulate?path=:path*`
737737
- `go.mod`, pinned to the installed `emulate` package version
738738

739-
The scaffold currently enables the native `aws` and `resend` handlers. Use `npx emulate vercel init --service resend` to limit the function to one service.
739+
The scaffold currently enables the native `aws`, `resend`, and `vercel` handlers. Use `npx emulate vercel init --service vercel` to limit the function to one service.
740740

741741
State uses warm memory by default: cold starts reset to a fresh store, warm invocations reuse mutations, and concurrent function instances can diverge. For snapshots across cold starts, implement `vercel.Persistence` in `api/emulate.go` and pass it to `emulate.NewHandler`.
742742

@@ -777,7 +777,7 @@ export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({
777777
})
778778
```
779779

780-
Embedded mode is the broadest zero infra path for JavaScript emulator packages on Vercel preview deployments. The emulator code runs in the Next.js function, so OAuth callback URLs can point at the preview origin. For native Go `aws` and `resend` previews, use `npx emulate vercel init`.
780+
Embedded mode is the broadest zero infra path for JavaScript emulator packages on Vercel preview deployments. The emulator code runs in the Next.js function, so OAuth callback URLs can point at the preview origin. For native Go `aws`, `resend`, and `vercel` previews, use `npx emulate vercel init`.
781781

782782
### Native runtime proxy
783783

apps/web/app/docs/nextjs/page.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This creates:
1616
- `vercel.json`, with `/emulate/:path*` rewritten to `/api/emulate?path=:path*`
1717
- `go.mod`, pinned to the installed `emulate` package version
1818

19-
The scaffold currently enables the native `aws` and `resend` handlers. Use `npx emulate vercel init --service resend` to limit the function to one service.
19+
The scaffold currently enables the native `aws`, `resend`, and `vercel` handlers. Use `npx emulate vercel init --service vercel` to limit the function to one service.
2020

2121
State uses warm memory by default: cold starts reset to a fresh store, warm invocations reuse mutations, and concurrent function instances can diverge. For snapshots across cold starts, implement `vercel.Persistence` in `api/emulate.go` and pass it to `emulate.NewHandler`.
2222

@@ -62,7 +62,7 @@ This creates the following routes:
6262
- `/emulate/github/**` serves the GitHub emulator
6363
- `/emulate/google/**` serves the Google emulator
6464

65-
Embedded mode is the broadest zero infra path for JavaScript emulator packages on Vercel preview deployments. The emulator code runs in the Next.js function, so OAuth callback URLs can point at the preview origin. For native Go `aws` and `resend` previews, use `npx emulate vercel init`.
65+
Embedded mode is the broadest zero infra path for JavaScript emulator packages on Vercel preview deployments. The emulator code runs in the Next.js function, so OAuth callback URLs can point at the preview origin. For native Go `aws`, `resend`, and `vercel` previews, use `npx emulate vercel init`.
6666

6767
## Native Runtime Proxy
6868

apps/web/app/docs/vercel/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Vercel API
22

3-
Every endpoint below is fully stateful with Vercel-style JSON responses and cursor-based pagination.
3+
Every endpoint below is fully stateful with Vercel-style JSON responses and cursor-based pagination. The native Go runtime implements this same Vercel REST surface for local CLI runs and Vercel Go Function previews.
44

55
## User & Teams
66

cmd/emulate/main.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
coreconfig "github.com/vercel-labs/emulate/internal/core/config"
2222
emuruntime "github.com/vercel-labs/emulate/internal/runtime"
2323
"github.com/vercel-labs/emulate/internal/services/resend"
24+
"github.com/vercel-labs/emulate/internal/services/vercel"
2425
)
2526

2627
var version = "dev"
@@ -104,14 +105,15 @@ func runStart(ctx context.Context, args []string, stdout io.Writer, stderr io.Wr
104105
}
105106
var seedServices []string
106107
var resendSeed *resend.SeedConfig
108+
var vercelSeed *vercel.SeedConfig
107109
if *seedValue != "" {
108110
loaded, err := coreconfig.Load(coreconfig.LoadOptions{Path: *seedValue})
109111
if err != nil {
110112
fmt.Fprintf(stderr, "Failed to load seed config: %v\n", err)
111113
return 1
112114
}
113115
if unsupported := unsupportedNativeSeedServices(loaded.Data); len(unsupported) > 0 {
114-
fmt.Fprintf(stderr, "The native Go runtime only supports --seed for resend. Unsupported seed config services: %s\n", strings.Join(unsupported, ", "))
116+
fmt.Fprintf(stderr, "The native Go runtime only supports --seed for resend and vercel. Unsupported seed config services: %s\n", strings.Join(unsupported, ", "))
115117
return 1
116118
}
117119
seedServices = coreconfig.InferServices(loaded.Data, nativeSeedServiceNames())
@@ -123,6 +125,14 @@ func runStart(ctx context.Context, args []string, stdout io.Writer, stderr io.Wr
123125
}
124126
resendSeed = &cfg
125127
}
128+
if raw, ok := loaded.Data["vercel"]; ok {
129+
var cfg vercel.SeedConfig
130+
if err := json.Unmarshal(raw, &cfg); err != nil {
131+
fmt.Fprintf(stderr, "Failed to parse vercel seed config: %v\n", err)
132+
return 1
133+
}
134+
vercelSeed = &cfg
135+
}
126136
}
127137
services, err := parseServices(*serviceValue)
128138
if err != nil {
@@ -142,6 +152,7 @@ func runStart(ctx context.Context, args []string, stdout io.Writer, stderr io.Wr
142152
BaseURL: baseURL,
143153
Services: services,
144154
ResendSeed: resendSeed,
155+
VercelSeed: vercelSeed,
145156
})
146157
httpServer := &nethttp.Server{
147158
Handler: server.Handler,
@@ -330,7 +341,7 @@ func parseServices(value string) ([]string, error) {
330341
}
331342

332343
func nativeSeedServiceNames() []string {
333-
return []string{"resend"}
344+
return []string{"resend", "vercel"}
334345
}
335346

336347
func unsupportedNativeSeedServices(data map[string]json.RawMessage) []string {

internal/runtime/server.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/vercel-labs/emulate/internal/core/ui"
1010
"github.com/vercel-labs/emulate/internal/services/aws"
1111
"github.com/vercel-labs/emulate/internal/services/resend"
12+
"github.com/vercel-labs/emulate/internal/services/vercel"
1213
)
1314

1415
const HealthPath = "/_emulate/health"
@@ -20,6 +21,7 @@ type ServerOptions struct {
2021
Store *store.Store
2122
AssetStore *coreassets.Store
2223
ResendSeed *resend.SeedConfig
24+
VercelSeed *vercel.SeedConfig
2325
}
2426

2527
type Server struct {
@@ -85,6 +87,13 @@ func NewServer(options ServerOptions) *Server {
8587
Seed: options.ResendSeed,
8688
})
8789
}
90+
if serviceEnabled(services, "vercel") {
91+
vercel.Register(router, vercel.Options{
92+
Store: runtimeStore,
93+
BaseURL: options.BaseURL,
94+
Seed: options.VercelSeed,
95+
})
96+
}
8897
router.NotFound(func(c *corehttp.Context) {
8998
c.JSON(http.StatusNotFound, map[string]any{"message": "Not Found"})
9099
})

internal/runtime/server_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,32 @@ func TestNewHandlerDoesNotMountResendWhenDisabled(t *testing.T) {
197197
t.Fatalf("status = %d, body = %s", res.Code, res.Body.String())
198198
}
199199
}
200+
201+
func TestNewHandlerMountsVercelWhenEnabled(t *testing.T) {
202+
handler := NewHandler(ServerOptions{Services: []string{"vercel"}, BaseURL: "http://localhost:4010"})
203+
204+
res := httptest.NewRecorder()
205+
req := httptest.NewRequest(http.MethodGet, "/v2/user", nil)
206+
req.Header.Set("Authorization", "Bearer test_token_admin")
207+
handler.ServeHTTP(res, req)
208+
209+
if res.Code != http.StatusOK {
210+
t.Fatalf("status = %d, body = %s", res.Code, res.Body.String())
211+
}
212+
if !strings.Contains(res.Body.String(), `"username":"admin"`) {
213+
t.Fatalf("unexpected body: %s", res.Body.String())
214+
}
215+
}
216+
217+
func TestNewHandlerDoesNotMountVercelWhenDisabled(t *testing.T) {
218+
handler := NewHandler(ServerOptions{Services: []string{"resend"}})
219+
220+
res := httptest.NewRecorder()
221+
req := httptest.NewRequest(http.MethodGet, "/v2/user", nil)
222+
req.Header.Set("Authorization", "Bearer test_token_admin")
223+
handler.ServeHTTP(res, req)
224+
225+
if res.Code != http.StatusNotFound {
226+
t.Fatalf("status = %d, body = %s", res.Code, res.Body.String())
227+
}
228+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package vercel
2+
3+
import (
4+
"net/http"
5+
6+
corehttp "github.com/vercel-labs/emulate/internal/core/http"
7+
corestore "github.com/vercel-labs/emulate/internal/core/store"
8+
)
9+
10+
func (s *Service) registerAPIKeyRoutes(router *corehttp.Router) {
11+
router.Post("/v1/api-keys", func(c *corehttp.Context) {
12+
user, ok := s.currentUser(c)
13+
if !ok {
14+
return
15+
}
16+
body, err := parseJSONBody(c.Request)
17+
if err != nil {
18+
writeVercelError(c, http.StatusBadRequest, "bad_request", "Invalid JSON body")
19+
return
20+
}
21+
name := stringValue(body["name"])
22+
if name == "" {
23+
name = "API Key"
24+
}
25+
teamID := c.Query("teamId")
26+
var teamValue any
27+
if teamID != "" {
28+
teamValue = teamID
29+
}
30+
tokenString := "vercel_api_" + generateSecret()
31+
uid := generateUID("ak")
32+
s.store.APIKeys.Insert(corestore.Record{
33+
"uid": uid,
34+
"name": name,
35+
"teamId": teamValue,
36+
"userId": stringField(user, "uid"),
37+
"tokenString": tokenString,
38+
})
39+
c.JSON(http.StatusOK, map[string]any{
40+
"apiKeyString": tokenString,
41+
"apiKey": map[string]any{
42+
"id": uid,
43+
"name": name,
44+
"teamId": teamValue,
45+
"createdAt": nowMillis(),
46+
},
47+
})
48+
})
49+
50+
router.Get("/v1/api-keys", func(c *corehttp.Context) {
51+
user, ok := s.currentUser(c)
52+
if !ok {
53+
return
54+
}
55+
teamID := c.Query("teamId")
56+
keys := make([]map[string]any, 0)
57+
for _, key := range s.store.APIKeys.FindBy("userId", stringField(user, "uid")) {
58+
if teamID != "" && stringField(key, "teamId") != teamID {
59+
continue
60+
}
61+
keys = append(keys, map[string]any{
62+
"id": stringField(key, "uid"),
63+
"name": stringField(key, "name"),
64+
"teamId": key["teamId"],
65+
"createdAt": timeMillisField(key, "created_at"),
66+
})
67+
}
68+
c.JSON(http.StatusOK, map[string]any{"keys": keys})
69+
})
70+
71+
router.Delete("/v1/api-keys/:keyId", func(c *corehttp.Context) {
72+
user, ok := s.currentUser(c)
73+
if !ok {
74+
return
75+
}
76+
key := firstRecord(s.store.APIKeys.FindBy("uid", c.Param("keyId")))
77+
if key == nil {
78+
writeVercelError(c, http.StatusNotFound, "not_found", "API key not found")
79+
return
80+
}
81+
if stringField(key, "userId") != stringField(user, "uid") {
82+
writeVercelError(c, http.StatusForbidden, "forbidden", "Not authorized to delete this API key")
83+
return
84+
}
85+
s.store.APIKeys.Delete(intField(key, "id"))
86+
c.JSON(http.StatusOK, map[string]any{})
87+
})
88+
}

0 commit comments

Comments
 (0)