Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ go install github.com/tinywasm/goflare/cmd/goflare@latest

- `goflare auth --check`: Validate `CLOUDFLARE_API_TOKEN` from environment.
- `goflare build`: Infer mode from `edge/main.go` imports and produce artifacts.
- `goflare deploy`: Direct Upload v2. ⚠️ Designed for CI/CD environments.
- `goflare deploy`: Direct Upload v2. ⚠️ Designed for CI/CD environments. Now includes automatic Pages project provisioning and robust error reporting.

## GitHub Setup
Deployment is designed to run in CI. Register secrets in:
Expand Down
8 changes: 4 additions & 4 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ func (g *Goflare) Auth() error {
}

func (g *Goflare) validateToken(token string) error {
client := &cfClient{
token: token,
httpClient: http.DefaultClient,
baseURL: g.BaseURL,
client := &CfClient{
Token: token,
HttpClient: http.DefaultClient,
BaseURL: g.BaseURL,
}

if _, err := client.get("/user/tokens/verify"); err != nil {
Expand Down
199 changes: 144 additions & 55 deletions cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,116 +7,148 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)

const cfAPIBase = "https://api.cloudflare.com/client/v4"

type cfClient struct {
token string
baseURL string // default: cfAPIBase; overridden in tests
httpClient *http.Client
type CfClient struct {
Token string
BaseURL string // default: cfAPIBase; overridden in tests
HttpClient *http.Client
}

func (c *cfClient) get(path string) ([]byte, error) {
func (c *CfClient) get(path string) ([]byte, error) {
return c.do(http.MethodGet, path, nil)
}

func (c *cfClient) post(path string, body []byte) ([]byte, error) {
func (c *CfClient) post(path string, body []byte) ([]byte, error) {
return c.do(http.MethodPost, path, bytes.NewReader(body))
}

func (c *cfClient) put(path string, body []byte) ([]byte, error) {
func (c *CfClient) put(path string, body []byte) ([]byte, error) {
return c.do(http.MethodPut, path, bytes.NewReader(body))
}

func (c *cfClient) putMultipart(path string, body io.Reader, contentType string) ([]byte, error) {
req, err := http.NewRequest(http.MethodPut, c.baseURL+path, body)
func (c *CfClient) putMultipart(path string, body io.Reader, contentType string) ([]byte, error) {
req, err := http.NewRequest(http.MethodPut, c.BaseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", contentType)

resp, err := c.httpClient.Do(req)
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

return parseCFResponse(resp)
return parseCFResponse(http.MethodPut, path, resp)
}

func (c *cfClient) do(method, path string, body io.Reader) ([]byte, error) {
req, err := http.NewRequest(method, c.baseURL+path, body)
func (c *CfClient) do(method, path string, body io.Reader) ([]byte, error) {
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Authorization", "Bearer "+c.Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}

resp, err := c.httpClient.Do(req)
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

return parseCFResponse(resp)
return parseCFResponse(method, path, resp)
}

// DeployPages uploads the Pages build output (from config.OutputDir) to Cloudflare Pages.
// validateDeployScopes confirms that the token has the necessary permissions to
// manage Pages projects and deployments.
func (g *Goflare) ValidateDeployScopes(client *CfClient) error {
path := fmt.Sprintf("/accounts/%s/pages/projects", g.Config.AccountID)
if _, err := client.get(path); err != nil {
return fmt.Errorf(
"the token cannot access Pages on account %s.\n"+
" - Verify permission Account → Cloudflare Pages → Edit\n"+
" - Verify that CLOUDFLARE_ACCOUNT_ID is correct\n"+
"Detail: %w", g.Config.AccountID, err)
}
return nil
}

func (g *Goflare) createPagesProject(client *CfClient) error {
g.Logger("Pages project not found — creating", g.Config.ProjectName)
createPath := fmt.Sprintf("/accounts/%s/pages/projects", g.Config.AccountID)
body, _ := json.Marshal(map[string]string{
"name": g.Config.ProjectName,
"production_branch": "main",
})
_, err := client.post(createPath, body)
if err != nil {
var apiErr *cfError
if errors.As(err, &apiErr) && apiErr.alreadyExists() {
return nil
}
return fmt.Errorf("failed to create Pages project: %w", err)
}
return nil
}

func (g *Goflare) DeployPages() error {
token, err := g.token()
if err != nil {
return err
}

client := &cfClient{
token: token,
baseURL: g.BaseURL,
httpClient: http.DefaultClient,
client := &CfClient{
Token: token,
BaseURL: g.BaseURL,
HttpClient: http.DefaultClient,
}

// 2. Ensure Pages project exists
projectPath := fmt.Sprintf("/accounts/%s/pages/projects/%s", g.Config.AccountID, g.Config.ProjectName)
_, err = client.get(projectPath)
if err != nil {
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "8000007") {
// Create project
createPath := fmt.Sprintf("/accounts/%s/pages/projects", g.Config.AccountID)
body := map[string]string{
"name": g.Config.ProjectName,
"production_branch": "main",
}
bodyJSON, _ := json.Marshal(body)
_, err = client.post(createPath, bodyJSON)
if err != nil {
return fmt.Errorf("failed to create Pages project: %w", err)
}
} else {
var apiErr *cfError
notFound := errors.As(err, &apiErr) && (apiErr.Status == http.StatusNotFound || apiErr.Code == 8000007)
if !notFound {
return fmt.Errorf("failed to check Pages project: %w", err)
}
if err := g.createPagesProject(client); err != nil {
return err
}
}

// 3. Get upload JWT
// 3. Get upload JWT — retry because a newly created project takes time to be ready.
tokenPath := fmt.Sprintf("/accounts/%s/pages/projects/%s/uploadToken", g.Config.AccountID, g.Config.ProjectName)
tokenResp, err := client.post(tokenPath, nil)
var tokenResp []byte
err = g.retry(5, g.RetryBackoff, func() error {
var e error
tokenResp, e = client.post(tokenPath, nil)
return e
})
if err != nil {
return fmt.Errorf("failed to get upload token: %w", err)
return fmt.Errorf("failed to get upload Token: %w", err)
}
var tokenData struct {
JWT string `json:"jwt"`
}
if err := json.Unmarshal(tokenResp, &tokenData); err != nil {
return fmt.Errorf("failed to parse upload token: %w", err)
return fmt.Errorf("failed to parse upload Token: %w", err)
}

// 4. Walk PublicDir and FunctionsDir, collect all files for the manifest.
Expand Down Expand Up @@ -175,10 +207,10 @@ func (g *Goflare) DeployPages() error {
}

// 5. Upload files in batches of 50
uploadClient := &cfClient{
token: tokenData.JWT,
baseURL: g.BaseURL,
httpClient: http.DefaultClient,
uploadClient := &CfClient{
Token: tokenData.JWT,
BaseURL: g.BaseURL,
HttpClient: http.DefaultClient,
}
for i := 0; i < len(files); i += 50 {
end := i + 50
Expand Down Expand Up @@ -250,14 +282,14 @@ func detectContentType(filename string) string {
}
}

func (g *Goflare) configurePagesDomain(client *cfClient) error {
func (g *Goflare) configurePagesDomain(client *CfClient) error {
path := fmt.Sprintf("/accounts/%s/pages/projects/%s/domains", g.Config.AccountID, g.Config.ProjectName)
body := map[string]string{"name": g.Config.Domain}
bodyJSON, _ := json.Marshal(body)
_, err := client.post(path, bodyJSON)
if err != nil {
// If already exists, ignore
if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "8000045") {
var apiErr *cfError
if errors.As(err, &apiErr) && apiErr.alreadyExists() {
return nil
}
return err
Expand All @@ -266,7 +298,7 @@ func (g *Goflare) configurePagesDomain(client *cfClient) error {
}

// DeployWorker uploads the Worker build output to Cloudflare Workers.
func (g *Goflare) getWorkerSubdomain(client *cfClient) string {
func (g *Goflare) getWorkerSubdomain(client *CfClient) string {
path := fmt.Sprintf("/accounts/%s/workers/subdomain", g.Config.AccountID)
data, err := client.get(path)
if err != nil {
Expand Down Expand Up @@ -321,10 +353,10 @@ func (g *Goflare) DeployWorker() error {

mw.Close()

client := &cfClient{
token: token,
baseURL: g.BaseURL,
httpClient: http.DefaultClient,
client := &CfClient{
Token: token,
BaseURL: g.BaseURL,
HttpClient: http.DefaultClient,
}

path := fmt.Sprintf("/accounts/%s/workers/scripts/%s", g.Config.AccountID, g.Config.WorkerName)
Expand All @@ -345,28 +377,85 @@ type cfAPIError struct {
Message string `json:"message"`
}

func parseCFResponse(resp *http.Response) (json.RawMessage, error) {
type cfError struct {
Status int // status HTTP
Code int // primer errors[].code, si hay
Message string // resumen legible
Errors []cfAPIError // todos los errores del envelope
Body string // cuerpo crudo truncado (fallback)
Path string // método + ruta que falló
}

func (e *cfError) Error() string {
if len(e.Errors) > 0 {
return fmt.Sprintf("CF API %s → HTTP %d: %s", e.Path, e.Status, e.Message)
}
return fmt.Sprintf("CF API %s → HTTP %d, success=false, body: %s", e.Path, e.Status, e.Body)
}

func (e *cfError) alreadyExists() bool {
for _, x := range e.Errors {
if x.Code == 8000009 || x.Code == 8000045 || strings.Contains(x.Message, "already exists") {
return true
}
}
return false
}

func parseCFResponse(method, path string, resp *http.Response) (json.RawMessage, error) {
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read CF response: %w", err)
}
var env cfEnvelope
if err := json.Unmarshal(data, &env); err != nil {
return nil, fmt.Errorf("parse CF envelope: %w", err)
return nil, &cfError{
Status: resp.StatusCode,
Path: method + " " + path,
Body: truncate(string(data), 500),
}
}
if !env.Success {
if !env.Success || resp.StatusCode >= 400 {
ce := &cfError{
Status: resp.StatusCode,
Errors: env.Errors,
Path: method + " " + path,
Body: truncate(string(data), 500),
}
if len(env.Errors) > 0 {
ce.Code = env.Errors[0].Code
var msgs []string
for _, e := range env.Errors {
msgs = append(msgs, fmt.Sprintf("%s (code: %d)", e.Message, e.Code))
}
return nil, fmt.Errorf("CF API error: %s", strings.Join(msgs, ", "))
ce.Message = strings.Join(msgs, ", ")
}
return nil, fmt.Errorf("CF API returned success=false")
return nil, ce
}
return env.Result, nil
}

func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

// retry executes fn up to n times with exponential backoff.
func (g *Goflare) retry(n int, base time.Duration, fn func() error) error {
var err error
for i := 0; i < n; i++ {
if err = fn(); err == nil {
return nil
}
if i < n-1 {
time.Sleep(base << i)
}
}
return err
}

func addFilePart(mw *multipart.Writer, fieldName, filePath string) error {
f, err := os.Open(filePath)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion docs/PLAN_CF_API_CLIENT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# PLAN — `cfClient` / `parseCFResponse`: transparencia de errores y preflight de permisos
# [COMPLETED] PLAN — `cfClient` / `parseCFResponse`: transparencia de errores y preflight de permisos

> **Repo destino:** `tinywasm/goflare` (NO el demo). Plan para copiar manualmente a
> goflare, revisar y ejecutar allí.
Expand Down
2 changes: 1 addition & 1 deletion docs/PLAN_PAGES_DEPLOY.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# PLAN — `cloudflare.go` / `DeployPages`: auto-provisión robusta del proyecto Pages
# [COMPLETED] PLAN — `cloudflare.go` / `DeployPages`: auto-provisión robusta del proyecto Pages

> **Repo destino:** `tinywasm/goflare` (NO el demo). Este doc es un plan para copiar
> manualmente al repo de goflare, revisarlo y ejecutarlo allí.
Expand Down
5 changes: 4 additions & 1 deletion goflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/tinywasm/assetmin"
"github.com/tinywasm/client"
Expand Down Expand Up @@ -46,7 +47,8 @@ type Goflare struct {
Config *Config // exported so CLI can read it after LoadConfigFromEnv
log func(message ...any)
BaseURL string
stagingDir string // temporary directory for build artifacts
stagingDir string // temporary directory for build artifacts
RetryBackoff time.Duration // base duration for retries (defaults to 1s)
}

func syncJSRuntime(mode string) {
Expand Down Expand Up @@ -104,6 +106,7 @@ func New(cfg *Config) *Goflare {
Config: cfg,
BaseURL: cfAPIBase,
stagingDir: staging,
RetryBackoff: time.Second,
}

// If PublicDir is present, create a client to compile web/client.go.
Expand Down
Loading
Loading