Relayer
DocsRoutingnative-routes

Native Routes (Go)#

route.go is an API route written in Go. URLs map the same way as route.php; only the handler compiles to a native binary. Declare func GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS in the standard http.HandlerFunc shape, with no main() or framework import. It suits CPU-heavy endpoints.

// src/Pages/api/hello/route.go
package main

import (
    "encoding/json"
    "net/http"
)

func GET(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"message": "Hello from Go!"})
}
vendor/bin/relayer native:build     # once per route.go change
curl -i http://localhost:8080/api/hello

Building#

relayer native:build compiles every route.go under src/Pages and the root middleware.go to var/cache/native/bin/<slug>. <slug> is the URL pattern with / replaced by __ (/api/users/[id] → api__users__[id]).

  • It needs go on PATH (or GO_BIN=/path/to/go), which is why it is separate from relayer compile.
  • A go.mod / go.sum next to route.go is used, so third-party modules work. Only route.go and go.mod / go.sum are copied, so a neighboring helper.go is not compiled; import shared code as a module.
  • Two routes with the same slug, a route.go with no handler, and one that is not package main are build errors.
  • Nothing rebuilds during a request. An unbuilt route answers a JSON 500 pointing at native:build, and in dev so does a binary older than its route.go.

The scaffolded Dockerfile has no Go and does not run native:build. In that production image every route.go is an unbuilt 500, so run native:build in a build stage with Go and copy var/cache/native/bin to the final image.

Request flow#

There is no long-running Go server. PHP starts the binary as a CGI child process per request (proc_open), with a few milliseconds of overhead per call. The handler sees:

  • r.Method, r.URL, r.Header, r.Host, r.RemoteAddr, r.Body. A client's Proxy: header is not forwarded, and the port in r.RemoteAddr is 0.
  • Dynamic segments as JSON in RELAYER_ROUTE_PARAMS, and the pattern in RELAYER_ROUTE_PATTERN.
  var params map[string]string
  json.Unmarshal([]byte(os.Getenv("RELAYER_ROUTE_PARAMS")), &params)
  id := params["id"]
  • The PHP process's real environment variables, but not .env values. Pass settings Go needs as real environment variables from the shell or container.
  • A multipart/form-data body rebuilt from what PHP parsed, so r.FormFile works. An upload PHP refused becomes a JSON 500.

Methods and failures#

Methods follow route.php: an undeclared OPTIONS is 204 + Allow, HEAD with a GET runs GET and drops the body, and any other undeclared method is 405 + Allow.

A non-zero exit (including panic), output that is not a CGI response, and exceeding the timeout all become a JSON 500. stderr goes to the PSR-3 logger (warning for a successful run). The timeout defaults to 30 seconds and NATIVE_ROUTE_TIMEOUT changes it. It does not work on a PHP with proc_open in disable_functions.

What does not apply#

route.go has no autowiring, PageContext, Request, #[Auth] or requireAuth(); protect it with the root middleware. _private folders exclude route.go too, and a directory holds one handler (route.go next to route.php or a page is a scan error).

middleware.go#

The root middleware can be written in Go too. Write one func Proxy in src/Pages/middleware.go; native:build builds it to var/cache/native/bin/middleware, and it starts per request before route matching.

// src/Pages/middleware.go
package main

import "net/http"

func Proxy(w http.ResponseWriter, r *http.Request) {
    // Short-circuit: without Relayer-Next, this is the final response
    if r.Header.Get("X-Api-Key") == "" && len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(401)
        w.Write([]byte(`{"error":"missing api key"}`))
        return
    }

    // Continue: with Relayer-Next, the matched page / route runs
    w.Header().Set("Relayer-Next", "1")
    w.Header().Set("Relayer-Request-Header-X-User", "42") // request header the route sees
    w.Header().Set("X-Proxied-By", "go")                  // response header merged into the final response
}
  • With Relayer-Next: 1 → continue; the body is ignored. Relayer-Rewrite-Path: /other routes to another path, and Relayer-Request-Header-<Name>: value adds or overwrites a request header. Other response headers merge into the final response (except Status:, Content-Type and Content-Length).
  • Without Relayer-Next → short-circuit: Status: (default 200), headers and body go out as is.
  • Relayer-* headers never reach the client.
  • RELAYER_MATCHED_PATTERN holds the pattern the path would match (empty for a 404).
  • Having both middleware.php and middleware.go throws RuntimeException.
  • A failure is a JSON 500, even for a page request.
  • Every page and route pays a few milliseconds. /_defer/* and /_profiler run outside it, so checks that must cover them belong in the guard.
Last revised — 2026-11-01
Revisions (1)
  • v0.31.0: 新規ページ