From 41a43ba63b0b7afb859e877b50ca478c2e46f22e Mon Sep 17 00:00:00 2001 From: lo5 Date: Thu, 4 Nov 2021 20:11:04 -0700 Subject: [PATCH] Add base URL support (#1104) * wip - apps functional + martin's changes to paths.js and index.html * wip: Respect base url when running tour * wip: Fix file upload; move all paths to ui.ts * wip: Merge file server and store; point /f to /f/ * wip: Fix file upload and browser download (download() / unload() broken) * wip: Fix browser-upload, py-download, py-unload * wip: Make test.py usable via pytest * wip: Point socket and proxy to _s/ _p/; add test for proxy * wip: Fix public/private dir hosting; add tests * wip: Fix cache routing * wip: Expose cache on Site and AsyncSite; add caching test * wip: Fix multipart image handling * fix: point static assets without baseURL to /static, not //static * Add -proxy, -public-dir to make pytest happy * Revert changes to index.tsx * Publish new version to NPM * Bump h2o-wave package version --- Makefile | 3 +- client.go | 6 +- cmd/wave/main.go | 1 + conf.go | 1 + file_server.go | 90 +++++++++++++- file_store.go | 127 ------------------- ide/src/ide.ts | 3 +- proxy.go | 5 +- py/examples/tour.py | 12 +- py/h2o_wave/core.py | 99 ++++++++------- py/tests/test.py | 268 +++++++++++++++++++++++++---------------- server.go | 51 +++++--- socket.go | 6 +- ts/index.ts | 4 +- ts/package-lock.json | 85 +------------ ts/package.json | 2 +- ui/config/paths.js | 39 ++---- ui/package.json | 2 +- ui/public/index.html | 8 +- ui/src/app.tsx | 2 +- ui/src/file_upload.tsx | 2 +- ui/src/router.tsx | 5 +- ui/src/ui.ts | 14 ++- web_server.go | 52 +++++--- 24 files changed, 439 insertions(+), 448 deletions(-) delete mode 100644 file_store.go diff --git a/Makefile b/Makefile index 5e94e51ff4..e41d72741b 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,6 @@ setup-ts: ## Set up NPM package and symlinks cd ts && npm ci && npm run build cd ts && npm link cd ui && npm link h2o-wave - cd u && npm link h2o-wave .PHONY: build build: build-ui build-server ## Build everything @@ -86,7 +85,7 @@ build-docker: . run: ## Run server - go run cmd/wave/main.go -web-dir ./ui/build -debug -editable + go run cmd/wave/main.go -web-dir ./ui/build -debug -editable -proxy -public-dir /assets/@./assets run-db: ## Run database server go run cmd/wavedb/main.go diff --git a/client.go b/client.go index 697c5b2685..2b482aa5e4 100644 --- a/client.go +++ b/client.go @@ -62,10 +62,11 @@ type Client struct { routes []string // watched routes data chan []byte // send data editable bool // allow editing? // TODO move to user; tie to role + baseURL string } -func newClient(addr string, auth *Auth, session *Session, broker *Broker, conn *websocket.Conn, editable bool) *Client { - return &Client{uuid.New().String(), auth, addr, session, broker, conn, nil, make(chan []byte, 256), editable} +func newClient(addr string, auth *Auth, session *Session, broker *Broker, conn *websocket.Conn, editable bool, baseURL string) *Client { + return &Client{uuid.New().String(), auth, addr, session, broker, conn, nil, make(chan []byte, 256), editable, baseURL} } func (c *Client) refreshToken() error { @@ -110,6 +111,7 @@ func (c *Client) listen() { } m := parseMsg(msg) + m.addr = resolveURL(m.addr, c.baseURL) switch m.t { case patchMsgT: if c.editable { // allow only if editing is enabled diff --git a/cmd/wave/main.go b/cmd/wave/main.go index ae3bc8fb5e..911e772cda 100644 --- a/cmd/wave/main.go +++ b/cmd/wave/main.go @@ -78,6 +78,7 @@ func main() { flag.BoolVar(&version, "version", false, "print version and exit") stringVar(&conf.Listen, "listen", ":10101", "listen on this address") + stringVar(&conf.BaseURL, "base-url", "/", "the base URL (path prefix) to be used for resolving relative URLs (e.g. /foo/ or /foo/bar/, without the host)") stringVar(&conf.WebDir, "web-dir", "./www", "directory to serve web assets from, hosted at /") stringVar(&conf.DataDir, "data-dir", "./data", "directory to store site data") stringsVar(&conf.PublicDirs, "public-dir", "additional directory to serve files from, in the format \"[url-path]@[filesystem-path]\", e.g. \"/public/files/@/some/local/path\" will host /some/local/path/foo.txt at /public/files/foo.txt; multiple directory mappings allowed") diff --git a/conf.go b/conf.go index d4e9526b99..4e66d0cc90 100644 --- a/conf.go +++ b/conf.go @@ -38,6 +38,7 @@ type ServerConf struct { Version string BuildDate string Listen string + BaseURL string WebDir string DataDir string PublicDirs Strings diff --git a/file_server.go b/file_server.go index e935ee19b2..df04e5bf24 100644 --- a/file_server.go +++ b/file_server.go @@ -15,13 +15,17 @@ package wave import ( + "encoding/json" "errors" + "fmt" + "io" "net/http" "os" "path" "path/filepath" "strings" + "github.com/google/uuid" "github.com/h2oai/wave/pkg/keychain" ) @@ -31,14 +35,16 @@ type FileServer struct { keychain *keychain.Keychain auth *Auth handler http.Handler + baseURL string } -func newFileServer(dir string, keychain *keychain.Keychain, auth *Auth) http.Handler { +func newFileServer(dir string, keychain *keychain.Keychain, auth *Auth, baseURL string) http.Handler { return &FileServer{ dir, keychain, auth, http.FileServer(http.Dir(dir)), + baseURL, } } @@ -46,6 +52,11 @@ var ( errInvalidUnloadPath = errors.New("invalid file path") ) +// UploadResponse represents a response to a file upload operation. +type UploadResponse struct { + Files []string `json:"files"` +} + func (fs *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -64,9 +75,34 @@ func (fs *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } echo(Log{"t": "file_download", "path": r.URL.Path}) - r.URL.Path = strings.TrimPrefix(r.URL.Path, "/_f") // public + r.URL.Path = strings.TrimPrefix(r.URL.Path, fs.baseURL) // public fs.handler.ServeHTTP(w, r) + case http.MethodPost: + // Disallow if: + // - unauthorized api call + // - auth enabled and unauthorized + if !fs.keychain.Allow(r) && (fs.auth != nil && !fs.auth.allow(r)) { // API or UI + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + files, err := fs.acceptFiles(r) + if err != nil { + echo(Log{"t": "file_upload", "error": err.Error()}) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + res, err := json.Marshal(UploadResponse{Files: files}) + if err != nil { + echo(Log{"t": "file_upload", "error": err.Error()}) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write(res) + case http.MethodDelete: // TODO garbage collection @@ -86,6 +122,56 @@ func (fs *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (fs *FileServer) acceptFiles(r *http.Request) ([]string, error) { + if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB + return nil, fmt.Errorf("failed parsing upload form from request: %v", err) + } + + form := r.MultipartForm + files, ok := form.File["files"] + if !ok { + return nil, errors.New("want 'files' field in upload form, got none") + } + + uploadPaths := make([]string, len(files)) + for i, file := range files { + + id, err := uuid.NewRandom() + if err != nil { + return nil, fmt.Errorf("failed generating file id: %v", err) + } + + src, err := file.Open() + if err != nil { + return nil, fmt.Errorf("failed opening uploaded file: %v", err) + } + defer src.Close() + + fileID := id.String() + uploadDir := filepath.Join(fs.dir, fileID) + + if err := os.MkdirAll(uploadDir, 0700); err != nil { + return nil, fmt.Errorf("failed creating upload dir %s: %v", uploadDir, err) + } + + basename := filepath.Base(file.Filename) + uploadPath := filepath.Join(uploadDir, basename) + + dst, err := os.Create(uploadPath) + if err != nil { + return nil, fmt.Errorf("failed writing uploaded file %s: %v", uploadPath, err) + } + defer dst.Close() + + if _, err = io.Copy(dst, src); err != nil { + return nil, fmt.Errorf("failed copying uploaded file %s: %v", uploadPath, err) + } + + uploadPaths[i] = path.Join(fs.baseURL, fileID, basename) + } + return uploadPaths, nil +} + func (fs *FileServer) deleteFile(url string) error { tokens := strings.Split(path.Clean(url), "/") if len(tokens) != 4 { // /_f/uuid/file.ext diff --git a/file_store.go b/file_store.go deleted file mode 100644 index 0723bec857..0000000000 --- a/file_store.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2020 H2O.ai, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package wave - -import ( - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path" - "path/filepath" - - "github.com/google/uuid" - "github.com/h2oai/wave/pkg/keychain" -) - -// FileStore represents a file store. -type FileStore struct { - dir string - keychain *keychain.Keychain - auth *Auth -} - -func newFileStore(dir string, keychain *keychain.Keychain, auth *Auth) http.Handler { - return &FileStore{dir, keychain, auth} -} - -// UploadResponse represents a response to a file upload operation. -type UploadResponse struct { - Files []string `json:"files"` -} - -func (fs *FileStore) ServeHTTP(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - // Disallow if: - // - unauthorized api call - // - auth enabled and unauthorized - if !fs.keychain.Allow(r) && (fs.auth != nil && !fs.auth.allow(r)) { // API or UI - http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - return - } - - files, err := fs.acceptFiles(r) - if err != nil { - echo(Log{"t": "file_upload", "error": err.Error()}) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - res, err := json.Marshal(UploadResponse{Files: files}) - if err != nil { - echo(Log{"t": "file_upload", "error": err.Error()}) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(res) - default: - echo(Log{"t": "file_upload", "method": r.Method, "path": r.URL.Path, "error": "method not allowed"}) - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - } -} - -func (fs *FileStore) acceptFiles(r *http.Request) ([]string, error) { - if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB - return nil, fmt.Errorf("failed parsing upload form from request: %v", err) - } - - form := r.MultipartForm - files, ok := form.File["files"] - if !ok { - return nil, errors.New("want 'files' field in upload form, got none") - } - - uploadPaths := make([]string, len(files)) - for i, file := range files { - - id, err := uuid.NewRandom() - if err != nil { - return nil, fmt.Errorf("failed generating file id: %v", err) - } - - src, err := file.Open() - if err != nil { - return nil, fmt.Errorf("failed opening uploaded file: %v", err) - } - defer src.Close() - - fileID := id.String() - uploadDir := filepath.Join(fs.dir, fileID) - - if err := os.MkdirAll(uploadDir, 0700); err != nil { - return nil, fmt.Errorf("failed creating upload dir %s: %v", uploadDir, err) - } - - basename := filepath.Base(file.Filename) - uploadPath := filepath.Join(uploadDir, basename) - - dst, err := os.Create(uploadPath) - if err != nil { - return nil, fmt.Errorf("failed writing uploaded file %s: %v", uploadPath, err) - } - defer dst.Close() - - if _, err = io.Copy(dst, src); err != nil { - return nil, fmt.Errorf("failed copying uploaded file %s: %v", uploadPath, err) - } - - uploadPaths[i] = path.Join("/_f", fileID, basename) - } - return uploadPaths, nil -} diff --git a/ide/src/ide.ts b/ide/src/ide.ts index 2fdef13679..94bfe63cc6 100644 --- a/ide/src/ide.ts +++ b/ide/src/ide.ts @@ -20,7 +20,8 @@ interface ProxyResult { } async function proxy(req: ProxyRequest): Promise { - const res = await fetch('/_p', { method: 'POST', body: JSON.stringify(req) }) + // TODO: prefix baseURL + const res = await fetch('/_p/', { method: 'POST', body: JSON.stringify(req) }) return await res.json() } diff --git a/proxy.go b/proxy.go index dda4e7cc06..67a38ba43c 100644 --- a/proxy.go +++ b/proxy.go @@ -66,10 +66,7 @@ func newProxy(auth *Auth, maxRequestSize, maxResponseSize int64) *Proxy { func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: - // Disallow if: - // - unauthorized api call - // - auth not enabled or auth enabled and unauthorized - if p.auth == nil || (p.auth != nil && !p.auth.allow(r)) { + if p.auth != nil && !p.auth.allow(r) { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } diff --git a/py/examples/tour.py b/py/examples/tour.py index f6d8a595e0..8d7dfc1dc8 100644 --- a/py/examples/tour.py +++ b/py/examples/tour.py @@ -17,6 +17,7 @@ html_formatter = HtmlFormatter(full=True, style='xcode') example_dir = os.path.dirname(os.path.realpath(__file__)) +_base_url = os.environ.get('H2O_WAVE_BASE_URL', '/') _app_address = urlparse(os.environ.get(f'H2O_WAVE_APP_ADDRESS', 'http://127.0.0.1:8000')) _app_host, _app_port = _app_address.hostname, '10102' default_example_name = 'hello_world' @@ -36,9 +37,11 @@ def __init__(self, filename: str, title: str, description: str, source: str): self.is_app = source.find('@app(') > 0 async def start(self): + env = dict(H2O_WAVE_BASE_URL=_base_url) # The environment passed into Popen must include SYSTEMROOT, otherwise Popen will fail when called # inside python during initialization if %PATH% is configured, but without %SYSTEMROOT%. - env = {'SYSTEMROOT': os.environ['SYSTEMROOT']} if sys.platform.lower().startswith('win') else {} + if sys.platform.lower().startswith('win'): + env['SYSTEMROOT'] = os.environ['SYSTEMROOT'] if self.is_app: self.process = subprocess.Popen([ sys.executable, '-m', 'uvicorn', @@ -180,7 +183,7 @@ async def setup_page(q: Q): q.page['header'] = ui.header_card(box='header', title=app_title, subtitle=f'{len(catalog)} Interactive Examples') q.page['blurb'] = ui.section_card(box='blurb', title='', subtitle='', items=[]) q.page['code'] = ui.frame_card(box='code', title='', content='') - q.page['preview'] = ui.frame_card(box='preview', title='Preview', path='/demo') + q.page['preview'] = ui.frame_card(box='preview', title='Preview', path=f'{_base_url}demo') await q.page.save() @@ -191,7 +194,7 @@ def make_blurb(q: Q, example: Example): blurb_card.subtitle = example.description # HACK: Recreate dropdown every time (by dynamic name) to control value (needed for next / prev btn functionality). items = [ui.dropdown(name=q.args['#'] or default_example_name, width='300px', value=example.name, trigger=True, - choices=[ui.choice(name=e.name, label=e.title) for e in catalog.values()])] + choices=[ui.choice(name=e.name, label=e.title) for e in catalog.values()])] if example.previous_example: items.append(ui.button(name=f'#{example.previous_example.name}', label='Previous')) if example.next_example: @@ -229,7 +232,7 @@ async def show_example(q: Q, example: Example): # HACK # The ?e= appended to the path forces the frame to reload. # The url param is not actually used. - preview_card.path = f'/demo?e={active_example.name}' + preview_card.path = f'{_base_url}demo?e={active_example.name}' await q.page.save() @@ -245,6 +248,7 @@ async def serve(q: Q): await show_example(q, catalog[search or q.args['#'] or default_example_name]) + example_filenames = [line.strip() for line in read_lines(os.path.join(example_dir, 'tour.conf')) if not line.strip().startswith('#')] catalog = load_examples(example_filenames) diff --git a/py/h2o_wave/core.py b/py/h2o_wave/core.py index 10f973be19..b4546e1e6c 100644 --- a/py/h2o_wave/core.py +++ b/py/h2o_wave/core.py @@ -46,7 +46,9 @@ def __init__(self): self.internal_address = _get_env('INTERNAL_ADDRESS', _default_internal_address) self.app_address = _get_env('APP_ADDRESS', _get_env('EXTERNAL_ADDRESS', self.internal_address)) self.app_mode = _get_env('APP_MODE', UNICAST) - self.hub_address = _get_env('ADDRESS', 'http://127.0.0.1:10101') + self.hub_base_url = _get_env('BASE_URL', '/') + self.hub_host_address = _get_env('ADDRESS', 'http://127.0.0.1:10101') + self.hub_address = self.hub_host_address + self.hub_base_url self.hub_access_key_id: str = _get_env('ACCESS_KEY_ID', 'access_key_id') self.hub_access_key_secret: str = _get_env('ACCESS_KEY_SECRET', 'access_key_secret') self.app_access_key_id: str = _get_env('APP_ACCESS_KEY_ID', None) or secrets.token_urlsafe(16) @@ -113,6 +115,10 @@ def _guard_key(key: str): raise KeyError('invalid key type: want str or int') +def _rebase(host: str, path: str): + return f"{host}{path.lstrip('/')}" + + class ServiceError(Exception): pass @@ -400,49 +406,43 @@ def _keys(self, text: str) -> List[str]: class _AsyncServerCache(_ServerCacheBase): - def __init__(self): - self._http = httpx.AsyncClient( - auth=(_config.hub_access_key_id, _config.hub_access_key_secret), - verify=False, - ) + def __init__(self, http: httpx.AsyncClient): + self._http = http async def get(self, shard: str, key: str, default=None) -> Any: - res = await self._http.get(f'{_config.hub_address}/_c/{shard}/{key}') + res = await self._http.get(f'{_config.hub_address}_c/{shard}/{key}') if res.status_code == 200: - return json.loads(res.text) + return unmarshal(res.text) return default async def keys(self, shard: str) -> List[str]: - res = await self._http.get(f'{_config.hub_address}/_c/{shard}') + res = await self._http.get(f'{_config.hub_address}_c/{shard}') return self._keys(res.text) if res.status_code == 200 else [] async def set(self, shard: str, key: str, value: Any): - content = json.dumps(value) - res = await self._http.put(f'{_config.hub_address}/_c/{shard}/{key}', content=content) + content = marshal(value) + res = await self._http.put(f'{_config.hub_address}_c/{shard}/{key}', content=content) if res.status_code != 200: raise ServiceError(f'Request failed (code={res.status_code}): {res.text}') class _ServerCache(_ServerCacheBase): - def __init__(self): - self._http = httpx.Client( - auth=(_config.hub_access_key_id, _config.hub_access_key_secret), - verify=False, - ) + def __init__(self, http: httpx.Client): + self._http = http def get(self, shard: str, key: str, default=None): - res = self._http.get(f'{_config.hub_address}/_c/{shard}/{key}') + res = self._http.get(f'{_config.hub_address}_c/{shard}/{key}') if res.status_code == 200: - return json.loads(res.text) + return unmarshal(res.text) return default def keys(self, shard: str) -> List[str]: - res = self._http.get(f'{_config.hub_address}/_c/{shard}') + res = self._http.get(f'{_config.hub_address}_c/{shard}') return self._keys(res.text) if res.status_code == 200 else [] def set(self, shard: str, key: str, value: Any): - content = json.dumps(value) - res = self._http.put(f'{_config.hub_address}/_c/{shard}/{key}', content=content) + content = marshal(value) + res = self._http.put(f'{_config.hub_address}_c/{shard}/{key}', content=content) if res.status_code != 200: raise ServiceError(f'Request failed (code={res.status_code}): {res.text}') @@ -616,6 +616,7 @@ def __init__(self): auth=(_config.hub_access_key_id, _config.hub_access_key_secret), verify=False, ) + self.cache = _ServerCache(self._http) def __getitem__(self, url) -> Page: return Page(self, url) @@ -625,7 +626,7 @@ def __delitem__(self, key: str): page.drop() def _save(self, url: str, patch: str): - res = self._http.patch(f'{_config.hub_address}{url}', content=patch) + res = self._http.patch(_rebase(_config.hub_address, url), content=patch) if res.status_code != 200: raise ServiceError(f'Request failed (code={res.status_code}): {res.text}') @@ -639,7 +640,7 @@ def load(self, url) -> dict: Returns: The serialized page. """ - res = self._http.get(f'{_config.hub_address}{url}', headers=_content_type_json) + res = self._http.get(_rebase(_config.hub_address, url), headers=_content_type_json) if res.status_code != 200: raise ServiceError(f'Request failed (code={res.status_code}): {res.text}') return res.json() @@ -654,8 +655,8 @@ def upload(self, files: List[str]) -> List[str]: Returns: A list of remote URLs for the uploaded files, in order. """ - upload_url = f'{_config.hub_address}/_f' - res = self._http.post(upload_url, files=[('files', (os.path.basename(f), open(f, 'rb'))) for f in files]) + res = self._http.post(f'{_config.hub_address}_f/', + files=[('files', (os.path.basename(f), open(f, 'rb'))) for f in files]) if res.status_code == 200: return json.loads(res.text)['files'] raise ServiceError(f'Upload failed (code={res.status_code}): {res.text}') @@ -676,7 +677,7 @@ def download(self, url: str, path: str) -> str: filepath = os.path.join(path, os.path.basename(url)) if os.path.isdir(path) else path with open(filepath, 'wb') as f: - with self._http.stream('GET', f'{_config.hub_address}{url}') as r: + with self._http.stream('GET', f'{_config.hub_host_address}{url}') as r: for chunk in r.iter_bytes(): f.write(chunk) @@ -689,7 +690,7 @@ def unload(self, url: str): Args: url: The URL of the file to delete. """ - res = self._http.delete(f'{_config.hub_address}{url}') + res = self._http.delete(f'{_config.hub_host_address}{url}') if res.status_code == 200: return raise ServiceError(f'Unload failed (code={res.status_code}): {res.text}') @@ -711,10 +712,10 @@ def uplink(self, path: str, content_type: str, file: FileContent) -> str: Returns: The stream endpoint, typically used as an image path. """ - endpoint = f'/_m/{path}' + endpoint = f'_m/{path}' res = self._http.post(f'{_config.hub_address}{endpoint}', files={'f': ('f', file, content_type)}) if res.status_code == 200: - return endpoint + return _config.hub_base_url + endpoint raise ServiceError(f'Uplink failed (code={res.status_code}): {res.text}') def unlink(self, path: str): @@ -724,12 +725,19 @@ def unlink(self, path: str): Args: path: The path of the stream """ - endpoint = f'/_m/{path}' + endpoint = f'_m/{path}' res = self._http.delete(f'{_config.hub_address}{endpoint}') if res.status_code == 200: - return endpoint + return raise ServiceError(f'Unlink failed (code={res.status_code}): {res.text}') + def proxy(self, method: str, url: str, headers: Optional[Dict[str, List[str]]] = None, body: Optional[str] = None): + req = dict(method=method, url=url, headers=headers, body=body) + res = self._http.post(f'{_config.hub_address}_p/', content=marshal(req)) + if res.status_code == 200: + return res.json() + raise ServiceError(f'Proxy request failed (code={res.status_code}): {res.text}') + site = Site() @@ -744,6 +752,7 @@ def __init__(self): auth=(_config.hub_access_key_id, _config.hub_access_key_secret), verify=False, ) + self.cache = _AsyncServerCache(self._http) def __getitem__(self, url) -> AsyncPage: return AsyncPage(self, url) @@ -753,7 +762,7 @@ def __delitem__(self, key: str): page.drop() async def _save(self, url: str, patch: str): - res = await self._http.patch(f'{_config.hub_address}{url}', content=patch) + res = await self._http.patch(_rebase(_config.hub_address, url), content=patch) if res.status_code != 200: raise ServiceError(f'Request failed (code={res.status_code}): {res.text}') @@ -767,7 +776,7 @@ async def load(self, url) -> dict: Returns: The serialized page. """ - res = await self._http.get(f'{_config.hub_address}{url}', headers=_content_type_json) + res = await self._http.get(_rebase(_config.hub_address, url), headers=_content_type_json) if res.status_code != 200: raise ServiceError(f'Request failed (code={res.status_code}): {res.text}') return res.json() @@ -782,8 +791,8 @@ async def upload(self, files: List[str]) -> List[str]: Returns: A list of remote URLs for the uploaded files, in order. """ - upload_url = f'{_config.hub_address}/_f' - res = await self._http.post(upload_url, files=[('files', (os.path.basename(f), open(f, 'rb'))) for f in files]) + res = await self._http.post(f'{_config.hub_address}_f/', + files=[('files', (os.path.basename(f), open(f, 'rb'))) for f in files]) if res.status_code == 200: return json.loads(res.text)['files'] raise ServiceError(f'Upload failed (code={res.status_code}): {res.text}') @@ -803,7 +812,7 @@ async def download(self, url: str, path: str) -> str: filepath = os.path.join(path, os.path.basename(url)) if os.path.isdir(path) else path with open(filepath, 'wb') as f: - async with self._http.stream('GET', f'{_config.hub_address}{url}') as r: + async with self._http.stream('GET', f'{_config.hub_host_address}{url}') as r: async for chunk in r.aiter_bytes(): f.write(chunk) @@ -816,7 +825,7 @@ async def unload(self, url: str): Args: url: The URL of the file to delete. """ - res = await self._http.delete(f'{_config.hub_address}{url}') + res = await self._http.delete(f'{_config.hub_host_address}{url}') if res.status_code == 200: return raise ServiceError(f'Unload failed (code={res.status_code}): {res.text}') @@ -838,10 +847,10 @@ async def uplink(self, path: str, content_type: str, file: FileContent) -> str: Returns: The stream endpoint, typically used as an image path. """ - endpoint = f'/_m/{path}' + endpoint = f'_m/{path}' res = await self._http.post(f'{_config.hub_address}{endpoint}', files={'f': ('f', file, content_type)}) if res.status_code == 200: - return endpoint + return _config.hub_base_url + endpoint raise ServiceError(f'Uplink failed (code={res.status_code}): {res.text}') async def unlink(self, path: str): @@ -851,12 +860,20 @@ async def unlink(self, path: str): Args: path: The path of the stream """ - endpoint = f'/_m/{path}' + endpoint = f'_m/{path}' res = await self._http.delete(f'{_config.hub_address}{endpoint}') if res.status_code == 200: - return endpoint + return raise ServiceError(f'Unlink failed (code={res.status_code}): {res.text}') + async def proxy(self, method: str, url: str, headers: Optional[Dict[str, List[str]]] = None, + body: Optional[str] = None): + req = dict(method=method, url=url, headers=headers, body=body) + res = await self._http.post(f'{_config.hub_address}_p/', content=marshal(req)) + if res.status_code == 200: + return res.json() + raise ServiceError(f'Proxy request failed (code={res.status_code}): {res.text}') + def _kv(key: str, index: str, value: Any): return dict(k=key, v=value) if index is None or index == '' else dict(k=key, i=index, v=value) diff --git a/py/tests/test.py b/py/tests/test.py index 8492253f8a..23520ae3da 100644 --- a/py/tests/test.py +++ b/py/tests/test.py @@ -11,34 +11,14 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import os import sys import difflib import json -from h2o_wave import site, Page, data - -all_tests = [] -only_tests = [] - - -def test(f): - all_tests.append(f) - return f +import httpx -def test_only(f): - only_tests.append(f) - return f - - -def run_tests(): - - tests = only_tests if len(only_tests) else all_tests - for t in tests: - print(t.__name__) - page = site['/test'] - page.drop() - t(page) +from h2o_wave import site, Page, data, Expando def make_card(**props): @@ -73,81 +53,95 @@ def make_page(**cards) -> dict: return dict(p=dict(c=cards)) def dump_for_comparison(x: dict): return json.dumps(x, indent=2, sort_keys=True).splitlines(keepends=True) -def expect(actual: dict, expected: dict): +def compare(actual: dict, expected: dict) -> bool: a = dump_for_comparison(actual) b = dump_for_comparison(expected) - if a != b: - diff = difflib.Differ().compare(a, b) - sys.stdout.write('\n------------------- Actual --------------------\n') - sys.stdout.writelines(a) - sys.stdout.write('\n------------------- Expected --------------------\n') - sys.stdout.writelines(b) - sys.stdout.write('\n------------------- Diff --------------------\n') - sys.stdout.writelines(diff) - raise ValueError('actual != expected') + if a == b: + return True + + diff = difflib.Differ().compare(a, b) + sys.stdout.write('\n------------------- Actual --------------------\n') + sys.stdout.writelines(a) + sys.stdout.write('\n------------------- Expected --------------------\n') + sys.stdout.writelines(b) + sys.stdout.write('\n------------------- Diff --------------------\n') + sys.stdout.writelines(diff) + return False sample_fields = ['a', 'b', 'c'] -@test -def test_new_empty_card(page: Page): +def test_new_empty_card(): + page = site['/test'] + page.drop() page['card1'] = dict() page.save() - expect(page.load(), make_page(card1=make_card())) + assert compare(page.load(), make_page(card1=make_card())) -@test -def test_new_card_with_props(page: Page): +def test_new_card_with_props(): + page = site['/test'] + page.drop() page['card1'] = dict(s="foo", i=42, f=4.2, bt=True, bf=False, n=None) page.save() - expect(page.load(), make_page(card1=make_card(s="foo", i=42, f=4.2, bt=True, bf=False))) + assert compare(page.load(), make_page(card1=make_card(s="foo", i=42, f=4.2, bt=True, bf=False))) -@test -def test_new_card_with_map_buf(page: Page): +def test_new_card_with_map_buf(): + page = site['/test'] + page.drop() page['card1'] = dict(data=data(fields=sample_fields)) page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data={})))) + assert compare(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data={})))) -@test -def test_new_card_with_fix_buf(page: Page): +def test_new_card_with_fix_buf(): + page = site['/test'] + page.drop() page['card1'] = dict(data=data(fields=sample_fields, size=3)) page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf(fields=sample_fields, data=[None] * 3)))) + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf(fields=sample_fields, data=[None] * 3)))) -@test -def test_new_card_with_cyc_buf(page: Page): +def test_new_card_with_cyc_buf(): + page = site['/test'] + page.drop() page['card1'] = dict(data=data(fields=sample_fields, size=-3)) page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf(fields=sample_fields, data=[None] * 3, i=0)))) + assert compare(page.load(), + make_page(card1=make_card(data=make_cyc_buf(fields=sample_fields, data=[None] * 3, i=0)))) -@test -def test_load_card_with_map_buf(page: Page): +def test_load_card_with_map_buf(): + page = site['/test'] + page.drop() page['card1'] = dict(data=data(fields=sample_fields, rows=dict(foo=[1, 2, 3]))) page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict(foo=[1, 2, 3]))))) + assert compare(page.load(), + make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict(foo=[1, 2, 3]))))) -@test -def test_load_card_with_fix_buf(page: Page): +def test_load_card_with_fix_buf(): + page = site['/test'] + page.drop() page['card1'] = dict(data=data(fields=sample_fields, rows=[[1, 2, 3]])) page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf(fields=sample_fields, data=[[1, 2, 3]])))) + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf(fields=sample_fields, data=[[1, 2, 3]])))) -@test -def test_load_card_with_cyc_buf(page: Page): +def test_load_card_with_cyc_buf(): + page = site['/test'] + page.drop() page['card1'] = dict(data=data(fields=sample_fields, rows=[[1, 2, 3]], size=-10)) page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf(fields=sample_fields, data=[[1, 2, 3]], i=0)))) + assert compare(page.load(), + make_page(card1=make_card(data=make_cyc_buf(fields=sample_fields, data=[[1, 2, 3]], i=0)))) -@test -def test_prop_set(page: Page): +def test_prop_set(): + page = site['/test'] + page.drop() page['card1'] = dict() page.save() @@ -158,95 +152,101 @@ def test_prop_set(page: Page): c.bt = True c.bf = False page.save() - expect(page.load(), make_page(card1=make_card(s="foo", i=42, f=4.2, bt=True, bf=False))) + assert compare(page.load(), make_page(card1=make_card(s="foo", i=42, f=4.2, bt=True, bf=False))) c.s = "bar" c.i = 420 c.f = 40.2 page.save() - expect(page.load(), make_page(card1=make_card(s="bar", i=420, f=40.2, bt=True, bf=False))) + assert compare(page.load(), make_page(card1=make_card(s="bar", i=420, f=40.2, bt=True, bf=False))) -@test -def test_map_prop_set(page: Page): +def test_map_prop_set(): + page = site['/test'] + page.drop() page['card1'] = dict(m=dict(s="foo")) page.save() c = page['card1'] c.m.s = "bar" page.save() - expect(page.load(), make_page(card1=make_card(m=dict(s="bar")))) + assert compare(page.load(), make_page(card1=make_card(m=dict(s="bar")))) c.m.s = None c.m.s2 = "bar" page.save() - expect(page.load(), make_page(card1=make_card(m=dict(s2="bar")))) + assert compare(page.load(), make_page(card1=make_card(m=dict(s2="bar")))) -@test -def test_map_prop_set_deep(page: Page): +def test_map_prop_set_deep(): + page = site['/test'] + page.drop() page['card1'] = dict(m=dict(m=dict(m=dict(s="foo")))) page.save() c = page['card1'] c.m.m.m.s = "bar" page.save() - expect(page.load(), make_page(card1=make_card(m=dict(m=dict(m=dict(s="bar")))))) + assert compare(page.load(), make_page(card1=make_card(m=dict(m=dict(m=dict(s="bar")))))) c.m.m.m = dict(answer=42) page.save() - expect(page.load(), make_page(card1=make_card(m=dict(m=dict(m=dict(answer=42)))))) + assert compare(page.load(), make_page(card1=make_card(m=dict(m=dict(m=dict(answer=42)))))) -@test -def test_array_prop_set(page: Page): +def test_array_prop_set(): + page = site['/test'] + page.drop() page['card1'] = dict(a=[1, 2, 3]) page.save() c = page['card1'] c.a[2] = 33 page.save() - expect(page.load(), make_page(card1=make_card(a=[1, 2, 33]))) + assert compare(page.load(), make_page(card1=make_card(a=[1, 2, 33]))) c.a[33] = 100 # index out of bounds - expect(page.load(), make_page(card1=make_card(a=[1, 2, 33]))) + assert compare(page.load(), make_page(card1=make_card(a=[1, 2, 33]))) -@test -def test_array_prop_set_deep(page: Page): +def test_array_prop_set_deep(): + page = site['/test'] + page.drop() page['card1'] = dict(a=[[[[42]]]]) page.save() c = page['card1'] c.a[0][0][0][0] = 420 page.save() - expect(page.load(), make_page(card1=make_card(a=[[[[420]]]]))) + assert compare(page.load(), make_page(card1=make_card(a=[[[[420]]]]))) c.a[0][0][0] = [42, 420] page.save() - expect(page.load(), make_page(card1=make_card(a=[[[[42, 420]]]]))) + assert compare(page.load(), make_page(card1=make_card(a=[[[[42, 420]]]]))) -@test -def test_map_buf_init(page: Page): +def test_map_buf_init(): + page = site['/test'] + page.drop() c = page.add('card1', dict(data=data(fields=sample_fields))) c.data = dict(foo=[1, 2, 3], bar=[4, 5, 6], baz=[7, 8, 9]) page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( + assert compare(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( foo=[1, 2, 3], bar=[4, 5, 6], baz=[7, 8, 9], ))))) -@test -def test_map_buf_write(page: Page): +def test_map_buf_write(): + page = site['/test'] + page.drop() c = page.add('card1', dict(data=data(fields=sample_fields))) c.data.foo = [1, 2, 3] c.data.bar = [4, 5, 6] c.data.baz = [7, 8, 9] page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( + assert compare(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( foo=[1, 2, 3], bar=[4, 5, 6], baz=[7, 8, 9], @@ -254,7 +254,7 @@ def test_map_buf_write(page: Page): c.data.baz[1] = 42 page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( + assert compare(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( foo=[1, 2, 3], bar=[4, 5, 6], baz=[7, 42, 9], @@ -262,7 +262,7 @@ def test_map_buf_write(page: Page): c.data.baz[1] = [41, 42, 43] page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( + assert compare(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( foo=[1, 2, 3], bar=[4, 5, 6], baz=[7, [41, 42, 43], 9], @@ -270,72 +270,76 @@ def test_map_buf_write(page: Page): c.data.baz[1][1] = 999 page.save() - expect(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( + assert compare(page.load(), make_page(card1=make_card(data=make_map_buf(fields=sample_fields, data=dict( foo=[1, 2, 3], bar=[4, 5, 6], baz=[7, [41, 999, 43], 9], ))))) -@test -def test_fix_buf_init(page: Page): +def test_fix_buf_init(): + page = site['/test'] + page.drop() c = page.add('card1', dict(data=data(fields=sample_fields, size=3))) c.data = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf( fields=sample_fields, data=[[1, 2, 3], [4, 5, 6], [7, 8, 9]], )))) -@test -def test_fix_buf_write(page: Page): +def test_fix_buf_write(): + page = site['/test'] + page.drop() c = page.add('card1', dict(data=data(fields=sample_fields, size=3))) c.data[0] = [1, 2, 3] c.data[1] = [4, 5, 6] c.data[2] = [7, 8, 9] page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf( fields=sample_fields, data=[[1, 2, 3], [4, 5, 6], [7, 8, 9]], )))) c.data[2][1] = 42 page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf( fields=sample_fields, data=[[1, 2, 3], [4, 5, 6], [7, 42, 9]], )))) c.data[2][1] = [41, 42, 43] page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf( fields=sample_fields, data=[[1, 2, 3], [4, 5, 6], [7, [41, 42, 43], 9]], )))) c.data[2][1][1] = 999 page.save() - expect(page.load(), make_page(card1=make_card(data=make_fix_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_fix_buf( fields=sample_fields, data=[[1, 2, 3], [4, 5, 6], [7, [41, 999, 43], 9]], )))) -@test -def test_cyc_buf_init(page: Page): +def test_cyc_buf_init(): + page = site['/test'] + page.drop() c = page.add('card1', dict(data=data(fields=sample_fields, size=-3))) c.data = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]] # insert 4 instead of 3; should circle back page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_cyc_buf( fields=sample_fields, data=[[10, 11, 12], [4, 5, 6], [7, 8, 9]], i=1, )))) -@test -def test_cyc_buf_write(page: Page): +def test_cyc_buf_write(): + page = site['/test'] + page.drop() c = page.add('card1', dict(data=data(fields=sample_fields, size=-3))) c.data[0] = [1, 2, 3] c.data[1] = [4, 5, 6] @@ -343,7 +347,7 @@ def test_cyc_buf_write(page: Page): c.data[100] = [10, 11, 12] # keys don't matter c.data[101] = [13, 14, 15] # keys don't matter page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_cyc_buf( fields=sample_fields, data=[[10, 11, 12], [13, 14, 15], [7, 8, 9]], i=2, @@ -351,7 +355,7 @@ def test_cyc_buf_write(page: Page): c.data[2][1] = 42 page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_cyc_buf( fields=sample_fields, data=[[10, 11, 12], [13, 14, 15], [7, 42, 9]], i=2, @@ -359,7 +363,7 @@ def test_cyc_buf_write(page: Page): c.data[2][1] = [41, 42, 43] page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_cyc_buf( fields=sample_fields, data=[[10, 11, 12], [13, 14, 15], [7, [41, 42, 43], 9]], i=2, @@ -367,11 +371,65 @@ def test_cyc_buf_write(page: Page): c.data[2][1][1] = 999 page.save() - expect(page.load(), make_page(card1=make_card(data=make_cyc_buf( + assert compare(page.load(), make_page(card1=make_card(data=make_cyc_buf( fields=sample_fields, data=[[10, 11, 12], [13, 14, 15], [7, [41, 999, 43], 9]], i=2, )))) -run_tests() +def test_proxy(): + # waved -proxy must be set + url = 'https://wave.h2o.ai' + response = Expando(site.proxy('get', url)) + if response.error: + assert False + else: + result = Expando(response.result) + assert result.code == 400 + assert len(result.headers) > 0 + + +def _read_file(path: str): + with open(path, 'r') as f: + return f.read() + + +def test_file_server(): + f1 = 'temp_file1.txt' + with open(f1, 'w') as f: + f.writelines([f'line {i + 1}' for i in range(10)]) + paths = site.upload([f1]) + f2 = 'temp_file2.txt' + f2 = site.download(paths[0], f2) + s1 = _read_file(f1) + s2 = _read_file(f2) + os.remove(f1) + os.remove(f2) + assert s1 == s2 + + +def test_public_dir(): + base_url = os.getenv('H2O_WAVE_BASE_URL', '/') + p = site.download(f'{base_url}assets/brand/h2o.svg', 'h2o.svg') + svg = _read_file(p) + os.remove(p) + assert svg.index(' 0 diff --git a/server.go b/server.go index d899fce4b6..b4334c5254 100644 --- a/server.go +++ b/server.go @@ -42,6 +42,18 @@ func echo(m Log) { } } +func handleWithBaseURL(baseURL string) func(string, http.Handler) { + return func(pattern string, handler http.Handler) { + http.Handle(baseURL+pattern, handler) + } +} + +func resolveURL(path, baseURL string) string { + // TODO: Ugly - add leading slash for compatibility. + // TODO: Strip leading slash in Py/R clients? + return "/" + strings.TrimPrefix(path, baseURL) +} + // Run runs the HTTP server. func Run(conf ServerConf) { for _, line := range strings.Split(fmt.Sprintf(logo, conf.Version, conf.BuildDate), "\n") { @@ -53,11 +65,13 @@ func Run(conf ServerConf) { initSite(site, conf.Init) } + handle := handleWithBaseURL(conf.BaseURL) + broker := newBroker(site, conf.Editable, conf.NoStore, conf.NoLog) go broker.run() if conf.Debug { - http.Handle("/_d/site", newDebugHandler(broker)) + handle("_d/site", newDebugHandler(broker)) } var auth *Auth @@ -67,37 +81,36 @@ func Run(conf ServerConf) { if auth, err = newAuth(conf.Auth); err != nil { panic(fmt.Errorf("failed connecting to OIDC provider: %v", err)) } - http.Handle("/_auth/init", newLoginHandler(auth)) - http.Handle("/_auth/callback", newAuthHandler(auth)) - http.Handle("/_auth/logout", newLogoutHandler(auth, broker)) + handle("_auth/init", newLoginHandler(auth)) + handle("_auth/callback", newAuthHandler(auth)) + handle("_auth/logout", newLogoutHandler(auth, broker)) } - http.Handle("/_s", newSocketServer(broker, auth, conf.Editable)) // XXX terminate sockets when logged out + handle("_s/", newSocketServer(broker, auth, conf.Editable, conf.BaseURL)) // XXX terminate sockets when logged out fileDir := filepath.Join(conf.DataDir, "f") - http.Handle("/_f", newFileStore(fileDir, conf.Keychain, auth)) - http.Handle("/_f/", newFileServer(fileDir, conf.Keychain, auth)) + handle("_f/", newFileServer(fileDir, conf.Keychain, auth, conf.BaseURL+"_f")) for _, dir := range conf.PrivateDirs { prefix, src := splitDirMapping(dir) echo(Log{"t": "private_dir", "source": src, "address": prefix}) - http.Handle(prefix, http.StripPrefix(prefix, newDirServer(src, conf.Keychain, auth))) + handle(prefix, http.StripPrefix(conf.BaseURL+prefix, newDirServer(src, conf.Keychain, auth))) } for _, dir := range conf.PublicDirs { prefix, src := splitDirMapping(dir) echo(Log{"t": "public_dir", "source": src, "address": prefix}) - http.Handle(prefix, http.StripPrefix(prefix, http.FileServer(http.Dir(src)))) + handle(prefix, http.StripPrefix(conf.BaseURL+prefix, http.FileServer(http.Dir(src)))) } - http.Handle("/_c/", newCache("/_c/", conf.Keychain, conf.MaxCacheRequestSize)) - http.Handle("/_m/", newMultipartServer("/_m/", conf.Keychain, auth, conf.MaxRequestSize)) + handle("_c/", newCache(conf.BaseURL+"_c/", conf.Keychain, conf.MaxCacheRequestSize)) + handle("_m/", newMultipartServer(conf.BaseURL+"_m/", conf.Keychain, auth, conf.MaxRequestSize)) if conf.Proxy { - http.Handle("/_p", newProxy(auth, conf.MaxProxyRequestSize, conf.MaxProxyResponseSize)) + handle("_p/", newProxy(auth, conf.MaxProxyRequestSize, conf.MaxProxyResponseSize)) } if conf.IDE { - ide := http.StripPrefix("/_ide", http.FileServer(http.Dir(path.Join(conf.WebDir, "_ide")))) - http.Handle("/_ide", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ide := http.StripPrefix("_ide", http.FileServer(http.Dir(path.Join(conf.WebDir, "_ide")))) + handle("_ide", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if auth != nil && !auth.allow(r) { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return @@ -106,9 +119,13 @@ func Run(conf ServerConf) { })) } - http.Handle("/", newWebServer(site, broker, auth, conf.Keychain, conf.MaxRequestSize, conf.WebDir, conf.Header)) + webServer, err := newWebServer(site, broker, auth, conf.Keychain, conf.MaxRequestSize, conf.BaseURL, conf.WebDir, conf.Header) + if err != nil { + panic(err) + } + handle("", webServer) - echo(Log{"t": "listen", "address": conf.Listen, "webroot": conf.WebDir}) + echo(Log{"t": "listen", "address": conf.Listen, "web-dir": conf.WebDir, "base-url": conf.BaseURL}) if conf.CertFile != "" && conf.KeyFile != "" { if err := http.ListenAndServeTLS(conf.Listen, conf.CertFile, conf.KeyFile, nil); err != nil { @@ -126,7 +143,7 @@ func splitDirMapping(m string) (string, string) { if len(xs) < 2 { panic(fmt.Sprintf("invalid directory mapping: want \"remote@local\", got %s", m)) } - return xs[0], xs[1] + return strings.TrimLeft(xs[0], "/"), xs[1] } func readRequestWithLimit(w http.ResponseWriter, r io.ReadCloser, n int64) ([]byte, error) { diff --git a/socket.go b/socket.go index b3bbde0e4a..660a79b2f7 100644 --- a/socket.go +++ b/socket.go @@ -23,13 +23,15 @@ type SocketServer struct { broker *Broker auth *Auth editable bool + baseURL string } -func newSocketServer(broker *Broker, auth *Auth, editable bool) *SocketServer { +func newSocketServer(broker *Broker, auth *Auth, editable bool, baseURL string) *SocketServer { return &SocketServer{ broker, auth, editable, + baseURL, } } @@ -49,7 +51,7 @@ func (s *SocketServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - client := newClient(getRemoteAddr(r), s.auth, user, s.broker, conn, s.editable) + client := newClient(getRemoteAddr(r), s.auth, user, s.broker, conn, s.editable, s.baseURL) go client.flush() go client.listen() } diff --git a/ts/index.ts b/ts/index.ts index bc7a2a82ec..1e73d99c7d 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -807,7 +807,7 @@ const export const disconnect = () => refreshRateB(0), - connect = (handle: WaveEventHandler): Wave => { + connect = (address: S, handle: WaveEventHandler): Wave => { let _socket: WebSocket | null = null, _page: XPage | null = null, @@ -904,7 +904,7 @@ export const if (_socket) _socket.close() }) - reconnect(toSocketAddress('/_s')) + reconnect(toSocketAddress(address)) return { fork, push } } diff --git a/ts/package-lock.json b/ts/package-lock.json index 968c70ae62..cd632d7620 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -1,89 +1,8 @@ { "name": "h2o-wave", - "version": "0.2.0", - "lockfileVersion": 2, + "version": "1.0.0", + "lockfileVersion": 1, "requires": true, - "packages": { - "": { - "name": "h2o-wave", - "version": "0.2.0", - "license": "Apache-2.0", - "devDependencies": { - "terser": "^5.7.0", - "typescript": "^4.2.4" - } - }, - "node_modules/buffer-from": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-support": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", - "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/terser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.7.0.tgz", - "integrity": "sha512-HP5/9hp2UaZt5fYkuhNBR8YyRcT8juw8+uFbAme53iN9hblvKnLUTKkmwJG6ocWpIKf8UK4DoeWG4ty0J6S6/g==", - "dev": true, - "dependencies": { - "commander": "^2.20.0", - "source-map": "~0.7.2", - "source-map-support": "~0.5.19" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/typescript": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - } - }, "dependencies": { "buffer-from": { "version": "1.1.1", diff --git a/ts/package.json b/ts/package.json index a44c1fd5ca..d61b6f6bc5 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,6 +1,6 @@ { "name": "h2o-wave", - "version": "0.2.0", + "version": "1.0.0", "description": "H2O Wave browser module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/ui/config/paths.js b/ui/config/paths.js index b3fd764aec..b1b895b196 100644 --- a/ui/config/paths.js +++ b/ui/config/paths.js @@ -1,25 +1,12 @@ -'use strict'; +'use strict' -const path = require('path'); -const fs = require('fs'); -const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); +const path = require('path') +const fs = require('fs') // Make sure any symlinks in the project folder are resolved: // https://github.com/facebook/create-react-app/issues/637 -const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); - -// We use `PUBLIC_URL` environment variable or "homepage" field to infer -// "public path" at which the app is served. -// webpack needs to know it to put the right