Skip to content

Commit

Permalink
Server should serve vscode for web (#11)
Browse files Browse the repository at this point in the history
* Update our server so we can serve vscode for web

* We need to check in workbench.html and turn it into a template which
we then render.
* We also need to properly load all the extensions.
  • Loading branch information
jlewi authored Apr 3, 2024
1 parent 7515b38 commit 0a44acc
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 0 deletions.
47 changes: 47 additions & 0 deletions app/pkg/server/extensions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package server

import (
"github.com/go-logr/zapr"
"github.com/pkg/errors"
"go.uber.org/zap"
"os"
"path/filepath"
)

// findExtensionsInDir returns a list of all the extensions
func findExtensionsInDir(extDir string) ([]string, error) {
log := zapr.NewLogger(zap.L())
if extDir == "" {
return nil, errors.New("extensions dir is empty")
}
entries, err := os.ReadDir(extDir)

if err != nil {
return nil, errors.Wrapf(err, "failed to read directory %s", extDir)
}

extLocations := make([]string, 0, len(entries))

for _, entry := range entries {
if !entry.IsDir() {
continue
}

// Extensions should contain a package.json file
pkgFile := filepath.Join(extDir, entry.Name(), "package.json")

_, err := os.Stat(pkgFile)
if err != nil && os.IsNotExist(err) {
log.Info("dir does not contain a package.json file; skipping it as an extension", "dir", entry.Name())
continue
}
if err != nil {
return nil, errors.Wrapf(err, "Failed to stat %s", pkgFile)
}

extPath := filepath.Join(extDir, entry.Name())
log.Info("Found extension", "dir", extPath)
extLocations = append(extLocations, extPath)
}
return extLocations, nil
}
134 changes: 134 additions & 0 deletions app/pkg/server/server.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
package server

import (
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"github.com/go-logr/zapr"
"github.com/jlewi/foyle/app/pkg/config"
"github.com/pkg/errors"
"go.uber.org/zap"
"html/template"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
)

// Server is the main application server for foyle
type Server struct {
config config.Config
engine *gin.Engine

// builtinExtensionPaths is a list of serving paths to the built in extensions
builtinExtensionPaths []string
}

// NewServer creates a new server
Expand All @@ -31,6 +39,11 @@ func NewServer(config config.Config) (*Server, error) {
return s, nil
}

type staticMapping struct {
relativePath string
root string
}

// createGinEngine sets up the gin engine which is a router
func (s *Server) createGinEngine() error {
log := zapr.NewLogger(zap.L())
Expand All @@ -39,10 +52,131 @@ func (s *Server) createGinEngine() error {
router := gin.Default()
router.GET("/healthz", healthCheck)

// Serve the static assets for vscode.
// There should be several directories located in ${ASSETS_DIR}/vscode
// The second argument to Static is the directory to act as the root for the static files.

vsCodeRPath := "/out"
extensionsMapping := staticMapping{
relativePath: "extensions",
root: filepath.Join(s.config.GetAssetsDir(), "vscode/extensions"),
}
mappings := []staticMapping{
{
// TODO(jeremy): Can we change "/out" to "/vscode"? We'd have to update various paths in workbench.html
relativePath: vsCodeRPath,
root: filepath.Join(s.config.GetAssetsDir(), "vscode/out-vscode-reh-web-min"),
},
{
relativePath: "resources",
root: filepath.Join(s.config.GetAssetsDir(), "vscode/resources"),
},
extensionsMapping,
}

for _, m := range mappings {
log.Info("Adding vscode static assets", "relativePath", m.relativePath, "root", m.root)
router.Static(m.relativePath, m.root)
}

if err := s.setHTMLTemplates(router); err != nil {
return err
}

// Set the builtin extensions
if err := s.setVSCodeBuiltinExtensionPaths(extensionsMapping); err != nil {
return err
}
// The workbench endpoint serves the workbench.html page which is the main entry point for vscode for web
router.GET("/workbench", s.handleGetWorkbench)
s.engine = router
return nil
}

// setVSCodeBuiltinExtensionPaths sets the builtin extension paths for the extensions that ship with vscode
func (s *Server) setVSCodeBuiltinExtensionPaths(m staticMapping) error {
if s.builtinExtensionPaths == nil {
s.builtinExtensionPaths = make([]string, 0, 30)
}

locs, err := findExtensionsInDir(m.root)
if err != nil {
return err
}

for _, l := range locs {
relPath, err := filepath.Rel(m.root, l)
if err != nil {
return errors.Wrapf(err, "Failed to get relative path for %s relative to %s", l, m.root)
}
s.builtinExtensionPaths = append(s.builtinExtensionPaths, filepath.Join(m.relativePath, relPath))
}
return nil
}

// handleGetWorkBench handles the request to the workbench endpoint
func (s *Server) handleGetWorkbench(c *gin.Context) {
// Use the value of the "Host" from the request
// Extract the origin so that we can make it work behind reverse proxies and the like.
host := c.Request.Host

// Lets default the scheme to https but if the host is localhost then assume its http
scheme := "https"

if strings.HasPrefix(host, "localhost:") {
scheme = "http"
}

workbenchOpts := IWorkbenchConstructionOptions{
AdditionalBuiltinExtensions: make([]VSCodeUriComponents, 0, len(s.builtinExtensionPaths)),
}

// TODO(jeremy): Should this be cached as a function of scheme and host?
for _, location := range s.builtinExtensionPaths {
workbenchOpts.AdditionalBuiltinExtensions = append(workbenchOpts.AdditionalBuiltinExtensions, VSCodeUriComponents{
Scheme: scheme,
Authority: host,
// Path should be the serving path of the extension
Path: location,
})
}

opts, err := json.Marshal(workbenchOpts)
if err != nil {
if err := c.AbortWithError(http.StatusInternalServerError, err); err != nil {
// N.B does AbortWithError alway return the error?
log.Printf("error marshalling workbench options %v", err)
}
return
}

// Important baseUrl should not have a trailing slash. If it has a trailing slash it will mess up some
// of the client side code
baseUrl := scheme + "://" + host
c.HTML(http.StatusOK, "workbench.html", gin.H{
"WorkbenchWebBaseUrl": baseUrl,
"WorkbenchNLSBaseURL": baseUrl + "/nls",
"WorkbenchWebConfiguration": string(opts),
"WorkbenchAuthSession": "",
})
}

// setHTMLTemplates sets the HTML templates for the server.
func (s *Server) setHTMLTemplates(router *gin.Engine) error {
// Since we are using go:embed to load the templates we can't use the built in
// gin.LoadHTMLGlob/LoadHTMLFile function. Instead we have to manually parse the templates.
// This code is based on the code in that file

// Load the templates we need to explicitly set a name for the template because we aren't using LoadHTMLGlob
// We don't set delims because we are just using the default delimiters
templ, err := template.New("workbench.html").Funcs(router.FuncMap).Parse(workbenchTemplateStr)
if err != nil {
return errors.Wrapf(err, "Failed to parse workbench template")
}
router.SetHTMLTemplate(templ)
return nil
}

// Run starts the http server
func (s *Server) Run() error {
address := fmt.Sprintf("%s:%d", s.config.Server.BindAddress, s.config.Server.HttpPort)
Expand Down
6 changes: 6 additions & 0 deletions app/pkg/server/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package server

import _ "embed"

//go:embed templates/workbench.html
var workbenchTemplateStr string
87 changes: 87 additions & 0 deletions app/pkg/server/templates/workbench.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!--
This html template is based on https://github.com/microsoft/vscode/blob/main/src/vs/code/browser/workbench/workbench.html
I changed the file to use GoTemplates for variable substitution
-->
<!DOCTYPE html>
<html>
<head>
<script>
performance.mark('code/didStartRenderer');
</script>
<meta charset="utf-8" />

<!-- Mobile tweaks -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Code">
<link rel="apple-touch-icon" href="{{ .WorkbenchWebBaseUrl }}/resources/server/code-192.png" />

<!-- Disable pinch zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">

<!-- Workbench Configuration -->
<meta id="vscode-workbench-web-configuration" data-settings="{{ .WorkbenchWebConfiguration}}">

<!-- Workbench Auth Session -->
<meta id="vscode-workbench-auth-session" data-settings="{{ .WorkbenchAuthSession}}">

<!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="{{ .WorkbenchWebBaseUrl }}/resources/server/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{ .WorkbenchWebBaseUrl }}/resources/server/manifest.json" crossorigin="use-credentials" />
<link data-name="vs/workbench/workbench.web.main" rel="stylesheet" href="{{ .WorkbenchWebBaseUrl }}/out/vs/workbench/workbench.web.main.css">

</head>

<body aria-label="">
</body>

<!-- Startup (do not modify order of script tags!) -->
<script src="{{ .WorkbenchWebBaseUrl }}/out/vs/loader.js"></script>
<script src="{{ .WorkbenchWebBaseUrl }}/out/vs/webPackagePaths.js"></script>
<script>
baseUrl = new URL('{{ .WorkbenchWebBaseUrl }}', window.location.origin).toString();
// Strip the trailing slash from baseUrl because if we don't then we will get "//"
// If we don't do this than we get 404s trying to start webWorkerExtensionHostIframe.html
baseUrl = baseUrl.replace(/\/$/, '');
Object.keys(self.webPackagePaths).map(function (key, index) {
self.webPackagePaths[key] = `${baseUrl}/node_modules/${key}/${self.webPackagePaths[key]}`;
});

// Set up nls if the user is not using the default language (English)
const nlsConfig = {};
// Normalize locale to lowercase because translationServiceUrl is case-sensitive.
// ref: https://github.com/microsoft/vscode/issues/187795
const locale = localStorage.getItem('vscode.nls.locale') || navigator.language.toLowerCase();
if (!locale.startsWith('en')) {
nlsConfig['vs/nls'] = {
availableLanguages: {
'*': locale
},
translationServiceUrl: '{{ .WorkbenchNLSBaseURL }}'
};
}

require.config({
// TODO(jeremy): Should we switch to useing "vscode" rather than out? Should ew make that a template
// parameter
baseUrl: `${baseUrl}/out`,
recordStats: true,
trustedTypesPolicy: window.trustedTypes?.createPolicy('amdLoader', {
createScriptURL(value) {
if(value.startsWith(window.location.origin)) {
return value;
}
throw new Error(`Invalid script url: ${value}`)
}
}),
paths: self.webPackagePaths,
...nlsConfig
});
</script>
<script>
performance.mark('code/willLoadWorkbenchMain');
</script>
<script src="{{ .WorkbenchWebBaseUrl }}/out/vs/workbench/workbench.web.main.nls.js"></script>
<script src="{{ .WorkbenchWebBaseUrl }}/out/vs/workbench/workbench.web.main.js"></script>
<script src="{{ .WorkbenchWebBaseUrl }}/out/vs/code/browser/workbench/workbench.js"></script>
</html>
18 changes: 18 additions & 0 deletions app/pkg/server/workbench.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package server

// IWorkbenchConstructionOptions contains the configuration for the vscode workbench
// Keep it in sync with IWorkbenchConstructionOptions
// https://github.com/microsoft/vscode/blob/d5d14242969257ffff1815ef3bec45d1f2eb0e81/src/vs/workbench/browser/web.api.ts#L134
//
// This struct contains the options passed to the workbench.html template.
type IWorkbenchConstructionOptions struct {
AdditionalBuiltinExtensions []VSCodeUriComponents `json:"additionalBuiltinExtensions,omitempty"`
}

type VSCodeUriComponents struct {
Scheme string `json:"scheme,omitempty"`
Authority string `json:"authority,omitempty"`
Path string `json:"path,omitempty"`
Query string `json:"query,omitempty"`
Fragment string `json:"fragment,omitempty"`
}

0 comments on commit 0a44acc

Please sign in to comment.