├── setup.py ├── screenshot.png ├── MANIFEST.in ├── labextension ├── .gitignore ├── tsconfig.json ├── .yarnrc.yml ├── README.md ├── LICENSE ├── package.json └── src │ ├── tokens.ts │ └── index.ts ├── docs ├── requirements.txt ├── source │ ├── _static │ │ └── images │ │ │ ├── logo │ │ │ └── favicon.ico │ │ │ ├── nbextension-tree.png │ │ │ └── labextension-launcher.png │ ├── install.md │ ├── convenience │ │ ├── new.md │ │ └── packages │ │ │ └── theia.md │ ├── launchers.md │ ├── examples.md │ ├── index.md │ ├── arbitrary-ports-hosts.md │ ├── conf.py │ ├── standalone.md │ └── server-process.md ├── Makefile └── make.bat ├── jupyter_server_proxy ├── etc │ ├── nbconfig │ │ └── tree.d │ │ │ └── jupyter-server-proxy.json │ ├── jupyter_server_config.d │ │ └── jupyter-server-proxy.json │ └── jupyter_notebook_config.d │ │ └── jupyter-server-proxy.json ├── _version.py ├── standalone │ ├── __init__.py │ ├── activity.py │ ├── proxy.py │ └── app.py ├── unixsock.py ├── utils.py ├── static │ └── tree.js ├── __init__.py ├── api.py ├── websocket.py ├── rawsocket.py └── config.py ├── contrib ├── template │ ├── cookiecutter.json │ └── {{cookiecutter.project_name}} │ │ ├── jupyter_{{cookiecutter.project_name}}_proxy │ │ └── __init__.py │ │ └── setup.py ├── code-server-traitlet │ ├── README.md │ └── jupyter_notebook_config.py └── theia │ ├── setup.py │ ├── jupyter_theia_proxy │ ├── __init__.py │ └── icons │ │ └── theia.svg │ └── README.rst ├── tests ├── acceptance │ ├── resources │ │ ├── index.html │ │ └── jupyter_config.json │ ├── __init__.robot │ ├── Classic.robot │ ├── Lab.robot │ ├── test_acceptance.py │ └── Notebook.robot ├── test_utils.py ├── test_config.py ├── resources │ ├── rawsocket.py │ ├── gzipserver.py │ ├── eventstream.py │ ├── proxyextension.py │ ├── httpinfo.py │ ├── redirectserver.py │ ├── websocket.py │ └── jupyter_server_config.py ├── conftest.py └── test_standalone.py ├── .flake8 ├── .readthedocs.yaml ├── .github ├── dependabot.yaml └── workflows │ ├── linkcheck.yaml │ ├── publish.yaml │ └── test.yaml ├── LICENSE ├── .pre-commit-config.yaml ├── RELEASE.md ├── .gitignore ├── README.md ├── pyproject.toml └── CONTRIBUTING.md /setup.py: -------------------------------------------------------------------------------- 1 | # this file intentionally left blank for legacy tools to find 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/jupyter-server-proxy/HEAD/screenshot.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | recursive-include labextension * 3 | prune labextension/node_modules 4 | -------------------------------------------------------------------------------- /labextension/.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.egg-info/ 5 | .ipynb_checkpoints 6 | ../.idea/ 7 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | myst-parser 2 | sphinx-autobuild 3 | sphinx-book-theme 4 | sphinx-copybutton 5 | sphinxext-opengraph 6 | sphinxext-rediraffe 7 | -------------------------------------------------------------------------------- /docs/source/_static/images/logo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/jupyter-server-proxy/HEAD/docs/source/_static/images/logo/favicon.ico -------------------------------------------------------------------------------- /jupyter_server_proxy/etc/nbconfig/tree.d/jupyter-server-proxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "load_extensions": { 3 | "jupyter_server_proxy/tree": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/source/_static/images/nbextension-tree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/jupyter-server-proxy/HEAD/docs/source/_static/images/nbextension-tree.png -------------------------------------------------------------------------------- /docs/source/_static/images/labextension-launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/jupyter-server-proxy/HEAD/docs/source/_static/images/labextension-launcher.png -------------------------------------------------------------------------------- /contrib/template/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_name": "", 3 | "author_name": "Project Jupyter Contributors", 4 | "author_email": "projectjupyter@gmail.com" 5 | } 6 | -------------------------------------------------------------------------------- /tests/acceptance/resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hello World 4 | 5 | 6 |

Hello World

