Skip to content

Commit 80b5600

Browse files
committed
Add embed errors
1 parent e9b7463 commit 80b5600

File tree

9 files changed

+187
-31
lines changed

9 files changed

+187
-31
lines changed

coderd/coderd.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ func New(options *Options) *API {
7676

7777
r := chi.NewRouter()
7878
api := &API{
79-
Options: options,
80-
Handler: r,
79+
Options: options,
80+
Handler: r,
81+
siteHandler: site.Handler(),
8182
}
8283
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0)
8384

@@ -95,14 +96,19 @@ func New(options *Options) *API {
9596
tracing.HTTPMW(api.TracerProvider, "coderd.http"),
9697
)
9798

98-
r.Route("/@{user}/{workspaceagent}/apps/{application}", func(r chi.Router) {
99+
apps := func(r chi.Router) {
99100
r.Use(
100101
httpmw.RateLimitPerMinute(options.APIRateLimit),
101102
apiKeyMiddleware,
102103
httpmw.ExtractUserParam(api.Database),
103104
)
104105
r.Get("/*", api.workspaceAppsProxyPath)
105-
})
106+
}
107+
// %40 is the encoded character of the @ symbol. VS Code Web does
108+
// not handle character encoding properly, so it's safe to assume
109+
// other applications might not as well.
110+
r.Route("/%40{user}/{workspaceagent}/apps/{application}", apps)
111+
r.Route("/@{user}/{workspaceagent}/apps/{application}", apps)
106112

107113
r.Route("/api/v2", func(r chi.Router) {
108114
r.NotFound(func(rw http.ResponseWriter, r *http.Request) {
@@ -338,14 +344,15 @@ func New(options *Options) *API {
338344
r.Get("/state", api.workspaceBuildState)
339345
})
340346
})
341-
r.NotFound(site.DefaultHandler().ServeHTTP)
347+
r.NotFound(api.siteHandler.ServeHTTP)
342348
return api
343349
}
344350

345351
type API struct {
346352
*Options
347353

348354
Handler chi.Router
355+
siteHandler http.Handler
349356
websocketWaitMutex sync.Mutex
350357
websocketWaitGroup sync.WaitGroup
351358
workspaceAgentCache *wsconncache.Cache

coderd/workspaceapps.go

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/coder/coder/coderd/database"
1616
"github.com/coder/coder/coderd/httpapi"
1717
"github.com/coder/coder/coderd/httpmw"
18+
"github.com/coder/coder/site"
1819
)
1920

2021
func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request) {
@@ -113,23 +114,15 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
113114
return
114115
}
115116

116-
conn, release, err := api.workspaceAgentCache.Acquire(r, agent.ID)
117-
if err != nil {
118-
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
119-
Message: fmt.Sprintf("dial workspace agent: %s", err),
120-
})
121-
return
122-
}
123-
defer release()
124-
125117
proxy := httputil.NewSingleHostReverseProxy(appURL)
126-
// Write the error directly using our format!
118+
// Write an error using our embed handler
127119
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
128-
httpapi.Write(w, http.StatusBadGateway, httpapi.Response{
129-
Message: err.Error(),
130-
})
120+
r = r.WithContext(site.WithAPIResponse(r.Context(), site.APIResponse{
121+
StatusCode: http.StatusBadGateway,
122+
Message: err.Error(),
123+
}))
124+
api.siteHandler.ServeHTTP(w, r)
131125
}
132-
proxy.Transport = conn.HTTPTransport()
133126
path := chi.URLParam(r, "*")
134127
if !strings.HasSuffix(r.URL.Path, "/") && path == "" {
135128
// Web applications typically request paths relative to the
@@ -139,6 +132,27 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
139132
http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect)
140133
return
141134
}
135+
if r.URL.RawQuery == "" && appURL.RawQuery != "" {
136+
// If the application defines a default set of query parameters,
137+
// we should always respect them. The reverse proxy will merge
138+
// query parameters for server-side requests, but sometimes
139+
// client-side applications require the query parameters to render
140+
// properly. With code-server, this is the "folder" param.
141+
r.URL.RawQuery = appURL.RawQuery
142+
http.Redirect(rw, r, r.URL.String(), http.StatusTemporaryRedirect)
143+
return
144+
}
142145
r.URL.Path = path
146+
147+
conn, release, err := api.workspaceAgentCache.Acquire(r, agent.ID)
148+
if err != nil {
149+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
150+
Message: fmt.Sprintf("dial workspace agent: %s", err),
151+
})
152+
return
153+
}
154+
defer release()
155+
156+
proxy.Transport = conn.HTTPTransport()
143157
proxy.ServeHTTP(rw, r)
144158
}

coderd/workspaceapps_test.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
3838

