├── .dockerignore ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── publish.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── NOTICE ├── README.md ├── analysis_options.yaml ├── aviary.yaml ├── bin └── webdev_proxy.dart ├── dart_test.yaml ├── lib └── src │ ├── command_runner.dart │ ├── command_utils.dart │ ├── executable.dart │ ├── logging.dart │ ├── port_utils.dart │ ├── proxy_root_index_handler.dart │ ├── serve_command.dart │ ├── sse_proxy_handler.dart │ ├── webdev_arg_utils.dart │ ├── webdev_proc_utils.dart │ ├── webdev_proxy_server.dart │ └── webdev_server.dart ├── pubspec.yaml └── test ├── chromedriver_utils.dart ├── command_utils_test.dart ├── port_utils_test.dart ├── proxy_server_test.dart ├── sse_proxy_handler_test.dart ├── util.dart ├── web ├── index.dart ├── index.dart.js └── index.html ├── webdev_arg_utils_test.dart ├── webdev_proc_utils_test.dart ├── webdev_proxy_server_test.dart └── webdev_server_test.dart /.dockerignore: -------------------------------------------------------------------------------- 1 | .dart_tool/ 2 | .git/ 3 | .packages 4 | pubspec.lock -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Workiva/fedx 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | gha: 9 | patterns: ["*"] 10 | - package-ecosystem: pub 11 | versioning-strategy: increase 12 | directory: / 13 | schedule: 14 | interval: weekly -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - 'master' 8 | tags: 9 | - '**' 10 | 11 | jobs: 12 | build: 13 | uses: Workiva/gha-dart-oss/.github/workflows/build.yaml@v0.1.7 14 | 15 | checks: 16 | uses: Workiva/gha-dart-oss/.github/workflows/checks.yaml@v0.1.7 17 | 18 | test-unit: 19 | strategy: 20 | matrix: 21 | sdk: [ 2.19.6, stable ] 22 | uses: Workiva/gha-dart-oss/.github/workflows/test-unit.yaml@v0.1.7 23 | with: 24 | sdk: ${{ matrix.sdk }} 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+' 7 | 8 | permissions: 9 | contents: write 10 | id-token: write 11 | pull-requests: write 12 | 13 | jobs: 14 | publish: 15 | uses: Workiva/gha-dart-oss/.github/workflows/publish.yaml@v0.1.7 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool 2 | .packages 3 | pubspec.lock -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.12 2 | 3 | - Update ranges of dependencies so that in Dart 3 we can resolve to analyzer 6, while still working with Dart 2.19. https://github.com/Workiva/webdev_proxy/pull/46 4 | 5 | ## 0.1.11 6 | 7 | - Update webdev installation message to use `dart pub global activate` rather 8 | than `pub global activate`. 9 | 10 | ## 0.1.10 11 | 12 | - Use Dart 2.19.6 in CI 13 | 14 | ## 0.1.9 15 | 16 | - Updated to null safety. Dart SDK minimum of 2.12. 17 | 18 | ## 0.1.8 19 | 20 | - Dependency upgrades https://github.com/Workiva/webdev_proxy/pull/26 21 | 22 | ## 0.1.7 23 | 24 | - #24 Use dart pub instead of just pub 25 | 26 | ## 0.1.6 27 | 28 | - Dependency upgrades #20 29 | 30 | ## 0.1.5 31 | 32 | - #19 Replace deprecated commands with new dart commands 33 | 34 | ## 0.1.4 35 | 36 | - #17 Fix wildcard to include branch names with slashes 37 | 38 | ## 0.1.3 39 | 40 | - Dependency upgrades 41 | 42 | ## 0.1.2 43 | 44 | - Update the minimum Dart SDK version to 2.11.0 45 | 46 | ## 0.1.1 47 | 48 | - Fix bug when using `--hostname` that would cause the build daemon to fail to 49 | start. 50 | 51 | ## 0.1.0 52 | 53 | - Initial release! 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Workiva Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | -------------------------------------------------- 16 | 17 | This webdev_proxy software is based on a number of software repositories with 18 | separate copyright notices and/or license terms. Your use of the source 19 | code for the these software repositories is subject to the terms and 20 | conditions of the following licenses: 21 | 22 | build: https://github.com/dart-lang/build 23 | Copyright ©2016 Dart project authors. All rights reserved. 24 | Licensed under the BSD 3-Clause License: https://github.com/dart-lang/build/blob/master/LICENSE 25 | 26 | webdev: https://github.com/dart-lang/webdev 27 | Copyright ©2017 Dart project authors. All rights reserved. 28 | Licensed under the BSD-3 Clause License: https://github.com/dart-lang/webdev/blob/master/webdev/LICENSE 29 | 30 | sse: https://github.com/dart-lang/sse 31 | Copyright ©2019 Dart project authors. All rights reserved. 32 | Licensed under the BSD-3 Clause License: https://github.com/dart-lang/sse/blob/master/LICENSE -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | webdev_proxy 2 | Copyright 2019 Workiva Inc. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | -------------------------------------------------- 17 | 18 | This webdev_proxy software is based on a number of software repositories with 19 | separate copyright notices and/or license terms. Your use of the source 20 | code for the these software repositories is subject to the terms and 21 | conditions of the following licenses: 22 | 23 | build: https://github.com/dart-lang/build 24 | Copyright ©2016 Dart project authors. All rights reserved. 25 | Licensed under the BSD 3-Clause License: https://github.com/dart-lang/build/blob/master/LICENSE 26 | 27 | webdev: https://github.com/dart-lang/webdev 28 | Copyright ©2017 Dart project authors. All rights reserved. 29 | Licensed under the BSD-3 Clause License: https://github.com/dart-lang/webdev/blob/master/webdev/LICENSE 30 | 31 | sse: https://github.com/dart-lang/sse 32 | Copyright ©2019 Dart project authors. All rights reserved. 33 | Licensed under the BSD-3 Clause License: https://github.com/dart-lang/sse/blob/master/LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple HTTP proxy for [webdev's serve command][webdev-serve] that adds support 2 | for rewriting certain requests, namely rewriting 404s to instead serve the root 3 | index (`/`). 4 | 5 | ## Requirements 6 | 7 | The latest release of `webdev_proxy` requires the following: 8 | 9 | - Dart SDK `2.11` or later 10 | - `webdev` globally activated at a version in the `>=1.0.1 <3.0.0` range 11 | - Be sure to follow the [installation instructions for webdev][webdev-install] 12 | 13 | ## Installation 14 | 15 | `webdev_proxy` is intended to be ["activated"][pub-global-activate] rather than 16 | installed as a package dependency. 17 | 18 | ```bash 19 | $ dart pub global activate webdev_proxy 20 | ``` 21 | 22 | Learn more about activating and using packages [here][pub-global]. 23 | 24 | ## Usage 25 | 26 | `webdev_proxy` supports one command: `serve` 27 | 28 | ### `webdev_proxy serve` 29 | 30 | ``` 31 | Run `webdev serve` (a local web development server) behind a proxy that supports HTML5 routing by rewriting not-found requests to index.html. 32 | 33 | Usage: webdev_proxy serve [-- [webdev serve arguments]] 34 | -h, --help Print this usage information. 35 | --[no-]rewrite-404s Rewrite every request that returns a 404 to /index.html 36 | (defaults to on) 37 | 38 | Run "webdev_proxy help" to see global options. 39 | 40 | You may use any of the following options supported by `webdev serve` by passing them after the `--` separator. 41 | 42 | webdev serve --help: 43 | ==================== 44 | 45 | Run a local web development server and a file system watcher that rebuilds on changes. 46 | 47 | Usage: webdev serve [arguments] [[:]]... 48 | -h, --help Print this usage information. 49 | -o, --output A directory to write the result of a build to. Or a mapping from a top-level directory in the package to the directory to write a filtered build output to. For example 50 | "web:deploy". 51 | A value of "NONE" indicates that no "--output" value should be passed to `build_runner`. 52 | (defaults to "NONE") 53 | 54 | -r, --[no-]release Build with release mode defaults for builders. 55 | --[no-]build-web-compilers If a dependency on `build_web_compilers` is required to run. 56 | (defaults to on) 57 | 58 | -v, --verbose Enables verbose logging. 59 | --auto Automatically performs an action after each build: 60 | 61 | restart: Reload modules and re-invoke main (loses current state) 62 | refresh: Performs a full page refresh. 63 | [restart, refresh] 64 | 65 | --chrome-debug-port Specify which port the Chrome debugger is listening on. If used with launch-in-chrome Chrome will be started with the debugger listening on this port. 66 | --[no-]debug Enable the launching of DevTools (Alt + D). Must use with either --launch-in-chrome or --chrome-debug-port. 67 | --hostname Specify the hostname to serve on. 68 | (defaults to "localhost") 69 | 70 | --[no-]launch-in-chrome Automatically launches your application in Chrome with the debug port open. Use chrome-debug-port to specify a specific port to attach to an already running chrome instance 71 | instead. 72 | 73 | --log-requests Enables logging for each request to the server. 74 | 75 | Run "webdev help" to see global options. 76 | ``` 77 | 78 | Note that you can configure the underlying `webdev serve` process by passing any 79 | of its supported command-line arguments after the `--` separator. 80 | 81 | #### Examples 82 | 83 | To run the default server and proxy: 84 | 85 | ```bash 86 | $ webdev_proxy serve 87 | ``` 88 | 89 | To pass arguments to `webdev serve`, e.g. to enable auto-refresh: 90 | 91 | ```bash 92 | $ webdev_proxy serve -- --auto=refresh 93 | ``` 94 | 95 | [pub-global]: https://dart.dev/tools/pub/cmd/pub-global 96 | [pub-global-activate]: https://dart.dev/tools/pub/cmd/pub-global#activating-a-package 97 | [webdev-install]: https://github.com/dart-lang/webdev#requirements 98 | [webdev-serve]: https://github.com/dart-lang/webdev/tree/master/webdev#webdev-serve 99 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:lints/recommended.yaml 2 | -------------------------------------------------------------------------------- /aviary.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | -------------------------------------------------------------------------------- /bin/webdev_proxy.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:io'; 16 | 17 | import 'package:webdev_proxy/src/executable.dart' as executable; 18 | 19 | /// Entrypoint for the `webdev_proxy` executable. 20 | void main(List args) async { 21 | exit(await executable.run(args)); 22 | } 23 | -------------------------------------------------------------------------------- /dart_test.yaml: -------------------------------------------------------------------------------- 1 | concurrency: 1 2 | reporter: expanded 3 | retry: 2 4 | timeout: 60s 5 | -------------------------------------------------------------------------------- /lib/src/command_runner.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | import 'dart:async'; 15 | import 'package:args/args.dart'; 16 | import 'package:args/command_runner.dart'; 17 | import 'package:logging/logging.dart'; 18 | 19 | import 'package:webdev_proxy/src/logging.dart'; 20 | import 'package:webdev_proxy/src/serve_command.dart'; 21 | 22 | /// Command runner for the `webdev_proxy` executable. 23 | class WebdevProxy extends CommandRunner { 24 | static const verboseFlag = 'verbose'; 25 | 26 | WebdevProxy() 27 | : super('webdev_proxy', 28 | 'A simple dart proxy for `webdev serve` (uses the `shelf_proxy` package).') { 29 | addCommand(ServeCommand()); 30 | argParser.addFlag(verboseFlag, abbr: 'v', help: 'Enable verbose output.'); 31 | } 32 | 33 | @override 34 | Future run(Iterable args) async { 35 | // The help command returns null, so return success code in that case. 36 | return (await super.run(args)) ?? 0; 37 | } 38 | 39 | @override 40 | Future runCommand(ArgResults topLevelResults) async { 41 | final verbose = topLevelResults[verboseFlag] == true; 42 | Logger.root.level = verbose ? Level.ALL : Level.INFO; 43 | Logger.root.onRecord.listen(stdIOLogListener(verbose: verbose)); 44 | return super.runCommand(topLevelResults); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/command_utils.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'package:args/args.dart'; 16 | 17 | /// Calls [usageException] if any positional args are found in [argResults] 18 | /// before the `--` separator, as they are disallowed. 19 | void assertNoPositionalArgsBeforeSeparator( 20 | String command, 21 | ArgResults argResults, 22 | void Function(String msg) usageException, 23 | ) { 24 | if (argResults.rest.isEmpty) { 25 | return; 26 | } 27 | 28 | void throwUsageException() { 29 | usageException( 30 | 'webdev_proxy $command does not support positional args before the `--`' 31 | 'separator, which should separate proxy args from `webdev` args.', 32 | ); 33 | } 34 | 35 | final separatorPos = argResults.arguments.indexOf('--'); 36 | if (separatorPos < 0) { 37 | throwUsageException(); 38 | return; 39 | } 40 | final expectedRest = argResults.arguments.skip(separatorPos + 1).toList(); 41 | if (argResults.rest.length != expectedRest.length) { 42 | throwUsageException(); 43 | return; 44 | } 45 | for (var i = 0; i < argResults.rest.length; i++) { 46 | if (expectedRest[i] != argResults.rest[i]) { 47 | throwUsageException(); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/src/executable.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:async'; 16 | 17 | import 'package:args/command_runner.dart'; 18 | import 'package:io/ansi.dart'; 19 | import 'package:io/io.dart' show ExitCode; 20 | 21 | import 'package:webdev_proxy/src/command_runner.dart'; 22 | 23 | /// Runs the [WebdevProxy] command-runner and returns its exit code. 24 | /// 25 | /// Also catches and prints [UsageException]s. 26 | Future run(List args) async { 27 | try { 28 | // Explicitly `await` here so we can catch any usage exceptions. 29 | return await WebdevProxy().run(args); 30 | } on UsageException catch (e) { 31 | print(red.wrap(e.message)); 32 | print(''); 33 | print(e.usage); 34 | return ExitCode.usage.code; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/logging.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // The logging utility in this file was originally modeled after: 16 | // https://github.com/dart-lang/build/blob/0e79b63c6387adbb7e7f4c4f88d572b1242d24df/build_runner/lib/src/logging/std_io_logging.dart 17 | 18 | import 'dart:async'; 19 | import 'dart:convert' as convert; 20 | import 'dart:io' as io; 21 | 22 | import 'package:io/ansi.dart'; 23 | import 'package:logging/logging.dart'; 24 | import 'package:stack_trace/stack_trace.dart'; 25 | 26 | // Ensures this message does not get overwritten by later logs. 27 | const _logSuffix = '\n'; 28 | 29 | final Logger log = Logger('Proxy'); 30 | 31 | StringBuffer colorLog(LogRecord record, {bool verbose = false}) { 32 | AnsiCode color; 33 | if (record.level < Level.WARNING) { 34 | color = cyan; 35 | } else if (record.level < Level.SEVERE) { 36 | color = yellow; 37 | } else { 38 | color = red; 39 | } 40 | final level = color.wrap('[${record.level}]'); 41 | final eraseLine = ansiOutputEnabled && !verbose ? '\x1b[2K\r' : ''; 42 | final lines = [ 43 | '$eraseLine$level ${_loggerName(record, verbose)}${record.message}' 44 | ]; 45 | var error = record.error; 46 | if (error != null) { 47 | lines.add(error); 48 | } 49 | 50 | if (record.stackTrace != null && verbose) { 51 | final trace = Trace.from(record.stackTrace!).terse; 52 | lines.add(trace); 53 | } 54 | 55 | final message = StringBuffer(lines.join('\n')); 56 | 57 | // We always add an extra newline at the end of each message, so it 58 | // isn't multiline unless we see > 2 lines. 59 | final multiLine = convert.LineSplitter.split(message.toString()).length > 2; 60 | 61 | if (record.level > Level.INFO || !ansiOutputEnabled || multiLine || verbose) { 62 | if (!lines.last.toString().endsWith('\n')) { 63 | // Add a newline to the output so the last line isn't written over. 64 | message.writeln(''); 65 | } 66 | } 67 | return message; 68 | } 69 | 70 | /// Returns a human readable string for a duration. 71 | /// 72 | /// Handles durations that span up to hours - this will not be a good fit for 73 | /// durations that are longer than days. 74 | /// 75 | /// Always attempts 2 'levels' of precision. Will show hours/minutes, 76 | /// minutes/seconds, seconds/tenths of a second, or milliseconds depending on 77 | /// the largest level that needs to be displayed. 78 | String humanReadable(Duration duration) { 79 | if (duration < const Duration(seconds: 1)) { 80 | return '${duration.inMilliseconds}ms'; 81 | } 82 | if (duration < const Duration(minutes: 1)) { 83 | return '${(duration.inMilliseconds / 1000.0).toStringAsFixed(1)}s'; 84 | } 85 | if (duration < const Duration(hours: 1)) { 86 | final minutes = duration.inMinutes; 87 | final remaining = duration - Duration(minutes: minutes); 88 | return '${minutes}m ${remaining.inSeconds}s'; 89 | } 90 | final hours = duration.inHours; 91 | final remaining = duration - Duration(hours: hours); 92 | return '${hours}h ${remaining.inMinutes}m'; 93 | } 94 | 95 | /// Logs an asynchronous [action] with [description] before and after. 96 | /// 97 | /// Returns a future that completes after the action and logging finishes. 98 | Future logTimedAsync( 99 | Logger logger, 100 | String description, 101 | Future Function() action, { 102 | Level level = Level.INFO, 103 | }) async { 104 | final watch = Stopwatch()..start(); 105 | logger.log(level, '$description...'); 106 | final result = await action(); 107 | watch.stop(); 108 | final time = '${humanReadable(watch.elapsed)}$_logSuffix'; 109 | logger.log(level, '$description completed, took $time'); 110 | return result; 111 | } 112 | 113 | /// Logs a synchronous [action] with [description] before and after. 114 | /// 115 | /// Returns a future that completes after the action and logging finishes. 116 | T logTimedSync( 117 | Logger logger, 118 | String description, 119 | T Function() action, { 120 | Level level = Level.INFO, 121 | }) { 122 | final watch = Stopwatch()..start(); 123 | logger.log(level, '$description...'); 124 | final result = action(); 125 | watch.stop(); 126 | final time = '${humanReadable(watch.elapsed)}$_logSuffix'; 127 | logger.log(level, '$description completed, took $time'); 128 | return result; 129 | } 130 | 131 | Function(LogRecord) stdIOLogListener({bool verbose = false}) => 132 | (record) => io.stdout.write(colorLog(record, verbose: verbose)); 133 | 134 | String _loggerName(LogRecord record, bool verbose) { 135 | final knownNames = const [ 136 | 'Proxy', 137 | ]; 138 | final maybeSplit = record.level >= Level.WARNING ? '\n' : ''; 139 | return verbose || !knownNames.contains(record.loggerName) 140 | ? '${record.loggerName}:$maybeSplit' 141 | : ''; 142 | } 143 | -------------------------------------------------------------------------------- /lib/src/port_utils.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:io'; 16 | 17 | /// Returns a port that is probably, but not definitely, not in use. 18 | /// 19 | /// This has a built-in race condition: another process may bind this port at 20 | /// any time after this call has returned. 21 | Future findUnusedPort() async { 22 | int port; 23 | ServerSocket socket; 24 | try { 25 | socket = 26 | await ServerSocket.bind(InternetAddress.loopbackIPv6, 0, v6Only: true); 27 | } on SocketException { 28 | socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); 29 | } 30 | port = socket.port; 31 | await socket.close(); 32 | return port; 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/proxy_root_index_handler.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'package:shelf/shelf.dart' as shelf; 16 | import 'package:shelf_proxy/shelf_proxy.dart' as shelf_proxy; 17 | 18 | /// Returns a handler that always fetches the root index path via the given 19 | /// [proxyHandler], which is expected to be a handler returned from the 20 | /// `shelf_proxy` package's [shelf_proxy.proxyHandler] function. 21 | /// 22 | /// Each request given to the returned handler will be used to create a copy 23 | /// that is identical except that its URI's path is `/`. 24 | shelf.Handler proxyRootIndexHandler(shelf.Handler proxyHandler) { 25 | return (shelf.Request req) { 26 | final indexRequest = shelf.Request( 27 | 'GET', req.requestedUri.replace(path: '/'), 28 | context: req.context, 29 | encoding: req.encoding, 30 | headers: req.headers, 31 | protocolVersion: req.protocolVersion); 32 | return proxyHandler(indexRequest); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /lib/src/serve_command.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:async'; 16 | import 'dart:io'; 17 | 18 | import 'package:args/command_runner.dart'; 19 | import 'package:io/ansi.dart'; 20 | import 'package:io/io.dart'; 21 | 22 | import 'package:webdev_proxy/src/command_runner.dart'; 23 | import 'package:webdev_proxy/src/command_utils.dart'; 24 | import 'package:webdev_proxy/src/logging.dart'; 25 | import 'package:webdev_proxy/src/port_utils.dart'; 26 | import 'package:webdev_proxy/src/webdev_arg_utils.dart'; 27 | import 'package:webdev_proxy/src/webdev_proc_utils.dart'; 28 | import 'package:webdev_proxy/src/webdev_proxy_server.dart'; 29 | import 'package:webdev_proxy/src/webdev_server.dart'; 30 | 31 | /// The `serve` command for the [WebdevProxy] command-runner. 32 | /// 33 | /// Runs `webdev serve` behind a simple HTTP proxy. 34 | class ServeCommand extends Command { 35 | static const rewrite404sFlag = 'rewrite-404s'; 36 | 37 | ServeCommand() { 38 | argParser.addFlag(rewrite404sFlag, 39 | defaultsTo: true, 40 | help: 'Rewrite every request that returns a 404 to /index.html'); 41 | } 42 | 43 | @override 44 | String get description => 45 | 'Run `webdev serve` (a local web development server) behind a proxy that ' 46 | 'supports HTML5 routing by rewriting not-found requests to index.html.'; 47 | 48 | @override 49 | String get invocation => 50 | '${super.invocation.replaceFirst(' [arguments]', '')} ' 51 | '[-- [webdev serve arguments]]'; 52 | 53 | @override 54 | String? get usageFooter => _webdevCompatibilityHelp; 55 | 56 | @override 57 | String get name => 'serve'; 58 | 59 | bool get _canUseWebdev => !_missingWebdev && _hasCompatibleWebdev; 60 | 61 | bool get _hasCompatibleWebdev => 62 | webdevCompatibility.allows(getGlobalWebdevVersion()!); 63 | 64 | bool get _missingWebdev => getGlobalWebdevVersion() == null; 65 | 66 | @override 67 | void printUsage() { 68 | super.printUsage(); 69 | 70 | // We want to append the `webdev help serve` output to our own usage output 71 | // since we allow the passing of `webdev serve` args after the `--` 72 | // separator. However, we do it here instead of in [usageFooter] so that we 73 | // can run the process with [ProcessStartMode.inheritStdio], resulting in a 74 | // better output since it is then able to detect the tty. 75 | if (_hasCompatibleWebdev) { 76 | printWebdevServeHelp(); 77 | } 78 | } 79 | 80 | String? get _webdevCompatibilityHelp { 81 | if (_missingWebdev) { 82 | return red.wrap( 83 | 'This command requires that `webdev` be activated globally.\n' 84 | 'Please run the following:\n' 85 | '\tdart pub global activate webdev "$webdevCompatibility"', 86 | ); 87 | } else if (!_hasCompatibleWebdev) { 88 | return red.wrap( 89 | 'This command is only compatible with `webdev $webdevCompatibility`, ' 90 | 'but you currently have webdev ${getGlobalWebdevVersion()} active.\n' 91 | 'Please run the following to activate a compatible version:\n' 92 | '\tdart pub global activate webdev "$webdevCompatibility"', 93 | ); 94 | } 95 | return null; 96 | } 97 | 98 | @override 99 | Future run() async { 100 | if (!_canUseWebdev) { 101 | print(_webdevCompatibilityHelp); 102 | return ExitCode.usage.code; 103 | } 104 | 105 | // This command doesn't allow specifying any arguments at the webdev_proxy 106 | // level. Instead arguments should be passed to the `webdev` process by 107 | // passing them after the `--` separator. 108 | // 109 | // To enforce this, we validate that [argResults.rest] is exactly equal to 110 | // all the arguments after the `--`. 111 | assertNoPositionalArgsBeforeSeparator(name, argResults!, usageException); 112 | 113 | final exitCodeCompleter = Completer(); 114 | var interruptReceived = false; 115 | final proxies = []; 116 | var proxiesFailed = false; 117 | StreamSubscription? sigintSub; 118 | WebdevServer? webdevServer; 119 | 120 | void shutDown(int code) async { 121 | await Future.wait([ 122 | if (sigintSub != null) sigintSub.cancel(), 123 | if (webdevServer != null) webdevServer.close(), 124 | ...proxies.map((proxy) => proxy.close()), 125 | ]); 126 | if (!exitCodeCompleter.isCompleted) { 127 | exitCodeCompleter.complete(code); 128 | } 129 | } 130 | 131 | // Shutdown everything and exit on user interrupt. 132 | sigintSub = ProcessSignal.sigint.watch().listen((_) { 133 | interruptReceived = true; 134 | log.info('Interrupt received, exiting.\n'); 135 | shutDown(ExitCode.success.code); 136 | }); 137 | 138 | // Parse the hostname to serve each dir on (defaults to localhost). 139 | final hostnameResults = parseHostname(argResults!.rest); 140 | final hostname = hostnameResults.hostname; 141 | final remainingArgs = hostnameResults.remainingArgs; 142 | 143 | // Parse the directory:port mappings that will be used by the proxy servers. 144 | // Each proxy will be mapped to a `webdev serve` instance on another port. 145 | final portsToServeByDir = parseDirectoryArgs(argResults!.rest); 146 | 147 | // Find open ports for each of the directories to be served by webdev. 148 | final portsToProxyByDir = { 149 | for (final dir in portsToServeByDir.keys) dir: await findUnusedPort() 150 | }; 151 | 152 | // Start the underlying `webdev serve` process. 153 | webdevServer = await WebdevServer.start([ 154 | if (hostname != 'localhost') '--hostname=$hostname', 155 | ...remainingArgs, 156 | for (final dir in portsToServeByDir.keys) 157 | '$dir:${portsToProxyByDir[dir]}', 158 | ]); 159 | 160 | // Stop proxies and exit if webdev exits. 161 | unawaited(webdevServer.exitCode.then((code) { 162 | if (!interruptReceived && !proxiesFailed) { 163 | log.info('Terminating proxy because webdev serve exited.\n'); 164 | shutDown(code); 165 | } 166 | })); 167 | 168 | // Start a proxy server for each directory. 169 | for (final dir in portsToServeByDir.keys) { 170 | try { 171 | proxies.add(await WebdevProxyServer.start( 172 | dir: dir, 173 | hostname: hostname, 174 | portToProxy: portsToProxyByDir[dir], 175 | portToServe: portsToServeByDir[dir]!, 176 | rewrite404s: argResults![rewrite404sFlag] == true, 177 | )); 178 | } catch (e, stackTrace) { 179 | proxiesFailed = true; 180 | log.severe( 181 | 'Failed to start proxy server on port ${portsToServeByDir[dir]}', 182 | e, 183 | stackTrace); 184 | shutDown(ExitCode.unavailable.code); 185 | break; 186 | } 187 | } 188 | 189 | return exitCodeCompleter.future; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /lib/src/sse_proxy_handler.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:async'; 16 | import 'dart:convert'; 17 | 18 | import 'package:http/http.dart' as http; 19 | import 'package:shelf/shelf.dart' as shelf; 20 | import 'package:shelf_proxy/shelf_proxy.dart' as shelf_proxy; 21 | 22 | String _sseHeaders(String origin) => 'HTTP/1.1 200 OK\r\n' 23 | 'Content-Type: text/event-stream\r\n' 24 | 'Cache-Control: no-cache\r\n' 25 | 'Connection: keep-alive\r\n' 26 | 'Access-Control-Allow-Credentials: true\r\n' 27 | 'Access-Control-Allow-Origin: $origin\r\n' 28 | '\r\n'; 29 | 30 | /// [SseProxyHandler] proxies two-way communications of JSON encodable data 31 | /// between clients and an [SseHandler]. 32 | /// 33 | /// This handler provides the same communication interface as [SseHandler], but 34 | /// simply forwards data back and forth between clients and the actual server. 35 | class SseProxyHandler { 36 | final _httpClient = http.Client(); 37 | late final shelf.Handler _incomingMessageProxyHandler = 38 | shelf_proxy.proxyHandler( 39 | _serverUri, 40 | client: _httpClient, 41 | proxyName: _proxyName, 42 | ); 43 | final String? _proxyName; 44 | final Uri _proxyUri; 45 | final Uri _serverUri; 46 | 47 | /// Creates an SSE proxy handler that will handle EventSource requests to 48 | /// [proxyUri] by proxying them to [serverUri]. 49 | SseProxyHandler(Uri proxyUri, Uri serverUri, {String? proxyName}) 50 | : _proxyUri = proxyUri, 51 | _serverUri = serverUri, 52 | _proxyName = proxyName; 53 | 54 | shelf.Handler get handler => _handle; 55 | 56 | Future _createSseConnection( 57 | shelf.Request req, String path) async { 58 | final serverReq = http.StreamedRequest( 59 | req.method, _serverUri.replace(query: req.requestedUri.query)) 60 | ..followRedirects = false 61 | ..headers.addAll(req.headers) 62 | ..headers['Host'] = _serverUri.authority 63 | ..sink.close(); 64 | 65 | final serverResponse = await _httpClient.send(serverReq); 66 | 67 | req.hijack((channel) { 68 | final sink = utf8.encoder.startChunkedConversion(channel.sink) 69 | ..add(_sseHeaders(req.headers['origin'] ?? '')); 70 | 71 | StreamSubscription serverSseSub; 72 | StreamSubscription? reqChannelSub; 73 | 74 | serverSseSub = 75 | utf8.decoder.bind(serverResponse.stream).listen(sink.add, onDone: () { 76 | reqChannelSub?.cancel(); 77 | sink.close(); 78 | }); 79 | 80 | reqChannelSub = channel.stream.listen((_) { 81 | // SSE is unidirectional. 82 | }, onDone: () { 83 | serverSseSub.cancel(); 84 | sink.close(); 85 | }); 86 | }); 87 | } 88 | 89 | Future _handle(shelf.Request req) async { 90 | final path = req.requestedUri.path; 91 | if (path != _proxyUri.path) { 92 | return shelf.Response.notFound(''); 93 | } 94 | 95 | if (req.headers['accept'] == 'text/event-stream' && req.method == 'GET') { 96 | return _createSseConnection(req, path); 97 | } 98 | 99 | if (req.headers['accept'] != 'text/event-stream' && req.method == 'POST') { 100 | return _handleIncomingMessage(req); 101 | } 102 | 103 | return shelf.Response.notFound(''); 104 | } 105 | 106 | Future _handleIncomingMessage(shelf.Request req) async { 107 | return _incomingMessageProxyHandler(req); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/src/webdev_arg_utils.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | final _defaultWebDirs = const ['web']; 16 | final _dirPattern = RegExp( 17 | // Matches and captures any directory path, e.g. `web` or `test/nested/dir/` 18 | r'^([\w/]+)' 19 | // Optional non-capturing group since webdev allows for the port to be omitted 20 | r'(?:' 21 | // Matches and captures any port, e.g. `:8080` or `:9001` 22 | r':(\d+)' 23 | // Ends the optional non-capturing group 24 | r')?$'); 25 | 26 | /// Returns a mapping of directories to ports parsed from command-line [args] in 27 | /// the form of `:`. 28 | /// 29 | /// If no mappings are specified in [args], the default mapping of web:8080 is 30 | /// returned. 31 | Map parseDirectoryArgs(List args) { 32 | final result = {}; 33 | var basePort = 8080; 34 | final dirArgs = args.where((_dirPattern.hasMatch)); 35 | if (dirArgs.isEmpty) { 36 | for (final dir in _defaultWebDirs) { 37 | result[dir] = basePort++; 38 | } 39 | } else { 40 | for (final arg in dirArgs) { 41 | final splitOption = arg.split(':'); 42 | if (splitOption.length == 2) { 43 | result[splitOption.first] = int.parse(splitOption.last); 44 | } else { 45 | result[arg] = basePort++; 46 | } 47 | } 48 | } 49 | return result; 50 | } 51 | 52 | /// Returns the value of the `--hostname` option from a list of command-line 53 | /// [args] only if it is specified. 54 | /// 55 | /// Otherwise, returns a default of `'localhost'`. 56 | ParseHostnameResults parseHostname(List args) { 57 | var hostname = 'localhost'; 58 | final remainingArgs = []; 59 | var skipNext = false; 60 | for (var i = 0; i < args.length; i++) { 61 | if (skipNext) { 62 | skipNext = false; 63 | continue; 64 | } else if (!args[i].startsWith('--hostname')) { 65 | remainingArgs.add(args[i]); 66 | } else if (args[i].contains('=')) { 67 | // --hostname= 68 | hostname = args[i].split('=')[1]; 69 | } else if (i + 1 < args.length && !args[i + 1].startsWith('-')) { 70 | // --hostname 71 | hostname = args[i + 1]; 72 | skipNext = true; 73 | } 74 | } 75 | return ParseHostnameResults(hostname, remainingArgs); 76 | 77 | // TODO: Use when webdev `--hostname=any` support is released 78 | // HttpMultiServer supports `any` as a more flexible localhost. 79 | // return 'any'; 80 | } 81 | 82 | class ParseHostnameResults { 83 | final String hostname; 84 | final List remainingArgs; 85 | ParseHostnameResults(this.hostname, this.remainingArgs); 86 | } 87 | -------------------------------------------------------------------------------- /lib/src/webdev_proc_utils.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:convert'; 16 | import 'dart:io'; 17 | 18 | import 'package:io/ansi.dart'; 19 | import 'package:meta/meta.dart'; 20 | import 'package:pub_semver/pub_semver.dart'; 21 | 22 | /// The range of `webdev` versions with which this `webdev_proxy` package is 23 | /// compatible. 24 | final webdevCompatibility = VersionConstraint.parse('>=1.0.1 <4.0.0'); 25 | 26 | @visibleForTesting 27 | ProcessResult? cachedWebdevVersionResult; 28 | 29 | /// Returns the version of the `webdev` package that is currently globally 30 | /// activated, or `null` if it is not activated. 31 | Version? getGlobalWebdevVersion() { 32 | cachedWebdevVersionResult ??= Process.runSync( 33 | 'dart', 34 | ['pub', 'global', 'run', 'webdev', '--version'], 35 | stdoutEncoding: utf8, 36 | ); 37 | if (cachedWebdevVersionResult!.exitCode != 0) { 38 | return null; 39 | } 40 | return Version.parse(cachedWebdevVersionResult!.stdout.toString().trim()); 41 | } 42 | 43 | /// Prints the output from `webdev help serve` with a header that explains how 44 | /// it applies in the context of the `webdev_proxy serve` executable. 45 | /// 46 | /// Returns the exit code from running `webdev help serve`. 47 | Future printWebdevServeHelp() async { 48 | print( 49 | yellow.wrap('\n' 50 | 'You may use any of the following options supported by `webdev serve` by ' 51 | 'passing them after the `--` separator.\n\n' 52 | 'webdev serve --help:\n' 53 | '====================\n'), 54 | ); 55 | final process = await Process.start( 56 | 'dart', 57 | ['pub', 'global', 'run', 'webdev', 'help', 'serve'], 58 | mode: ProcessStartMode.inheritStdio, 59 | ); 60 | return process.exitCode; 61 | } 62 | -------------------------------------------------------------------------------- /lib/src/webdev_proxy_server.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:io'; 16 | 17 | import 'package:http_multi_server/http_multi_server.dart'; 18 | import 'package:io/ansi.dart'; 19 | import 'package:shelf/shelf.dart' as shelf; 20 | import 'package:shelf/shelf_io.dart' as shelf_io; 21 | import 'package:shelf_proxy/shelf_proxy.dart' as shelf_proxy; 22 | import 'package:webdev_proxy/src/proxy_root_index_handler.dart'; 23 | import 'package:webdev_proxy/src/sse_proxy_handler.dart'; 24 | 25 | import 'logging.dart'; 26 | 27 | /// A wrapper around an [HttpServer] configured to proxy the server spawned by a 28 | /// `webdev serve` process. 29 | class WebdevProxyServer { 30 | final HttpServer _server; 31 | 32 | WebdevProxyServer._(this._server); 33 | 34 | /// Returns the port that this server is listening on. 35 | int get port => _server.port; 36 | 37 | /// Permanently stops this proxy server from listening for new connections and 38 | /// closes all active connections immediately. 39 | Future close() async { 40 | await _server.close(force: true); 41 | } 42 | 43 | /// Starts a proxy for a webdev server that is serving [dir] and listening on 44 | /// [hostname] and [portToProxy]. 45 | /// 46 | /// The proxy will listen on [portToServe], which defaults to `0` meaning that 47 | /// the server will pick any available port. 48 | /// 49 | /// The proxy will rewrite any request to the webdev server that 404s to a 50 | /// request that fetches the root index path (`/`) unless [rewrite404s] is 51 | /// false. 52 | static Future start({ 53 | required String dir, 54 | required String hostname, 55 | required int? portToProxy, 56 | int portToServe = 0, 57 | bool rewrite404s = true, 58 | }) async { 59 | final serverHostname = hostname == 'any' ? 'localhost' : hostname; 60 | final serverUri = Uri.parse('http://$serverHostname:$portToProxy'); 61 | final serverSseUri = serverUri.replace(path: r'/$sseHandler'); 62 | final sseUri = Uri.parse(r'/$sseHandler'); 63 | 64 | final proxyHandler = 65 | shelf_proxy.proxyHandler(serverUri, proxyName: 'webdev_proxy'); 66 | var cascade = shelf.Cascade() 67 | .add(SseProxyHandler(sseUri, serverSseUri).handler) 68 | .add(proxyHandler); 69 | if (rewrite404s) { 70 | cascade = cascade.add(proxyRootIndexHandler(proxyHandler)); 71 | } 72 | 73 | final server = await HttpMultiServer.bind(hostname, portToServe); 74 | shelf_io.serveRequests(server, cascade.handler); 75 | final proxyHostname = hostname == 'any' ? '::' : hostname; 76 | log.info(green 77 | .wrap('Serving `$dir` proxy on http://$proxyHostname:$portToServe\n')); 78 | log.fine('... forwards to http://$serverHostname:$portToProxy'); 79 | return WebdevProxyServer._(server); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/src/webdev_server.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:async'; 16 | import 'dart:io'; 17 | 18 | import 'package:webdev_proxy/src/logging.dart'; 19 | 20 | /// A simple abstraction over a `webdev serve ...` process. 21 | class WebdevServer { 22 | /// The `webdev serve ...` process. 23 | final Process _process; 24 | 25 | WebdevServer._(this._process); 26 | 27 | /// Returns a Future which completes with the exit code of the underlying 28 | /// `webdev serve` process. 29 | Future get exitCode => _process.exitCode; 30 | 31 | /// Permanently stops this proxy server from listening for new connections and 32 | /// closes all active connections immediately. 33 | Future close() async { 34 | _process.kill(); 35 | await _process.exitCode; 36 | } 37 | 38 | /// Starts a `webdev serve` process with the given [args] and returns a 39 | /// [WebdevServer] abstraction over said process. 40 | static Future start(List args, 41 | {ProcessStartMode mode = ProcessStartMode.inheritStdio}) async { 42 | final webdevArgs = ['pub', 'global', 'run', 'webdev', 'serve', ...args]; 43 | log.fine('Running `dart ${webdevArgs.join(' ')}'); 44 | final process = await Process.start( 45 | 'dart', 46 | webdevArgs, 47 | mode: mode, 48 | ); 49 | return WebdevServer._(process); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: webdev_proxy 2 | version: 0.1.13 3 | private: true 4 | homepage: https://github.com/Workiva/webdev_proxy 5 | 6 | description: > 7 | A simple HTTP proxy for webdev's serve command. Supports apps that use HTML5 8 | routing by rewriting 404s to the root index. 9 | 10 | environment: 11 | sdk: '>=2.19.0 <3.0.0' 12 | 13 | dependencies: 14 | args: ^2.3.1 15 | http: '>=0.13.3 <2.0.0' 16 | http_multi_server: ^3.2.1 17 | io: ^1.0.3 18 | logging: ^1.1.0 19 | meta: ^1.16.0 20 | pub_semver: ^2.1.2 21 | shelf: ^1.2.0 22 | shelf_proxy: ^1.0.0 23 | stack_trace: ^1.10.0 24 | 25 | dev_dependencies: 26 | # These two build deps are required by webdev. 27 | build_runner: ^2.1.2 28 | build_web_compilers: '>3.0.0 <5.0.0' 29 | lints: '>=2.0.1 <5.0.0' 30 | 31 | shelf_static: ^1.1.0 32 | sse: ^4.1.0 33 | test: ^1.17.12 34 | webdriver: ^3.0.0 35 | 36 | executables: 37 | webdev_proxy: 38 | -------------------------------------------------------------------------------- /test/chromedriver_utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:test/test.dart'; 5 | import 'package:webdev_proxy/src/port_utils.dart'; 6 | import 'package:webdriver/async_io.dart' as wd; 7 | 8 | const chromeDriverPort = 4444; 9 | const chromeDriverUrlBase = 'wd/hub'; 10 | 11 | Future startChromeDriver() async { 12 | try { 13 | final chromeDriver = await Process.start( 14 | 'chromedriver', ['--port=4444', '--url-base=wd/hub']); 15 | addTearDown(() { 16 | if (!chromeDriver.kill()) { 17 | chromeDriver.kill(ProcessSignal.sigkill); 18 | } 19 | }); 20 | 21 | // On windows this takes a while to boot up, wait for a message on stdout 22 | // indicating ChromeDriver started successfully. 23 | final stdOutLines = chromeDriver.stdout 24 | .transform(utf8.decoder) 25 | .transform(LineSplitter()) 26 | .asBroadcastStream(); 27 | 28 | final stdErrLines = chromeDriver.stderr 29 | .transform(utf8.decoder) 30 | .transform(LineSplitter()) 31 | .asBroadcastStream(); 32 | 33 | stdOutLines.listen((line) => print('ChromeDriver stdout: $line')); 34 | stdErrLines.listen((line) => print('ChromeDriver stderr: $line')); 35 | 36 | await stdOutLines.firstWhere( 37 | (line) => line.contains('ChromeDriver was started successfully')); 38 | } catch (e) { 39 | throw StateError( 40 | 'Could not start ChromeDriver. Is it installed?\nError: $e'); 41 | } 42 | } 43 | 44 | Future createWebDriver() async { 45 | final debugPort = await findUnusedPort(); 46 | final capabilities = wd.Capabilities.chrome 47 | ..addAll({ 48 | wd.Capabilities.chromeOptions: { 49 | 'args': [ 50 | 'remote-debugging-port=$debugPort', 51 | '--headless', 52 | ] 53 | } 54 | }); 55 | final webDriver = await wd.createDriver( 56 | spec: wd.WebDriverSpec.JsonWire, 57 | desired: capabilities, 58 | uri: Uri.parse( 59 | 'http://localhost:$chromeDriverPort/$chromeDriverUrlBase/')); 60 | addTearDown(webDriver.quit); 61 | return webDriver; 62 | } 63 | -------------------------------------------------------------------------------- /test/command_utils_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'package:args/args.dart'; 17 | import 'package:test/test.dart'; 18 | 19 | import 'package:webdev_proxy/src/command_utils.dart'; 20 | 21 | void main() { 22 | group('assertNoPositionalArgsBeforeSeparator', () { 23 | final argParser = ArgParser()..addCommand('command'); 24 | String? capturedUsageException; 25 | void usageException(String msg) => capturedUsageException = msg; 26 | 27 | tearDown(() { 28 | capturedUsageException = null; 29 | }); 30 | 31 | group('does not report usage exception if', () { 32 | test('if rest args are empty', () { 33 | final argResults = argParser.parse(['command']); 34 | assertNoPositionalArgsBeforeSeparator( 35 | 'serve', argResults.command!, usageException); 36 | expect(capturedUsageException, isNull); 37 | }); 38 | 39 | test('if expected flag is parsed', () { 40 | argParser.addFlag('flag'); 41 | final argResults = argParser.parse(['command', '--flag']); 42 | assertNoPositionalArgsBeforeSeparator( 43 | 'serve', argResults.command!, usageException); 44 | expect(capturedUsageException, isNull); 45 | }); 46 | 47 | test('if args are only passed after -- separator', () { 48 | final argResults = argParser.parse(['command', '--', 'after']); 49 | assertNoPositionalArgsBeforeSeparator( 50 | 'serve', argResults.command!, usageException); 51 | expect(capturedUsageException, isNull); 52 | }); 53 | }); 54 | 55 | group('reports usage exception if', () { 56 | test('-- separator is missing before webdev args', () { 57 | final argResults = argParser.parse(['command', 'foo']); 58 | assertNoPositionalArgsBeforeSeparator( 59 | 'serve', argResults.command!, usageException); 60 | expect(capturedUsageException, isNotNull); 61 | }); 62 | 63 | test('positional args exist before -- separator', () { 64 | final argResults = 65 | argParser.parse(['command', 'before', '--', 'after']); 66 | assertNoPositionalArgsBeforeSeparator( 67 | 'serve', argResults.command!, usageException); 68 | expect(capturedUsageException, isNotNull); 69 | }); 70 | }); 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /test/port_utils_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'dart:io'; 17 | 18 | import 'package:test/test.dart'; 19 | 20 | import 'package:webdev_proxy/src/port_utils.dart'; 21 | 22 | void main() { 23 | group('findUnusedPort()', () { 24 | test('should return an open port', () async { 25 | final port = await findUnusedPort(); 26 | late ServerSocket socket; 27 | try { 28 | socket = await ServerSocket.bind('localhost', port); 29 | } catch (e) { 30 | fail('Expected $port to be open for binding.\n$e'); 31 | } 32 | expect(socket.port, port); 33 | await socket.close(); 34 | }); 35 | 36 | test('should return distinct ports when called multiple times', () async { 37 | final port1 = await findUnusedPort(); 38 | final port2 = await findUnusedPort(); 39 | expect(port1, isNot(port2), reason: 'Ports should be distinct.'); 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /test/proxy_server_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'dart:io'; 17 | 18 | import 'package:http/http.dart' as http; 19 | import 'package:shelf/shelf.dart' as shelf; 20 | import 'package:shelf/shelf_io.dart' as shelf_io; 21 | import 'package:shelf_static/shelf_static.dart'; 22 | import 'package:sse/server/sse_handler.dart'; 23 | import 'package:test/test.dart'; 24 | import 'package:webdev_proxy/src/port_utils.dart'; 25 | 26 | import 'package:webdev_proxy/src/webdev_proxy_server.dart'; 27 | 28 | import 'chromedriver_utils.dart'; 29 | 30 | void main() { 31 | late WebdevProxyServer proxy; 32 | late HttpServer server; 33 | late SseHandler serverSse; 34 | 35 | setUpAll(() async { 36 | await startChromeDriver(); 37 | }); 38 | 39 | setUp(() async { 40 | final staticWebHandler = createStaticHandler('test/web', 41 | listDirectories: true, defaultDocument: 'index.html'); 42 | 43 | serverSse = SseHandler(Uri.parse(r'/$sseHandler')); 44 | final serverCascade = 45 | shelf.Cascade().add(serverSse.handler).add(staticWebHandler); 46 | server = await shelf_io.serve( 47 | serverCascade.handler, 'localhost', await findUnusedPort()); 48 | }); 49 | 50 | tearDown(() async { 51 | await proxy.close(); 52 | await server.close(force: true); 53 | }); 54 | 55 | test('Proxies URL that exists', () async { 56 | proxy = await WebdevProxyServer.start( 57 | dir: 'test', 58 | hostname: 'localhost', 59 | portToProxy: server.port, 60 | portToServe: await findUnusedPort(), 61 | ); 62 | 63 | final response = 64 | await http.get(Uri.parse('http://localhost:${proxy.port}/index.dart')); 65 | expect(response.statusCode, 200); 66 | expect(response.body, isNotEmpty); 67 | }); 68 | 69 | test('Proxies the /\$sseHandler endpoint', () async { 70 | proxy = await WebdevProxyServer.start( 71 | dir: 'test', 72 | hostname: 'localhost', 73 | portToProxy: server.port, 74 | portToServe: await findUnusedPort(), 75 | ); 76 | 77 | final webDriver = await createWebDriver(); 78 | await webDriver.get('http://localhost:${proxy.port}'); 79 | var connection = await serverSse.connections.next; 80 | connection.sink.add('blah'); 81 | expect(await connection.stream.first, 'blah'); 82 | }); 83 | 84 | test('Rewrites 404s to /index.html when enabled', () async { 85 | proxy = await WebdevProxyServer.start( 86 | dir: 'test', 87 | hostname: 'localhost', 88 | portToProxy: server.port, 89 | rewrite404s: true, 90 | ); 91 | 92 | final response = await http 93 | .get(Uri.parse('http://localhost:${proxy.port}/path/to/nothing')); 94 | expect(response.statusCode, 200); 95 | expect(response.body, startsWith('')); 96 | }); 97 | 98 | test('Does not rewrite 404s to /index.html when disabled', () async { 99 | proxy = await WebdevProxyServer.start( 100 | dir: 'test', 101 | hostname: 'localhost', 102 | portToProxy: server.port, 103 | rewrite404s: false, 104 | ); 105 | 106 | final response = await http 107 | .get(Uri.parse('http://localhost:${proxy.port}/path/to/nothing')); 108 | expect(response.statusCode, 404); 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /test/sse_proxy_handler_test.dart: -------------------------------------------------------------------------------- 1 | @TestOn('vm') 2 | import 'dart:async'; 3 | import 'dart:io'; 4 | 5 | import 'package:shelf/shelf.dart' as shelf; 6 | import 'package:shelf/shelf_io.dart' as io; 7 | import 'package:shelf_static/shelf_static.dart'; 8 | import 'package:sse/server/sse_handler.dart'; 9 | import 'package:test/test.dart'; 10 | import 'package:webdev_proxy/src/port_utils.dart'; 11 | import 'package:webdriver/async_io.dart'; 12 | 13 | import 'package:webdev_proxy/src/sse_proxy_handler.dart'; 14 | 15 | import 'chromedriver_utils.dart'; 16 | 17 | void main() { 18 | late HttpServer proxy; 19 | late HttpServer server; 20 | late SseHandler serverSse; 21 | late WebDriver webdriver; 22 | 23 | setUpAll(() async { 24 | await startChromeDriver(); 25 | }); 26 | 27 | setUp(() async { 28 | const ssePath = r'/$sseHandler'; 29 | 30 | final staticWebHandler = createStaticHandler('test/web', 31 | listDirectories: true, defaultDocument: 'index.html'); 32 | 33 | serverSse = SseHandler(Uri.parse(ssePath)); 34 | final serverCascade = shelf.Cascade().add(serverSse.handler); 35 | server = await io.serve( 36 | serverCascade.handler, 'localhost', await findUnusedPort()); 37 | 38 | final proxySse = SseProxyHandler(Uri.parse(ssePath), 39 | Uri.parse('http://localhost:${server.port}$ssePath')); 40 | final proxyCascade = shelf.Cascade() 41 | .add(proxySse.handler) 42 | .add(_faviconHandler) 43 | .add(staticWebHandler); 44 | proxy = await io.serve( 45 | proxyCascade.handler, 'localhost', await findUnusedPort()); 46 | 47 | webdriver = await createWebDriver(); 48 | }); 49 | 50 | tearDown(() async { 51 | await proxy.close(); 52 | await server.close(); 53 | }); 54 | 55 | test('Can round trip messages', () async { 56 | await webdriver.get('http://localhost:${proxy.port}'); 57 | 58 | // Give webdriver more time to get its act together? 59 | await Future.delayed(Duration(seconds: 3)); 60 | 61 | var connection = await serverSse.connections.next; 62 | connection.sink.add('blah'); 63 | expect(await connection.stream.first, 'blah'); 64 | }); 65 | 66 | test('Multiple clients can connect', () async { 67 | var connections = serverSse.connections; 68 | await webdriver.get('http://localhost:${proxy.port}'); 69 | var conn1 = await connections.next; 70 | conn1.sink.add('one'); 71 | expect(await conn1.stream.first, 'one'); 72 | await webdriver.get('http://localhost:${proxy.port}'); 73 | var conn2 = await connections.next; 74 | conn2.sink.add('two'); 75 | expect(await conn2.stream.first, 'two'); 76 | }); 77 | 78 | test('Routes data correctly', () async { 79 | var connections = serverSse.connections; 80 | await webdriver.get('http://localhost:${proxy.port}'); 81 | var connectionA = await connections.next; 82 | connectionA.sink.add('foo'); 83 | expect(await connectionA.stream.first, 'foo'); 84 | 85 | await webdriver.get('http://localhost:${proxy.port}'); 86 | var connectionB = await connections.next; 87 | connectionB.sink.add('bar'); 88 | expect(await connectionB.stream.first, 'bar'); 89 | }); 90 | 91 | test('Can close from the server', () async { 92 | expect(serverSse.numberOfClients, 0); 93 | await webdriver.get('http://localhost:${proxy.port}'); 94 | var connection = await serverSse.connections.next; 95 | expect(serverSse.numberOfClients, 1); 96 | await connection.sink.close(); 97 | await pumpEventQueue(); 98 | expect(serverSse.numberOfClients, 0); 99 | }); 100 | 101 | test('Can close from the client-side', () async { 102 | expect(serverSse.numberOfClients, 0); 103 | await webdriver.get('http://localhost:${proxy.port}'); 104 | var connection = await serverSse.connections.next; 105 | expect(serverSse.numberOfClients, 1); 106 | 107 | var closeButton = await webdriver.findElement(const By.tagName('button')); 108 | await closeButton.click(); 109 | 110 | // Should complete since the connection is closed. 111 | await connection.stream.toList(); 112 | expect(serverSse.numberOfClients, 0); 113 | }); 114 | 115 | test('Cancelling the listener closes the connection', () async { 116 | expect(serverSse.numberOfClients, 0); 117 | await webdriver.get('http://localhost:${proxy.port}'); 118 | var connection = await serverSse.connections.next; 119 | expect(serverSse.numberOfClients, 1); 120 | 121 | var sub = connection.stream.listen((_) {}); 122 | await sub.cancel(); 123 | await pumpEventQueue(); 124 | expect(serverSse.numberOfClients, 0); 125 | }); 126 | 127 | test('Disconnects when navigating away', () async { 128 | await webdriver.get('http://localhost:${proxy.port}'); 129 | await serverSse.connections.next; 130 | expect(serverSse.numberOfClients, 1); 131 | await webdriver.quit(); 132 | expect(serverSse.numberOfClients, 0); 133 | }); 134 | } 135 | 136 | FutureOr _faviconHandler(shelf.Request request) { 137 | if (request.url.path.endsWith('favicon.ico')) { 138 | return shelf.Response.ok(''); 139 | } 140 | return shelf.Response.notFound(''); 141 | } 142 | -------------------------------------------------------------------------------- /test/util.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:convert'; 16 | import 'dart:io'; 17 | 18 | import 'package:test/test.dart'; 19 | 20 | Future activateWebdev(String constraint) async { 21 | final process = await Process.start( 22 | 'dart', 23 | ['pub', 'global', 'activate', 'webdev', constraint], 24 | mode: ProcessStartMode.inheritStdio, 25 | ); 26 | expect(await process.exitCode, 0, 27 | reason: 'Failed to global activate webdev $constraint.'); 28 | expect(isWebdevGlobalActivated(), isTrue, 29 | reason: "Webdev should be globally active, but isn't."); 30 | } 31 | 32 | Future deactivateWebdev() async { 33 | if (!isWebdevGlobalActivated()) { 34 | return; 35 | } 36 | final process = await Process.start( 37 | 'dart', 38 | ['pub', 'global', 'deactivate', 'webdev'], 39 | mode: ProcessStartMode.inheritStdio, 40 | ); 41 | expect(await process.exitCode, 0, 42 | reason: 'Failed to global deactivate webdev.'); 43 | expect(isWebdevGlobalActivated(), isFalse, 44 | reason: 'Webdev should not be globally active, but is.'); 45 | } 46 | 47 | final webdevGlobalPattern = RegExp(r'webdev [\d.]+'); 48 | 49 | bool isWebdevGlobalActivated() { 50 | final procResult = Process.runSync( 51 | 'dart', 52 | ['pub', 'global', 'list'], 53 | stdoutEncoding: utf8, 54 | ); 55 | return procResult.stdout 56 | .toString() 57 | .split('\n') 58 | .any(webdevGlobalPattern.hasMatch); 59 | } 60 | -------------------------------------------------------------------------------- /test/web/index.dart: -------------------------------------------------------------------------------- 1 | import 'dart:html'; 2 | 3 | import 'package:sse/client/sse_client.dart'; 4 | 5 | void main() { 6 | var channel = SseClient(r'/$sseHandler'); 7 | 8 | document.querySelector('button')!.onClick.listen((_) { 9 | channel.sink.close(); 10 | }); 11 | 12 | channel.stream.listen((s) { 13 | channel.sink.add(s); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /test/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SSE Broadcast Channel Test 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/webdev_arg_utils_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'package:test/test.dart'; 17 | 18 | import 'package:webdev_proxy/src/webdev_arg_utils.dart'; 19 | 20 | void main() { 21 | group('parseDirectoryArgs()', () { 22 | test('defaults to web:8080', () { 23 | expect(parseDirectoryArgs([]), {'web': 8080}); 24 | }); 25 | 26 | test('no matching args', () { 27 | expect(parseDirectoryArgs(['--foo=bar', '-v', '--flag']), {'web': 8080}); 28 | }); 29 | 30 | test('one mapping', () { 31 | expect(parseDirectoryArgs(['web:8080']), {'web': 8080}); 32 | }); 33 | 34 | test('multiple distinct mappings', () { 35 | expect(parseDirectoryArgs(['web:8080', 'test:8081']), 36 | {'web': 8080, 'test': 8081}); 37 | }); 38 | 39 | test('last one wins when dir has multiple ports', () { 40 | expect(parseDirectoryArgs(['web:8080', 'web:8081']), {'web': 8081}); 41 | }); 42 | 43 | test('ports omitted', () { 44 | expect(parseDirectoryArgs(['web', 'test']), {'web': 8080, 'test': 8081}); 45 | }); 46 | 47 | test('nested directory', () { 48 | expect(parseDirectoryArgs(['web/nested/dir/:9000']), 49 | {'web/nested/dir/': 9000}); 50 | }); 51 | }); 52 | 53 | group('parseHostname', () { 54 | test('with no args', () { 55 | final result = parseHostname([]); 56 | expect(result.hostname, 'localhost'); 57 | expect(result.remainingArgs, []); 58 | }); 59 | 60 | test('with no hostname arg', () { 61 | final result = parseHostname(['--release']); 62 | expect(result.hostname, 'localhost'); 63 | expect(result.remainingArgs, ['--release']); 64 | }); 65 | 66 | test('--hostname=', () { 67 | final result = parseHostname(['--hostname=0.0.0.0']); 68 | expect(result.hostname, '0.0.0.0'); 69 | expect(result.remainingArgs, []); 70 | }); 71 | 72 | test('--hostname= with trailing args', () { 73 | final result = parseHostname(['--hostname=0.0.0.0', '--release']); 74 | expect(result.hostname, '0.0.0.0'); 75 | expect(result.remainingArgs, ['--release']); 76 | }); 77 | 78 | test('--hostname value', () { 79 | final result = parseHostname(['--hostname', '0.0.0.0']); 80 | expect(result.hostname, '0.0.0.0'); 81 | expect(result.remainingArgs, []); 82 | }); 83 | 84 | test('--hostname value with trailing args', () { 85 | final result = parseHostname(['--hostname', '0.0.0.0', '--release']); 86 | expect(result.hostname, '0.0.0.0'); 87 | expect(result.remainingArgs, ['--release']); 88 | }); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /test/webdev_proc_utils_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'dart:io'; 17 | 18 | import 'package:pub_semver/pub_semver.dart'; 19 | import 'package:test/test.dart'; 20 | 21 | import 'package:webdev_proxy/src/webdev_proc_utils.dart'; 22 | 23 | import 'util.dart'; 24 | 25 | void main() { 26 | group('getGlobalWebdevVersion()', () { 27 | setUp(() { 28 | cachedWebdevVersionResult = null; 29 | }); 30 | 31 | tearDown(() { 32 | cachedWebdevVersionResult = null; 33 | }); 34 | 35 | test('with webdev not activated', () async { 36 | await deactivateWebdev(); 37 | expect(getGlobalWebdevVersion(), isNull); 38 | }); 39 | 40 | test('with webdev activated', () async { 41 | if (Platform.version.contains('2.19')) { 42 | await activateWebdev('2.0.0'); 43 | expect(getGlobalWebdevVersion(), Version.parse('2.0.0')); 44 | } else if (Platform.version.contains('3.')) { 45 | await activateWebdev('3.0.0'); 46 | expect(getGlobalWebdevVersion(), Version.parse('3.0.0')); 47 | } else { 48 | throw Exception('Unsupported Dart version: ${Platform.version}'); 49 | } 50 | }); 51 | }); 52 | 53 | test('printWebdevServeHelp', () async { 54 | await activateWebdev(webdevCompatibility.toString()); 55 | expect(printWebdevServeHelp(), completion(0)); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /test/webdev_proxy_server_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'dart:io'; 17 | 18 | import 'package:http/http.dart' as http; 19 | import 'package:shelf/shelf.dart' as shelf; 20 | import 'package:shelf/shelf_io.dart' as shelf_io; 21 | import 'package:shelf_static/shelf_static.dart'; 22 | import 'package:sse/server/sse_handler.dart'; 23 | import 'package:test/test.dart'; 24 | import 'package:webdev_proxy/src/port_utils.dart'; 25 | 26 | import 'package:webdev_proxy/src/webdev_proxy_server.dart'; 27 | 28 | import 'chromedriver_utils.dart'; 29 | 30 | void main() { 31 | late WebdevProxyServer proxy; 32 | late HttpServer server; 33 | late SseHandler serverSse; 34 | 35 | setUpAll(() async { 36 | await startChromeDriver(); 37 | }); 38 | 39 | setUp(() async { 40 | final staticWebHandler = createStaticHandler('test/web', 41 | listDirectories: true, defaultDocument: 'index.html'); 42 | 43 | serverSse = SseHandler(Uri.parse(r'/$sseHandler')); 44 | final serverCascade = 45 | shelf.Cascade().add(serverSse.handler).add(staticWebHandler); 46 | server = await shelf_io.serve( 47 | serverCascade.handler, 'localhost', await findUnusedPort()); 48 | }); 49 | 50 | tearDown(() async { 51 | await proxy.close(); 52 | await server.close(force: true); 53 | }); 54 | 55 | test('Proxies URL that exists', () async { 56 | proxy = await WebdevProxyServer.start( 57 | dir: 'test', 58 | hostname: 'localhost', 59 | portToProxy: server.port, 60 | ); 61 | 62 | final response = 63 | await http.get(Uri.parse('http://localhost:${proxy.port}/index.dart')); 64 | expect(response.statusCode, 200); 65 | expect(response.body, isNotEmpty); 66 | }); 67 | 68 | test('Proxies the /\$sseHandler endpoint', () async { 69 | proxy = await WebdevProxyServer.start( 70 | dir: 'test', 71 | hostname: 'localhost', 72 | portToProxy: server.port, 73 | ); 74 | 75 | final webdriver = await createWebDriver(); 76 | await webdriver.get('http://localhost:${proxy.port}'); 77 | var connection = await serverSse.connections.next; 78 | connection.sink.add('blah'); 79 | expect(await connection.stream.first, 'blah'); 80 | }); 81 | 82 | test('Rewrites 404s to /index.html when enabled', () async { 83 | proxy = await WebdevProxyServer.start( 84 | dir: 'test', 85 | hostname: 'localhost', 86 | portToProxy: server.port, 87 | rewrite404s: true, 88 | ); 89 | 90 | final response = await http 91 | .get(Uri.parse('http://localhost:${proxy.port}/path/to/nothing')); 92 | expect(response.statusCode, 200); 93 | expect(response.body, startsWith('')); 94 | }); 95 | 96 | test('Does not rewrite 404s to /index.html when disabled', () async { 97 | proxy = await WebdevProxyServer.start( 98 | dir: 'test', 99 | hostname: 'localhost', 100 | portToProxy: server.port, 101 | rewrite404s: false, 102 | ); 103 | 104 | final response = await http 105 | .get(Uri.parse('http://localhost:${proxy.port}/path/to/nothing')); 106 | expect(response.statusCode, 404); 107 | }); 108 | } 109 | -------------------------------------------------------------------------------- /test/webdev_server_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Workiva Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | @TestOn('vm') 16 | import 'dart:io'; 17 | 18 | import 'package:http/http.dart' as http; 19 | import 'package:test/test.dart'; 20 | import 'package:webdev_proxy/src/port_utils.dart'; 21 | import 'package:webdev_proxy/src/webdev_proc_utils.dart'; 22 | import 'package:webdev_proxy/src/webdev_server.dart'; 23 | 24 | import 'util.dart'; 25 | 26 | void main() async { 27 | late WebdevServer? webdevServer; 28 | 29 | setUpAll(() async { 30 | await activateWebdev(webdevCompatibility.toString()); 31 | }); 32 | 33 | tearDown(() async { 34 | await webdevServer?.close(); 35 | }); 36 | 37 | test('Serves a directory', () async { 38 | final port = await findUnusedPort(); 39 | webdevServer = 40 | await WebdevServer.start(['test:$port'], mode: ProcessStartMode.normal); 41 | 42 | // We don't have a good way of knowing when the `webdev serve` process has 43 | // started listening on the port, so we send a request periodically until it 44 | // succeeds. If the code under test is broken, this test will timeout. 45 | http.Response response; 46 | while (true) { 47 | try { 48 | response = 49 | await http.get(Uri.parse('http://localhost:$port/web/index.dart')); 50 | } catch (_) { 51 | await Future.delayed(Duration(milliseconds: 250)); 52 | continue; 53 | } 54 | if (response.statusCode == 200) { 55 | break; 56 | } 57 | } 58 | 59 | expect(response.statusCode, 200); 60 | expect(response.body, isNotEmpty); 61 | }); 62 | } 63 | --------------------------------------------------------------------------------