7 | 8 | 9 | -------------------------------------------------------------------------------- /jupyter_server_proxy/etc/jupyter_server_config.d/jupyter-server-proxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyter_server_proxy": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter_server_proxy/etc/jupyter_notebook_config.d/jupyter-server-proxy.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyter_server_proxy": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter_server_proxy/_version.py: -------------------------------------------------------------------------------- 1 | # __version__ should be updated using tbump, based on configuration in 2 | # pyproject.toml, according to instructions in RELEASE.md. 3 | # 4 | __version__ = "4.4.1-0.dev" 5 | -------------------------------------------------------------------------------- /jupyter_server_proxy/standalone/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import StandaloneProxyServer 2 | 3 | 4 | def main(): 5 | StandaloneProxyServer.launch_instance() 6 | 7 | 8 | if __name__ == "__main__": 9 | main() 10 | -------------------------------------------------------------------------------- /contrib/code-server-traitlet/README.md: -------------------------------------------------------------------------------- 1 | ## code-server-traitlet 2 | 3 | Create a code-server launcher [via traitlet](https://jupyter-server-proxy.readthedocs.io/en/latest/server-process.html#specifying-config-via-traitlets). 4 | 5 | Via https://github.com/betatim/vscode-binder. 6 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from jupyter_server_proxy import utils 2 | 3 | 4 | def test_call_with_asked_args(): 5 | def _test_func(a, b): 6 | c = a * b 7 | return c 8 | 9 | assert utils.call_with_asked_args(_test_func, {"a": 5, "b": 4, "c": 8}) == 20 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # flake8 is used for linting Python code setup to automatically run with 2 | # pre-commit. 3 | # 4 | # ref: https://flake8.pycqa.org/en/latest/user/configuration.html 5 | # 6 | 7 | [flake8] 8 | # E: style errors 9 | # W: style warnings 10 | # C: complexity 11 | # D: docstring warnings (unused pydocstyle extension) 12 | ignore = E, C, W, D 13 | -------------------------------------------------------------------------------- /jupyter_server_proxy/unixsock.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | from tornado.netutil import Resolver 4 | 5 | 6 | class UnixResolver(Resolver): 7 | def initialize(self, socket_path): 8 | self.socket_path = socket_path 9 | 10 | async def resolve(self, host, port, *args, **kwargs): 11 | return [(socket.AF_UNIX, self.socket_path)] 12 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Configuration on how ReadTheDocs (RTD) builds our documentation 2 | # ref: https://readthedocs.org/projects/jupyter-server-proxy/ 3 | # ref: https://docs.readthedocs.io/en/stable/config-file/v2.html 4 | # 5 | version: 2 6 | 7 | sphinx: 8 | configuration: docs/source/conf.py 9 | 10 | build: 11 | os: ubuntu-22.04 12 | tools: 13 | python: "3.11" 14 | 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | -------------------------------------------------------------------------------- /contrib/code-server-traitlet/jupyter_notebook_config.py: -------------------------------------------------------------------------------- 1 | # load the config object for traitlets based configuration 2 | c = get_config() # noqa 3 | 4 | 5 | c.ServerProxy.servers = { 6 | "code-server": { 7 | "command": [ 8 | "code-server", 9 | "--auth=none", 10 | "--disable-telemetry", 11 | "--bind-addr=localhost:{port}", 12 | ], 13 | "timeout": 20, 14 | "launcher_entry": {"title": "VS Code"}, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/acceptance/resources/jupyter_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerProxy": { 3 | "servers": { 4 | "foo": { 5 | "command": ["python", "-m", "http.server", "{port}"], 6 | "launcher_entry": { 7 | "title": "foo" 8 | } 9 | }, 10 | "bar": { 11 | "command": ["python", "-m", "http.server", "{port}"], 12 | "launcher_entry": { 13 | "title": "bar" 14 | }, 15 | "new_browser_tab": false 16 | } 17 | } 18 | }, 19 | "LanguageServerManager": { 20 | "autodetect": false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from traitlets.config import Config 3 | 4 | from jupyter_server_proxy.config import ServerProxy 5 | 6 | 7 | def test_deprecated_config(): 8 | expected = "ServerProxy.host_whitelist is deprecated in jupyter-server-proxy 3.0.0, use ServerProxy.host_allowlist instead" 9 | cfg = Config() 10 | cfg.ServerProxy.host_whitelist = ["jupyter.example.org"] 11 | with pytest.warns(UserWarning, match=expected): 12 | server_proxy = ServerProxy(config=cfg) 13 | assert server_proxy.host_allowlist == ["jupyter.example.org"] 14 | -------------------------------------------------------------------------------- /docs/source/install.md: -------------------------------------------------------------------------------- 1 | (install)= 2 | 3 | # Installation 4 | 5 | Jupyter Server Proxy can be installed with `pip`. You must install 6 | 7 | ```bash 8 | pip install jupyter-server-proxy 9 | ``` 10 | 11 | If using `pip install -e` please install the server extension explicitly: 12 | 13 | ```bash 14 | jupyter serverextension enable --sys-prefix jupyter_server_proxy 15 | ``` 16 | 17 | If you have multiple virtualenvs or conda environments, you 18 | must install `jupyter-server-proxy` into the same environment 19 | your notebook is running from, rather than where your kernels are. 20 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # dependabot.yaml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 2 | # 3 | # Notes: 4 | # - Status and logs from dependabot are provided at 5 | # https://github.com/jupyterhub/jupyter-server-proxy/network/updates. 6 | # - YAML anchors are not supported here or in GitHub Workflows. 7 | # 8 | version: 2 9 | updates: 10 | # Maintain dependencies in our GitHub Workflows 11 | - package-ecosystem: github-actions 12 | directory: / 13 | labels: [ci] 14 | schedule: 15 | interval: monthly 16 | time: "05:00" 17 | timezone: Etc/UTC 18 | -------------------------------------------------------------------------------- /contrib/template/{{cookiecutter.project_name}}/jupyter_{{cookiecutter.project_name}}_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Return config on servers to start for {{cookiecutter.project_name}} 3 | 4 | See https://jupyter-server-proxy.readthedocs.io/en/latest/server-process.html 5 | for more information. 6 | """ 7 | import os 8 | 9 | 10 | def setup_{{cookiecutter.project_name}}(): 11 | return { 12 | 'command': [], 13 | 'environment': {}, 14 | 'launcher_entry': { 15 | 'title': '{{cookiecutter.project_name}}', 16 | 'icon_path': os.path.join(os.path.dirname(os.path.abspath(__file__)), 'icons', '{{cookiecutter.project_name}}.svg') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /labextension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "strictNullChecks": true, 19 | "target": "es2019", 20 | "outDir": "./lib", 21 | "rootDir": "./src", 22 | "types": [] 23 | }, 24 | "include": ["src/*"] 25 | } 26 | -------------------------------------------------------------------------------- /contrib/theia/setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name="jupyter-theia-proxy", 5 | version="1.0dev", 6 | url="https://github.com/jupyterhub/jupyter-server-proxy/tree/HEAD/contrib/theia", 7 | author="Project Jupyter Contributors", 8 | description="projectjupyter@gmail.com", 9 | packages=setuptools.find_packages(), 10 | keywords=["Jupyter"], 11 | classifiers=["Framework :: Jupyter"], 12 | install_requires=["jupyter-server-proxy"], 13 | entry_points={ 14 | "jupyter_serverproxy_servers": [ 15 | "theia = jupyter_theia_proxy:setup_theia", 16 | ] 17 | }, 18 | package_data={ 19 | "jupyter_theia_proxy": ["icons/*"], 20 | }, 21 | ) 22 | -------------------------------------------------------------------------------- /tests/resources/rawsocket.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import socket 4 | import sys 5 | 6 | if len(sys.argv) != 2: 7 | print(f"Usage: {sys.argv[0]} TCPPORT|SOCKPATH") 8 | sys.exit(1) 9 | where = sys.argv[1] 10 | try: 11 | port = int(where) 12 | family = socket.AF_INET 13 | addr = ("localhost", port) 14 | except ValueError: 15 | family = socket.AF_UNIX 16 | addr = where 17 | 18 | with socket.create_server(addr, family=family) as serv: 19 | while True: 20 | # only handle a single connection at a time 21 | sock, caddr = serv.accept() 22 | while True: 23 | s = sock.recv(1024) 24 | if not s: 25 | break 26 | sock.send(s.swapcase()) 27 | sock.close() 28 | -------------------------------------------------------------------------------- /docs/source/convenience/new.md: -------------------------------------------------------------------------------- 1 | (convenience:new)= 2 | 3 | # Making a new convenience package 4 | 5 | There is a [cookiecutter](https://github.com/cookiecutter/cookiecutter) 6 | template provided in this repo that can be used to make new packages. 7 | 8 | ```bash 9 | pip install cookiecutter 10 | cookiecutter contrib/template -o contrib/ 11 | ``` 12 | 13 | This should ask you a bunch of questions, and generate a directory 14 | named after your project with a python package. From there, you should: 15 | 16 | 1. Edit the `__init__.py` file to fill in the command used to start your 17 | process, any environment variables, and title of the launcher icon. 18 | 2. (Optionally) Add a square svg icon for your launcher in the `icons` 19 | subfolder, with the same name as your project. 20 | -------------------------------------------------------------------------------- /contrib/template/{{cookiecutter.project_name}}/setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup( 4 | name="jupyter-{{cookiecutter.project_name}}-proxy", 5 | version='1.0dev', 6 | url="https://github.com/jupyterhub/jupyter-server-proxy/tree/HEAD/contrib/{{cookiecutter.project_name}}", 7 | author="{{cookiecutter.author_name}}", 8 | description="{{cookiecutter.author_email}}", 9 | packages=setuptools.find_packages(), 10 | keywords=['Jupyter'], 11 | classifiers=['Framework :: Jupyter'], 12 | install_requires=[ 13 | 'jupyter-server-proxy' 14 | ], 15 | entry_points={ 16 | 'jupyter_serverproxy_servers': [ 17 | '{{cookiecutter.project_name}} = jupyter_{{cookiecutter.project_name}}_proxy:setup_{{cookiecutter.project_name}}', 18 | ] 19 | }, 20 | package_data={ 21 | 'jupyter_{{cookiecutter.project_name}}_proxy': ['icons/*'], 22 | }, 23 | ) 24 | -------------------------------------------------------------------------------- /tests/resources/gzipserver.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import sys 3 | from http.server import BaseHTTPRequestHandler, HTTPServer 4 | from io import BytesIO 5 | 6 | 7 | class GzipServer(BaseHTTPRequestHandler): 8 | def do_GET(self): 9 | fileobj = BytesIO() 10 | f = gzip.GzipFile(fileobj=fileobj, mode="w") 11 | f.write(b"this is a test") 12 | f.close() 13 | content = fileobj.getvalue() 14 | self.send_response(200) 15 | self.send_header("Content-length", str(len(content))) 16 | self.send_header("Content-type", "text/html") 17 | self.send_header("Content-Encoding", "gzip") 18 | self.end_headers() 19 | self.wfile.write(content) 20 | self.wfile.flush() 21 | 22 | 23 | if __name__ == "__main__": 24 | port = int(sys.argv[1]) 25 | server_address = ("", port) 26 | httpd = HTTPServer(server_address, GzipServer) 27 | httpd.serve_forever() 28 | -------------------------------------------------------------------------------- /contrib/theia/jupyter_theia_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Return config on servers to start for theia 3 | 4 | See https://jupyter-server-proxy.readthedocs.io/en/latest/server-process.html 5 | for more information. 6 | """ 7 | 8 | import os 9 | import shutil 10 | 11 | 12 | def setup_theia(): 13 | # Make sure theia is in $PATH 14 | def _theia_command(port): 15 | full_path = shutil.which("theia") 16 | if not full_path: 17 | raise FileNotFoundError("Can not find theia executable in $PATH") 18 | return ["theia", "start", ".", "--hostname=127.0.0.1", "--port=" + str(port)] 19 | 20 | return { 21 | "command": _theia_command, 22 | "environment": {"USE_LOCAL_GIT": "true"}, 23 | "launcher_entry": { 24 | "title": "Theia IDE", 25 | "icon_path": os.path.join( 26 | os.path.dirname(os.path.abspath(__file__)), "icons", "theia.svg" 27 | ), 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /docs/source/launchers.md: -------------------------------------------------------------------------------- 1 | (launchers)= 2 | 3 | # GUI Launchers 4 | 5 | Jupyter Server Proxy automatically adds entries for {ref}`registered 6 | Server Processes ` in both the classic Jupyter Notebook 7 | interface and the JupyterLab Launcher. 8 | 9 | ## Classic Notebook Extension 10 | 11 | By default, an entry is made for each server process under the 'New' 12 | menu in the notebook's default tree view. Note that a new instance 13 | is **not** launched every time you click an item - if the process 14 | is already running, it is reused. 15 | 16 | ```{image} _static/images/nbextension-tree.png 17 | 18 | ``` 19 | 20 | ## JupyterLab Extension 21 | 22 | A JupyterLab extension is bundled with the Python package to provide launch 23 | buttons in JupyterLab's Launcher panel for registered server processes. 24 | 25 | ```{image} _static/images/labextension-launcher.png 26 | 27 | ``` 28 | 29 | Clicking on them opens the proxied application in a new browser window. 30 | -------------------------------------------------------------------------------- /labextension/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | # jlpm in JupyterLab 4 is yarn 3 2 | # 3 | # JupyterLab 4 is used when building the extension as specified via 4 | # ../pyproject.toml's build-system config, but the built extension can be used by 5 | # JupyterLab 3 as well. 6 | 7 | enableInlineBuilds: false 8 | enableTelemetry: false 9 | httpTimeout: 60000 10 | nodeLinker: node-modules 11 | npmRegistryServer: https://registry.npmjs.org/ 12 | installStatePath: ./build/.cache/yarn/install-state.gz 13 | cacheFolder: ./build/.cache/yarn/cache 14 | # logFilters codes described: https://yarnpkg.com/advanced/error-codes 15 | logFilters: 16 | - code: YN0002 # MISSING_PEER_DEPENDENCY 17 | level: discard 18 | - code: YN0006 # SOFT_LINK_BUILD 19 | level: discard 20 | - code: YN0007 # MUST_BUILD 21 | level: discard 22 | - code: YN0008 # MUST_REBUILD 23 | level: discard 24 | - code: YN0013 # FETCH_NOT_CACHED 25 | level: discard 26 | - code: YN0019 # UNUSED_CACHE_ENTRY 27 | level: discard 28 | -------------------------------------------------------------------------------- /.github/workflows/linkcheck.yaml: -------------------------------------------------------------------------------- 1 | # This is a GitHub workflow defining a set of jobs with a set of steps. ref: 2 | # https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions 3 | # 4 | name: Linkcheck 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - "docs/**" 10 | - "**/linkcheck.yaml" 11 | push: 12 | paths: 13 | - "docs/**" 14 | - "**/linkcheck.yaml" 15 | branches-ignore: 16 | - "dependabot/**" 17 | - "pre-commit-ci-update-config" 18 | workflow_dispatch: 19 | 20 | jobs: 21 | linkcheck: 22 | runs-on: ubuntu-22.04 23 | steps: 24 | - uses: actions/checkout@v6 25 | - uses: actions/setup-python@v6 26 | with: 27 | python-version: "3.12" 28 | 29 | - name: Install deps 30 | run: pip install -r docs/requirements.txt 31 | 32 | - name: make linkcheck 33 | run: | 34 | cd docs 35 | make linkcheck SPHINXOPTS='--color -W --keep-going' 36 | -------------------------------------------------------------------------------- /tests/resources/eventstream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import tornado.escape 4 | import tornado.ioloop 5 | import tornado.options 6 | import tornado.web 7 | import tornado.websocket 8 | from tornado.options import define, options 9 | 10 | 11 | class Application(tornado.web.Application): 12 | def __init__(self): 13 | handlers = [ 14 | (r"/stream/(\d+)", StreamHandler), 15 | ] 16 | super().__init__(handlers) 17 | 18 | 19 | class StreamHandler(tornado.web.RequestHandler): 20 | async def get(self, seconds): 21 | for i in range(int(seconds)): 22 | await asyncio.sleep(0.5) 23 | self.write(f"data: {i}\n\n") 24 | await self.flush() 25 | 26 | 27 | def main(): 28 | define("port", default=8888, help="run on the given port", type=int) 29 | options.parse_command_line() 30 | app = Application() 31 | app.listen(options.port) 32 | tornado.ioloop.IOLoop.current().start() 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /docs/source/examples.md: -------------------------------------------------------------------------------- 1 | (examples)= 2 | 3 | # Examples 4 | 5 | If you are looking for examples on how to configure `jupyter-server-proxy`, you might want to check existing 6 | projects on GitHub, such as: 7 | 8 | - [jupyter-pgweb-proxy](https://github.com/illumidesk/jupyter-pgweb-proxy): Run [pgweb](https://github.com/sosedoff/pgweb) (cross-platform PostgreSQL client) 9 | - [jupyter-pluto-proxy](https://github.com/illumidesk/jupyter-pluto-proxy): Run [Pluto.jl](https://github.com/fonsp/Pluto.jl) (notebooks for Julia) 10 | - [jupyterserverproxy-openrefine](https://github.com/psychemedia/jupyterserverproxy-openrefine): Run [OpenRefine](https://openrefine.org/) (tool for working with messy data) 11 | - [gator](https://github.com/mamba-org/gator): Run the Mamba Navigator (JupyterLab-based standalone application) 12 | 13 | Projects can also add the `jupyter-server-proxy` topic to the GitHub repository to make it more discoverable: 14 | [https://github.com/topics/jupyter-server-proxy](https://github.com/topics/jupyter-server-proxy) 15 | -------------------------------------------------------------------------------- /tests/acceptance/__init__.robot: -------------------------------------------------------------------------------- 1 | *** Comments *** 2 | To learn more about these .robot files, see 3 | https://robotframework-jupyterlibrary.readthedocs.io/en/stable/. 4 | 5 | *** Settings *** 6 | Documentation Acceptance tests for jupyter-server-proxy 7 | Library JupyterLibrary 8 | Library OperatingSystem 9 | Library Process 10 | Suite Setup Set Up 11 | Suite Teardown Clean Up 12 | 13 | *** Keywords *** 14 | Set Up 15 | ${notebook dir} = Set Variable ${OUTPUT DIR}${/}notebooks 16 | Copy Directory resources ${notebook dir} 17 | Set Screenshot Directory EMBED 18 | Set Environment Variable 19 | ... name=JUPYTER_CONFIG_DIR 20 | ... value=${notebook dir} 21 | Wait For New Jupyter Server To Be Ready 22 | ... %{JUPYTER_LIBRARY_APP_COMMAND} 23 | ... stdout=${OUTPUT DIR}${/}server.log 24 | ... notebook_dir=${notebook dir} 25 | ... cwd=${notebook dir} 26 | 27 | Clean Up 28 | Close all Browsers 29 | Terminate All Jupyter Servers 30 | Terminate All Processes 31 | -------------------------------------------------------------------------------- /jupyter_server_proxy/utils.py: -------------------------------------------------------------------------------- 1 | def call_with_asked_args(callback, args): 2 | """ 3 | Call callback with only the args it wants from args 4 | 5 | Example 6 | >>> def cb(a): 7 | ... return a * 5 8 | 9 | >>> print(call_with_asked_args(cb, {'a': 4, 'b': 8})) 10 | 20 11 | """ 12 | # FIXME: support default args 13 | # FIXME: support kwargs 14 | # co_varnames contains both args and local variables, in order. 15 | # We only pick the local variables 16 | asked_arg_names = callback.__code__.co_varnames[: callback.__code__.co_argcount] 17 | asked_arg_values = [] 18 | missing_args = [] 19 | for asked_arg_name in asked_arg_names: 20 | if asked_arg_name in args: 21 | asked_arg_values.append(args[asked_arg_name]) 22 | else: 23 | missing_args.append(asked_arg_name) 24 | if missing_args: 25 | raise TypeError( 26 | "{}() missing required positional argument: {}".format( 27 | callback.__code__.co_name, ", ".join(missing_args) 28 | ) 29 | ) 30 | return callback(*asked_arg_values) 31 | -------------------------------------------------------------------------------- /labextension/README.md: -------------------------------------------------------------------------------- 1 | # `@jupyterhub/jupyter-server-proxy` 2 | 3 | A pre-built JupyterLab extension that adds items to the JupyterLab [Launcher] 4 | to open server processes managed by the python package 5 | [`jupyter-server-proxy`](https://pypi.org/project/jupyter-server-proxy). 6 | 7 | [launcher]: https://jupyterlab.readthedocs.io/en/stable/extension/extension_points.html#launcher 8 | 9 | ## Prerequisites 10 | 11 | - JupyterLab >=3,<5 12 | 13 | ## Installation 14 | 15 | Use your preferred Python package manager to install `jupyter-server-proxy`: 16 | 17 | ```bash 18 | pip install jupyter-server-proxy 19 | ``` 20 | 21 | or 22 | 23 | ```bash 24 | conda install -c conda-forge jupyter-server-proxy 25 | ``` 26 | 27 | > As a _prebuilt_ extension, it will "just work," only a simple page reload should be required 28 | > to see launcher items. However, a full restart of `jupyter_server` or `notebook` is required 29 | > to reload the `jupyter_server_proxy` serverextension which provides most of the functionality. 30 | 31 | For a full development and testing installation, see the 32 | [contributing guide](https://github.com/jupyterhub/jupyter-server-proxy/blob/main/CONTRIBUTING.md). 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS ?= 6 | SPHINXBUILD ?= sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 20 | 21 | # Manually added commands 22 | # ---------------------------------------------------------------------------- 23 | 24 | # For local development: 25 | # - builds and rebuilds html on changes to source 26 | # - starts a livereload enabled webserver and opens up a browser 27 | devenv: 28 | sphinx-autobuild -b html --open-browser "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS) 29 | 30 | # For local development and CI: 31 | # - verifies that links are valid 32 | linkcheck: 33 | $(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(BUILDDIR)/linkcheck" $(SPHINXOPTS) 34 | @echo 35 | @echo "Link check complete; look for any errors in the above output " \ 36 | "or in $(BUILDDIR)/linkcheck/output.txt." 37 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | if "%1" == "devenv" goto devenv 15 | if "%1" == "linkcheck" goto linkcheck 16 | goto default 17 | 18 | 19 | :default 20 | %SPHINXBUILD% >NUL 2>NUL 21 | if errorlevel 9009 ( 22 | echo. 23 | echo.The 'sphinx-build' command was not found. Open and read README.md! 24 | exit /b 1 25 | ) 26 | %SPHINXBUILD% -M %1 "%SOURCEDIR%" "%BUILDDIR%" %SPHINXOPTS% 27 | goto end 28 | 29 | 30 | :help 31 | %SPHINXBUILD% -M help "%SOURCEDIR%" "%BUILDDIR%" %SPHINXOPTS% 32 | goto end 33 | 34 | 35 | :devenv 36 | sphinx-autobuild >NUL 2>NUL 37 | if errorlevel 9009 ( 38 | echo. 39 | echo.The 'sphinx-autobuild' command was not found. Open and read README.md! 40 | exit /b 1 41 | ) 42 | sphinx-autobuild -b html --open-browser "../jupyterhub/schema.yaml" "%SOURCEDIR%" "%BUILDDIR%/html" %SPHINXOPTS% 43 | goto end 44 | 45 | 46 | :linkcheck 47 | %SPHINXBUILD% -b linkcheck "%SOURCEDIR%" "%BUILDDIR%/linkcheck" %SPHINXOPTS% 48 | echo. 49 | echo.Link check complete; look for any errors in the above output 50 | echo.or in "%BUILDDIR%/linkcheck/output.txt". 51 | goto end 52 | 53 | 54 | :end 55 | popd 56 | -------------------------------------------------------------------------------- /tests/acceptance/Classic.robot: -------------------------------------------------------------------------------- 1 | *** Comments *** 2 | To learn more about these .robot files, see 3 | https://robotframework-jupyterlibrary.readthedocs.io/en/stable/. 4 | 5 | *** Settings *** 6 | Documentation Server Proxies in Notebook Classic 7 | Library JupyterLibrary 8 | Suite Setup Start Notebook Classic Tests 9 | Test Setup Switch Window MAIN 10 | Test Tags app:classic 11 | 12 | *** Keywords *** 13 | Start Notebook Classic Tests 14 | Open Notebook Classic 15 | Set Screenshot Directory ${OUTPUT DIR}${/}notebook-classic 16 | 17 | Click Launcher 18 | [Arguments] ${title} 19 | Click Element css:#new-dropdown-button 20 | Click Element css:a[role\="menuitem"][href*="${title}"] 21 | 22 | *** Test Cases *** 23 | Notebook Classic Loads 24 | Capture Page Screenshot 00-smoke.png 25 | 26 | Launch Browser Tab 27 | Click Launcher foo 28 | Wait Until Keyword Succeeds 3x 0.5s Switch Window title:Hello World 29 | Location Should Contain foo 30 | Wait Until Page Contains Hello World timeout=10s 31 | Close Window 32 | 33 | Launch Another Browser Tab 34 | Click Launcher bar 35 | Wait Until Keyword Succeeds 3x 0.5s Switch Window title:Hello World 36 | Location Should Contain bar 37 | Wait Until Page Contains Hello World timeout=10s 38 | Close Window 39 | -------------------------------------------------------------------------------- /tests/acceptance/Lab.robot: -------------------------------------------------------------------------------- 1 | *** Comments *** 2 | To learn more about these .robot files, see 3 | https://robotframework-jupyterlibrary.readthedocs.io/en/stable/. 4 | 5 | *** Settings *** 6 | Documentation Server Proxies in Lab 7 | Library JupyterLibrary 8 | Suite Setup Start Lab Tests 9 | Test Tags app:lab 10 | 11 | 12 | *** Variables *** 13 | ${CSS_LAUNCHER_CARD} css:.jp-LauncherCard-label 14 | 15 | *** Test Cases *** 16 | Lab Loads 17 | Capture Page Screenshot 00-smoke.png 18 | 19 | Launch Browser Tab 20 | Click Launcher foo 21 | Wait Until Keyword Succeeds 3x 0.5s Switch Window title:Hello World 22 | Location Should Contain foo 23 | Wait Until Page Contains Hello World timeout=10s 24 | Close Window 25 | [Teardown] Switch Window title:JupyterLab 26 | 27 | Launch Lab Tab 28 | Click Launcher bar 29 | Wait Until Page Contains Element css:iframe 30 | Select Frame css:iframe 31 | Wait Until Page Contains Hello World timeout=10s 32 | 33 | *** Keywords *** 34 | Start Lab Tests 35 | Open JupyterLab 36 | Tag With JupyterLab Metadata 37 | Set Screenshot Directory ${OUTPUT DIR}${/}lab 38 | 39 | Click Launcher 40 | [Arguments] ${title} 41 | ${item} = Set Variable ${CSS_LAUNCHER_CARD}\[title^\="${title}"] 42 | Wait Until Element Is Visible ${item} timeout=10s 43 | Click Element ${item} 44 | -------------------------------------------------------------------------------- /tests/resources/proxyextension.py: -------------------------------------------------------------------------------- 1 | from jupyter_server.utils import url_path_join 2 | 3 | from jupyter_server_proxy.handlers import ProxyHandler 4 | 5 | 6 | class NewHandler(ProxyHandler): 7 | async def http_get(self): 8 | return await self.proxy() 9 | 10 | async def open(self): 11 | host = "127.0.0.1" 12 | port = 54321 13 | return await super().proxy_open(host, port) 14 | 15 | def post(self): 16 | return self.proxy() 17 | 18 | def put(self): 19 | return self.proxy() 20 | 21 | def delete(self): 22 | return self.proxy() 23 | 24 | def head(self): 25 | return self.proxy() 26 | 27 | def patch(self): 28 | return self.proxy() 29 | 30 | def options(self): 31 | return self.proxy() 32 | 33 | def proxy(self): 34 | host = "127.0.0.1" 35 | port = 54321 36 | proxied_path = "" 37 | return super().proxy(host, port, proxied_path) 38 | 39 | 40 | def _jupyter_server_extension_points(): 41 | return [{"module": "proxyextension"}] 42 | 43 | 44 | def _load_jupyter_server_extension(nb_server_app): 45 | web_app = nb_server_app.web_app 46 | base_url = web_app.settings["base_url"] 47 | proxy_path = url_path_join(base_url, "newproxy/" + "?") 48 | handlers = [(proxy_path, NewHandler)] 49 | web_app.add_handlers(".*$", handlers) 50 | 51 | 52 | # For backward compatibility 53 | load_jupyter_server_extension = _load_jupyter_server_extension 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Data Science 8 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /labextension/LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Data Science 8 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/resources/httpinfo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple webserver to respond with an echo of the sent request. It can listen to 3 | either a tcp port or a unix socket. 4 | """ 5 | 6 | import argparse 7 | import socket 8 | import sys 9 | from http.server import BaseHTTPRequestHandler, HTTPServer 10 | from pathlib import Path 11 | 12 | 13 | class EchoRequestInfo(BaseHTTPRequestHandler): 14 | def do_GET(self): 15 | self.send_response(200) 16 | self.send_header("Content-type", "text/plain") 17 | self.end_headers() 18 | self.wfile.write(f"{self.requestline}\n".encode()) 19 | self.wfile.write(f"{self.headers}\n".encode()) 20 | 21 | def address_string(self): 22 | """ 23 | Overridden to fix logging when serving on Unix socket. 24 | 25 | FIXME: There are still broken pipe messages showing up in the jupyter 26 | server logs when running tests with the unix sockets. 27 | """ 28 | if isinstance(self.client_address, str): 29 | return self.client_address # Unix sock 30 | return super().address_string() 31 | 32 | 33 | if sys.platform != "win32": 34 | 35 | class HTTPUnixServer(HTTPServer): 36 | address_family = socket.AF_UNIX 37 | 38 | 39 | if __name__ == "__main__": 40 | ap = argparse.ArgumentParser() 41 | ap.add_argument("--port", type=int) 42 | ap.add_argument("--unix-socket") 43 | args = ap.parse_args() 44 | 45 | if args.unix_socket: 46 | unix_socket = Path(args.unix_socket) 47 | if unix_socket.exists(): 48 | unix_socket.unlink() 49 | httpd = HTTPUnixServer(args.unix_socket, EchoRequestInfo) 50 | else: 51 | httpd = HTTPServer(("127.0.0.1", args.port), EchoRequestInfo) 52 | httpd.serve_forever() 53 | -------------------------------------------------------------------------------- /tests/acceptance/test_acceptance.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | HERE = Path(__file__).parent 9 | OUTPUT = HERE.parent.parent / "build/robot" 10 | JUPYTER_SERVER_INFO = None 11 | 12 | 13 | try: 14 | import notebook 15 | 16 | NOTEBOOK_VERSION = int(notebook.__version__.split(".")[0]) 17 | except ImportError: 18 | NOTEBOOK_VERSION = None 19 | 20 | try: 21 | import jupyter_server 22 | 23 | JUPYTER_SERVER_INFO = jupyter_server.version_info 24 | except ImportError: 25 | pass 26 | 27 | 28 | def test_robot(): 29 | """run acceptance tests with robotframework""" 30 | pytest.importorskip("JupyterLibrary") 31 | 32 | env = dict(**os.environ) 33 | robot_args = ["robot", "--consolecolors=on", f"--outputdir={OUTPUT}"] 34 | 35 | if NOTEBOOK_VERSION and NOTEBOOK_VERSION >= 7: 36 | robot_args += ["--exclude", "app:classic"] 37 | else: 38 | robot_args += ["--exclude", "app:notebook"] 39 | 40 | # JUPYTER_LIBRARY_* env vars documentation: 41 | # https://robotframework-jupyterlibrary.readthedocs.io/en/stable/LIMITS.html#notebookapp-vs-serverapp 42 | if JUPYTER_SERVER_INFO is None: 43 | env.update( 44 | JUPYTER_LIBRARY_APP_COMMAND="jupyter-notebook", 45 | JUPYTER_LIBRARY_APP="NotebookApp", 46 | ) 47 | else: 48 | env.update( 49 | JUPYTER_LIBRARY_APP_COMMAND="jupyter-server", 50 | JUPYTER_LIBRARY_APP="ServerApp", 51 | ) 52 | 53 | if OUTPUT.exists(): 54 | shutil.rmtree(OUTPUT) 55 | 56 | return_code = subprocess.call( 57 | [*robot_args, str(HERE)], 58 | cwd=str(HERE), 59 | env=env, 60 | ) 61 | 62 | assert return_code == 0 63 | -------------------------------------------------------------------------------- /tests/acceptance/Notebook.robot: -------------------------------------------------------------------------------- 1 | *** Comments *** 2 | To learn more about these .robot files, see 3 | https://robotframework-jupyterlibrary.readthedocs.io/en/stable/. 4 | 5 | *** Settings *** 6 | Documentation Server Proxies in Notebook 7 | Library JupyterLibrary 8 | Suite Setup Start Notebook Tests 9 | Test Tags app:notebook 10 | 11 | *** Variables *** 12 | ${XP_NEW_MENU} xpath://jp-toolbar[contains(@class, "jp-FileBrowser-toolbar")]//*[contains(text(), "New")] 13 | ${XP_OPEN_COMMAND} xpath://li[@data-command = "server-proxy:open"] 14 | 15 | *** Test Cases *** 16 | Notebook Loads 17 | Capture Page Screenshot 00-smoke.png 18 | 19 | Launch Browser Tab 20 | Launch With Toolbar Menu foo 21 | Wait Until Keyword Succeeds 3x 0.5s Switch Window title:Hello World 22 | Location Should Contain foo 23 | Wait Until Page Contains Hello World timeout=10s 24 | Close Window 25 | [Teardown] Switch Window title:Home 26 | 27 | Launch Another Browser Tab 28 | Launch With Toolbar Menu bar 29 | Wait Until Keyword Succeeds 3x 0.5s Switch Window title:Hello World 30 | Location Should Contain bar 31 | Wait Until Page Contains Hello World timeout=10s 32 | Close Window 33 | [Teardown] Switch Window title:Home 34 | 35 | *** Keywords *** 36 | Start Notebook Tests 37 | Open Notebook 38 | Tag With JupyterLab Metadata 39 | Set Screenshot Directory ${OUTPUT DIR}${/}notebook 40 | 41 | Launch With Toolbar Menu 42 | [Arguments] ${title} 43 | Mouse Over ${XP_NEW_MENU} 44 | Click Element ${XP_NEW_MENU} 45 | ${item} = Set Variable ${XP_OPEN_COMMAND}//div[text() = '${title}'] 46 | Wait Until Element Is Visible ${item} 47 | Mouse Over ${item} 48 | Click Element ${item} 49 | -------------------------------------------------------------------------------- /jupyter_server_proxy/static/tree.js: -------------------------------------------------------------------------------- 1 | define(["jquery", "base/js/namespace", "base/js/utils"], function ( 2 | $, 3 | Jupyter, 4 | utils, 5 | ) { 6 | var $ = require("jquery"); 7 | var Jupyter = require("base/js/namespace"); 8 | var utils = require("base/js/utils"); 9 | 10 | var base_url = utils.get_body_data("baseUrl"); 11 | 12 | function load() { 13 | if (!Jupyter.notebook_list) return; 14 | 15 | var servers_info_url = base_url + "server-proxy/servers-info"; 16 | $.get(servers_info_url, function (data) { 17 | /* locate the right-side dropdown menu of apps and notebooks */ 18 | var $menu = $(".tree-buttons").find(".dropdown-menu"); 19 | 20 | /* create a divider */ 21 | var $divider = $("
  • ").attr("role", "presentation").addClass("divider"); 22 | 23 | /* add the divider */ 24 | $menu.append($divider); 25 | 26 | $.each(data.server_processes, function (_, server_process) { 27 | if (!server_process.launcher_entry.enabled) { 28 | return; 29 | } 30 | 31 | /* create our list item */ 32 | var $entry_container = $("
  • ") 33 | .attr("role", "presentation") 34 | .addClass("new-" + server_process.name); 35 | 36 | /* create our list item's link */ 37 | var $entry_link = $("") 38 | .attr("role", "menuitem") 39 | .attr("tabindex", "-1") 40 | .attr("href", base_url + server_process.launcher_entry.path_info) 41 | .attr("target", "_blank") 42 | .text(server_process.launcher_entry.title); 43 | 44 | /* add the link to the item and 45 | * the item to the menu */ 46 | $entry_container.append($entry_link); 47 | $menu.append($entry_container); 48 | }); 49 | }); 50 | } 51 | 52 | return { 53 | load_ipython_extension: load, 54 | }; 55 | }); 56 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to Jupyter Server Proxy's documentation! 2 | 3 | Jupyter Server Proxy lets you run arbitrary external processes (such 4 | as RStudio, Shiny Server, syncthing, PostgreSQL, etc) alongside your 5 | notebook, and provide authenticated web access to them. 6 | 7 | ```{note} 8 | This project used to be called **nbserverproxy**. if you have an older 9 | version of nbserverproxy installed, remember to uninstall it before installing 10 | jupyter-server-proxy - otherwise they may conflict 11 | ``` 12 | 13 | The primary use cases are: 14 | 15 | 1. Use with JupyterHub / Binder to allow launching users into web 16 | interfaces that have nothing to do with Jupyter - such as RStudio, 17 | Shiny, or OpenRefine. 18 | 2. Allow access from frontend javascript (in classic notebook or 19 | JupyterLab extensions) to access web APIs of other processes 20 | running locally in a safe manner. This is used by the [JupyterLab 21 | extension](https://github.com/dask/dask-labextension) for 22 | [dask](https://www.dask.org/). 23 | 24 | ## Contents 25 | 26 | ```{toctree} 27 | :maxdepth: 2 28 | 29 | install 30 | server-process 31 | launchers 32 | arbitrary-ports-hosts 33 | standalone 34 | ``` 35 | 36 | ## Convenience packages for popular applications 37 | 38 | This repository contains various python packages 39 | set up with appropriate {ref}`entrypoints ` 40 | so pip installing them automatically sets up common config 41 | for popular applications. 42 | 43 | ```{toctree} 44 | :maxdepth: 1 45 | 46 | convenience/packages/theia 47 | ``` 48 | 49 | Making and contributing a {ref}`new convenience package ` 50 | is very much appreciated. 51 | 52 | ## Examples 53 | 54 | ```{toctree} 55 | :maxdepth: 1 56 | 57 | examples 58 | ``` 59 | 60 | ## Contributing 61 | 62 | Contributions of all kinds - documentation, issues, blog posts, code, are most welcome! 63 | 64 | ```{toctree} 65 | :maxdepth: 2 66 | 67 | convenience/new 68 | ``` 69 | 70 | ## Changelog 71 | 72 | ```{toctree} 73 | :maxdepth: 2 74 | 75 | changelog 76 | ``` 77 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # pre-commit is a tool to perform a predefined set of tasks manually and/or 2 | # automatically before git commits are made. 3 | # 4 | # Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level 5 | # 6 | # Common tasks 7 | # 8 | # - Run on all files: pre-commit run --all-files 9 | # - Register git hooks: pre-commit install --install-hooks 10 | # 11 | repos: 12 | # Autoformat: Python code, syntax patterns are modernized 13 | - repo: https://github.com/asottile/pyupgrade 14 | rev: v3.20.0 15 | hooks: 16 | - id: pyupgrade 17 | args: 18 | - --py38-plus 19 | 20 | # Autoformat: Python code 21 | - repo: https://github.com/PyCQA/autoflake 22 | rev: v2.3.1 23 | hooks: 24 | - id: autoflake 25 | # args ref: https://github.com/PyCQA/autoflake#advanced-usage 26 | args: 27 | - --in-place 28 | 29 | # Autoformat: Python code 30 | - repo: https://github.com/pycqa/isort 31 | rev: 6.1.0 32 | hooks: 33 | - id: isort 34 | 35 | # Autoformat: Python code 36 | - repo: https://github.com/psf/black-pre-commit-mirror 37 | rev: 25.9.0 38 | hooks: 39 | - id: black 40 | exclude: "contrib\/template\/.*" 41 | 42 | # Autoformat: markdown, yaml 43 | - repo: https://github.com/pre-commit/mirrors-prettier 44 | rev: v4.0.0-alpha.8 45 | hooks: 46 | - id: prettier 47 | 48 | # Misc... 49 | - repo: https://github.com/pre-commit/pre-commit-hooks 50 | rev: v6.0.0 51 | # ref: https://github.com/pre-commit/pre-commit-hooks#hooks-available 52 | hooks: 53 | # Autoformat: Makes sure files end in a newline and only a newline. 54 | - id: end-of-file-fixer 55 | 56 | # Autoformat: Sorts entries in requirements.txt. 57 | - id: requirements-txt-fixer 58 | 59 | # Lint: Check for files with names that would conflict on a 60 | # case-insensitive filesystem like MacOS HFS+ or Windows FAT. 61 | - id: check-case-conflict 62 | 63 | # Lint: Checks that non-binary executables have a proper shebang. 64 | - id: check-executables-have-shebangs 65 | 66 | # Lint: Python code 67 | - repo: https://github.com/PyCQA/flake8 68 | rev: "7.3.0" 69 | hooks: 70 | - id: flake8 71 | 72 | # pre-commit.ci config reference: https://pre-commit.ci/#configuration 73 | ci: 74 | autoupdate_schedule: monthly 75 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to make a release 2 | 3 | `jupyter-server-proxy` is a package available on [PyPI] and [conda-forge]. 4 | These are instructions on how to make a release. 5 | 6 | ## Pre-requisites 7 | 8 | - Push rights to [github.com/jupyterhub/jupyter-server-proxy] 9 | - Push rights to [conda-forge/jupyter-server-proxy-feedstock] 10 | 11 | ## Steps to make a release 12 | 13 | 1. Create a PR updating `labextension/yarn.lock` and continue only 14 | when its merged. 15 | 16 | This helps us avoid leaving known vulnerabilities are unfixed. To do this, 17 | delete the file and manually perform the the `build dist` step in the 18 | `.github/workflows/publish.yaml` workflow's `build` job as summarized below. 19 | 20 | ```shell 21 | # git clean -xfd can be needed to ensure labextension/yarn.lock 22 | # gets re-created during pyproject-build 23 | rm labextension/yarn.lock 24 | 25 | pip install --upgrade pip build 26 | pyproject-build 27 | ``` 28 | 29 | 2. Create a PR updating `docs/source/changelog.md` with [github-activity] and 30 | continue only when its merged. 31 | 32 | 3. Checkout main and make sure it is up to date. 33 | 34 | ```shell 35 | git checkout main 36 | git fetch origin main 37 | git reset --hard origin/main 38 | ``` 39 | 40 | 4. Update the version, make commits, and push a git tag with `tbump`. 41 | 42 | ```shell 43 | pip install tbump 44 | tbump --dry-run ${VERSION} 45 | 46 | # run 47 | tbump ${VERSION} 48 | ``` 49 | 50 | Following this, the [CI system] will build and publish a release. 51 | 52 | 5. Reset the version back to dev, e.g. `4.0.1-0.dev` after releasing `4.0.0`. 53 | 54 | ```shell 55 | tbump --no-tag ${NEXT_VERSION}-0.dev 56 | ``` 57 | 58 | 6. Following the release to PyPI, an automated PR should arrive to 59 | [conda-forge/jupyter-server-proxy-feedstock] with instructions. 60 | 61 | [github-activity]: https://github.com/executablebooks/github-activity 62 | [github.com/jupyterhub/jupyter-server-proxy]: https://github.com/jupyterhub/jupyter-server-proxy 63 | [pypi]: https://pypi.org/project/jupyter-server-proxy/ 64 | [conda-forge]: https://anaconda.org/conda-forge/repo2docker_service 65 | [conda-forge/jupyter-server-proxy-feedstock]: https://github.com/conda-forge/jupyter-server-proxy-feedstock 66 | [ci system]: https://github.com/jupyterhub/jupyter-server-proxy/actions/workflows/release.yaml 67 | -------------------------------------------------------------------------------- /tests/resources/redirectserver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple webserver that returns 301 redirects to test Location header rewriting. 3 | """ 4 | 5 | import argparse 6 | from http.server import BaseHTTPRequestHandler, HTTPServer 7 | from urllib.parse import urlparse 8 | 9 | 10 | class RedirectHandler(BaseHTTPRequestHandler): 11 | """Handler that returns 301 redirects with relative Location headers.""" 12 | 13 | def do_GET(self): 14 | """ 15 | Handle GET requests: 16 | - Requests without trailing slash: 301 redirect to path with trailing slash 17 | - Requests with trailing slash: 200 OK 18 | - /redirect-to/target: 301 redirect to /target 19 | """ 20 | # Parse the path to separate path and query string 21 | parsed = urlparse(self.path) 22 | path = parsed.path 23 | query = parsed.query 24 | 25 | if path.startswith("/redirect-to/"): 26 | # Extract the target path (remove /redirect-to prefix) 27 | target = path[len("/redirect-to") :] 28 | # Preserve query string if present 29 | if query: 30 | target = f"{target}?{query}" 31 | self.send_response(301) 32 | self.send_header("Location", target) 33 | self.send_header("Content-type", "text/plain") 34 | self.end_headers() 35 | self.wfile.write(b"Redirecting...\n") 36 | elif not path.endswith("/"): 37 | # Add trailing slash, preserve query string 38 | new_path = path + "/" 39 | if query: 40 | new_location = f"{new_path}?{query}" 41 | else: 42 | new_location = new_path 43 | self.send_response(301) 44 | self.send_header("Location", new_location) 45 | self.send_header("Content-type", "text/plain") 46 | self.end_headers() 47 | self.wfile.write(b"Redirecting...\n") 48 | else: 49 | # Normal response 50 | self.send_response(200) 51 | self.send_header("Content-type", "text/plain") 52 | self.end_headers() 53 | self.wfile.write(f"Success: {self.path}\n".encode()) 54 | 55 | 56 | if __name__ == "__main__": 57 | ap = argparse.ArgumentParser() 58 | ap.add_argument("--port", type=int, required=True) 59 | args = ap.parse_args() 60 | 61 | httpd = HTTPServer(("127.0.0.1", args.port), RedirectHandler) 62 | httpd.serve_forever() 63 | -------------------------------------------------------------------------------- /docs/source/convenience/packages/theia.md: -------------------------------------------------------------------------------- 1 | # Theia IDE 2 | 3 | [Theia](https://theia-ide.org/) is a configurable web based IDE 4 | built with components from [Visual Studio Code](https://code.visualstudio.com/). 5 | 6 | This package is a plugin for [jupyter-server-proxy](https://jupyter-server-proxy.readthedocs.io/) 7 | that lets you run an instance of theia alongside your notebook, primarily 8 | in a JupyterHub / Binder environment. 9 | 10 | ## Installing Theia 11 | 12 | 1. [Install the Yarn package manager](https://classic.yarnpkg.com/en/docs/install/) with one of the available 13 | methods. 14 | 15 | 2. Theia is highly configurable, so you need to decide which features you want 16 | in your theia install. Make a `package.json` with the list of extensions you want, 17 | following [the instructions here](https://theia-ide.org/docs/composing_applications/). 18 | 19 | Here is an example: 20 | 21 | ```js 22 | { 23 | "private": true, 24 | "dependencies": { 25 | "@theia/callhierarchy": "latest", 26 | "@theia/editor-preview": "latest", 27 | "@theia/file-search": "latest", 28 | "@theia/git": "latest", 29 | "@theia/json": "latest", 30 | "@theia/languages": "latest", 31 | "@theia/markers": "latest", 32 | "@theia/merge-conflicts": "latest", 33 | "@theia/messages": "latest", 34 | "@theia/mini-browser": "latest", 35 | "@theia/monaco": "latest", 36 | "@theia/navigator": "latest", 37 | "@theia/outline-view": "latest", 38 | "@theia/preferences": "latest", 39 | "@theia/preview": "latest", 40 | "@theia/python": "latest", 41 | "@theia/search-in-workspace": "latest", 42 | "@theia/terminal": "latest", 43 | "@theia/textmate-grammars": "latest", 44 | "@theia/typescript": "latest", 45 | "typescript": "latest", 46 | "yarn": "^1.12.3" 47 | }, 48 | "devDependencies": { 49 | "@theia/cli": "latest" 50 | } 51 | } 52 | ``` 53 | 54 | 3. Run the following commands in the same location as your package.json file 55 | to install all packages & build theia. 56 | 57 | ```bash 58 | yarn 59 | yarn theia build 60 | ``` 61 | 62 | This should set up theia to run and be built. 63 | 64 | 4. Add the `node_modules/.bin` directory to your `$PATH`, so `jupyter-theia-proxy` can 65 | find the `theia` command. 66 | 67 | ```bash 68 | export PATH="$(pwd)/node_modules/.bin:${PATH}" 69 | ``` 70 | -------------------------------------------------------------------------------- /jupyter_server_proxy/__init__.py: -------------------------------------------------------------------------------- 1 | from jupyter_server.utils import url_path_join as ujoin 2 | 3 | from ._version import __version__ # noqa 4 | from .api import IconHandler, ServersInfoHandler 5 | from .config import ServerProxy as ServerProxyConfig 6 | from .config import get_entrypoint_server_processes, make_handlers 7 | from .handlers import setup_handlers 8 | 9 | 10 | # Jupyter Extension points 11 | def _jupyter_server_extension_points(): 12 | return [ 13 | { 14 | "module": "jupyter_server_proxy", 15 | } 16 | ] 17 | 18 | 19 | def _jupyter_nbextension_paths(): 20 | return [ 21 | { 22 | "section": "tree", 23 | "dest": "jupyter_server_proxy", 24 | "src": "static", 25 | "require": "jupyter_server_proxy/tree", 26 | } 27 | ] 28 | 29 | 30 | def _jupyter_labextension_paths(): 31 | return [ 32 | { 33 | "src": "labextension", 34 | "dest": "@jupyterhub/jupyter-server-proxy", 35 | } 36 | ] 37 | 38 | 39 | def _load_jupyter_server_extension(nbapp): 40 | # Set up handlers picked up via config 41 | base_url = nbapp.web_app.settings["base_url"] 42 | serverproxy_config = ServerProxyConfig(parent=nbapp) 43 | 44 | server_processes = list(serverproxy_config.servers.values()) 45 | server_processes += get_entrypoint_server_processes() 46 | server_handlers = make_handlers(base_url, server_processes) 47 | nbapp.web_app.add_handlers(".*", server_handlers) 48 | 49 | # Set up default non-server handler 50 | setup_handlers( 51 | nbapp.web_app, 52 | serverproxy_config, 53 | ) 54 | 55 | icons = {} 56 | for sp in server_processes: 57 | if sp.launcher_entry.enabled and sp.launcher_entry.icon_path: 58 | icons[sp.name] = sp.launcher_entry.icon_path 59 | 60 | nbapp.web_app.add_handlers( 61 | ".*", 62 | [ 63 | ( 64 | ujoin(base_url, "server-proxy/servers-info"), 65 | ServersInfoHandler, 66 | {"server_processes": server_processes}, 67 | ), 68 | (ujoin(base_url, "server-proxy/icon/(.*)"), IconHandler, {"icons": icons}), 69 | ], 70 | ) 71 | 72 | nbapp.log.debug( 73 | "[jupyter-server-proxy] Started with known servers: %s", 74 | ", ".join([p.name for p in server_processes]), 75 | ) 76 | 77 | 78 | # For backward compatibility 79 | load_jupyter_server_extension = _load_jupyter_server_extension 80 | _jupyter_server_extension_paths = _jupyter_server_extension_points 81 | -------------------------------------------------------------------------------- /jupyter_server_proxy/standalone/activity.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime 4 | 5 | from jupyterhub.utils import exponential_backoff, isoformat 6 | from tornado import httpclient, ioloop 7 | from tornado.log import app_log as log 8 | 9 | 10 | async def notify_activity(): 11 | """ 12 | Regularly notify JupyterHub of activity. 13 | See https://github.com/jupyterhub/jupyterhub/blob/4.x/jupyterhub/singleuser/extension.py#L389 14 | """ 15 | 16 | client = httpclient.AsyncHTTPClient() 17 | last_activity_timestamp = isoformat(datetime.utcnow()) 18 | failure_count = 0 19 | 20 | activity_url = os.environ.get("JUPYTERHUB_ACTIVITY_URL") 21 | server_name = os.environ.get("JUPYTERHUB_SERVER_NAME") 22 | api_token = os.environ.get("JUPYTERHUB_API_TOKEN") 23 | 24 | if not (activity_url and server_name and api_token): 25 | log.error( 26 | "Could not find environment variables to send notification to JupyterHub" 27 | ) 28 | return 29 | 30 | async def notify(): 31 | """Send Notification, return if successful""" 32 | nonlocal failure_count 33 | log.debug(f"Notifying Hub of activity {last_activity_timestamp}") 34 | 35 | req = httpclient.HTTPRequest( 36 | url=activity_url, 37 | method="POST", 38 | headers={ 39 | "Authorization": f"token {api_token}", 40 | "Content-Type": "application/json", 41 | }, 42 | body=json.dumps( 43 | { 44 | "servers": { 45 | server_name: {"last_activity": last_activity_timestamp} 46 | }, 47 | "last_activity": last_activity_timestamp, 48 | } 49 | ), 50 | ) 51 | 52 | try: 53 | await client.fetch(req) 54 | return True 55 | except httpclient.HTTPError as e: 56 | failure_count += 1 57 | log.error(f"Error notifying Hub of activity: {e}") 58 | return False 59 | 60 | # Try sending notification for 1 minute 61 | await exponential_backoff( 62 | notify, 63 | fail_message="Failed to notify Hub of activity", 64 | start_wait=1, 65 | max_wait=15, 66 | timeout=60, 67 | ) 68 | 69 | if failure_count > 0: 70 | log.info(f"Sent hub activity after {failure_count} retries") 71 | 72 | 73 | def start_activity_update(interval): 74 | pc = ioloop.PeriodicCallback(notify_activity, 1e3 * interval, 0.1) 75 | pc.start() 76 | -------------------------------------------------------------------------------- /labextension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jupyterhub/jupyter-server-proxy", 3 | "version": "4.4.1-0.dev", 4 | "description": "A JupyterLab extension accompanying the PyPI package jupyter-server-proxy adding launcher items for configured server processes.", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/jupyterhub/jupyter-server-proxy", 11 | "bugs": { 12 | "url": "https://github.com/jupyterhub/jupyter-server-proxy/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "Ryan Lovett & Yuvi Panda", 17 | "email": "rylo@berkeley.edu" 18 | }, 19 | "files": [ 20 | "LICENSE", 21 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}" 22 | ], 23 | "main": "lib/index.js", 24 | "types": "lib/index.d.ts", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/jupyterhub/jupyter-server-proxy.git" 28 | }, 29 | "scripts": { 30 | "build": "jlpm run build:lib && jlpm run build:labextension:dev", 31 | "build:prod": "jlpm clean && jlpm run build:lib && jlpm run build:labextension", 32 | "build:labextension": "jupyter labextension build .", 33 | "build:labextension:dev": "jupyter labextension build --development True .", 34 | "build:lib": "tsc -b", 35 | "clean": "jlpm run clean:lib", 36 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 37 | "clean:labextension": "rimraf jupyter_server_proxy/labextension", 38 | "clean:all": "jlpm run clean:lib && jlpm run clean:labextension", 39 | "install:extension": "jupyter labextension develop --overwrite .", 40 | "watch": "run-p watch:src watch:labextension", 41 | "watch:src": "jlpm build:lib -w --preserveWatchOutput", 42 | "watch:labextension": "jupyter labextension watch .", 43 | "deduplicate": "yarn-deduplicate -s fewer --fail", 44 | "lint:prettier": "prettier --write \"../.github/**/*.yaml\" \"../*.{yaml,yml,md}\" \"../docs/**/*.md\" \"src/**/*.{tsx,ts}\" \"./*.{yml,json,md}\"" 45 | }, 46 | "dependencies": { 47 | "@jupyterlab/application": "^3.0 || ^4.0", 48 | "@jupyterlab/filebrowser": "^3.0 || ^4.0", 49 | "@jupyterlab/launcher": "^3.0 || ^4.0" 50 | }, 51 | "devDependencies": { 52 | "@jupyterlab/builder": "^4.0.6", 53 | "npm-run-all": "^4.1.5", 54 | "prettier": "^3.0.3", 55 | "rimraf": "^5.0.1", 56 | "typescript": "~5.2.2", 57 | "yarn-berry-deduplicate": "^6.0.0" 58 | }, 59 | "jupyterlab": { 60 | "extension": true, 61 | "outputDir": "../jupyter_server_proxy/labextension" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /contrib/theia/README.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Theia IDE 3 | ========= 4 | 5 | `Theia `_ is a configurable web based IDE 6 | built with components from `Visual Studio Code `_. 7 | 8 | This package is a plugin for `jupyter-server-proxy `_ 9 | that lets you run an instance of theia alongside your notebook, primarily 10 | in a JupyterHub / Binder environment. 11 | 12 | Installing Theia 13 | ================ 14 | 15 | #. `Install the Yarn package manager 16 | `_ with one of the available 17 | methods. 18 | 19 | #. Theia is highly configurable, so you need to decide which features you want 20 | in your theia install. Make a ``package.json`` with the list of extensions you want, 21 | following `the instructions here `_. 22 | 23 | Here is an example: 24 | 25 | 26 | .. code:: js 27 | 28 | { 29 | "private": true, 30 | "dependencies": { 31 | "@theia/callhierarchy": "latest", 32 | "@theia/editor-preview": "latest", 33 | "@theia/file-search": "latest", 34 | "@theia/git": "latest", 35 | "@theia/json": "latest", 36 | "@theia/languages": "latest", 37 | "@theia/markers": "latest", 38 | "@theia/merge-conflicts": "latest", 39 | "@theia/messages": "latest", 40 | "@theia/mini-browser": "latest", 41 | "@theia/monaco": "latest", 42 | "@theia/navigator": "latest", 43 | "@theia/outline-view": "latest", 44 | "@theia/preferences": "latest", 45 | "@theia/preview": "latest", 46 | "@theia/python": "latest", 47 | "@theia/search-in-workspace": "latest", 48 | "@theia/terminal": "latest", 49 | "@theia/textmate-grammars": "latest", 50 | "@theia/typescript": "latest", 51 | "typescript": "latest", 52 | "yarn": "^1.12.3" 53 | }, 54 | "devDependencies": { 55 | "@theia/cli": "latest" 56 | } 57 | } 58 | 59 | #. Run the following commands in the same location as your package.json file 60 | to install all packages & build theia. 61 | 62 | .. code:: bash 63 | 64 | yarn 65 | yarn theia build 66 | 67 | This should set up theia to run and be built. 68 | 69 | #. Add the ``node_modules/.bin`` directory to your ``$PATH``, so ``jupyter-theia-proxy`` can 70 | find the ``theia`` command. 71 | 72 | .. code:: bash 73 | 74 | export PATH="$(pwd)/node_modules/.bin:${PATH}" 75 | -------------------------------------------------------------------------------- /jupyter_server_proxy/api.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | 3 | from jupyter_server.base.handlers import JupyterHandler 4 | from jupyter_server.utils import url_path_join as ujoin 5 | from tornado import web 6 | 7 | 8 | class ServersInfoHandler(JupyterHandler): 9 | def initialize(self, server_processes): 10 | self.server_processes = server_processes 11 | 12 | @web.authenticated 13 | async def get(self): 14 | data = [] 15 | # Pick out and send only metadata 16 | # Don't send anything that might be a callable, or leak sensitive info 17 | for sp in self.server_processes: 18 | # Manually recurse to convert namedtuples into JSONable structures 19 | item = { 20 | "name": sp.name, 21 | "launcher_entry": { 22 | "enabled": sp.launcher_entry.enabled, 23 | "title": sp.launcher_entry.title, 24 | "path_info": sp.launcher_entry.path_info, 25 | "category": sp.launcher_entry.category, 26 | }, 27 | "new_browser_tab": sp.new_browser_tab, 28 | } 29 | if sp.launcher_entry.icon_path: 30 | icon_url = ujoin(self.base_url, "server-proxy", "icon", sp.name) 31 | item["launcher_entry"]["icon_url"] = icon_url 32 | 33 | data.append(item) 34 | 35 | self.write({"server_processes": data}) 36 | 37 | 38 | # FIXME: Should be a StaticFileHandler subclass 39 | class IconHandler(JupyterHandler): 40 | """ 41 | Serve launcher icons 42 | """ 43 | 44 | def initialize(self, icons): 45 | """ 46 | icons is a dict of titles to paths 47 | """ 48 | self.icons = icons 49 | 50 | async def get(self, name): 51 | if name not in self.icons: 52 | raise web.HTTPError(404) 53 | path = self.icons[name] 54 | 55 | # Guess mimetype appropriately 56 | # Stolen from https://github.com/tornadoweb/tornado/blob/b399a9d19c45951e4561e6e580d7e8cf396ef9ff/tornado/web.py#L2881 57 | mime_type, encoding = mimetypes.guess_type(path) 58 | if encoding == "gzip": 59 | content_type = "application/gzip" 60 | # As of 2015-07-21 there is no bzip2 encoding defined at 61 | # http://www.iana.org/assignments/media-types/media-types.xhtml 62 | # So for that (and any other encoding), use octet-stream. 63 | elif encoding is not None: 64 | content_type = "application/octet-stream" 65 | elif mime_type is not None: 66 | content_type = mime_type 67 | # if mime_type not detected, use application/octet-stream 68 | else: 69 | content_type = "application/octet-stream" 70 | 71 | with open(self.icons[name]) as f: 72 | self.write(f.read()) 73 | self.set_header("Content-Type", content_type) 74 | -------------------------------------------------------------------------------- /jupyter_server_proxy/standalone/proxy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from logging import Logger 4 | 5 | from jupyter_server.utils import ensure_async 6 | from jupyterhub import __version__ as __jh_version__ 7 | from jupyterhub.services.auth import HubOAuthenticated 8 | from tornado import web 9 | from tornado.log import app_log 10 | from tornado.web import RequestHandler 11 | from tornado.websocket import WebSocketHandler 12 | 13 | from ..handlers import SuperviseAndProxyHandler 14 | 15 | 16 | def make_standalone_proxy( 17 | base_proxy_class: type, proxy_kwargs: dict 18 | ) -> tuple[type | None, dict]: 19 | if not issubclass(base_proxy_class, SuperviseAndProxyHandler): 20 | app_log.error( 21 | "Cannot create a 'StandaloneHubProxyHandler' from a class not inheriting from 'SuperviseAndProxyHandler'" 22 | ) 23 | return None, dict() 24 | 25 | class StandaloneHubProxyHandler(HubOAuthenticated, base_proxy_class): 26 | """ 27 | Base class for standalone proxies. 28 | Will restrict access to the application by authentication with the JupyterHub API. 29 | """ 30 | 31 | def __init__(self, *args, **kwargs): 32 | super().__init__(*args, **kwargs) 33 | self.environment = {} 34 | self.timeout = 60 35 | self.no_authentication = False 36 | 37 | @property 38 | def log(self) -> Logger: 39 | return app_log 40 | 41 | @property 42 | def hub_users(self): 43 | if "hub_user" in self.settings: 44 | return {self.settings["hub_user"]} 45 | return set() 46 | 47 | @property 48 | def hub_groups(self): 49 | if "hub_group" in self.settings: 50 | return {self.settings["hub_group"]} 51 | return set() 52 | 53 | def set_default_headers(self): 54 | self.set_header("X-JupyterHub-Version", __jh_version__) 55 | 56 | def prepare(self, *args, **kwargs): 57 | pass 58 | 59 | def check_origin(self, origin: str = None): 60 | # Skip JupyterHandler.check_origin 61 | return WebSocketHandler.check_origin(self, origin) 62 | 63 | def check_xsrf_cookie(self): 64 | # Skip HubAuthenticated.check_xsrf_cookie 65 | pass 66 | 67 | def write_error(self, status_code: int, **kwargs): 68 | # ToDo: Return proper error page, like in jupyter-server/JupyterHub 69 | return RequestHandler.write_error(self, status_code, **kwargs) 70 | 71 | async def proxy(self, port, path): 72 | if self.no_authentication: 73 | return await super().proxy(port, path) 74 | else: 75 | return await ensure_async(self.oauth_proxy(port, path)) 76 | 77 | @web.authenticated 78 | async def oauth_proxy(self, port, path): 79 | return await super().proxy(port, path) 80 | 81 | return StandaloneHubProxyHandler, proxy_kwargs 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Manually added entries 2 | # 3 | jsdist/ 4 | jupyter_server_proxy/labextension/ 5 | node_modules/ 6 | package-lock.json 7 | .yarn-packages/ 8 | *.tsbuildinfo 9 | .DS_Store 10 | 11 | # Copy pasted entries from: 12 | # https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore 13 | # 14 | 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | cover/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | db.sqlite3 74 | db.sqlite3-journal 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | .pybuilder/ 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | # For a library or package, you might want to ignore these files since the code is 99 | # intended to run in multiple environments; otherwise, check them in: 100 | # .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 110 | __pypackages__/ 111 | 112 | # Celery stuff 113 | celerybeat-schedule 114 | celerybeat.pid 115 | 116 | # SageMath parsed files 117 | *.sage.py 118 | 119 | # Environments 120 | .env 121 | .venv 122 | env/ 123 | venv/ 124 | ENV/ 125 | env.bak/ 126 | venv.bak/ 127 | 128 | # Spyder project settings 129 | .spyderproject 130 | .spyproject 131 | 132 | # Rope project settings 133 | .ropeproject 134 | 135 | # mkdocs documentation 136 | /site 137 | 138 | # mypy 139 | .mypy_cache/ 140 | .dmypy.json 141 | dmypy.json 142 | 143 | # Pyre type checker 144 | .pyre/ 145 | 146 | # pytype static type analyzer 147 | .pytype/ 148 | 149 | # Cython debug symbols 150 | cython_debug/ 151 | labextension/.yarn/ 152 | -------------------------------------------------------------------------------- /docs/source/arbitrary-ports-hosts.md: -------------------------------------------------------------------------------- 1 | (arbitrary-ports)= 2 | 3 | # Accessing Arbitrary Ports or Hosts 4 | 5 | If you already have a server running on localhost listening on 6 | a port, you can access it through the notebook at 7 | `/proxy/`. 8 | The URL will be rewritten to remove the above prefix. 9 | 10 | You can disable URL rewriting by using 11 | `/proxy/absolute/` so your server will receive the full 12 | URL in the request. 13 | 14 | This works for all ports listening on the local machine. 15 | 16 | You can also specify arbitrary hosts in order to proxy traffic from 17 | another machine on the network `/proxy/:`. 18 | 19 | For security reasons the host must match an entry in the `host_allowlist` in your configuration. 20 | 21 | ## With JupyterHub 22 | 23 | Let's say you are using a JupyterHub set up on a remote machine, and you have a 24 | process running on that machine listening on port 8080. If your hub URL is 25 | `myhub.org`, each user can access the service running on port 8080 with the URL 26 | `myhub.org/hub/user-redirect/proxy/8080`. The `user-redirect` will make sure 27 | that: 28 | 29 | 1. It provides a redirect to the correct URL for the particular 30 | user who is logged in. 31 | 2. If a user is not logged in, it'll present them with a login 32 | screen. They'll be redirected there after completing authentication. 33 | 34 | You can also set `c.Spawner.default_url` to `/proxy/8080` to have 35 | users be shown to your application directly after logging in - 36 | without ever seeing the notebook interface. 37 | 38 | ## Without JupyterHub 39 | 40 | A very similar set up works when you don't use JupyterHub. You 41 | can construct the URL with `/proxy/`. 42 | 43 | If your notebook url is `http://localhost:8888` and you have 44 | a process running listening on port 8080, you can access it with 45 | the URL `http://localhost:8888/proxy/8080`. 46 | 47 | This is mostly useful for testing, since you can normally just 48 | access services on your local machine directly. 49 | 50 | ## From Notebook Extension 51 | 52 | If you have a client side extension for the classic Jupyter Notebook 53 | interface (nbextension), you can construct the URL for accessing 54 | your service in this way: 55 | 56 | ```js 57 | define(["base/js/utils"], function (utils) { 58 | // Get base URL of current notebook server 59 | var base_url = utils.get_body_data("baseUrl"); 60 | 61 | // Construct URL of our proxied service 62 | var service_url = base_url + "proxy/" + port; 63 | 64 | // Do stuff with your service_url 65 | }); 66 | ``` 67 | 68 | You can then make HTTP / Websocket requests as you wish from your 69 | code. 70 | 71 | ## From JupyterLab Extension 72 | 73 | Accessing your service from a JupyterLab extension is similar to 74 | accessing it from a classic notebook extension. 75 | 76 | ```typescript 77 | import { PageConfig } from "@jupyterlab/coreutils"; 78 | 79 | // Get base URL of current notebook server 80 | let base_url = PageConfig.getBaseUrl(); 81 | 82 | // Construct URL of our proxied service 83 | let service_url = base_url + "proxy/" + port; 84 | 85 | // Do stuff with your service_url 86 | ``` 87 | -------------------------------------------------------------------------------- /labextension/src/tokens.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A collection of tokens and constants used in `index.ts`. 3 | * 4 | * `import`ing this file should have basically no side-effects. 5 | */ 6 | 7 | import type { IFrame } from "@jupyterlab/apputils"; 8 | 9 | import type { ReadonlyJSONObject } from "@lumino/coreutils"; 10 | 11 | /** 12 | * The canonical name of the extension, also used in CLI commands such as 13 | * `jupyter labextension disable`. 14 | */ 15 | export const NAME = "@jupyterhub/jupyter-server-proxy"; 16 | 17 | /** 18 | * The short namespace for commands, etc. 19 | */ 20 | export const NS = "server-proxy"; 21 | 22 | /** 23 | * The values for the `iframe` `sandbox` attribute. 24 | * 25 | * These are generally required for most non-trivial client applications, but 26 | * do not affect full browser tabs. 27 | * 28 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox 29 | */ 30 | export const sandbox = Object.freeze([ 31 | "allow-same-origin", 32 | "allow-scripts", 33 | "allow-popups", 34 | "allow-forms", 35 | "allow-downloads", 36 | "allow-modals", 37 | ]) as IFrame.SandboxExceptions[]; 38 | 39 | /** 40 | * The JSON schema for the open command arguments. 41 | * 42 | * @see https://lumino.readthedocs.io/en/latest/api/interfaces/commands.CommandRegistry.ICommandOptions.html 43 | */ 44 | export const argSchema = Object.freeze({ 45 | type: "object", 46 | properties: { 47 | id: { type: "string" }, 48 | title: { type: "string" }, 49 | url: { type: "string", format: "uri" }, 50 | newBrowserTab: { type: "boolean" }, 51 | }, 52 | }); 53 | 54 | /** 55 | * The identifying string names for server proxy commands. 56 | * 57 | * Namespaces are real objects that exist at runtime in the browser 58 | * 59 | * @see https://www.typescriptlang.org/docs/handbook/namespaces.html 60 | */ 61 | export namespace CommandIDs { 62 | /* Opens a new server proxy tab */ 63 | export const open = `${NS}:open`; 64 | } 65 | 66 | // Below here are TypeScript interfaces. These do _not_ exist at runtime 67 | // but are useful when working with untyped data such as provided by the server 68 | // @see https://www.typescriptlang.org/docs/handbook/interfaces.html 69 | 70 | /** 71 | * An interface for the arguments to the open command. 72 | */ 73 | export interface IOpenArgs extends ReadonlyJSONObject { 74 | id: string; 75 | title: string; 76 | url: string; 77 | newBrowserTab: boolean; 78 | } 79 | 80 | /** 81 | * An interface for the server response from `/server-proxy/servers-info` 82 | */ 83 | export interface IServersInfo { 84 | server_processes: IServerProcess[]; 85 | } 86 | 87 | /** 88 | * An interface for the public description of a single server proxy. 89 | */ 90 | export interface IServerProcess { 91 | new_browser_tab: boolean; 92 | launcher_entry: ILauncherEntry; 93 | name: string; 94 | } 95 | 96 | /** 97 | * An interface for launcher-card specific information. 98 | */ 99 | export interface ILauncherEntry { 100 | enabled: boolean; 101 | title: string; 102 | path_info: string; 103 | // the `?` means this argument may not exist, but if it does, it must be a string 104 | icon_url?: string; 105 | category?: string; 106 | } 107 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for Sphinx to build our documentation to HTML. 2 | # 3 | # Configuration reference: https://www.sphinx-doc.org/en/master/usage/configuration.html 4 | # 5 | import datetime 6 | 7 | # -- Project information ----------------------------------------------------- 8 | # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 9 | # 10 | project = "Jupyter Server Proxy" 11 | copyright = f"{datetime.date.today().year}, Project Jupyter Contributors" 12 | author = "Project Jupyter Contributors" 13 | 14 | 15 | # -- General Sphinx configuration --------------------------------------------------- 16 | # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 17 | # 18 | # Add any Sphinx extension module names here, as strings. They can be extensions 19 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 20 | # 21 | extensions = [ 22 | "myst_parser", 23 | "sphinx_copybutton", 24 | "sphinxext.opengraph", 25 | "sphinxext.rediraffe", 26 | ] 27 | root_doc = "index" 28 | source_suffix = [".md"] 29 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 30 | 31 | 32 | # -- Options for HTML output ------------------------------------------------- 33 | # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 34 | # 35 | # html_logo = "_static/images/logo/logo.png" 36 | html_favicon = "_static/images/logo/favicon.ico" 37 | html_static_path = ["_static"] 38 | 39 | # sphinx_book_theme reference: https://sphinx-book-theme.readthedocs.io/en/latest/?badge=latest 40 | html_theme = "sphinx_book_theme" 41 | html_theme_options = { 42 | "home_page_in_toc": True, 43 | "repository_url": "https://github.com/jupyterhub/jupyter-server-proxy/", 44 | "repository_branch": "main", 45 | "path_to_docs": "docs/source", 46 | "use_download_button": False, 47 | "use_edit_page_button": True, 48 | "use_issues_button": True, 49 | "use_repository_button": True, 50 | } 51 | 52 | 53 | # -- Options for linkcheck builder ------------------------------------------- 54 | # ref: https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-the-linkcheck-builder 55 | # 56 | linkcheck_ignore = [ 57 | r"(.*)github\.com(.*)#", # javascript based anchors 58 | r"(.*)/#%21(.*)/(.*)", # /#!forum/jupyter - encoded anchor edge case 59 | r"https://github.com/[^/]*$", # too many github usernames / searches in changelog 60 | "https://github.com/jupyterhub/jupyter-server-proxy/pull/", # too many PRs in changelog 61 | "https://github.com/jupyterhub/jupyter-server-proxy/compare/", # too many comparisons in changelog 62 | ] 63 | linkcheck_anchors_ignore = [ 64 | "/#!", 65 | "/#%21", 66 | ] 67 | 68 | 69 | # -- Options for the opengraph extension ------------------------------------- 70 | # ref: https://github.com/wpilibsuite/sphinxext-opengraph#options 71 | # 72 | # ogp_site_url is set automatically by RTD 73 | # ogp_image = "_static/images/logo/logo.png" 74 | ogp_use_first_image = True 75 | 76 | 77 | # -- Options for the rediraffe extension ------------------------------------- 78 | # ref: https://github.com/wpilibsuite/sphinxext-rediraffe#readme 79 | # 80 | # This extensions help us relocated content without breaking links. If a 81 | # document is moved internally, we should configure a redirect like below. 82 | # 83 | rediraffe_branch = "main" 84 | rediraffe_redirects = { 85 | # "old-file": "new-folder/new-file-name", 86 | } 87 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Reusable test fixtures for ``jupyter_server_proxy``.""" 2 | 3 | import os 4 | import shutil 5 | import socket 6 | import sys 7 | import time 8 | from pathlib import Path 9 | from subprocess import Popen 10 | from typing import Any, Generator, Tuple 11 | from urllib.error import URLError 12 | from urllib.request import urlopen 13 | from uuid import uuid4 14 | 15 | from pytest import TempPathFactory, fixture 16 | 17 | HERE = Path(__file__).parent 18 | RESOURCES = HERE / "resources" 19 | 20 | 21 | @fixture(scope="session") 22 | def a_token() -> str: 23 | """Get a random UUID to use for a token.""" 24 | return str(uuid4()) 25 | 26 | 27 | @fixture(scope="session") 28 | def an_unused_port() -> int: 29 | """Get a random unused port.""" 30 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 31 | s.bind(("127.0.0.1", 0)) 32 | s.listen(1) 33 | port = s.getsockname()[1] 34 | s.close() 35 | return port 36 | 37 | 38 | @fixture(params=["notebook", "lab"], scope="session") 39 | def a_server_cmd(request: Any) -> str: 40 | """Get a viable name for a command.""" 41 | return request.param 42 | 43 | 44 | @fixture(scope="session") 45 | def a_server( 46 | a_server_cmd: str, 47 | tmp_path_factory: TempPathFactory, 48 | an_unused_port: int, 49 | a_token: str, 50 | ) -> Generator[str, None, None]: 51 | """Get a running server.""" 52 | 53 | tmp_path = tmp_path_factory.mktemp(a_server_cmd) 54 | 55 | # get a copy of the resources 56 | tests = tmp_path / "tests" 57 | tests.mkdir() 58 | shutil.copytree(RESOURCES, tests / "resources") 59 | args = [ 60 | sys.executable, 61 | "-m", 62 | "jupyter", 63 | a_server_cmd, 64 | f"--port={an_unused_port}", 65 | "--no-browser", 66 | "--config=./tests/resources/jupyter_server_config.py", 67 | "--debug", 68 | ] 69 | 70 | # prepare an env 71 | env = dict(os.environ) 72 | env.update(JUPYTER_TOKEN=a_token, JUPYTER_PLATFORM_DIRS="1") 73 | 74 | # start the process 75 | server_proc = Popen(args, cwd=str(tmp_path), env=env) 76 | 77 | # prepare some URLss 78 | url = f"http://127.0.0.1:{an_unused_port}/" 79 | canary_url = f"{url}favicon.ico" 80 | shutdown_url = f"{url}api/shutdown?token={a_token}" 81 | 82 | wait_until_urlopen(canary_url) 83 | 84 | print(f"{a_server_cmd} is ready...", flush=True) 85 | 86 | yield url 87 | 88 | # clean up after server is no longer needed 89 | print(f"{a_server_cmd} shutting down...", flush=True) 90 | wait_until_urlopen(shutdown_url, data=[]) 91 | server_proc.wait() 92 | print(f"{a_server_cmd} is stopped", flush=True) 93 | 94 | 95 | def wait_until_urlopen(url, **kwargs): 96 | retries = 20 97 | 98 | while retries: 99 | try: 100 | urlopen(url, **kwargs) 101 | break 102 | except URLError: 103 | retries -= 1 104 | if not retries: 105 | print( 106 | f"{url} not ready, aborting", 107 | flush=True, 108 | ) 109 | raise 110 | print( 111 | f"{url} not ready, will try again in 0.5s [{retries} retries]", 112 | flush=True, 113 | ) 114 | time.sleep(0.5) 115 | 116 | 117 | @fixture 118 | def a_server_port_and_token( 119 | a_server: str, # noqa 120 | an_unused_port: int, 121 | a_token: str, 122 | ) -> Tuple[int, str]: 123 | """Get the port and token for a running server.""" 124 | return an_unused_port, a_token 125 | -------------------------------------------------------------------------------- /tests/resources/websocket.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Based on the chat demo from https://github.com/tornadoweb/tornado/blob/d6819307ee050bbd8ec5deb623e9150ce2220ef9/demos/websocket/chatdemo.py#L1 4 | # Original License: 5 | # 6 | # Copyright 2009 Facebook 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); you may 9 | # not use this file except in compliance with the License. You may obtain 10 | # a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 16 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 17 | # License for the specific language governing permissions and limitations 18 | # under the License. 19 | 20 | 21 | import json 22 | import os.path 23 | 24 | import tornado.escape 25 | import tornado.ioloop 26 | import tornado.options 27 | import tornado.web 28 | import tornado.websocket 29 | from tornado.options import define, options 30 | 31 | define("port", default=8888, help="run on the given port", type=int) 32 | 33 | 34 | class Application(tornado.web.Application): 35 | def __init__(self): 36 | handlers = [ 37 | (r"/", MainHandler), 38 | (r"/echosocket", EchoWebSocket), 39 | (r"/subprotocolsocket", SubprotocolWebSocket), 40 | (r"/headerssocket", HeadersWebSocket), 41 | ] 42 | settings = dict( 43 | cookie_secret="__RANDOM_VALUE__", 44 | template_path=os.path.join(os.path.dirname(__file__), "templates"), 45 | static_path=os.path.join(os.path.dirname(__file__), "static"), 46 | xsrf_cookies=True, 47 | ) 48 | super().__init__(handlers, **settings) 49 | 50 | 51 | class MainHandler(tornado.web.RequestHandler): 52 | def get(self): 53 | self.write("Hello, world!") 54 | 55 | 56 | class EchoWebSocket(tornado.websocket.WebSocketHandler): 57 | """Echoes back received messages.""" 58 | 59 | def on_message(self, message): 60 | self.write_message(message) 61 | 62 | 63 | class HeadersWebSocket(tornado.websocket.WebSocketHandler): 64 | """Echoes back incoming request headers.""" 65 | 66 | def on_message(self, message): 67 | self.write_message(json.dumps(dict(self.request.headers))) 68 | 69 | 70 | class SubprotocolWebSocket(tornado.websocket.WebSocketHandler): 71 | """ 72 | Echoes back requested subprotocols and selected subprotocol as a JSON 73 | encoded message, and selects subprotocols in a very particular way to help 74 | us test things. 75 | """ 76 | 77 | def __init__(self, *args, **kwargs): 78 | self._requested_subprotocols = None 79 | super().__init__(*args, **kwargs) 80 | 81 | def select_subprotocol(self, subprotocols): 82 | self._requested_subprotocols = subprotocols if subprotocols else None 83 | 84 | if not subprotocols: 85 | return None 86 | if "please_select_no_protocol" in subprotocols: 87 | return None 88 | if "favored" in subprotocols: 89 | return "favored" 90 | else: 91 | return subprotocols[0] 92 | 93 | def on_message(self, message): 94 | response = { 95 | "requested_subprotocols": self._requested_subprotocols, 96 | "selected_subprotocol": self.selected_subprotocol, 97 | } 98 | self.write_message(json.dumps(response)) 99 | 100 | 101 | def main(): 102 | tornado.options.parse_command_line() 103 | app = Application() 104 | app.listen(options.port) 105 | tornado.ioloop.IOLoop.current().start() 106 | 107 | 108 | if __name__ == "__main__": 109 | main() 110 | -------------------------------------------------------------------------------- /jupyter_server_proxy/websocket.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authenticated HTTP proxy for Jupyter Notebooks 3 | 4 | Some original inspiration from https://github.com/senko/tornado-proxy 5 | """ 6 | 7 | import inspect 8 | 9 | from jupyter_server.utils import ensure_async 10 | from tornado import httpclient, httputil, websocket 11 | 12 | 13 | class PingableWSClientConnection(websocket.WebSocketClientConnection): 14 | """A WebSocketClientConnection with an on_ping callback.""" 15 | 16 | def __init__(self, **kwargs): 17 | if "on_ping_callback" in kwargs: 18 | self._on_ping_callback = kwargs["on_ping_callback"] 19 | del kwargs["on_ping_callback"] 20 | super().__init__(**kwargs) 21 | 22 | def on_ping(self, data): 23 | if self._on_ping_callback: 24 | self._on_ping_callback(data) 25 | 26 | 27 | def pingable_ws_connect( 28 | request=None, 29 | on_message_callback=None, 30 | on_ping_callback=None, 31 | subprotocols=None, 32 | resolver=None, 33 | ): 34 | """ 35 | A variation on websocket_connect that returns a PingableWSClientConnection 36 | with on_ping_callback. 37 | """ 38 | # Copy and convert the headers dict/object (see comments in 39 | # AsyncHTTPClient.fetch) 40 | request.headers = httputil.HTTPHeaders(request.headers) 41 | request = httpclient._RequestProxy(request, httpclient.HTTPRequest._DEFAULTS) 42 | 43 | # resolver= parameter requires tornado >= 6.3. Only pass it if needed 44 | # (for Unix socket support), so older versions of tornado can still 45 | # work otherwise. 46 | kwargs = {"resolver": resolver} if resolver else {} 47 | conn = PingableWSClientConnection( 48 | request=request, 49 | compression_options={}, 50 | on_message_callback=on_message_callback, 51 | on_ping_callback=on_ping_callback, 52 | max_message_size=getattr( 53 | websocket, "_default_max_message_size", 10 * 1024 * 1024 54 | ), 55 | subprotocols=subprotocols, 56 | **kwargs, 57 | ) 58 | 59 | return conn.connect_future 60 | 61 | 62 | # from https://stackoverflow.com/questions/38663666/how-can-i-serve-a-http-page-and-a-websocket-on-the-same-url-in-tornado 63 | class WebSocketHandlerMixin(websocket.WebSocketHandler): 64 | def __init__(self, *args, **kwargs): 65 | super().__init__(*args, **kwargs) 66 | # since my parent doesn't keep calling the super() constructor, 67 | # I need to do it myself 68 | bases = inspect.getmro(type(self)) 69 | assert WebSocketHandlerMixin in bases 70 | meindex = bases.index(WebSocketHandlerMixin) 71 | try: 72 | nextparent = bases[meindex + 1] 73 | except IndexError: 74 | raise Exception( 75 | "WebSocketHandlerMixin should be followed " 76 | "by another parent to make sense" 77 | ) 78 | 79 | # undisallow methods --- t.ws.WebSocketHandler disallows methods, 80 | # we need to re-enable these methods 81 | def wrapper(method): 82 | def undisallow(*args2, **kwargs2): 83 | getattr(nextparent, method)(self, *args2, **kwargs2) 84 | 85 | return undisallow 86 | 87 | for method in [ 88 | "write", 89 | "redirect", 90 | "set_header", 91 | "set_cookie", 92 | "set_status", 93 | "flush", 94 | "finish", 95 | ]: 96 | setattr(self, method, wrapper(method)) 97 | nextparent.__init__(self, *args, **kwargs) 98 | 99 | async def get(self, *args, **kwargs): 100 | if self.request.headers.get("Upgrade", "").lower() != "websocket": 101 | return await self.http_get(*args, **kwargs) 102 | else: 103 | await ensure_async(super().get(*args, **kwargs)) 104 | -------------------------------------------------------------------------------- /tests/test_standalone.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | from tornado import testing 7 | 8 | from jupyter_server_proxy.standalone import StandaloneProxyServer 9 | 10 | """ 11 | Test if address and port are identified correctly 12 | """ 13 | 14 | 15 | def test_address_and_port_with_http_address(monkeypatch): 16 | monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "http://localhost/") 17 | proxy_server = StandaloneProxyServer() 18 | 19 | assert proxy_server.address == "localhost" 20 | assert proxy_server.port == 80 21 | 22 | 23 | def test_address_and_port_with_https_address(monkeypatch): 24 | monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "https://localhost/") 25 | proxy_server = StandaloneProxyServer() 26 | 27 | assert proxy_server.address == "localhost" 28 | assert proxy_server.port == 443 29 | 30 | 31 | def test_address_and_port_with_address_and_port(monkeypatch): 32 | monkeypatch.setenv("JUPYTERHUB_SERVICE_URL", "http://localhost:7777/") 33 | proxy_server = StandaloneProxyServer() 34 | 35 | assert proxy_server.address == "localhost" 36 | assert proxy_server.port == 7777 37 | 38 | 39 | class _TestStandaloneBase(testing.AsyncHTTPTestCase): 40 | runTest = None # Required for Tornado 6.1 41 | 42 | unix_socket: bool 43 | no_authentication: bool 44 | 45 | def get_app(self): 46 | command = [ 47 | sys.executable, 48 | str(Path(__file__).parent / "resources" / "httpinfo.py"), 49 | "--port={port}", 50 | "--unix-socket={unix_socket}", 51 | ] 52 | 53 | proxy_server = StandaloneProxyServer( 54 | command=command, 55 | base_url="/some/prefix", 56 | unix_socket=self.unix_socket, 57 | timeout=60, 58 | no_authentication=self.no_authentication, 59 | log_level=logging.DEBUG, 60 | ) 61 | 62 | return proxy_server.create_app() 63 | 64 | 65 | class TestStandaloneProxyRedirect(_TestStandaloneBase): 66 | """ 67 | Ensure requests are proxied to the application. We need to disable authentication here, 68 | as we do not want to be redirected to the JupyterHub Login. 69 | """ 70 | 71 | unix_socket = False 72 | no_authentication = True 73 | 74 | def test_add_slash(self): 75 | response = self.fetch("/some/prefix", follow_redirects=False) 76 | 77 | assert response.code == 301 78 | assert response.headers.get("Location") == "/some/prefix/" 79 | 80 | def test_wrong_prefix(self): 81 | response = self.fetch("/some/other/prefix") 82 | 83 | assert response.code == 404 84 | 85 | def test_on_prefix(self): 86 | response = self.fetch("/some/prefix/") 87 | assert response.code == 200 88 | 89 | body = response.body.decode() 90 | assert body.startswith("GET /") 91 | assert "X-Forwarded-Context: /some/prefix/" in body 92 | assert "X-Proxycontextpath: /some/prefix/" in body 93 | 94 | 95 | @pytest.mark.skipif( 96 | sys.platform == "win32", reason="Unix socket not supported on Windows" 97 | ) 98 | class TestStandaloneProxyWithUnixSocket(_TestStandaloneBase): 99 | unix_socket = True 100 | no_authentication = True 101 | 102 | def test_with_unix_socket(self): 103 | response = self.fetch("/some/prefix/") 104 | assert response.code == 200 105 | 106 | body = response.body.decode() 107 | assert body.startswith("GET /") 108 | assert "X-Forwarded-Context: /some/prefix/" in body 109 | assert "X-Proxycontextpath: /some/prefix/" in body 110 | 111 | 112 | class TestStandaloneProxyLogin(_TestStandaloneBase): 113 | """ 114 | Ensure we redirect to JupyterHub login when authentication is enabled 115 | """ 116 | 117 | unix_socket = False 118 | no_authentication = False 119 | 120 | def test_redirect_to_login_url(self): 121 | response = self.fetch("/some/prefix/", follow_redirects=False) 122 | 123 | assert response.code == 302 124 | assert "Location" in response.headers 125 | -------------------------------------------------------------------------------- /jupyter_server_proxy/rawsocket.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple translation layer between tornado websockets and asyncio stream 3 | connections. 4 | 5 | This provides similar functionality to websockify 6 | (https://github.com/novnc/websockify) without needing an extra proxy hop 7 | or process through with all messages pass for translation. 8 | """ 9 | 10 | import asyncio 11 | 12 | from tornado import web 13 | 14 | from .handlers import NamedLocalProxyHandler, SuperviseAndProxyHandler 15 | 16 | 17 | class RawSocketProtocol(asyncio.Protocol): 18 | """ 19 | A protocol handler for the proxied stream connection. 20 | Sends any received blocks directly as websocket messages. 21 | """ 22 | 23 | def __init__(self, handler): 24 | self.handler = handler 25 | 26 | def data_received(self, data): 27 | "Send the buffer as a websocket message." 28 | self.handler._record_activity() 29 | # ignore async "semi-synchronous" result, waiting is only needed for control flow and errors 30 | # (see https://github.com/tornadoweb/tornado/blob/bdfc017c66817359158185561cee7878680cd841/tornado/websocket.py#L1073) 31 | self.handler.write_message(data, binary=True) 32 | 33 | def connection_lost(self, exc): 34 | "Close the websocket connection." 35 | self.handler.log.info( 36 | f"Raw websocket {self.handler.name} connection lost: {exc}" 37 | ) 38 | self.handler.close() 39 | 40 | 41 | class RawSocketHandler(NamedLocalProxyHandler): 42 | """ 43 | HTTP handler that proxies websocket connections into a backend stream. 44 | All other HTTP requests return 405. 45 | """ 46 | 47 | def _create_ws_connection(self, proto: asyncio.BaseProtocol): 48 | "Create the appropriate backend asyncio connection" 49 | loop = asyncio.get_running_loop() 50 | if self.unix_socket is not None: 51 | self.log.info(f"RawSocket {self.name} connecting to {self.unix_socket}") 52 | return loop.create_unix_connection(proto, self.unix_socket) 53 | else: 54 | self.log.info(f"RawSocket {self.name} connecting to port {self.port}") 55 | return loop.create_connection(proto, "localhost", self.port) 56 | 57 | async def proxy(self, port, path): 58 | raise web.HTTPError( 59 | 405, "this raw_socket_proxy backend only supports websocket connections" 60 | ) 61 | 62 | async def proxy_open(self, host, port, proxied_path=""): 63 | """ 64 | Open the backend connection. host and port are ignored (as they are in 65 | the parent for unix sockets) since they are always passed known values. 66 | """ 67 | transp, proto = await self._create_ws_connection( 68 | lambda: RawSocketProtocol(self) 69 | ) 70 | self.ws_transp = transp 71 | self.ws_proto = proto 72 | self._record_activity() 73 | self.log.info(f"RawSocket {self.name} connected") 74 | 75 | def on_message(self, message): 76 | "Send websocket messages as stream writes, encoding if necessary." 77 | self._record_activity() 78 | if isinstance(message, str): 79 | message = message.encode("utf-8") 80 | self.ws_transp.write( 81 | message 82 | ) # buffered non-blocking. should block (needs new enough tornado) 83 | 84 | def on_ping(self, message): 85 | "No-op" 86 | self._record_activity() 87 | 88 | def on_close(self): 89 | "Close the backend connection." 90 | self.log.info(f"RawSocket {self.name} connection closed") 91 | if hasattr(self, "ws_transp"): 92 | self.ws_transp.close() 93 | 94 | 95 | class SuperviseAndRawSocketHandler(SuperviseAndProxyHandler, RawSocketHandler): 96 | async def _http_ready_func(self, p): 97 | # not really HTTP here, just try an empty connection 98 | try: 99 | transp, _ = await self._create_ws_connection(asyncio.Protocol) 100 | except OSError as exc: 101 | self.log.debug(f"RawSocket {self.name} connection check failed: {exc}") 102 | return False 103 | transp.close() 104 | return True 105 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | # This is a GitHub workflow defining a set of jobs with a set of steps. 2 | # ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions 3 | # 4 | # Publish PyPI and NPM artifacts 5 | # 6 | name: Publish 7 | 8 | on: 9 | pull_request: 10 | paths-ignore: 11 | - "docs/**" 12 | - "contrib/**" 13 | - "**.md" 14 | - ".github/workflows/*" 15 | - "!.github/workflows/publish.yaml" 16 | push: 17 | paths-ignore: 18 | - "docs/**" 19 | - "contrib/**" 20 | - "**.md" 21 | - ".github/workflows/*" 22 | - "!.github/workflows/publish.yaml" 23 | branches-ignore: 24 | - "dependabot/**" 25 | - "pre-commit-ci-update-config" 26 | tags: 27 | - "*" 28 | workflow_dispatch: 29 | 30 | jobs: 31 | build: 32 | runs-on: ubuntu-22.04 33 | 34 | steps: 35 | - uses: actions/checkout@v6 36 | 37 | - uses: actions/setup-python@v6 38 | with: 39 | python-version: "3.12" 40 | 41 | - uses: actions/setup-node@v6 42 | with: 43 | cache: yarn 44 | node-version: "lts/*" 45 | registry-url: https://registry.npmjs.org 46 | cache-dependency-path: labextension/yarn.lock 47 | 48 | - name: Update root build packages 49 | run: | 50 | pip install --upgrade build pip 51 | 52 | - name: Build dist 53 | run: | 54 | pyproject-build 55 | cd dist && sha256sum * | tee SHA256SUMS 56 | 57 | - name: Check dist sizes 58 | run: | 59 | set -eux 60 | ls -lathr dist 61 | [[ $(find dist -type f -size +200k) ]] && exit 1 || echo ok 62 | 63 | - name: Javascript package 64 | run: | 65 | mkdir jsdist 66 | cd jsdist 67 | npm pack ../labextension 68 | sha256sum * | tee SHA256SUMS 69 | 70 | - name: Upload Python artifact 71 | uses: actions/upload-artifact@v5 72 | with: 73 | name: dist-${{ github.run_attempt }} 74 | path: dist 75 | if-no-files-found: error 76 | 77 | - name: Upload Javascript artifact 78 | uses: actions/upload-artifact@v5 79 | with: 80 | name: jsdist-${{ github.run_attempt }} 81 | path: jsdist 82 | if-no-files-found: error 83 | 84 | # https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 85 | publish-pypi: 86 | runs-on: ubuntu-22.04 87 | needs: 88 | - build 89 | 90 | steps: 91 | - uses: actions/setup-python@v6 92 | with: 93 | python-version: "3.12" 94 | 95 | - name: Download artifacts from build 96 | uses: actions/download-artifact@v6 97 | with: 98 | name: dist-${{ github.run_attempt }} 99 | path: dist 100 | 101 | # The PyPI publishing action will try to publish this checksum file as if 102 | # it was a Python package if it remains in dist, so we clean it up first. 103 | - name: Cleanup SHA256SUMS 104 | run: | 105 | cat dist/SHA256SUMS 106 | rm dist/SHA256SUMS 107 | 108 | - name: Publish to PyPI 109 | if: startsWith(github.ref, 'refs/tags') 110 | uses: pypa/gh-action-pypi-publish@release/v1 111 | with: 112 | password: ${{ secrets.PYPI_PASSWORD }} 113 | 114 | # https://docs.github.com/en/actions/language-and-framework-guides/publishing-nodejs-packages#publishing-packages-to-the-npm-registry 115 | publish-npm: 116 | runs-on: ubuntu-22.04 117 | needs: 118 | - build 119 | 120 | steps: 121 | # actions/setup-node creates an .npmrc file that references NODE_AUTH_TOKEN 122 | - uses: actions/setup-node@v6 123 | with: 124 | node-version: "lts/*" 125 | registry-url: https://registry.npmjs.org 126 | 127 | - name: Download artifacts from build 128 | uses: actions/download-artifact@v6 129 | with: 130 | name: jsdist-${{ github.run_attempt }} 131 | path: jsdist 132 | 133 | - run: | 134 | npm publish --dry-run ./jsdist/jupyterhub-jupyter-server-proxy-*.tgz 135 | 136 | - run: | 137 | npm publish ./jsdist/jupyterhub-jupyter-server-proxy-*.tgz 138 | if: startsWith(github.ref, 'refs/tags') 139 | env: 140 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 141 | -------------------------------------------------------------------------------- /contrib/theia/jupyter_theia_proxy/icons/theia.svg: -------------------------------------------------------------------------------- 1 | 2 | 19 | 20 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 50 | 52 | 72 | 76 | 80 | 84 | 88 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jupyter Server Proxy 2 | 3 | [![ReadTheDocs badge](https://img.shields.io/readthedocs/jupyter-server-proxy?logo=read-the-docs)](https://jupyter-server-proxy.readthedocs.io/) 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/jupyterhub/jupyter-server-proxy/test.yaml?logo=github&branch=main)](https://github.com/jupyterhub/jupyter-server-proxy/actions) 5 | [![PyPI badge](https://img.shields.io/pypi/v/jupyter-server-proxy.svg?logo=pypi)](https://pypi.python.org/pypi/jupyter-server-proxy) 6 | [![Conda badge](https://img.shields.io/conda/vn/conda-forge/jupyter-server-proxy?logo=conda-forge)](https://anaconda.org/conda-forge/jupyter-server-proxy) 7 | [![NPM badge](https://img.shields.io/npm/v/@jupyterhub/jupyter-server-proxy.svg?logo=npm)](https://www.npmjs.com/package/@jupyterhub/jupyter-server-proxy) 8 | 9 | Jupyter Server Proxy lets you run arbitrary external processes (such as 10 | RStudio, Shiny Server, Syncthing, PostgreSQL, Code Server, etc) 11 | alongside your notebook server and provide authenticated web access to 12 | them using a path like `/rstudio` next to others like `/lab`. Alongside 13 | the python package that provides the main functionality, the JupyterLab 14 | extension (`@jupyterhub/jupyter-server-proxy`) provides buttons in the 15 | JupyterLab launcher window to get to RStudio for example. 16 | 17 | **Note:** This project used to be called **nbserverproxy**. As 18 | nbserverproxy is an older version of jupyter-server-proxy, uninstall 19 | nbserverproxy before installing jupyter-server-proxy to avoid conflicts. 20 | 21 | The primary use cases are: 22 | 23 | 1. Use with JupyterHub / Binder to allow launching users into web 24 | interfaces that have nothing to do with Jupyter - such as RStudio, 25 | Shiny, or OpenRefine. 26 | 2. Allow access from frontend javascript (in classic notebook or 27 | JupyterLab extensions) to access web APIs of other processes running 28 | locally in a safe manner. This is used by the [JupyterLab 29 | extension](https://github.com/dask/dask-labextension) for 30 | [dask](https://www.dask.org/). 31 | 32 | [The documentation](https://jupyter-server-proxy.readthedocs.io/) 33 | contains information on installation & usage. 34 | 35 | ## Security warning 36 | 37 | Jupyter Server Proxy is often used to start a user defined process listening to 38 | some network port (e.g. `http://localhost:4567`) for a user starting a Jupyter Server 39 | that only that user has permission to access. The user can then access the 40 | started process proxied through the Jupyter Server. 41 | 42 | For safe use of Jupyter Server Proxy, you should ensure that the process started 43 | by Jupyter Server proxy can't be accessed directly by another user and bypass 44 | the Jupyter Server's authorization! 45 | 46 | A common strategy to enforce access proxied via Jupyter Server is to start 47 | Jupyter Server within a container and only allow network access to the Jupyter 48 | Server via the container. 49 | 50 | > For more insights, see [Ryan Lovett's comment about 51 | > it](https://github.com/jupyterhub/jupyter-server-proxy/pull/359#issuecomment-1350118197). 52 | 53 | ## Install 54 | 55 | ### Python package 56 | 57 | #### `pip` 58 | 59 | ```bash 60 | pip install jupyter-server-proxy 61 | ``` 62 | 63 | #### `conda` 64 | 65 | ```bash 66 | conda install -c conda-forge jupyter-server-proxy 67 | ``` 68 | 69 | ### Jupyter Client Extensions 70 | 71 | A JupyterLab and Notebook extension is bundled with the Python package to 72 | provide: 73 | 74 | - servers in the _New_ dropwdown of the Notebook Tree view 75 | - launch buttons in JupyterLab's Launcher panel for registered server processes. 76 | - ![a screenshot of the JupyterLab Launcher](docs/source/_static/images/labextension-launcher.png "launch proxied servers as JupyterLab panels or new browser tabs") 77 | 78 | #### Client compatibility 79 | 80 | For historical compatibility ranges, see the table below: 81 | 82 | | `jupyter-server-proxy` | `notebook` | `jupyterlab` | 83 | | :--------------------: | :--------: | :----------: | 84 | | `4.1.x` | `>=6,<8` | `>=3,<5` | 85 | | `4.0.x` | `>=6,<7` | `>=3,<4` | 86 | | `3.x` | `>=6,<7` | `>=2,<4` | 87 | 88 | ## Disable 89 | 90 | ### Server extension 91 | 92 | ```bash 93 | jupyter serverextension disable --sys-prefix jupyter_server_proxy 94 | jupyter server extension disable --sys-prefix jupyter_server_proxy 95 | ``` 96 | 97 | ### Notebook Classic extension 98 | 99 | ```bash 100 | jupyter nbextension disable --sys-prefix --py jupyter_server_proxy 101 | ``` 102 | 103 | ### JupyterLab extension 104 | 105 | ```bash 106 | jupyter labextension disable @jupyterhub/jupyter-server-proxy 107 | ``` 108 | 109 | #### Local development 110 | 111 | To setup a local development environment, see the [contributing guide](https://github.com/jupyterhub/jupyter-server-proxy/blob/main/CONTRIBUTING.md). 112 | -------------------------------------------------------------------------------- /tests/resources/jupyter_server_config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | 5 | def _get_path(*args): 6 | """Resolve a path relative to this file""" 7 | return str(Path(__file__).parent.resolve().joinpath(*args)) 8 | 9 | 10 | sys.path.append(_get_path()) 11 | 12 | # load the config object for traitlets based configuration 13 | c = get_config() # noqa 14 | 15 | 16 | def mappathf(path): 17 | p = path + "mapped" 18 | return p 19 | 20 | 21 | def translate_ciao(path, host, response, orig_response, port): 22 | # Assume that the body has not been modified by any previous rewrite 23 | assert response.body == orig_response.body 24 | 25 | response.code = 418 26 | response.reason = "I'm a teapot" 27 | response.headers["i-like"] = "tacos" 28 | response.headers["Proxied-Host-Port"] = f"{host}:{port}" 29 | response.headers["Proxied-Path"] = path 30 | response.body = response.body.replace(b"ciao", b"hello") 31 | 32 | 33 | def hello_to_foo(response): 34 | response.body = response.body.replace(b"hello", b"foo") 35 | 36 | 37 | # Example from the rewrite_response docstring 38 | def dog_to_cat(response): 39 | response.headers["I-Like"] = "tacos" 40 | response.body = response.body.replace(b"dog", b"cat") 41 | 42 | 43 | # Example from the rewrite_response docstring 44 | def cats_only(response, path): 45 | if path.startswith("/cat-club"): 46 | response.code = 403 47 | response.body = b"dogs not allowed" 48 | 49 | 50 | def my_env(): 51 | return {"MYVAR": "String with escaped {{var}}"} 52 | 53 | 54 | c.ServerProxy.servers = { 55 | "python-http": { 56 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 57 | }, 58 | "python-http-abs": { 59 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 60 | "absolute_url": True, 61 | }, 62 | "python-http-port54321": { 63 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 64 | "port": 54321, 65 | }, 66 | "python-http-mappath": { 67 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 68 | "mappath": { 69 | "/": "/index.html", 70 | }, 71 | }, 72 | "python-http-mappathf": { 73 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 74 | "mappath": mappathf, 75 | }, 76 | "python-http-callable-env": { 77 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 78 | "environment": my_env, 79 | }, 80 | "python-websocket": { 81 | "command": [sys.executable, _get_path("websocket.py"), "--port={port}"], 82 | "request_headers_override": { 83 | "X-Custom-Header": "pytest-23456", 84 | }, 85 | }, 86 | "python-eventstream": { 87 | "command": [sys.executable, _get_path("eventstream.py"), "--port={port}"] 88 | }, 89 | "python-unix-socket-true": { 90 | "command": [ 91 | sys.executable, 92 | _get_path("httpinfo.py"), 93 | "--unix-socket={unix_socket}", 94 | ], 95 | "unix_socket": True, 96 | }, 97 | "python-unix-socket-file": { 98 | "command": [ 99 | sys.executable, 100 | _get_path("httpinfo.py"), 101 | "--unix-socket={unix_socket}", 102 | ], 103 | "unix_socket": "/tmp/jupyter-server-proxy-test-socket", 104 | }, 105 | "python-unix-socket-file-no-command": { 106 | # this server process can be started earlier by first interacting with 107 | # python-unix-socket-file 108 | "unix_socket": "/tmp/jupyter-server-proxy-test-socket", 109 | }, 110 | "python-request-headers": { 111 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 112 | "request_headers_override": { 113 | "X-Custom-Header": "pytest-23456", 114 | }, 115 | }, 116 | "python-gzipserver": { 117 | "command": [sys.executable, _get_path("gzipserver.py"), "{port}"], 118 | }, 119 | "python-http-rewrite-response": { 120 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 121 | "rewrite_response": translate_ciao, 122 | "port": 54323, 123 | }, 124 | "python-chained-rewrite-response": { 125 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 126 | "rewrite_response": [translate_ciao, hello_to_foo], 127 | }, 128 | "python-cats-only-rewrite-response": { 129 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 130 | "rewrite_response": [dog_to_cat, cats_only], 131 | }, 132 | "python-dogs-only-rewrite-response": { 133 | "command": [sys.executable, _get_path("httpinfo.py"), "--port={port}"], 134 | "rewrite_response": [cats_only, dog_to_cat], 135 | }, 136 | "python-proxyto54321-no-command": {"port": 54321}, 137 | "python-rawsocket-tcp": { 138 | "command": [sys.executable, _get_path("rawsocket.py"), "{port}"], 139 | "raw_socket_proxy": True, 140 | }, 141 | "python-rawsocket-unix": { 142 | "command": [sys.executable, _get_path("rawsocket.py"), "{unix_socket}"], 143 | "unix_socket": True, 144 | "raw_socket_proxy": True, 145 | }, 146 | "python-redirect": { 147 | "command": [sys.executable, _get_path("redirectserver.py"), "--port={port}"], 148 | }, 149 | "python-redirect-abs": { 150 | "command": [sys.executable, _get_path("redirectserver.py"), "--port={port}"], 151 | "absolute_url": True, 152 | }, 153 | } 154 | 155 | c.ServerProxy.non_service_rewrite_response = hello_to_foo 156 | 157 | c.ServerApp.jpserver_extensions = {"proxyextension": True} 158 | 159 | c.NotebookApp.nbserver_extensions = {"proxyextension": True} 160 | 161 | # disable notebook 7 banner 162 | c.ServerApp.show_banner = False 163 | 164 | # disable slow/noisy discovery of language servers 165 | c.LanguageServerManager.autodetect = False 166 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # build-system 2 | # - ref: https://peps.python.org/pep-0517/ 3 | # 4 | [build-system] 5 | build-backend = "hatchling.build" 6 | requires = [ 7 | "hatch-jupyter-builder >=0.8.3", 8 | "hatchling >=1.18.0", 9 | "jupyterlab >=4.0.6,<5.0.0a0", 10 | ] 11 | 12 | 13 | # project 14 | # - ref 1: https://peps.python.org/pep-0621/ 15 | # - ref 2: https://hatch.pypa.io/latest/config/metadata/#project-metadata 16 | # 17 | [project] 18 | name = "jupyter_server_proxy" 19 | version = "4.4.1-0.dev" 20 | description = "A Jupyter server extension to run additional processes and proxy to them that comes bundled JupyterLab extension to launch pre-defined processes." 21 | keywords = ["jupyter", "jupyterlab", "jupyterlab-extension"] 22 | authors = [ 23 | { name = "Ryan Lovett", email = "rylo@berkeley.edu" }, 24 | { name = "Yuvi Panda", email = "yuvipanda@gmail.com" }, 25 | { name = "Jupyter Development Team", email = "jupyter@googlegroups.com" }, 26 | ] 27 | readme = "README.md" 28 | license = { file = "LICENSE" } 29 | requires-python = ">=3.8" 30 | classifiers = [ 31 | "Framework :: Jupyter", 32 | "Framework :: Jupyter :: JupyterLab", 33 | "Framework :: Jupyter :: JupyterLab :: 3", 34 | "Framework :: Jupyter :: JupyterLab :: 4", 35 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 36 | "Framework :: Jupyter :: JupyterLab :: Extensions", 37 | "License :: OSI Approved :: BSD License", 38 | "Operating System :: MacOS :: MacOS X", 39 | "Operating System :: Microsoft :: Windows", 40 | "Operating System :: POSIX :: Linux", 41 | "Programming Language :: Python", 42 | "Programming Language :: Python :: 3", 43 | ] 44 | dependencies = [ 45 | "aiohttp", 46 | "importlib_metadata >=4.8.3 ; python_version<\"3.10\"", 47 | "jupyter-server >=1.24.0", 48 | "simpervisor >=1.0.0", 49 | "tornado >=6.1.0", 50 | "traitlets >= 5.1.0", 51 | ] 52 | 53 | [project.optional-dependencies] 54 | test = [ 55 | "jupyter-server-proxy[standalone]", 56 | "pytest", 57 | "pytest-asyncio", 58 | "pytest-cov", 59 | "pytest-html", 60 | ] 61 | # acceptance tests additionally require firefox and geckodriver 62 | acceptance = [ 63 | "jupyter-server-proxy[test]", 64 | "robotframework-jupyterlibrary >=0.4.2", 65 | ] 66 | standalone = [ 67 | "jupyterhub" 68 | ] 69 | classic = [ 70 | "jupyter-server <2", 71 | "jupyterlab >=3.0.0,<4.0.0a0", 72 | "notebook <7.0.0a0", 73 | ] 74 | lab = [ 75 | "jupyter-server >=2", 76 | "jupyterlab >=4.0.5,<5.0.0a0", 77 | "nbclassic", 78 | "notebook >=7", 79 | ] 80 | 81 | [project.urls] 82 | Documentation = "https://jupyter-server-proxy.readthedocs.io" 83 | Source = "https://github.com/jupyterhub/jupyter-server-proxy" 84 | Tracker = "https://github.com/jupyterhub/jupyter-server-proxy/issues" 85 | 86 | [project.scripts] 87 | jupyter-standaloneproxy = "jupyter_server_proxy.standalone:main" 88 | 89 | 90 | # hatch ref: https://hatch.pypa.io/latest/ 91 | # 92 | [tool.hatch.build.targets.sdist] 93 | artifacts = [ 94 | "jupyter_server_proxy/labextension", 95 | ] 96 | exclude = [ 97 | ".git", 98 | ".github", 99 | ".readthedocs.yaml", 100 | ".pre-commit-config.yaml", 101 | "contrib", 102 | "docs", 103 | ] 104 | 105 | [tool.hatch.build.targets.wheel.shared-data] 106 | "jupyter_server_proxy/etc" = "etc/jupyter" 107 | "jupyter_server_proxy/labextension" = "share/jupyter/labextensions/@jupyterhub/jupyter-server-proxy" 108 | "jupyter_server_proxy/static" = "share/jupyter/nbextensions/jupyter_server_proxy" 109 | 110 | [tool.hatch.metadata] 111 | # Set to true to allow testing of git+https://github.com/user/repo@sha dependencies 112 | allow-direct-references = false 113 | 114 | [tool.hatch.build.hooks.jupyter-builder] 115 | build-function = "hatch_jupyter_builder.npm_builder" 116 | ensured-targets = [ 117 | "jupyter_server_proxy/labextension/static/style.js", 118 | "jupyter_server_proxy/labextension/package.json", 119 | "jupyter_server_proxy/labextension/static/third-party-licenses.json", 120 | ] 121 | skip-if-exists = ["jupyter_server_proxy/labextension/package.json"] 122 | 123 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 124 | path = "labextension" 125 | build_cmd = "build:prod" 126 | npm = ["jlpm"] 127 | 128 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 129 | path = "labextension" 130 | build_cmd = "build" 131 | npm = ["jlpm"] 132 | source_dir = "labextension/src" 133 | build_dir = "jupyter_server_proxy/labextension" 134 | 135 | 136 | # autoflake is used for autoformatting Python code 137 | # 138 | # ref: https://github.com/PyCQA/autoflake#readme 139 | # 140 | [tool.autoflake] 141 | ignore-init-module-imports = true 142 | remove-all-unused-imports = true 143 | remove-duplicate-keys = true 144 | remove-unused-variables = true 145 | 146 | 147 | # black is used for autoformatting Python code 148 | # 149 | # ref: https://black.readthedocs.io/en/stable/ 150 | # 151 | [tool.black] 152 | extend-exclude = 'contrib\/template\/' 153 | 154 | 155 | # isort is used for autoformatting Python code 156 | # 157 | # ref: https://pycqa.github.io/isort/ 158 | # 159 | [tool.isort] 160 | profile = "black" 161 | 162 | 163 | # tbump is used to simplify and standardize the release process when updating 164 | # the version, making a git commit and tag, and pushing changes. 165 | # 166 | # ref: https://github.com/your-tools/tbump#readme 167 | # 168 | [tool.tbump] 169 | github_url = "https://github.com/jupyterhub/jupyter-server-proxy" 170 | 171 | [tool.tbump.version] 172 | current = "4.4.1-0.dev" 173 | regex = ''' 174 | (?P\d+) 175 | \. 176 | (?P\d+) 177 | \. 178 | (?P\d+) 179 | -? 180 | (?P
    ((alpha|beta|rc)\.\d+)|)
    181 |     (?P(0\.dev)|)
    182 | '''
    183 | 
    184 | [tool.tbump.git]
    185 | message_template = "Bump to {new_version}"
    186 | tag_template = "v{new_version}"
    187 | 
    188 | [[tool.tbump.file]]
    189 | src = "jupyter_server_proxy/_version.py"
    190 | 
    191 | [[tool.tbump.file]]
    192 | src = "pyproject.toml"
    193 | 
    194 | [[tool.tbump.file]]
    195 | src = "labextension/package.json"
    196 | 
    197 | 
    198 | # pytest is used for running Python based tests
    199 | #
    200 | # ref: https://docs.pytest.org/en/stable/
    201 | #
    202 | [tool.pytest.ini_options]
    203 | addopts = [
    204 |     "--verbose",
    205 |     "--durations=10",
    206 |     "--color=yes",
    207 |     "--cov=jupyter_server_proxy",
    208 |     "--cov-branch",
    209 |     "--cov-context=test",
    210 |     "--cov-report=term-missing:skip-covered",
    211 |     "--cov-report=html:build/coverage",
    212 |     "--no-cov-on-fail",
    213 |     "--html=build/pytest/index.html",
    214 | ]
    215 | asyncio_mode = "auto"
    216 | testpaths = ["tests"]
    217 | cache_dir = "build/.cache/pytest"
    218 | 
    219 | 
    220 | # pytest-cov / coverage is used to measure code coverage of tests
    221 | #
    222 | # ref: https://coverage.readthedocs.io/en/stable/config.html
    223 | #
    224 | [tool.coverage.run]
    225 | data_file = "build/.coverage"
    226 | concurrency = [
    227 |     "multiprocessing",
    228 |     "thread"
    229 | ]
    230 | 
    231 | [tool.coverage.html]
    232 | show_contexts = true
    233 | 
    
    
    --------------------------------------------------------------------------------
    /.github/workflows/test.yaml:
    --------------------------------------------------------------------------------
      1 | # This is a GitHub workflow defining a set of jobs with a set of steps.
      2 | # ref: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
      3 | #
      4 | name: Test
      5 | 
      6 | on:
      7 |   pull_request:
      8 |     paths-ignore:
      9 |       - "docs/**"
     10 |       - "contrib/**"
     11 |       - "**.md"
     12 |       - ".github/workflows/*"
     13 |       - "!.github/workflows/test.yaml"
     14 |   push:
     15 |     paths-ignore:
     16 |       - "docs/**"
     17 |       - "contrib/**"
     18 |       - "**.md"
     19 |       - ".github/workflows/*"
     20 |       - "!.github/workflows/test.yaml"
     21 |     branches-ignore:
     22 |       - "dependabot/**"
     23 |       - "pre-commit-ci-update-config"
     24 |   schedule:
     25 |     # Run at 05:00 on monday and thursday, ref: https://crontab.guru/#0_5_*_*_1,4
     26 |     - cron: "0 5 * * 1,4"
     27 |   workflow_dispatch:
     28 | 
     29 | env:
     30 |   # avoid warnings about config paths
     31 |   JUPYTER_PLATFORM_DIRS: "1"
     32 |   # avoid looking at every version of pip ever released
     33 |   PIP_DISABLE_PIP_VERSION_CHECK: "1"
     34 | 
     35 | jobs:
     36 |   build:
     37 |     runs-on: ubuntu-22.04
     38 |     steps:
     39 |       - uses: actions/checkout@v6
     40 | 
     41 |       - uses: actions/setup-python@v6
     42 |         with:
     43 |           python-version: "3.12"
     44 | 
     45 |       - uses: actions/setup-node@v6
     46 |         with:
     47 |           cache: yarn
     48 |           node-version: "lts/*"
     49 |           registry-url: https://registry.npmjs.org
     50 |           cache-dependency-path: labextension/yarn.lock
     51 | 
     52 |       - name: Update root build packages
     53 |         run: pip install --upgrade build
     54 | 
     55 |       - name: Build Python package
     56 |         run: pyproject-build
     57 | 
     58 |       - name: Upload built artifacts
     59 |         uses: actions/upload-artifact@v5
     60 |         with:
     61 |           name: dist-${{ github.run_attempt }}
     62 |           path: ./dist
     63 | 
     64 |   test:
     65 |     name: ${{ matrix.os }} ${{ matrix.python-version }} ${{ matrix.pip-extras }} ${{ (matrix.pip-install-constraints != '' && '(oldest deps)') || '' }}
     66 |     needs: [build]
     67 |     timeout-minutes: 30
     68 |     runs-on: ${{ matrix.os }}
     69 |     defaults:
     70 |       run:
     71 |         shell: bash --noprofile --norc -eo pipefail {0} # windows default isn't bash
     72 | 
     73 |     strategy:
     74 |       fail-fast: false
     75 |       matrix:
     76 |         os: [ubuntu-22.04, windows-2022]
     77 |         python-version: ["3.8", "3.12"]
     78 |         pip-extras: [lab, classic]
     79 |         pip-install-constraints: [""]
     80 |         exclude:
     81 |           # windows should work for all test variations, but a limited selection
     82 |           # is run to avoid doubling the amount of test runs
     83 |           - os: windows-2022
     84 |             python-version: "3.12"
     85 |             pip-extras: classic
     86 |           - os: windows-2022
     87 |             python-version: "3.8"
     88 |             pip-extras: lab
     89 | 
     90 |           # pip-extras classic (notebook v6) isn't working with python 3.12 or
     91 |           # later, so we exclude it here and then include it below to run with
     92 |           # python 3.11 instead.
     93 |           - os: ubuntu-22.04
     94 |             python-version: "3.12"
     95 |             pip-extras: classic
     96 |         include:
     97 |           # Compensates for an excluded test case above
     98 |           - os: ubuntu-22.04
     99 |             python-version: "3.11"
    100 |             pip-extras: classic
    101 | 
    102 |           # this test is manually updated to reflect the lower bounds of
    103 |           # versions from dependencies
    104 |           - os: ubuntu-22.04
    105 |             python-version: "3.8"
    106 |             pip-extras: classic
    107 |             pip-install-constraints: >-
    108 |               jupyter-server==1.24.0
    109 |               simpervisor==1.0.0
    110 |               tornado==6.1.0
    111 |               traitlets==5.1.0
    112 | 
    113 |     steps:
    114 |       - uses: actions/checkout@v6
    115 | 
    116 |       - uses: actions/setup-python@v6
    117 |         with:
    118 |           python-version: "${{ matrix.python-version }}"
    119 | 
    120 |       - name: Update root build packages
    121 |         run: python -m pip install --upgrade pip
    122 | 
    123 |       - name: Download built artifacts
    124 |         uses: actions/download-artifact@v6
    125 |         with:
    126 |           name: dist-${{ github.run_attempt }}
    127 |           path: ./dist
    128 | 
    129 |       - name: Install Python package
    130 |         # NOTE: See CONTRIBUTING.md for a local development setup that differs
    131 |         #       slightly from this.
    132 |         #
    133 |         #       Pytest options are set in `pyproject.toml`.
    134 |         run: |
    135 |           pip install -vv $(ls ./dist/*.whl)\[acceptance,${{ matrix.pip-extras }}\] ${{ matrix.pip-install-constraints }}
    136 | 
    137 |       - name: List Python packages
    138 |         run: |
    139 |           pip freeze
    140 |           pip check
    141 | 
    142 |       - name: Check server extension for jupyter_server
    143 |         run: |
    144 |           jupyter server extension list
    145 |           jupyter server extension list 2>&1 | grep -iE "jupyter_server_proxy.*OK" -
    146 | 
    147 |       - name: Check server extension for notebook v6
    148 |         if: contains(matrix.pip-extras, 'classic')
    149 |         run: |
    150 |           jupyter serverextension list
    151 |           jupyter serverextension list 2>&1 | grep -iE "jupyter_server_proxy.*OK" -
    152 | 
    153 |       - name: Check frontend extension for notebook v6
    154 |         if: contains(matrix.pip-extras, 'classic')
    155 |         run: |
    156 |           jupyter nbextension list
    157 |           PYTHONUNBUFFERED=1 jupyter nbextension list 2>&1 | grep -A1 -iE '.*jupyter_server_proxy.*enabled' | grep -B1 -iE "Validating.*OK"
    158 | 
    159 |       - name: Check frontend extension for notebook v7+
    160 |         if: ${{ !contains(matrix.pip-extras, 'classic') }}
    161 |         run: |
    162 |           jupyter notebook extension list
    163 |           jupyter notebook extension list 2>&1 | grep -iE 'jupyter_server_proxy.*OK.*'
    164 | 
    165 |       - name: Check frontend extension for jupyterlab
    166 |         run: |
    167 |           jupyter lab extension list
    168 |           jupyter lab extension list 2>&1 | grep -iE 'jupyter_server_proxy.*OK.*'
    169 | 
    170 |       # we have installed a pre-built wheel and configured code coverage to
    171 |       # inspect "jupyter_server_proxy", by re-locating to another directory,
    172 |       # there is no confusion about "jupyter_server_proxy" referring to our
    173 |       # installed package rather than the local directory
    174 |       - name: Run tests
    175 |         run: |
    176 |           mkdir build
    177 |           cd build
    178 |           pytest -c ../pyproject.toml ../tests
    179 | 
    180 |       - name: Upload test reports
    181 |         if: always()
    182 |         uses: actions/upload-artifact@v5
    183 |         with:
    184 |           name: |-
    185 |             tests-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.pip-extras }}-${{ (matrix.pip-install-constraints != '' && 'oldest-') || '' }}${{ github.run_attempt }}
    186 |           path: |
    187 |             ./build/pytest
    188 |             ./build/coverage
    189 |             ./build/robot
    190 | 
    191 |       # GitHub action reference: https://github.com/codecov/codecov-action
    192 |       - uses: codecov/codecov-action@v5
    193 | 
    
    
    --------------------------------------------------------------------------------
    /CONTRIBUTING.md:
    --------------------------------------------------------------------------------
      1 | # Contributing
      2 | 
      3 | Welcome! As a [Jupyter](https://jupyter.org) project, we follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html).
      4 | 
      5 | To setup a local development environment and run tests, see the small section in
      6 | the README.md file.
      7 | 
      8 | ## Local development setup
      9 | 
     10 | ### `conda`
     11 | 
     12 | 
    13 | 14 | 15 | 16 | Optional, but especially recommended on non-Linux platforms... 17 | 18 | 19 | 20 | Using the `conda` (or `mamba` or `micromamba`) package manager with packages from 21 | [`conda-forge`](https://conda-forge.org/feedstock-outputs) can help isolate development 22 | environments on nearly any operating system and architecture. 23 | 24 | For example, after installing [`mambaforge`](https://conda-forge.org/miniforge), 25 | create a new environment with all heavy development and test dependencies: 26 | 27 | ```yaml 28 | mamba create --name=jupyter-server-proxy --channel=conda-forge "python=3.12" "nodejs=20" pip git geckodriver firefox 29 | mamba activate jupyter-server-proxy 30 | ``` 31 | 32 |
    33 | 34 | ### Python package 35 | 36 | ```bash 37 | # Clone the repo to your local environment 38 | git clone https://github.com/jupyterhub/jupyter-server-proxy.git 39 | # Change directory to the jupyter-server-proxy directory 40 | cd jupyter-server-proxy 41 | # Install package in development mode, with the latest Jupyter clients 42 | pip install -e ".[test,lab]" 43 | # Link your development version of the extension with JupyterLab and Notebook 44 | jupyter labextension develop --overwrite . 45 | # Server extension must be manually installed in develop mode 46 | jupyter server extension enable jupyter_server_proxy 47 | ``` 48 | 49 | ## Testing 50 | 51 | Run the tests: 52 | 53 | ```bash 54 | pytest 55 | ``` 56 | 57 | These generate test and coverage reports in `build/pytest` and `build/coverage`. 58 | 59 | ### Acceptance tests 60 | 61 | In `tests/acceptance`, a number of 62 | [`.robot` files](https://robotframework.org/robotframework/latest/RobotFrameworkUserGuide.html) 63 | emulate a user starting a Jupyter server, opening a real browser, clicking on 64 | screen elements, and seeing several working proxy servers in supported Jupyter clients. 65 | 66 | These tests are slower and more resource intensive than the unit and integration 67 | tests, and generate additional screenshots, browser logs, server logs, and report 68 | HTML in `build/robot`. 69 | 70 | #### Extra browser dependencies 71 | 72 | Compatible versions of [`geckodriver`](https://github.com/mozilla/geckodriver) 73 | and [`firefox`](https://www.mozilla.org/en-US/firefox) need to be on `$PATH`. 74 | 75 | These can be provisioned by [a `conda`-compatible installer](#conda), a system 76 | package manager, or as a last resort, direct binary downloads. 77 | 78 | #### Acceptance test dependencies 79 | 80 | To install the additional dependencies beyond the [Python package](#python-package) 81 | test dependencies, and run the tests against the latest Jupyter clients: 82 | 83 | ```bash 84 | pip install -e ".[test,acceptance,lab]" 85 | pytest 86 | ``` 87 | 88 | To run _only_ the acceptance tests, use the `-k` switch: 89 | 90 | ```bash 91 | pytest -k acceptance 92 | ``` 93 | 94 | #### Older Jupyter Clients 95 | 96 | To run the acceptance tests against the previous major versions of Notebook 97 | and JupyterLab, it is advisable to use a separate, isolated environment, testing the 98 | as-built assets from `pyproject-build`. 99 | 100 | After creating and activating such an environment with `virtualenv` or [`conda`](#conda): 101 | 102 | ```bash 103 | pip install --find-links ./dist/ --no-index-url jupyter-server-proxy[test,acceptance,classic] 104 | ``` 105 | 106 | ## Frontend Development 107 | 108 | To support a wide range of clients, both JupyterLab and Notebook Classic extensions 109 | are built and distributed, each with their own quirks. 110 | 111 | ### JupyterLab/Notebook extension 112 | 113 | The `./labextension/` directory contains the extension for the 114 | [`lumino`](https://github.com/jupyterlab/lumino/)-based JupyterLab and Notebook 115 | clients. 116 | 117 | #### `nodejs` 118 | 119 | Building this extension requires a compatible version of 120 | [`nodejs`](https://nodejs.org/en/download/package-manager), with a supported, long 121 | term support (LTS) release recommended. 122 | 123 | #### `jlpm` 124 | 125 | The `jlpm` command is a vendored, pinned version of the [`yarn`](https://yarnpkg.com) 126 | package manager. Installed with JupyterLab, it performs commands such 127 | as installing `npm` dependencies listed in `labextension/package.json`, building 128 | and watching the extension from source, and formatting web-related source code files. 129 | 130 | #### The built Lab extension 131 | 132 | During a [`pyproject-build`](https://pypi.org/project/build/) 133 | of the python package, a temporary JupyterLab and `jlpm` will be installed as part 134 | of the `build-system`, executing roughly the commands: 135 | 136 | ```bash 137 | cd labextension # Change to the root of the labextension 138 | jlpm # Install dependencies 139 | jlpm build:prod # Build: 140 | # - `labextension/lib` with type checking 141 | # - `jupyter_server_proxy/labextension` with minimization 142 | ``` 143 | 144 | During `pip install`, the built assets are copied to the user's 145 | `{sys.prefix}/share/jupyter/labextensions/@jupyterhub/jupyter-server-proxy` to be 146 | found by the application at startup. 147 | 148 | #### Developing the Lab extension 149 | 150 | For fine-grained access to the `jlpm` command and various build steps: 151 | 152 | ```bash 153 | pip install -e .[lab] # Ensure a compatible jlpm 154 | cd labextension # Change to the root of the labextension 155 | jlpm 156 | jlpm install:extension # Symlink into `{sys.prefix}/share/jupyter/labextensions` 157 | ``` 158 | 159 | Watch the source directory and automatically rebuild the `labextension/lib` 160 | and `jupyter_server_proxy/labextension` folders: 161 | 162 | ```bash 163 | cd labextension 164 | # Watch the source directory in one terminal, automatically rebuilding when needed 165 | jlpm watch 166 | # Run JupyterLab in another terminal 167 | jupyter lab 168 | ``` 169 | 170 | While running `jlpm watch`, every saved change to a `.ts` file will immediately be 171 | built locally and available in your running Jupyter client. "Hard" refresh JupyterLab or Notebook 172 | with CTRL-F5 or ⌘-F5 to load the change in your browser 173 | (you may need to wait several seconds for the extension to be fully rebuilt). 174 | 175 | #### Source Maps 176 | 177 | By default, the `jlpm build` and `jlpm watch` commands generate 178 | [source maps](https://firefox-source-docs.mozilla.org/devtools-user/debugger/how_to/use_a_source_map/) 179 | for this extension to improve debugging using the browser development tools, 180 | often revealed by pressing F12. 181 | 182 | To also generate source maps for the JupyterLab core application, run the following command: 183 | 184 | ```bash 185 | jupyter lab build --minimize=False 186 | ``` 187 | 188 | ### Notebook Classic extension 189 | 190 | The files in `jupyter_server_proxy/static` extend the Notebook Classic application's 191 | _Tree_ page. 192 | 193 | #### RequireJS 194 | 195 | The Notebook Classic extension uses the [`require.js`](https://requirejs.org) 196 | dependency injection system, and presently uses no dependencies beyond what is 197 | provided by Notebook Classic. 198 | 199 | #### The built Classic extension 200 | 201 | During a user's `pip install`, the static assets are copied to 202 | `{sys.prefix}/share/jupyter/nbextensions/jupyter_server_proxy`, to be 203 | found by the application at startup. 204 | 205 | #### Developing the Classic extension 206 | 207 | While this extension is served as-is once installed, for live development the 208 | extension assets must be linked: 209 | 210 | ```bash 211 | pip install -e ".[classic]" 212 | jupyter nbextension install --symlink --sys-prefix --py jupyter_server_proxy 213 | ``` 214 | 215 | After making changes, "hard" refresh the browser application, usually with 216 | CTRL-F5 or ⌘-F5. 217 | 218 | ## Documentation 219 | 220 | The documentation uses a fairly standard [Sphinx](https://www.sphinx-doc.org) 221 | build chain, and requires `make` on Linux/MacOS, which cannot be installed with 222 | `pip` 223 | 224 | > `make` is available from [`conda-forge`](#conda) as `make` for Linux/OSX, and `m2-make` 225 | > on Windows 226 | 227 | In addition to any system packages, building the documentation requires 228 | additional packages. To install the needed packages: 229 | 230 | ```bash 231 | pip install -r docs/requirements.txt 232 | ``` 233 | 234 | Once installed, enter the docs folder with: 235 | 236 | ```bash 237 | cd docs 238 | ``` 239 | 240 | ... then build the HTML site: 241 | 242 | ```bash 243 | make 244 | ``` 245 | 246 | ... or check that all hyperlinks can be resolved: 247 | 248 | ```bash 249 | make linkcheck 250 | ``` 251 | 252 | ... or start an auto-reloading server and open a web browser: 253 | 254 | ```bash 255 | make devenv 256 | ``` 257 | 258 | ## Linting 259 | 260 | During continuous integration (CI) the `pre-commit` package is used to run a 261 | number of checks, with each tool in a private virtual environment. If it is able to, 262 | the CI bot will push to a PR with fixes. 263 | 264 | By installing `pre-commit` with `pip` or `conda`, you can have this same experience, 265 | or inspect the configuration to try to recreate it yourself locally. 266 | -------------------------------------------------------------------------------- /labextension/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The main extension file, which provides plugins that can be loaded into 3 | * a Lumino application such as JupyterLab or Notebook 7. 4 | * 5 | * Outside of this file, of note is `./tokens.ts`, which provides a number of 6 | * run-time constants and compile-type interfaces for type-checking. 7 | * 8 | * Imports (mostly) adhere to the ES `import` semantics, exceptions noted below 9 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import 10 | */ 11 | 12 | // this is a type-only import for low-level components of the UI 13 | // @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html 14 | import type { 15 | // the base class for all on-screen components 16 | Widget, 17 | // the Lumino component for menu bars 18 | Menu, 19 | } from "@lumino/widgets"; 20 | 21 | import { 22 | // the dependency-injection (DI) Token required to request a dependency for... 23 | ILayoutRestorer, 24 | // any lumino-based Jupyter application... 25 | JupyterFrontEnd, 26 | // to be extended with... 27 | JupyterFrontEndPlugin, 28 | } from "@jupyterlab/application"; 29 | import { 30 | // a DI token for the toolbar, used to extend Notebook's Tree 31 | IToolbarWidgetRegistry, 32 | // the concrete class used to create iframes in the JupyterLab main area 33 | IFrame, 34 | // a wrapper for the iframe that handles house-keeping boilerplate 35 | MainAreaWidget, 36 | // a tracker which handles iframe placement housekeeping when JupyterLab is reloaded 37 | WidgetTracker, 38 | } from "@jupyterlab/apputils"; 39 | // utilities for working with URLs and the `jupyter-config-data` script 40 | import { PageConfig, URLExt } from "@jupyterlab/coreutils"; 41 | // a DI token for the file browser, present in JupyterLab and Notebook 7 42 | import { IDefaultFileBrowser } from "@jupyterlab/filebrowser"; 43 | // a DI token for the card-based Launcher, present in JupyterLab 44 | import { ILauncher } from "@jupyterlab/launcher"; 45 | // the application-wide configuration for making HTTP requests with headers, etc. 46 | import { ServerConnection } from "@jupyterlab/services"; 47 | 48 | // local imports from `tokens.ts` for immutable constants 49 | import { 50 | CommandIDs, 51 | IOpenArgs, 52 | IServerProcess, 53 | IServersInfo, 54 | NAME, 55 | NS, 56 | argSchema, 57 | sandbox, 58 | } from "./tokens"; 59 | 60 | /* 61 | * top level constants that won't change during the application lifecycle 62 | */ 63 | const baseUrl = PageConfig.getBaseUrl(); 64 | /** 65 | * The Notebook 7 sub-application: `tree`, `notebook`, `editor`, `terminal`, etc. 66 | */ 67 | const notebookPage = PageConfig.getOption("notebookPage"); 68 | /** 69 | * Whether the current application is `/tree`: otherwise we don't do anything 70 | */ 71 | const isTree = notebookPage === "tree"; 72 | /** 73 | * Whether this is a notebook app at all 74 | */ 75 | const isNotebook7 = !!notebookPage; 76 | 77 | /** 78 | * Data to register the extension with jupyterlab which also clarifies the shape of 79 | * what is required by the extension and passed to our provided activate function. 80 | * 81 | * This plugin is `export`ed at the end of the file. 82 | * 83 | * @see https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#application-plugins 84 | */ 85 | const plugin: JupyterFrontEndPlugin = { 86 | id: `${NAME}:add-launcher-entries`, 87 | autoStart: true, 88 | // to support JupyterLab and Notebook, we don't _require_ any other DI tokens... 89 | requires: [], 90 | // ... but some decisions will be made on the presence of these optional DI tokens, 91 | // which will be given to `activate` in the order listed here 92 | optional: [ 93 | ILauncher, 94 | ILayoutRestorer, 95 | IToolbarWidgetRegistry, 96 | IDefaultFileBrowser, 97 | ], 98 | activate, 99 | }; 100 | 101 | /** 102 | * The activate function is registered to be called on activation of the 103 | * JupyterLab/Notebook 7 plugin. 104 | * 105 | * @see https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html 106 | */ 107 | async function activate( 108 | app: JupyterFrontEnd, 109 | // `requires` would have show up here with concrete types ... 110 | // ... but all `optional` DI tokens _might_ be `null` 111 | launcher: ILauncher | null, 112 | restorer: ILayoutRestorer | null, 113 | toolbarRegistry: IToolbarWidgetRegistry | null, 114 | fileBrowser: IDefaultFileBrowser | null, 115 | ): Promise { 116 | // when viewing `/notebook`, `/terminal` or `/editor`, bail as early as possible 117 | if (isNotebook7 && !isTree) { 118 | return; 119 | } 120 | 121 | // server connection settings (such as headers) _can't_ be a global, as they 122 | // can potentially be configured by other extensions 123 | const serverSettings = ServerConnection.makeSettings(); 124 | 125 | // Fetch configured server processes from {base_url}/server-proxy/servers-info 126 | // TODO: consider moving this to a separate plugin 127 | // TODO: consider not blocking the application load 128 | const url = URLExt.join(baseUrl, `${NS}/servers-info`); 129 | const response = await ServerConnection.makeRequest(url, {}, serverSettings); 130 | 131 | if (!response.ok) { 132 | console.warn( 133 | "Could not fetch metadata about registered servers. Make sure jupyter-server-proxy is installed.", 134 | ); 135 | console.warn(response); 136 | return; 137 | } 138 | 139 | // load and trust the JSON as a type of data described by the `IServersInfo` interface 140 | // TODO: consider adding JSON schema-derived types 141 | const data = (await response.json()) as IServersInfo; 142 | 143 | // handle restoring persistent JupyterLab workspace widgets on page reload 144 | // this is created even in the Notebook `tree` page to reduce complexity below 145 | const tracker = new WidgetTracker>({ namespace: NS }); 146 | if (restorer) { 147 | void restorer.restore(tracker, { 148 | command: CommandIDs.open, 149 | args: (widget) => ({ 150 | url: widget.content.url, 151 | title: widget.content.title.label, 152 | newBrowserTab: false, 153 | id: widget.content.id, 154 | }), 155 | name: (widget) => widget.content.id, 156 | }); 157 | } 158 | 159 | // register commands 160 | // commands provide "loose" coupling, based on well-known strings and JSON-like 161 | // structures instead of heavy DI tokens 162 | const { commands, shell } = app; 163 | commands.addCommand(CommandIDs.open, { 164 | label: (args) => (args as IOpenArgs).title, 165 | describedBy: async () => { 166 | return { args: argSchema }; 167 | }, 168 | execute: (args) => { 169 | // the syntax below is an example of "destructuring" 170 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment 171 | const { id, title, url, newBrowserTab } = args as IOpenArgs; 172 | if (newBrowserTab) { 173 | window.open(url, "_blank"); 174 | return; 175 | } 176 | let widget = tracker.find((widget) => widget.content.id === id); 177 | if (!widget) { 178 | widget = newServerProxyWidget(id, url, title); 179 | } 180 | if (!tracker.has(widget)) { 181 | void tracker.add(widget); 182 | } 183 | if (!widget.isAttached) { 184 | shell.add(widget); 185 | } else { 186 | shell.activateById(widget.id); 187 | } 188 | return widget; 189 | }, 190 | }); 191 | 192 | // handle adding JupyterLab launcher cards 193 | // TODO: consider moving this to a separate plugin (keeping this ID) 194 | if (launcher) { 195 | for (let server_process of data.server_processes) { 196 | const { launcher_entry } = server_process; 197 | 198 | if (!launcher_entry.enabled) { 199 | continue; 200 | } 201 | 202 | launcher.add({ 203 | command: CommandIDs.open, 204 | args: argsForServer(server_process), 205 | category: launcher_entry.category, 206 | kernelIconUrl: launcher_entry.icon_url || void 0, 207 | }); 208 | } 209 | } 210 | 211 | // handle adding servers menu items to the Notebook 7 _Tree_ toolbar 212 | // TODO: consider moving this to a separate plugin 213 | if (isTree && toolbarRegistry && fileBrowser) { 214 | const { toolbar } = fileBrowser; 215 | const widgets = ((toolbar.layout || {}) as any).widgets as Widget[]; 216 | if (widgets && widgets.length) { 217 | for (const widget of widgets) { 218 | if (widget && (widget as any).menus) { 219 | // simple DOM queries can't be used, as there is no guarantee it is 220 | // attached yet 221 | const menu: Menu = (widget as any).menus[0]; 222 | menu.addItem({ type: "separator" }); 223 | for (const server_process of data.server_processes) { 224 | // create args, overriding all to launch in new heavyweight browser tabs 225 | let args = { 226 | ...argsForServer(server_process), 227 | newBrowserTab: true, 228 | }; 229 | menu.addItem({ command: CommandIDs.open, args }); 230 | } 231 | } 232 | } 233 | } 234 | } 235 | } 236 | 237 | /** 238 | * Create a new `iframe`, with a wrapper for including in the main area. 239 | */ 240 | function newServerProxyWidget( 241 | id: string, 242 | url: string, 243 | text: string, 244 | ): MainAreaWidget