3939
client, coderAPI := coderdtest.NewWithAPI(t, nil)
4040
user := coderdtest.CreateFirstUser(t, client)
41-
daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI)
41+
coderdtest.NewProvisionerDaemon(t, coderAPI)
4242
authToken := uuid.NewString()
4343
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
4444
Parse: echo.ParseComplete,
@@ -56,7 +56,7 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
5656
},
5757
Apps: []*proto.App{{
5858
Name: "example",
59-
Url: fmt.Sprintf("http://127.0.0.1:%d", tcpAddr.Port),
59+
Url: fmt.Sprintf("http://127.0.0.1:%d?query=true", tcpAddr.Port),
6060
}},
6161
}},
6262
}},
@@ -68,7 +68,6 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
6868
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
6969
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
7070
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
71-
daemonCloser.Close()
7271

7372
agentClient := codersdk.New(client.URL)
7473
agentClient.SessionToken = authToken
@@ -91,11 +90,22 @@ func TestWorkspaceAppsProxyPath(t *testing.T) {
9190
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
9291
})
9392

94-
t.Run("Proxies", func(t *testing.T) {
93+
t.Run("RedirectsWithQuery", func(t *testing.T) {
9594
t.Parallel()
9695
resp, err := client.Request(context.Background(), http.MethodGet, "/@me/"+workspace.Name+"/apps/example/", nil)
9796
require.NoError(t, err)
9897
defer resp.Body.Close()
98+
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
99+
loc, err := resp.Location()
100+
require.NoError(t, err)
101+
require.Equal(t, "query=true", loc.RawQuery)
102+
})
103+
104+
t.Run("Proxies", func(t *testing.T) {
105+
t.Parallel()
106+
resp, err := client.Request(context.Background(), http.MethodGet, "/@me/"+workspace.Name+"/apps/example/?query=true", nil)
107+
require.NoError(t, err)
108+
defer resp.Body.Close()
99109
body, err := io.ReadAll(resp.Body)
100110
require.NoError(t, err)
101111
require.Equal(t, "", string(body))
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
name: Develop code-server in Docker
3+
description: Run code-server in a Docker development environment
4+
tags: [local, docker]
5+
---
6+
7+
# code-server in Docker
8+
9+
## Getting started
10+
11+
Run `coder templates init` and select this template. Follow the instructions that appear.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
terraform {
2+
required_providers {
3+
coder = {
4+
source = "coder/coder"
5+
version = "0.4.2"
6+
}
7+
docker = {
8+
source = "kreuzwerker/docker"
9+
version = "~> 2.16.0"
10+
}
11+
}
12+
}
13+
14+
provider "coder" {
15+
}
16+
17+
data "coder_workspace" "me" {
18+
}
19+
20+
resource "coder_agent" "dev" {
21+
arch = "amd64"
22+
os = "linux"
23+
startup_script = "code-server --auth none"
24+
}
25+
26+
resource "coder_app" "code-server" {
27+
agent_id = coder_agent.dev.id
28+
url = "http://localhost:8080/?folder=/home/coder"
29+
}
30+
31+
resource "docker_container" "workspace" {
32+
count = data.coder_workspace.me.start_count
33+
image = "codercom/code-server:latest"
34+
# Uses lower() to avoid Docker restriction on container names.
35+
name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}"
36+
hostname = lower(data.coder_workspace.me.name)
37+
dns = ["1.1.1.1"]
38+
# Use the docker gateway if the access URL is 127.0.0.1
39+
entrypoint = ["sh", "-c", replace(coder_agent.dev.init_script, "127.0.0.1", "host.docker.internal")]
40+
env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"]
41+
host {
42+
host = "host.docker.internal"
43+
ip = "host-gateway"
44+
}
45+
}

site/embed.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package site
55

66
import (
77
"bytes"
8+
"context"
89
"embed"
910
"fmt"
1011
"io"
@@ -28,7 +29,16 @@ import (
2829
//go:embed out/bin/*
2930
var site embed.FS
3031

31-
func DefaultHandler() http.Handler {
32+
type apiResponseContextKey struct{}
33+
34+
// WithAPIResponse returns a context with the APIResponse value attached.
35+
// This is used to inject API response data to the index.html for additional
36+
// metadata in error pages.
37+
func WithAPIResponse(ctx context.Context, apiResponse APIResponse) context.Context {
38+
return context.WithValue(ctx, apiResponseContextKey{}, apiResponse)
39+
}
40+
41+
func Handler() http.Handler {
3242
// the out directory is where webpack builds are created. It is in the same
3343
// directory as this file (package site).
3444
siteFS, err := fs.Sub(site, "out")
@@ -38,11 +48,11 @@ func DefaultHandler() http.Handler {
3848
panic(err)
3949
}
4050

41-
return Handler(siteFS)
51+
return HandlerWithFS(siteFS)
4252
}
4353

4454
// Handler returns an HTTP handler for serving the static site.
45-
func Handler(fileSystem fs.FS) http.Handler {
55+
func HandlerWithFS(fileSystem fs.FS) http.Handler {
4656
// html files are handled by a text/template. Non-html files
4757
// are served by the default file server.
4858
//
@@ -90,8 +100,14 @@ func (h *handler) exists(filePath string) bool {
90100
}
91101

92102
type htmlState struct {
93-
CSP cspState
94-
CSRF csrfState
103+
APIResponse APIResponse
104+
CSP cspState
105+
CSRF csrfState
106+
}
107+
108+
type APIResponse struct {
109+
StatusCode int
110+
Message string
95111
}
96112

97113
type cspState struct {
@@ -139,6 +155,11 @@ func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
139155
CSRF: csrfState{Token: nosurf.Token(req)},
140156
}
141157

158+
apiResponseRaw := req.Context().Value(apiResponseContextKey{})
159+
if apiResponseRaw != nil {
160+
state.APIResponse = apiResponseRaw.(APIResponse)
161+
}
162+
142163
// First check if it's a file we have in our templates
143164
if h.serveHTML(resp, req, reqFile, state) {
144165
return

site/embed_slim.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import (
77
"net/http"
88
)
99

10-
func DefaultHandler() http.Handler {
10+
type APIResponse struct {
11+
StatusCode int
12+
Message string
13+
}
14+
15+
func Handler() http.Handler {
1116
return http.NotFoundHandler()
1217
}
18+
19+
func WithAPIResponse(ctx context.Context, _ APIResponse) context.Context {
20+
return ctx
21+
}

site/embed_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package site_test
55

66
import (
77
"context"
8+
"encoding/json"
89
"fmt"
910
"io"
1011
"net/http"
@@ -39,7 +40,7 @@ func TestCaching(t *testing.T) {
3940
},
4041
}
4142

42-
srv := httptest.NewServer(site.Handler(rootFS))
43+
srv := httptest.NewServer(site.HandlerWithFS(rootFS))
4344
defer srv.Close()
4445

4546
// Create a context
@@ -98,7 +99,7 @@ func TestServingFiles(t *testing.T) {
9899
},
99100
}
100101

101-
srv := httptest.NewServer(site.Handler(rootFS))
102+
srv := httptest.NewServer(site.HandlerWithFS(rootFS))
102103
defer srv.Close()
103104

104105
// Create a context
@@ -172,3 +173,40 @@ func TestShouldCacheFile(t *testing.T) {
172173
require.Equal(t, testCase.expected, got, fmt.Sprintf("Expected ShouldCacheFile(%s) to be %t", testCase.reqFile, testCase.expected))
173174
}
174175
}
176+
177+
func TestServeAPIResponse(t *testing.T) {
178+
t.Parallel()
179+
180+
// Create a test server
181+
rootFS := fstest.MapFS{
182+
"index.html": &fstest.MapFile{
183+
Data: []byte(`{"code":{{ .APIResponse.StatusCode }},"message":"{{ .APIResponse.Message }}"}`),
184+
},
185+
}
186+
187+
apiResponse := site.APIResponse{
188+
StatusCode: http.StatusBadGateway,
189+
Message: "This could be an error message!",
190+
}
191+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192+
r = r.WithContext(site.WithAPIResponse(r.Context(), apiResponse))
193+
site.HandlerWithFS(rootFS).ServeHTTP(w, r)
194+
}))
195+
defer srv.Close()
196+
197+
req, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
198+
require.NoError(t, err)
199+
resp, err := http.DefaultClient.Do(req)
200+
require.NoError(t, err)
201+
var body struct {
202+
Code int `json:"code"`
203+
Message string `json:"message"`
204+
}
205+
data, err := io.ReadAll(resp.Body)
206+
require.NoError(t, err)
207+
t.Logf("resp: %q", data)
208+
err = json.Unmarshal(data, &body)
209+
require.NoError(t, err)
210+
require.Equal(t, apiResponse.StatusCode, body.Code)
211+
require.Equal(t, apiResponse.Message, body.Message)
212+
}

site/htmlTemplates/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
<meta property="og:type" content="website" />
1818
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
1919
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
20+
<meta id="api-response" data-statuscode="{{ .APIResponse.StatusCode }}" data-message="{{ .APIResponse.Message }}" />
2021
<link rel="mask-icon" href="/static/favicon.svg" color="#000000" crossorigin="use-credentials" />
2122
<link rel="alternate icon" type="image/png" href="/favicon.png" />
2223
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

0 commit comments

Comments
 (0)