├── .github └── workflows │ └── ci-release.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── examples ├── README.md ├── bun │ ├── README.md │ ├── http-stream │ │ ├── .gitignore │ │ ├── README.md │ │ ├── bun.lockb │ │ ├── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── websocket │ │ ├── .gitignore │ │ ├── README.md │ │ ├── bun.lockb │ │ ├── index.ts │ │ ├── package.json │ │ └── tsconfig.json ├── cloudflare-workers │ ├── README.md │ ├── http-stream │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── wrangler.toml │ └── websocket │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── wrangler.toml ├── deno │ ├── README.md │ ├── http-stream │ │ ├── README.md │ │ ├── deno.json │ │ ├── deno.lock │ │ ├── import_map.json │ │ └── index.ts │ └── websocket │ │ ├── README.md │ │ ├── deno.json │ │ ├── deno.lock │ │ ├── import_map.json │ │ └── index.ts ├── fastly-compute │ ├── README.md │ ├── http-stream │ │ ├── .gitignore │ │ ├── README.md │ │ ├── fastly.toml │ │ ├── package-lock.json │ │ ├── package.json │ │ └── src │ │ │ └── index.js │ └── websocket │ │ ├── .gitignore │ │ ├── README.md │ │ ├── fastly.toml │ │ ├── package-lock.json │ │ ├── package.json │ │ └── src │ │ └── index.js ├── nextjs │ ├── README.md │ ├── http-stream │ │ ├── .gitignore │ │ ├── README.md │ │ ├── next.config.mjs │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ │ ├── app │ │ │ │ └── api │ │ │ │ │ ├── publish │ │ │ │ │ └── route.ts │ │ │ │ │ └── stream │ │ │ │ │ └── route.ts │ │ │ └── utils │ │ │ │ └── publisher.ts │ │ └── tsconfig.json │ └── websocket │ │ ├── .gitignore │ │ ├── README.md │ │ ├── next.config.mjs │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ ├── app │ │ │ └── api │ │ │ │ ├── broadcast │ │ │ │ └── route.ts │ │ │ │ └── websocket │ │ │ │ └── route.ts │ │ └── utils │ │ │ └── publisher.ts │ │ └── tsconfig.json ├── nodejs │ ├── README.md │ ├── http-stream │ │ ├── .gitignore │ │ ├── README.md │ │ ├── index.js │ │ ├── package-lock.json │ │ └── package.json │ └── websocket │ │ ├── .gitignore │ │ ├── README.md │ │ ├── index.js │ │ ├── package-lock.json │ │ └── package.json └── remix │ ├── README.md │ ├── http-stream │ ├── .gitignore │ ├── README.md │ ├── app │ │ ├── root.tsx │ │ ├── routes │ │ │ ├── api.publish.ts │ │ │ └── api.stream.ts │ │ └── utils │ │ │ └── publisher.ts │ ├── package-lock.json │ ├── package.json │ ├── remix.config.js │ ├── remix.env.d.ts │ └── tsconfig.json │ └── websocket │ ├── .gitignore │ ├── README.md │ ├── app │ ├── root.tsx │ ├── routes │ │ ├── api.broadcast.ts │ │ └── api.websocket.ts │ └── utils │ │ └── publisher.ts │ ├── package-lock.json │ ├── package.json │ ├── remix.config.js │ ├── remix.env.d.ts │ └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── auth │ ├── Basic.ts │ ├── Bearer.ts │ ├── IAuth.ts │ ├── Jwt.ts │ └── index.ts ├── data │ ├── Channel.ts │ ├── Format.ts │ ├── GripInstruct.ts │ ├── IExportedChannel.ts │ ├── IFormat.ts │ ├── IFormatExport.ts │ ├── IItem.ts │ ├── IItemExport.ts │ ├── Item.ts │ ├── http │ │ ├── HttpResponseFormat.ts │ │ ├── HttpStreamFormat.ts │ │ └── index.ts │ ├── index.ts │ └── websocket │ │ ├── ConnectionIdMissingException.ts │ │ ├── IWebSocketEvent.ts │ │ ├── WebSocketContext.ts │ │ ├── WebSocketDecodeEventException.ts │ │ ├── WebSocketEvent.ts │ │ ├── WebSocketException.ts │ │ ├── WebSocketMessageFormat.ts │ │ └── index.ts ├── engine │ ├── IGripConfig.ts │ ├── IPublisherClient.ts │ ├── PublishException.ts │ ├── Publisher.ts │ ├── PublisherClient.ts │ └── index.ts ├── fastly-fanout │ ├── index.ts │ ├── keys.ts │ └── utils.ts ├── index-node.ts ├── index.ts ├── node │ ├── index.ts │ └── utilities │ │ ├── index.ts │ │ └── ws-over-http.ts └── utilities │ ├── base64.ts │ ├── debug.ts │ ├── grip.ts │ ├── http.ts │ ├── index.ts │ ├── jwt.ts │ ├── keys.ts │ ├── string.ts │ ├── typedarray.ts │ ├── webSocketEvents.ts │ └── ws-over-http.ts ├── test └── unit │ ├── auth │ └── Auth.test.ts │ ├── data │ ├── Channel.test.ts │ ├── GripInstruct.test.ts │ ├── Item.test.ts │ ├── http │ │ ├── HttpResponseFormat.test.ts │ │ └── HttpStreamFormat.test.ts │ └── websocket │ │ ├── WebSocketContext.test.ts │ │ ├── WebSocketEvent.test.ts │ │ └── WebSocketMessageFormat.test.ts │ ├── engine │ ├── Publisher.test.ts │ └── PublisherClient.test.ts │ ├── fastly-fanout │ └── utils.test.ts │ ├── sampleKeys.ts │ └── utilities │ ├── base64.test.ts │ ├── grip.test.ts │ ├── http.test.ts │ ├── jwt.test.ts │ ├── keys.test.ts │ ├── string.test.ts │ ├── typedarray.test.ts │ ├── webSocketEvents.test.ts │ └── ws-over-http.test.ts ├── tsconfig.build.json ├── tsconfig.json └── types └── jspack.d.ts /.github/workflows/ci-release.yaml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | on: 3 | push: 4 | tags: 5 | # This looks like a regex, but it's actually a filter pattern 6 | # see https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet 7 | - 'v*.*.*' 8 | - 'v*.*.*-*' 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: "Checkout code" 16 | uses: actions/checkout@v4 17 | 18 | - name: Validate SemVer tag 19 | run: | 20 | TAG="${GITHUB_REF_NAME#refs/tags/}" 21 | if [[ ! "$TAG" =~ ^v[0-9]+(\.[0-9]+){2}(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then 22 | echo "::error::Invalid tag: $TAG. Must follow SemVer syntax (e.g., v1.2.3, v1.2.3-alpha)." 23 | exit 1 24 | fi 25 | shell: bash 26 | 27 | - name: Set up Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 'lts/*' 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | - name: Extract prerelease tag if present 34 | id: extract-tag 35 | run: | 36 | TAG="${GITHUB_REF_NAME#v}" # Remove the "v" prefix 37 | if [[ "$TAG" == *-* ]]; then 38 | PRERELEASE=${TAG#*-} # Remove everything before the dash 39 | PRERELEASE=${PRERELEASE%%.*} # Remove everything after the first period 40 | else 41 | PRERELEASE="latest" 42 | fi 43 | echo "DIST_TAG=$PRERELEASE" >> $GITHUB_ENV 44 | 45 | - name: Install npm dependencies 46 | run: npm install 47 | 48 | - name: Publish to npmjs.org 49 | env: 50 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | run: | 52 | echo "Publishing to npmjs.org using dist-tag: $DIST_TAG" 53 | npm publish --access=public --tag "$DIST_TAG" 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | 3 | node_modules/ 4 | 5 | /build/ 6 | /build-esm/ 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Fastly, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | In this directory you will find examples of using `@fanoutio/js-grip` to enable the 4 | use of GRIP in your application. For more details, see the `README` files under 5 | each example. 6 | 7 | * [`nodejs/`](./nodejs) - Usage with a Node.js application as the origin. 8 | * [`http-stream/`](./nodejs/http-stream) - HTTP streaming using GRIP. 9 | * [`websocket/`](./nodejs/websocket) - WebSocket-over-HTTP using GRIP. 10 | 11 | * [`fastly-compute/`](./fastly-compute) - Usage with a Fastly Compute application as 12 | the origin. 13 | * [`http-stream/`](./fastly-compute/http-stream) - HTTP streaming using GRIP. 14 | * [`websocket/`](./fastly-compute/websocket) - WebSocket-over-HTTP using GRIP. 15 | 16 | * [`bun/`](./bun) - Usage with a Bun application as the origin. 17 | * [`http-stream/`](./bun/http-stream) - HTTP streaming using GRIP. 18 | * [`websocket/`](./bun/websocket) - WebSocket-over-HTTP using GRIP. 19 | 20 | * [`deno/`](./deno) - Usage with a Deno application as the origin. 21 | * [`http-stream/`](./deno/http-stream) - HTTP streaming using GRIP. 22 | * [`websocket/`](./deno/websocket) - WebSocket-over-HTTP using GRIP. 23 | 24 | * [`cloudflare-workers/`](./cloudflare-workers) - Usage with a Cloudflare Workers application as the origin. 25 | * [`http-stream/`](./cloudflare-workers/http-stream) - HTTP streaming using GRIP. 26 | * [`websocket/`](./cloudflare-workers/websocket) - WebSocket-over-HTTP using GRIP. 27 | 28 | * [`remix/`](./remix) - Usage with a Remix application as the origin. 29 | * [`http-stream/`](./remix/http-stream) - HTTP streaming using GRIP. 30 | * [`websocket/`](./remix/websocket) - WebSocket-over-HTTP using GRIP. 31 | 32 | * [`nextjs/`](./nextjs) - Usage with a Next.js application as the origin. 33 | * [`http-stream/`](./nextjs/http-stream) - HTTP streaming using GRIP. 34 | * [`websocket/`](./nextjs/websocket) - WebSocket-over-HTTP using GRIP. 35 | -------------------------------------------------------------------------------- /examples/bun/http-stream/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /examples/bun/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Bun 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using a Bun application as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The request handling section of this example goes on to handle two routes: 50 | 51 | 1. A `GET` request at `/api/stream` 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler checks `gripStatus.isProxied` to make sure we are being run behind a valid 56 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 57 | or if the signature validation failed. 58 | 59 | ```typescript 60 | if (!gripStatus.isProxied) { 61 | // emit an error 62 | } 63 | ``` 64 | 65 | If successful, then the handler goes on to set up a GRIP instruction. 66 | This instruction asks the GRIP proxy to hold the current connection open 67 | as a streaming connection, listening to the channel named `'test'`. 68 | 69 | ```typescript 70 | const gripInstruct = new GripInstruct('test'); 71 | gripInstruct.setHoldStream(); 72 | ``` 73 | 74 | Finally, a response is generated and returned, including the 75 | `gripInstruct` in the response headers. 76 | 77 | ```typescript 78 | return new Response( 79 | '[stream open]\n', 80 | { 81 | status: 200, 82 | headers: { 83 | ...gripInstruct.toHeaders(), 84 | 'Content-Type': 'text/plain', 85 | }, 86 | }, 87 | ); 88 | ``` 89 | 90 | That's all that's needed to hold a connection open. Note that the connection between 91 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 92 | connection open with the client. 93 | 94 | 2. A `POST` request at `/api/publish` 95 | 96 | This handler starts by checking to make sure the content type header specifies that 97 | the body is of type `text/plain`. Afterward, the handler reads the request body into 98 | a string. 99 | 100 | ```typescript 101 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 102 | // emit an error 103 | } 104 | const body = await request.text(); 105 | ``` 106 | 107 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 108 | body text as a message to the `test` channel. Any listener on this channel, 109 | such as those that have opened a stream through the endpoint described above, 110 | will receive the message. 111 | 112 | ```typescript 113 | await publisher.publishHttpStream('test', body + '\n'); 114 | ``` 115 | 116 | Finally, it returns a simple success response message and ends. 117 | 118 | ```typescript 119 | return new Response( 120 | 'Ok\n', 121 | { 122 | status: 200, 123 | headers: { 124 | 'Content-Type': 'text/plain', 125 | }, 126 | }, 127 | ); 128 | ``` 129 | -------------------------------------------------------------------------------- /examples/bun/http-stream/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fanout/js-grip/eb199e0527de68439f86a6877bc57ee84edd8f8a/examples/bun/http-stream/bun.lockb -------------------------------------------------------------------------------- /examples/bun/http-stream/index.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, GripInstruct, parseGripUri, Publisher } from '@fanoutio/grip'; 4 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 5 | 6 | // Configure the Publisher. 7 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 8 | const gripUrl = Bun.env.GRIP_URL; 9 | if (gripUrl) { 10 | gripConfig = parseGripUri(gripUrl, { 'verify-key': Bun.env.GRIP_VERIFY_KEY }); 11 | } else { 12 | const fanoutServiceId = Bun.env.FANOUT_SERVICE_ID; 13 | const fanoutApiToken = Bun.env.FANOUT_API_TOKEN; 14 | if (fanoutServiceId != null && fanoutApiToken != null) { 15 | gripConfig = buildFanoutGripConfig({ 16 | serviceId: fanoutServiceId, 17 | apiToken: fanoutApiToken, 18 | }); 19 | } 20 | } 21 | const publisher = new Publisher(gripConfig); 22 | 23 | const server = Bun.serve({ 24 | port: 3000, 25 | async fetch(request: Request) { 26 | 27 | const requestUrl = new URL(request.url); 28 | 29 | // Find whether we are behind GRIP 30 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 31 | 32 | if (request.method === 'GET' && requestUrl.pathname === '/api/stream') { 33 | // Make sure we're behind a GRIP proxy before we proceed 34 | if (!gripStatus.isProxied) { 35 | return new Response( 36 | '[not proxied]\n', 37 | { 38 | status: 200, 39 | headers: { 40 | 'Content-Type': 'text/plain', 41 | }, 42 | }, 43 | ); 44 | } 45 | 46 | // Create some GRIP instructions and hold the stream 47 | const gripInstruct = new GripInstruct('test'); 48 | gripInstruct.setHoldStream(); 49 | 50 | // Return the response 51 | // Include the GRIP instructions in the response headers 52 | return new Response( 53 | '[stream open]\n', 54 | { 55 | status: 200, 56 | headers: { 57 | ...gripInstruct.toHeaders(), 58 | 'Content-Type': 'text/plain', 59 | }, 60 | }, 61 | ); 62 | } 63 | 64 | if (request.method === 'POST' && requestUrl.pathname === '/api/publish') { 65 | // Only accept text bodies 66 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 67 | return new Response( 68 | 'Body must be text/plain\n', { 69 | status: 415, 70 | headers: { 71 | 'Content-Type': 'text/plain', 72 | }, 73 | }, 74 | ); 75 | } 76 | 77 | // Read the body 78 | const body = await request.text(); 79 | 80 | // Publish the body to GRIP clients that listen to http-stream format 81 | await publisher.publishHttpStream('test', body + '\n'); 82 | 83 | // Return a success response 84 | return new Response( 85 | 'Ok\n', 86 | { 87 | status: 200, 88 | headers: { 89 | 'Content-Type': 'text/plain', 90 | }, 91 | }, 92 | ); 93 | } 94 | 95 | // Return an error response 96 | return new Response('Not found\n', { status: 404 }); 97 | }, 98 | }); 99 | 100 | console.log(`Listening on http://127.0.0.1:${server.port} ...`); 101 | -------------------------------------------------------------------------------- /examples/bun/http-stream/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "type": "module", 4 | "scripts": { 5 | "start": "bun ./index.ts" 6 | }, 7 | "devDependencies": { 8 | "@types/bun": "latest" 9 | }, 10 | "peerDependencies": { 11 | "typescript": "^5.0.0" 12 | }, 13 | "dependencies": { 14 | "@fanoutio/grip": "^4.3.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/bun/http-stream/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/bun/websocket/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /examples/bun/websocket/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fanout/js-grip/eb199e0527de68439f86a6877bc57ee84edd8f8a/examples/bun/websocket/bun.lockb -------------------------------------------------------------------------------- /examples/bun/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "type": "module", 4 | "scripts": { 5 | "start": "bun ./index.ts" 6 | }, 7 | "devDependencies": { 8 | "@types/bun": "latest" 9 | }, 10 | "peerDependencies": { 11 | "typescript": "^5.0.0" 12 | }, 13 | "dependencies": { 14 | "@fanoutio/grip": "^4.3.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/bun/websocket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/http-stream/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Cloudflare Workers 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using a Cloudflare Workers application as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The request handling section of this example goes on to handle two routes: 50 | 51 | 1. A `GET` request at `/api/stream` 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler checks `gripStatus.isProxied` to make sure we are being run behind a valid 56 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 57 | or if the signature validation failed. 58 | 59 | ```typescript 60 | if (!gripStatus.isProxied) { 61 | // emit an error 62 | } 63 | ``` 64 | 65 | If successful, then the handler goes on to set up a GRIP instruction. 66 | This instruction asks the GRIP proxy to hold the current connection open 67 | as a streaming connection, listening to the channel named `'test'`. 68 | 69 | ```typescript 70 | const gripInstruct = new GripInstruct('test'); 71 | gripInstruct.setHoldStream(); 72 | ``` 73 | 74 | Finally, a response is generated and returned, including the 75 | `gripInstruct` in the response headers. 76 | 77 | ```typescript 78 | return new Response( 79 | '[stream open]\n', 80 | { 81 | status: 200, 82 | headers: { 83 | ...gripInstruct.toHeaders(), 84 | 'Content-Type': 'text/plain', 85 | }, 86 | }, 87 | ); 88 | ``` 89 | 90 | That's all that's needed to hold a connection open. Note that the connection between 91 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 92 | connection open with the client. 93 | 94 | 2. A `POST` request at `/api/publish` 95 | 96 | This handler starts by checking to make sure the content type header specifies that 97 | the body is of type `text/plain`. Afterward, the handler reads the request body into 98 | a string. 99 | 100 | ```typescript 101 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 102 | // emit an error 103 | } 104 | const body = await request.text(); 105 | ``` 106 | 107 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 108 | body text as a message to the `test` channel. Any listener on this channel, 109 | such as those that have opened a stream through the endpoint described above, 110 | will receive the message. 111 | 112 | ```typescript 113 | await publisher.publishHttpStream('test', body + '\n'); 114 | ``` 115 | 116 | Finally, it returns a simple success response message and ends. 117 | 118 | ```typescript 119 | return new Response( 120 | 'Ok\n', 121 | { 122 | status: 200, 123 | headers: { 124 | 'Content-Type': 'text/plain', 125 | }, 126 | }, 127 | ); 128 | ``` 129 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/http-stream/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "private": true, 4 | "scripts": { 5 | "deploy": "wrangler deploy", 6 | "dev": "wrangler dev --port 3000", 7 | "start": "wrangler dev --port 3000" 8 | }, 9 | "devDependencies": { 10 | "@cloudflare/workers-types": "^4.20240222.0", 11 | "typescript": "^5.4.3", 12 | "wrangler": "^3.0.0" 13 | }, 14 | "dependencies": { 15 | "@fanoutio/grip": "^4.3.0" 16 | } 17 | } 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/http-stream/src/index.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, GripInstruct, parseGripUri, Publisher } from '@fanoutio/grip'; 4 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 5 | 6 | export interface Env { 7 | GRIP_URL?: string, 8 | GRIP_VERIFY_KEY?: string, 9 | FANOUT_SERVICE_ID?: string, 10 | FANOUT_API_TOKEN?: string, 11 | } 12 | 13 | export default { 14 | async fetch(request: Request, env: Env): Promise { 15 | 16 | const requestUrl = new URL(request.url); 17 | 18 | // Configure the Publisher. 19 | // Settings are stored in the environment 20 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 21 | const gripUrl = env.GRIP_URL; 22 | if (gripUrl) { 23 | gripConfig = parseGripUri(gripUrl, { 'verify-key': env.GRIP_VERIFY_KEY }); 24 | } else { 25 | const fanoutServiceId = env.FANOUT_SERVICE_ID; 26 | const fanoutApiToken = env.FANOUT_API_TOKEN; 27 | if (fanoutServiceId != null && fanoutApiToken != null) { 28 | gripConfig = buildFanoutGripConfig({ 29 | serviceId: fanoutServiceId, 30 | apiToken: fanoutApiToken, 31 | }); 32 | } 33 | } 34 | const publisher = new Publisher(gripConfig); 35 | 36 | // Find whether we are behind GRIP 37 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 38 | 39 | if (request.method === 'GET' && requestUrl.pathname === '/api/stream') { 40 | // Make sure we're behind a GRIP proxy before we proceed 41 | if (!gripStatus.isProxied) { 42 | return new Response( 43 | '[not proxied]\n', 44 | { 45 | status: 200, 46 | headers: { 47 | 'Content-Type': 'text/plain', 48 | }, 49 | }, 50 | ); 51 | } 52 | 53 | // Create some GRIP instructions and hold the stream 54 | const gripInstruct = new GripInstruct('test'); 55 | gripInstruct.setHoldStream(); 56 | 57 | // Return the response 58 | // Include the GRIP instructions in the response headers 59 | return new Response( 60 | '[stream open]\n', 61 | { 62 | status: 200, 63 | headers: { 64 | ...gripInstruct.toHeaders(), 65 | 'Content-Type': 'text/plain', 66 | }, 67 | }, 68 | ); 69 | } 70 | 71 | if (request.method === 'POST' && requestUrl.pathname === '/api/publish') { 72 | // Only accept text bodies 73 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 74 | return new Response( 75 | 'Body must be text/plain\n', { 76 | status: 415, 77 | headers: { 78 | 'Content-Type': 'text/plain', 79 | }, 80 | }, 81 | ); 82 | } 83 | 84 | // Read the body 85 | const body = await request.text(); 86 | 87 | // Publish the body to GRIP clients that listen to http-stream format 88 | await publisher.publishHttpStream('test', body + '\n'); 89 | 90 | // Return a success response 91 | return new Response( 92 | 'Ok\n', 93 | { 94 | status: 200, 95 | headers: { 96 | 'Content-Type': 'text/plain', 97 | }, 98 | }, 99 | ); 100 | } 101 | 102 | // Return an error response 103 | return new Response('Not found\n', { status: 404 }); 104 | }, 105 | }; 106 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/http-stream/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "module": "es2022", 6 | "moduleResolution": "node", 7 | "types": [ 8 | "@cloudflare/workers-types/2023-07-01" 9 | ], 10 | "resolveJsonModule": true, 11 | "allowJs": true, 12 | "checkJs": false, 13 | "noEmit": true, 14 | "isolatedModules": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "strict": true, 18 | "skipLibCheck": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/http-stream/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "http-stream" 2 | main = "src/index.ts" 3 | compatibility_date = "2024-03-04" 4 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/websocket/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "private": true, 4 | "scripts": { 5 | "deploy": "wrangler deploy", 6 | "dev": "wrangler dev --port 3000", 7 | "start": "wrangler dev --port 3000" 8 | }, 9 | "devDependencies": { 10 | "@cloudflare/workers-types": "^4.20240222.0", 11 | "typescript": "^5.4.3", 12 | "wrangler": "^3.0.0" 13 | }, 14 | "dependencies": { 15 | "@fanoutio/grip": "^4.3.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/websocket/src/index.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { 4 | type IGripConfig, 5 | encodeWebSocketEvents, 6 | getWebSocketContextFromReq, 7 | isWsOverHttp, 8 | parseGripUri, 9 | Publisher, 10 | WebSocketMessageFormat, 11 | } from '@fanoutio/grip'; 12 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 13 | 14 | export interface Env { 15 | GRIP_URL?: string, 16 | GRIP_VERIFY_KEY?: string, 17 | FANOUT_SERVICE_ID?: string, 18 | FANOUT_API_TOKEN?: string, 19 | } 20 | 21 | export default { 22 | async fetch(request: Request, env: Env): Promise { 23 | 24 | const requestUrl = new URL(request.url); 25 | 26 | // Configure the Publisher. 27 | // Settings are stored in the environment 28 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 29 | const gripUrl = env.GRIP_URL; 30 | if (gripUrl) { 31 | gripConfig = parseGripUri(gripUrl, { 'verify-key': env.GRIP_VERIFY_KEY }); 32 | } else { 33 | const fanoutServiceId = env.FANOUT_SERVICE_ID; 34 | const fanoutApiToken = env.FANOUT_API_TOKEN; 35 | if (fanoutServiceId != null && fanoutApiToken != null) { 36 | gripConfig = buildFanoutGripConfig({ 37 | serviceId: fanoutServiceId, 38 | apiToken: fanoutApiToken, 39 | }); 40 | } 41 | } 42 | const publisher = new Publisher(gripConfig); 43 | 44 | // Find whether we are behind GRIP 45 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 46 | 47 | // Find whether we have a WebSocket context 48 | let wsContext = null; 49 | if (gripStatus.isProxied && isWsOverHttp(request)) { 50 | wsContext = await getWebSocketContextFromReq(request); 51 | } 52 | 53 | if (request.method === 'POST' && requestUrl.pathname === '/api/websocket') { 54 | // Make sure we're behind a GRIP proxy before we proceed 55 | if (!gripStatus.isProxied) { 56 | return new Response( 57 | '[not proxied]\n', 58 | { 59 | status: 200, 60 | headers: { 61 | 'Content-Type': 'text/plain', 62 | }, 63 | }, 64 | ); 65 | } 66 | 67 | // Make sure we have a WebSocket context 68 | if (wsContext == null) { 69 | return new Response( 70 | '[not a websocket request]\n', 71 | { 72 | status: 400, 73 | headers: { 74 | 'Content-Type': 'text/plain', 75 | }, 76 | }, 77 | ); 78 | } 79 | 80 | // If this is a new connection, accept it and subscribe it to a channel 81 | if (wsContext.isOpening()) { 82 | wsContext.accept(); 83 | wsContext.subscribe('test'); 84 | } 85 | 86 | // wsContext has a buffer of queued-up incoming WebSocket messages. 87 | 88 | // Iterate this queue 89 | while (wsContext.canRecv()) { 90 | const message = wsContext.recv(); 91 | 92 | if (message == null) { 93 | // If return value is undefined then connection is closed 94 | // Messages like this go into a queue of outgoing WebSocket messages 95 | wsContext.close(); 96 | break; 97 | } 98 | 99 | // Echo the message 100 | // This is also a message that goes into the queue of outgoing WebSocket messages. 101 | wsContext.send(message); 102 | } 103 | 104 | // Serialize the outgoing messages 105 | const events = wsContext.getOutgoingEvents(); 106 | const responseBody = encodeWebSocketEvents(events); 107 | 108 | // Return the response 109 | return new Response( 110 | responseBody, 111 | { 112 | status: 200, 113 | headers: wsContext.toHeaders(), 114 | }, 115 | ); 116 | } 117 | 118 | if (request.method === 'POST' && requestUrl.pathname === '/api/broadcast') { 119 | // Only accept text bodies 120 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 121 | return new Response( 122 | 'Body must be text/plain\n', { 123 | status: 415, 124 | headers: { 125 | 'Content-Type': 'text/plain', 126 | }, 127 | }, 128 | ); 129 | } 130 | 131 | // Read the body 132 | const body = await request.text(); 133 | 134 | // Publish the body to GRIP clients that listen to ws-over-http format 135 | await publisher.publishFormats('test', new WebSocketMessageFormat(body)); 136 | 137 | // Return a success response 138 | return new Response( 139 | 'Ok\n', 140 | { 141 | status: 200, 142 | headers: { 143 | 'Content-Type': 'text/plain', 144 | }, 145 | }, 146 | ); 147 | } 148 | 149 | // Return an error response 150 | return new Response('Not found\n', { status: 404 }); 151 | }, 152 | }; 153 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/websocket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "module": "es2022", 6 | "moduleResolution": "node", 7 | "types": [ 8 | "@cloudflare/workers-types/2023-07-01" 9 | ], 10 | "resolveJsonModule": true, 11 | "allowJs": true, 12 | "checkJs": false, 13 | "noEmit": true, 14 | "isolatedModules": true, 15 | "allowSyntheticDefaultImports": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "strict": true, 18 | "skipLibCheck": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/cloudflare-workers/websocket/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "http-stream" 2 | main = "src/index.ts" 3 | compatibility_date = "2024-03-04" 4 | -------------------------------------------------------------------------------- /examples/deno/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Deno 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using a Deno application as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The request handling section of this example goes on to handle two routes: 50 | 51 | 1. A `GET` request at `/api/stream` 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler checks `gripStatus.isProxied` to make sure we are being run behind a valid 56 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 57 | or if the signature validation failed. 58 | 59 | ```typescript 60 | if (!gripStatus.isProxied) { 61 | // emit an error 62 | } 63 | ``` 64 | 65 | If successful, then the handler goes on to set up a GRIP instruction. 66 | This instruction asks the GRIP proxy to hold the current connection open 67 | as a streaming connection, listening to the channel named `'test'`. 68 | 69 | ```typescript 70 | const gripInstruct = new GripInstruct('test'); 71 | gripInstruct.setHoldStream(); 72 | ``` 73 | 74 | Finally, a response is generated and returned, including the 75 | `gripInstruct` in the response headers. 76 | 77 | ```typescript 78 | return new Response( 79 | '[stream open]\n', 80 | { 81 | status: 200, 82 | headers: { 83 | ...gripInstruct.toHeaders(), 84 | 'Content-Type': 'text/plain', 85 | }, 86 | }, 87 | ); 88 | ``` 89 | 90 | That's all that's needed to hold a connection open. Note that the connection between 91 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 92 | connection open with the client. 93 | 94 | 2. A `POST` request at `/api/publish` 95 | 96 | This handler starts by checking to make sure the content type header specifies that 97 | the body is of type `text/plain`. Afterward, the handler reads the request body into 98 | a string. 99 | 100 | ```typescript 101 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 102 | // emit an error 103 | } 104 | const body = await request.text(); 105 | ``` 106 | 107 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 108 | body text as a message to the `test` channel. Any listener on this channel, 109 | such as those that have opened a stream through the endpoint described above, 110 | will receive the message. 111 | 112 | ```typescript 113 | await publisher.publishHttpStream('test', body + '\n'); 114 | ``` 115 | 116 | Finally, it returns a simple success response message and ends. 117 | 118 | ```typescript 119 | return new Response( 120 | 'Ok\n', 121 | { 122 | status: 200, 123 | headers: { 124 | 'Content-Type': 'text/plain', 125 | }, 126 | }, 127 | ); 128 | ``` 129 | -------------------------------------------------------------------------------- /examples/deno/http-stream/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "deno run --allow-env --allow-read --allow-net index.ts" 4 | }, 5 | "deploy": { 6 | "project": "038ad35d-3201-4204-a76a-de9c79eb71c1", 7 | "exclude": [ 8 | "**/node_modules" 9 | ], 10 | "include": [], 11 | "entrypoint": "index.ts" 12 | } 13 | } -------------------------------------------------------------------------------- /examples/deno/http-stream/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "npm:@fanoutio/grip@^4.1": "npm:@fanoutio/grip@4.1.0" 6 | }, 7 | "npm": { 8 | "@fanoutio/grip@4.1.0": { 9 | "integrity": "sha512-jZW71/QIFWdQHCVgQs6VVNeYuvd2G2v+nX2hCiI6Q4BSPj4hkMboemyAkwnT7QoPjHbP4x54AzY+gzMZQTrWUA==", 10 | "dependencies": { 11 | "debug": "debug@4.3.4", 12 | "jose": "jose@5.2.3", 13 | "jspack": "jspack@0.0.4" 14 | } 15 | }, 16 | "debug@4.3.4": { 17 | "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", 18 | "dependencies": { 19 | "ms": "ms@2.1.2" 20 | } 21 | }, 22 | "jose@5.2.3": { 23 | "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", 24 | "dependencies": {} 25 | }, 26 | "jspack@0.0.4": { 27 | "integrity": "sha512-DC/lSTXYDDdYWzyY/9A1kMzp6Ov9mCRhZQ1cGg4te2w3y4/aKZTSspvbYN4LUsvSzMCb/H8z4TV9mYYW/bs3PQ==", 28 | "dependencies": {} 29 | }, 30 | "ms@2.1.2": { 31 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", 32 | "dependencies": {} 33 | } 34 | } 35 | }, 36 | "remote": {} 37 | } 38 | -------------------------------------------------------------------------------- /examples/deno/http-stream/import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /examples/deno/http-stream/index.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, GripInstruct, parseGripUri, Publisher } from 'npm:@fanoutio/grip@^4.1'; 4 | import { buildFanoutGripConfig } from 'npm:@fanoutio/grip@^4.1/fastly-fanout'; 5 | 6 | // Configure the Publisher. 7 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 8 | const gripUrl = Deno.env.GRIP_URL; 9 | if (gripUrl) { 10 | gripConfig = parseGripUri(gripUrl, { 'verify-key': Deno.env.GRIP_VERIFY_KEY }); 11 | } else { 12 | const fanoutServiceId = Deno.env.FANOUT_SERVICE_ID; 13 | const fanoutApiToken = Deno.env.FANOUT_API_TOKEN; 14 | if (fanoutServiceId != null && fanoutApiToken != null) { 15 | gripConfig = buildFanoutGripConfig({ 16 | serviceId: fanoutServiceId, 17 | apiToken: fanoutApiToken, 18 | }); 19 | } 20 | } 21 | const publisher = new Publisher(gripConfig); 22 | 23 | async function handler(request: Request) { 24 | 25 | const requestUrl = new URL(request.url); 26 | 27 | // Find whether we are behind GRIP 28 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 29 | 30 | if (request.method === 'GET' && requestUrl.pathname === '/api/stream') { 31 | // Make sure we're behind a GRIP proxy before we proceed 32 | if (!gripStatus.isProxied) { 33 | return new Response( 34 | '[not proxied]\n', 35 | { 36 | status: 200, 37 | headers: { 38 | 'Content-Type': 'text/plain', 39 | }, 40 | }, 41 | ); 42 | } 43 | 44 | // Create some GRIP instructions and hold the stream 45 | const gripInstruct = new GripInstruct('test'); 46 | gripInstruct.setHoldStream(); 47 | 48 | // Return the response 49 | // Include the GRIP instructions in the response headers 50 | return new Response( 51 | '[stream open]\n', 52 | { 53 | status: 200, 54 | headers: { 55 | ...gripInstruct.toHeaders(), 56 | 'Content-Type': 'text/plain', 57 | }, 58 | }, 59 | ); 60 | } 61 | 62 | if (request.method === 'POST' && requestUrl.pathname === '/api/publish') { 63 | // Only accept text bodies 64 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 65 | return new Response( 66 | 'Body must be text/plain\n', { 67 | status: 415, 68 | headers: { 69 | 'Content-Type': 'text/plain', 70 | }, 71 | }, 72 | ); 73 | } 74 | 75 | // Read the body 76 | const body = await request.text(); 77 | 78 | // Publish the body to GRIP clients that listen to http-stream format 79 | await publisher.publishHttpStream('test', body + '\n'); 80 | 81 | // Return a success response 82 | return new Response( 83 | 'Ok\n', 84 | { 85 | status: 200, 86 | headers: { 87 | 'Content-Type': 'text/plain', 88 | }, 89 | }, 90 | ); 91 | } 92 | 93 | // Return an error response 94 | return new Response('Not found\n', { status: 404 }); 95 | } 96 | 97 | Deno.serve({ port: 3000 }, handler); 98 | -------------------------------------------------------------------------------- /examples/deno/websocket/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "deno run --allow-env --allow-read --allow-net index.ts" 4 | }, 5 | "deploy": { 6 | "project": "038ad35d-3201-4204-a76a-de9c79eb71c1", 7 | "exclude": [ 8 | "**/node_modules" 9 | ], 10 | "include": [], 11 | "entrypoint": "index.ts" 12 | } 13 | } -------------------------------------------------------------------------------- /examples/deno/websocket/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "npm:@fanoutio/grip@^4.1": "npm:@fanoutio/grip@4.1.0" 6 | }, 7 | "npm": { 8 | "@fanoutio/grip@4.1.0": { 9 | "integrity": "sha512-jZW71/QIFWdQHCVgQs6VVNeYuvd2G2v+nX2hCiI6Q4BSPj4hkMboemyAkwnT7QoPjHbP4x54AzY+gzMZQTrWUA==", 10 | "dependencies": { 11 | "debug": "debug@4.3.4", 12 | "jose": "jose@5.2.3", 13 | "jspack": "jspack@0.0.4" 14 | } 15 | }, 16 | "debug@4.3.4": { 17 | "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", 18 | "dependencies": { 19 | "ms": "ms@2.1.2" 20 | } 21 | }, 22 | "jose@5.2.3": { 23 | "integrity": "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA==", 24 | "dependencies": {} 25 | }, 26 | "jspack@0.0.4": { 27 | "integrity": "sha512-DC/lSTXYDDdYWzyY/9A1kMzp6Ov9mCRhZQ1cGg4te2w3y4/aKZTSspvbYN4LUsvSzMCb/H8z4TV9mYYW/bs3PQ==", 28 | "dependencies": {} 29 | }, 30 | "ms@2.1.2": { 31 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", 32 | "dependencies": {} 33 | } 34 | } 35 | }, 36 | "remote": {} 37 | } 38 | -------------------------------------------------------------------------------- /examples/deno/websocket/import_map.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /examples/fastly-compute/http-stream/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /bin 3 | /pkg 4 | -------------------------------------------------------------------------------- /examples/fastly-compute/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Fastly Compute 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using Fastly Compute as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The request handling section of this example goes on to handle two routes: 50 | 51 | 1. A `GET` request at `/api/stream` 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler checks `gripStatus.isProxied` to make sure we are being run behind a valid 56 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 57 | or if the signature validation failed. 58 | 59 | ```javascript 60 | if (!gripStatus.isProxied) { 61 | // emit an error 62 | } 63 | ``` 64 | 65 | If successful, then the handler goes on to set up a GRIP instruction. 66 | This instruction asks the GRIP proxy to hold the current connection open 67 | as a streaming connection, listening to the channel named `'test'`. 68 | 69 | ```javascript 70 | const gripInstruct = new GripInstruct('test'); 71 | gripInstruct.setHoldStream(); 72 | ``` 73 | 74 | Finally, a response is generated and returned, including the 75 | `gripInstruct` in the response headers. 76 | 77 | ```javascript 78 | return new Response( 79 | '[stream open]\n', 80 | { 81 | status: 200, 82 | headers: { 83 | ...gripInstruct.toHeaders(), 84 | 'Content-Type': 'text/plain', 85 | }, 86 | }, 87 | ); 88 | ``` 89 | 90 | That's all that's needed to hold a connection open. Note that the connection between 91 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 92 | connection open with the client. 93 | 94 | 2. A `POST` request at `/api/publish` 95 | 96 | This handler starts by checking to make sure the content type header specifies that 97 | the body is of type `text/plain`. Afterward, the handler reads the request body into 98 | a string. 99 | 100 | ```javascript 101 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 102 | // emit an error 103 | } 104 | const body = await request.text(); 105 | ``` 106 | 107 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 108 | body text as a message to the `test` channel. Any listener on this channel, 109 | such as those that have opened a stream through the endpoint described above, 110 | will receive the message. 111 | 112 | ```javascript 113 | await publisher.publishHttpStream('test', body + '\n'); 114 | ``` 115 | 116 | Finally, it returns a simple success response message and ends. 117 | 118 | ```javascript 119 | return new Response( 120 | 'Ok\n', 121 | { 122 | status: 200, 123 | headers: { 124 | 'Content-Type': 'text/plain', 125 | }, 126 | }, 127 | ); 128 | ``` 129 | -------------------------------------------------------------------------------- /examples/fastly-compute/http-stream/fastly.toml: -------------------------------------------------------------------------------- 1 | # This file describes a Fastly Compute package. To learn more visit: 2 | # https://www.fastly.com/documentation/reference/compute/fastly-toml/ 3 | 4 | authors = ["komuro@fastly.com"] 5 | description = "GRIP demo of HTTP Streams on Fastly Fanout" 6 | language = "javascript" 7 | manifest_version = 3 8 | name = "fastly-http-stream" 9 | 10 | [local_server] 11 | 12 | [local_server.backends] 13 | 14 | [local_server.backends.publisher] 15 | override_host = "127.0.0.1:5561" 16 | url = "http://127.0.0.1:5561/" 17 | 18 | [local_server.secret_stores] 19 | 20 | [[local_server.secret_stores.fastly_http_stream_config]] 21 | data = "http://127.0.0.1:5561/" 22 | key = "GRIP_URL" 23 | 24 | [setup] 25 | 26 | [setup.secret_stores] 27 | 28 | [setup.secret_stores.fastly_http_stream_config] 29 | description = "Configuration data for HTTP stream service" 30 | 31 | [setup.secret_stores.fastly_http_stream_config.entries] 32 | 33 | [setup.secret_stores.fastly_http_stream_config.entries.GRIP_URL] 34 | description = "GRIP_URL" 35 | 36 | [setup.secret_stores.fastly_http_stream_config.entries.GRIP_VERIFY_KEY] 37 | description = "GRIP_VERIFY_KEY" 38 | 39 | [scripts] 40 | build = "npm run build" 41 | post_init = "npm install" 42 | -------------------------------------------------------------------------------- /examples/fastly-compute/http-stream/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@fanoutio/grip": "^4.3.0", 5 | "@fastly/js-compute": "^3.0.0", 6 | "jose": "^5.2.2" 7 | }, 8 | "scripts": { 9 | "build": "js-compute-runtime ./src/index.js ./bin/main.wasm", 10 | "start": "fastly compute serve --verbose --addr=\"127.0.0.1:3000\"", 11 | "deploy": "fastly compute publish" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/fastly-compute/http-stream/src/index.js: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | /// 4 | import { SecretStore } from 'fastly:secret-store'; 5 | import { GripInstruct, parseGripUri, Publisher } from '@fanoutio/grip'; 6 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 7 | 8 | addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); 9 | 10 | async function handleRequest({request}) { 11 | 12 | const requestUrl = new URL(request.url); 13 | 14 | // Configure the Publisher. 15 | // Settings are stored in a secret store 16 | const secretStore = new SecretStore('fastly_http_stream_config'); 17 | let gripConfig = 'http://127.0.0.1:5561/'; 18 | const gripUrl = (await secretStore.get('GRIP_URL'))?.plaintext(); 19 | if (gripUrl) { 20 | gripConfig = parseGripUri(gripUrl, { 'verify-key': (await secretStore.get('GRIP_VERIFY_KEY'))?.plaintext() }); 21 | } else { 22 | const fanoutServiceId = (await secretStore.get('FANOUT_SERVICE_ID'))?.plaintext(); 23 | const fanoutApiToken = (await secretStore.get('FANOUT_API_TOKEN'))?.plaintext(); 24 | if (fanoutServiceId != null && fanoutApiToken != null) { 25 | gripConfig = buildFanoutGripConfig({ 26 | serviceId: fanoutServiceId, 27 | apiToken: fanoutApiToken, 28 | }); 29 | } 30 | } 31 | 32 | // In Compute, we create a custom Publisher config that adds a backend 33 | // to the fetch parameter 34 | const publisher = new Publisher(gripConfig, { 35 | fetch(input, init) { 36 | return fetch(String(input), { ...init, backend: 'publisher' }); 37 | }, 38 | }); 39 | 40 | // Find whether we are behind GRIP 41 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 42 | 43 | if (request.method === 'GET' && requestUrl.pathname === '/api/stream') { 44 | // Make sure we're behind a GRIP proxy before we proceed 45 | if (!gripStatus.isProxied) { 46 | return new Response( 47 | '[not proxied]\n', 48 | { 49 | status: 200, 50 | headers: { 51 | 'Content-Type': 'text/plain', 52 | }, 53 | }, 54 | ); 55 | } 56 | 57 | // Create some GRIP instructions and hold the stream 58 | const gripInstruct = new GripInstruct('test'); 59 | gripInstruct.setHoldStream(); 60 | 61 | // Return the response 62 | // Include the GRIP instructions in the response headers 63 | return new Response( 64 | '[stream open]\n', 65 | { 66 | status: 200, 67 | headers: { 68 | ...gripInstruct.toHeaders(), 69 | 'Content-Type': 'text/plain', 70 | }, 71 | }, 72 | ); 73 | } 74 | 75 | if (request.method === 'POST' && requestUrl.pathname === '/api/publish') { 76 | // Only accept text bodies 77 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 78 | return new Response( 79 | 'Body must be text/plain\n', { 80 | status: 415, 81 | headers: { 82 | 'Content-Type': 'text/plain', 83 | }, 84 | }, 85 | ); 86 | } 87 | 88 | // Read the body 89 | const body = await request.text(); 90 | 91 | // Publish the body to GRIP clients that listen to http-stream format 92 | await publisher.publishHttpStream('test', body + '\n'); 93 | 94 | // Return a success response 95 | return new Response( 96 | 'Ok\n', 97 | { 98 | status: 200, 99 | headers: { 100 | 'Content-Type': 'text/plain', 101 | }, 102 | }, 103 | ); 104 | } 105 | 106 | // Return an error response 107 | return new Response('Not found\n', { status: 404 }); 108 | } 109 | -------------------------------------------------------------------------------- /examples/fastly-compute/websocket/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /bin 3 | /pkg 4 | -------------------------------------------------------------------------------- /examples/fastly-compute/websocket/fastly.toml: -------------------------------------------------------------------------------- 1 | # This file describes a Fastly Compute package. To learn more visit: 2 | # https://www.fastly.com/documentation/reference/compute/fastly-toml/ 3 | 4 | authors = ["komuro@fastly.com"] 5 | description = "GRIP demo of WebSocket-over-HTTP on Fastly Fanout" 6 | language = "javascript" 7 | manifest_version = 3 8 | name = "fastly-websocket" 9 | 10 | [local_server] 11 | 12 | [local_server.backends] 13 | 14 | [local_server.backends.publisher] 15 | override_host = "127.0.0.1:5561" 16 | url = "http://127.0.0.1:5561/" 17 | 18 | [local_server.secret_stores] 19 | 20 | [[local_server.secret_stores.fastly_websocket_config]] 21 | data = "http://127.0.0.1:5561/" 22 | key = "GRIP_URL" 23 | 24 | [setup] 25 | 26 | [setup.secret_stores] 27 | 28 | [setup.secret_stores.fastly_websocket_config] 29 | description = "Configuration data for WebSocket-over-HTTP service" 30 | 31 | [setup.secret_stores.fastly_websocket_config.entries] 32 | 33 | [setup.secret_stores.fastly_websocket_config.entries.GRIP_URL] 34 | description = "GRIP_URL" 35 | 36 | [setup.secret_stores.fastly_websocket_config.entries.GRIP_VERIFY_KEY] 37 | description = "GRIP_VERIFY_KEY" 38 | 39 | [scripts] 40 | build = "npm run build" 41 | post_init = "npm install" 42 | -------------------------------------------------------------------------------- /examples/fastly-compute/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@fanoutio/grip": "^4.3.0", 5 | "@fastly/js-compute": "^3.0.0", 6 | "jose": "^5.2.2" 7 | }, 8 | "scripts": { 9 | "build": "js-compute-runtime ./src/index.js ./bin/main.wasm", 10 | "start": "fastly compute serve --verbose --addr=\"127.0.0.1:3000\"", 11 | "deploy": "fastly compute publish" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Next.js 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using a Next.js application as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The example exposes two API Routes (Resource Routes): 50 | 51 | 1. A `GET` request at `/api/stream` (File: `src/app/api/stream/reoute.ts`) 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler calls `publisher.validateGripSig` to validate this header, storing the result in 56 | the `gripStatus` variable. 57 | 58 | It checks `gripStatus.isProxied` to make sure we are being run behind a valid 59 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 60 | or if the signature validation failed. 61 | 62 | ```typescript 63 | if (!gripStatus.isProxied) { 64 | // emit an error 65 | } 66 | ``` 67 | 68 | If successful, then the handler goes on to set up a GRIP instruction. 69 | This instruction asks the GRIP proxy to hold the current connection open 70 | as a streaming connection, listening to the channel named `'test'`. 71 | 72 | ```typescript 73 | const gripInstruct = new GripInstruct('test'); 74 | gripInstruct.setHoldStream(); 75 | ``` 76 | 77 | Finally, a response is generated and returned, including the 78 | `gripInstruct` in the response headers. 79 | 80 | ```typescript 81 | return new Response( 82 | '[stream open]\n', 83 | { 84 | status: 200, 85 | headers: { 86 | ...gripInstruct.toHeaders(), 87 | 'Content-Type': 'text/plain', 88 | }, 89 | }, 90 | ); 91 | ``` 92 | 93 | That's all that's needed to hold a connection open. Note that the connection between 94 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 95 | connection open with the client. 96 | 97 | 2. A `POST` request at `/api/publish` (File: `src/app/api/publish/route.ts`) 98 | 99 | This handler starts by checking that the content type header specifies that the body 100 | is of type `text/plain`. Afterward, the handler reads the request body into a string. 101 | 102 | ```typescript 103 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 104 | // emit an error 105 | } 106 | const body = await request.text(); 107 | ``` 108 | 109 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 110 | body text as a message to the `test` channel. Any listener on this channel, 111 | such as those that have opened a stream through the endpoint described above, 112 | will receive the message. 113 | 114 | ```typescript 115 | await publisher.publishHttpStream('test', body + '\n'); 116 | ``` 117 | 118 | Finally, it returns a simple success response message and ends. 119 | 120 | ```typescript 121 | return new Response( 122 | 'Ok\n', 123 | { 124 | status: 200, 125 | headers: { 126 | 'Content-Type': 'text/plain', 127 | }, 128 | }, 129 | ); 130 | ``` 131 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@fanoutio/grip": "^4.3.0", 13 | "next": "14.1.2", 14 | "react": "^18", 15 | "react-dom": "^18" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "typescript": "^5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/src/app/api/publish/route.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import publisher from '@/utils/publisher'; 4 | 5 | export async function POST(request: Request) { 6 | // Only accept text bodies 7 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 8 | return new Response( 9 | 'Body must be text/plain\n', { 10 | status: 415, 11 | headers: { 12 | 'Content-Type': 'text/plain', 13 | }, 14 | }, 15 | ); 16 | } 17 | 18 | // Read the body 19 | const body = await request.text(); 20 | 21 | // Publish the body to GRIP clients that listen to http-stream format 22 | await publisher.publishHttpStream('test', body + '\n'); 23 | 24 | // Return a success response 25 | return new Response( 26 | 'Ok\n', 27 | { 28 | status: 200, 29 | headers: { 30 | 'Content-Type': 'text/plain', 31 | }, 32 | }, 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/src/app/api/stream/route.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { GripInstruct } from '@fanoutio/grip'; 4 | import publisher from '@/utils/publisher'; 5 | 6 | export async function GET(request: Request) { 7 | // Find whether we are behind GRIP 8 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 9 | 10 | // Make sure we're behind a GRIP proxy before we proceed 11 | if (!gripStatus.isProxied) { 12 | return new Response( 13 | '[not proxied]\n', 14 | { 15 | status: 200, 16 | headers: { 17 | 'Content-Type': 'text/plain', 18 | }, 19 | }, 20 | ); 21 | } 22 | 23 | // Create some GRIP instructions and hold the stream 24 | const gripInstruct = new GripInstruct('test'); 25 | gripInstruct.setHoldStream(); 26 | 27 | // Return the response 28 | // Include the GRIP instructions in the response headers 29 | return new Response( 30 | '[stream open]\n', 31 | { 32 | status: 200, 33 | headers: { 34 | ...gripInstruct.toHeaders(), 35 | 'Content-Type': 'text/plain', 36 | }, 37 | }, 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/src/utils/publisher.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, parseGripUri, Publisher } from '@fanoutio/grip'; 4 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 5 | 6 | // Configure the Publisher. 7 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 8 | const gripUrl = process.env.GRIP_URL; 9 | if (gripUrl) { 10 | gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY }); 11 | } else { 12 | const fanoutServiceId = process.env.FANOUT_SERVICE_ID; 13 | const fanoutApiToken = process.env.FANOUT_API_TOKEN; 14 | if (fanoutServiceId != null && fanoutApiToken != null) { 15 | gripConfig = buildFanoutGripConfig({ 16 | serviceId: fanoutServiceId, 17 | apiToken: fanoutApiToken, 18 | }); 19 | } 20 | } 21 | const publisher = new Publisher(gripConfig); 22 | 23 | export default publisher; 24 | -------------------------------------------------------------------------------- /examples/nextjs/http-stream/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@fanoutio/grip": "^4.3.0", 13 | "next": "14.1.2", 14 | "react": "^18", 15 | "react-dom": "^18" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "typescript": "^5" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/src/app/api/broadcast/route.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { WebSocketMessageFormat } from '@fanoutio/grip'; 4 | import publisher from '@/utils/publisher'; 5 | 6 | export async function POST(request: Request) { 7 | // Only accept text bodies 8 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 9 | return new Response( 10 | 'Body must be text/plain\n', { 11 | status: 415, 12 | headers: { 13 | 'Content-Type': 'text/plain', 14 | }, 15 | }, 16 | ); 17 | } 18 | 19 | // Read the body 20 | const body = await request.text(); 21 | 22 | // Publish the body to GRIP clients that listen to ws-over-http format 23 | await publisher.publishFormats('test', new WebSocketMessageFormat(body)); 24 | 25 | // Return a success response 26 | return new Response( 27 | 'Ok\n', 28 | { 29 | status: 200, 30 | headers: { 31 | 'Content-Type': 'text/plain', 32 | }, 33 | }, 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/src/app/api/websocket/route.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { encodeWebSocketEvents, getWebSocketContextFromReq, isWsOverHttp } from '@fanoutio/grip'; 4 | import publisher from '@/utils/publisher'; 5 | 6 | export async function POST(request: Request) { 7 | 8 | // Find whether we are behind GRIP 9 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 10 | 11 | // Find whether we have a WebSocket context 12 | let wsContext = null; 13 | if (gripStatus.isProxied && isWsOverHttp(request)) { 14 | wsContext = await getWebSocketContextFromReq(request); 15 | } 16 | 17 | // Make sure we're behind a GRIP proxy before we proceed 18 | if (!gripStatus.isProxied) { 19 | return new Response( 20 | '[not proxied]\n', 21 | { 22 | status: 200, 23 | headers: { 24 | 'Content-Type': 'text/plain', 25 | }, 26 | }, 27 | ); 28 | } 29 | 30 | // Make sure we have a WebSocket context 31 | if (wsContext == null) { 32 | return new Response( 33 | '[not a websocket request]\n', 34 | { 35 | status: 400, 36 | headers: { 37 | 'Content-Type': 'text/plain', 38 | }, 39 | }, 40 | ); 41 | } 42 | 43 | // If this is a new connection, accept it and subscribe it to a channel 44 | if (wsContext.isOpening()) { 45 | wsContext.accept(); 46 | wsContext.subscribe('test'); 47 | } 48 | 49 | // wsContext has a buffer of queued-up incoming WebSocket messages. 50 | 51 | // Iterate this queue 52 | while (wsContext.canRecv()) { 53 | const message = wsContext.recv(); 54 | 55 | if (message == null) { 56 | // If return value is undefined then connection is closed 57 | // Messages like this go into a queue of outgoing WebSocket messages 58 | wsContext.close(); 59 | break; 60 | } 61 | 62 | // Echo the message 63 | // This is also a message that goes into the queue of outgoing WebSocket messages. 64 | wsContext.send(message); 65 | } 66 | 67 | // Serialize the outgoing messages 68 | const events = wsContext.getOutgoingEvents(); 69 | const responseBody = encodeWebSocketEvents(events); 70 | 71 | // Return the response 72 | return new Response( 73 | responseBody, 74 | { 75 | status: 200, 76 | headers: wsContext.toHeaders(), 77 | }, 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/src/utils/publisher.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, parseGripUri, Publisher } from '@fanoutio/grip'; 4 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 5 | 6 | // Configure the Publisher. 7 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 8 | const gripUrl = process.env.GRIP_URL; 9 | if (gripUrl) { 10 | gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY }); 11 | } else { 12 | const fanoutServiceId = process.env.FANOUT_SERVICE_ID; 13 | const fanoutApiToken = process.env.FANOUT_API_TOKEN; 14 | if (fanoutServiceId != null && fanoutApiToken != null) { 15 | gripConfig = buildFanoutGripConfig({ 16 | serviceId: fanoutServiceId, 17 | apiToken: fanoutApiToken, 18 | }); 19 | } 20 | } 21 | const publisher = new Publisher(gripConfig); 22 | 23 | export default publisher; 24 | -------------------------------------------------------------------------------- /examples/nextjs/websocket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/nodejs/http-stream/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /examples/nodejs/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Node.js 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using Node.js as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The request handling section of this example goes on to handle two routes: 50 | 51 | 1. A `GET` request at `/api/stream` 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler checks `gripStatus.isProxied` to make sure we are being run behind a valid 56 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 57 | or if the signature validation failed. 58 | 59 | ```javascript 60 | if (!gripStatus.isProxied) { 61 | // emit an error 62 | } 63 | ``` 64 | 65 | If successful, then the handler goes on to set up a GRIP instruction. 66 | This instruction asks the GRIP proxy to hold the current connection open 67 | as a streaming connection, listening on the channel named `'test'`. 68 | 69 | ```javascript 70 | const gripInstruct = new GripInstruct('test'); 71 | gripInstruct.setHoldStream(); 72 | ``` 73 | 74 | Finally, a response is generated and written, including the 75 | `gripInstruct` in the response headers. 76 | 77 | ```javascript 78 | res.writeHead(200, { 79 | ...gripInstruct.toHeaders(), 80 | 'Content-Type': 'text/plain', 81 | }); 82 | res.end('[stream open]\n'); 83 | ``` 84 | 85 | That's all that's needed to hold a connection open. Note that the connection between 86 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 87 | connection open with the client. 88 | 89 | 2. A `POST` request at `/api/publish` 90 | 91 | This handler starts by checking to make sure the content type is `text/plain`. 92 | Afterward, the handler reads the request body into a string. 93 | 94 | ```javascript 95 | if (req.headers['content-type'].split(';')[0] !== 'text/plain') { 96 | // emit an error 97 | } 98 | const body = await new Promise((resolve) => { 99 | const bodyParts = []; 100 | req.on('data', (chunk) => { bodyParts.push(chunk); }); 101 | req.on('end', () => { resolve(Buffer.concat(bodyParts).toString()); }); 102 | }); 103 | ``` 104 | 105 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 106 | body text as a message to the `test` channel. Any listener on this channel, 107 | such as those that have opened a stream through the endpoint described above, 108 | will receive the message. 109 | 110 | ```javascript 111 | await publisher.publishHttpStream('test', body + '\n'); 112 | ``` 113 | 114 | Finally, it writes a simple success response message and ends. 115 | 116 | ```javascript 117 | res.writeHead(200, { 118 | 'Content-Type': 'text/plain', 119 | }); 120 | res.end('Ok\n'); 121 | ``` 122 | -------------------------------------------------------------------------------- /examples/nodejs/http-stream/index.js: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import http from 'node:http'; 4 | import { GripInstruct, parseGripUri, Publisher } from '@fanoutio/grip'; 5 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 6 | import 'isomorphic-fetch'; // Only needed for Node < 16.15 7 | 8 | // Configure the Publisher. 9 | let gripConfig = 'http://127.0.0.1:5561/'; 10 | const gripUrl = process.env.GRIP_URL; 11 | if (gripUrl) { 12 | gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY }); 13 | } else { 14 | const fanoutServiceId = process.env.FANOUT_SERVICE_ID; 15 | const fanoutApiToken = process.env.FANOUT_API_TOKEN; 16 | if (fanoutServiceId != null && fanoutApiToken != null) { 17 | gripConfig = buildFanoutGripConfig({ 18 | serviceId: fanoutServiceId, 19 | apiToken: fanoutApiToken, 20 | }); 21 | } 22 | } 23 | const publisher = new Publisher(gripConfig); 24 | 25 | const server = http.createServer(async (req, res) => { 26 | 27 | const requestUrl = new URL(req.url, (req.socket.encrypted ? 'https://' : 'http://') + req.headers['host']); 28 | 29 | // Find whether we are behind GRIP 30 | const gripStatus = await publisher.validateGripSig(req.headers['grip-sig']); 31 | 32 | if (req.method === 'GET' && requestUrl.pathname === '/api/stream') { 33 | // Make sure we're behind a GRIP proxy before we proceed 34 | if (!gripStatus.isProxied) { 35 | res.writeHead(200, { 36 | 'Content-Type': 'text/plain', 37 | }); 38 | res.end('[not proxied]\n'); 39 | return; 40 | } 41 | 42 | // Create some GRIP instructions and hold the stream 43 | const gripInstruct = new GripInstruct('test'); 44 | gripInstruct.setHoldStream(); 45 | 46 | // Write the response 47 | // Include the GRIP instructions in the response headers 48 | res.writeHead(200, { 49 | ...gripInstruct.toHeaders(), 50 | 'Content-Type': 'text/plain', 51 | }); 52 | res.end('[stream open]\n'); 53 | return; 54 | } 55 | 56 | if (req.method === 'POST' && requestUrl.pathname === '/api/publish') { 57 | // Only accept text bodies 58 | if (req.headers['content-type'].split(';')[0] !== 'text/plain') { 59 | res.writeHead(415, { 60 | 'Content-Type': 'text/plain', 61 | }); 62 | res.end('Body must be text/plain\n'); 63 | return; 64 | } 65 | 66 | // Read the body 67 | const body = await new Promise((resolve) => { 68 | const bodyParts = []; 69 | req.on('data', (chunk) => { 70 | bodyParts.push(chunk); 71 | }); 72 | req.on('end', () => { 73 | resolve(Buffer.concat(bodyParts).toString()); 74 | }); 75 | }); 76 | 77 | // Publish the body to GRIP clients that listen to http-stream format 78 | await publisher.publishHttpStream('test', body + '\n'); 79 | 80 | // Write a success response 81 | res.writeHead(200, { 82 | 'Content-Type': 'text/plain', 83 | }); 84 | res.end('Ok\n'); 85 | return; 86 | } 87 | 88 | // Write an error response 89 | res.writeHead(404, { 90 | 'Content-Type': 'text/plain', 91 | }); 92 | res.end('Not found\n'); 93 | }); 94 | 95 | server.listen(3000, '0.0.0.0'); 96 | 97 | console.log('Server running...'); 98 | -------------------------------------------------------------------------------- /examples/nodejs/http-stream/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@fanoutio/grip": "^4.3.0", 9 | "isomorphic-fetch": "^3.0.0" 10 | } 11 | }, 12 | "node_modules/@fanoutio/grip": { 13 | "version": "4.3.0", 14 | "resolved": "https://registry.npmjs.org/@fanoutio/grip/-/grip-4.3.0.tgz", 15 | "integrity": "sha512-aBAc84BmCUTV1BSV+nYlHMLzMlf4O9TI0DTe8RSYdj2iHvPudHrb6C74VMRbuOi82tWaad1bOgpYZj3dKsP9Lg==", 16 | "dependencies": { 17 | "debug": "^4.3.4", 18 | "jose": "^5.2.2", 19 | "jspack": "0.0.4" 20 | }, 21 | "engines": { 22 | "node": ">= 16" 23 | } 24 | }, 25 | "node_modules/debug": { 26 | "version": "4.3.4", 27 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", 28 | "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", 29 | "dependencies": { 30 | "ms": "2.1.2" 31 | }, 32 | "engines": { 33 | "node": ">=6.0" 34 | }, 35 | "peerDependenciesMeta": { 36 | "supports-color": { 37 | "optional": true 38 | } 39 | } 40 | }, 41 | "node_modules/isomorphic-fetch": { 42 | "version": "3.0.0", 43 | "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", 44 | "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", 45 | "dependencies": { 46 | "node-fetch": "^2.6.1", 47 | "whatwg-fetch": "^3.4.1" 48 | } 49 | }, 50 | "node_modules/jose": { 51 | "version": "5.2.2", 52 | "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz", 53 | "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==", 54 | "funding": { 55 | "url": "https://github.com/sponsors/panva" 56 | } 57 | }, 58 | "node_modules/jspack": { 59 | "version": "0.0.4", 60 | "resolved": "https://registry.npmjs.org/jspack/-/jspack-0.0.4.tgz", 61 | "integrity": "sha512-DC/lSTXYDDdYWzyY/9A1kMzp6Ov9mCRhZQ1cGg4te2w3y4/aKZTSspvbYN4LUsvSzMCb/H8z4TV9mYYW/bs3PQ==" 62 | }, 63 | "node_modules/ms": { 64 | "version": "2.1.2", 65 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 66 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 67 | }, 68 | "node_modules/node-fetch": { 69 | "version": "2.7.0", 70 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", 71 | "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 72 | "dependencies": { 73 | "whatwg-url": "^5.0.0" 74 | }, 75 | "engines": { 76 | "node": "4.x || >=6.0.0" 77 | }, 78 | "peerDependencies": { 79 | "encoding": "^0.1.0" 80 | }, 81 | "peerDependenciesMeta": { 82 | "encoding": { 83 | "optional": true 84 | } 85 | } 86 | }, 87 | "node_modules/tr46": { 88 | "version": "0.0.3", 89 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 90 | "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 91 | }, 92 | "node_modules/webidl-conversions": { 93 | "version": "3.0.1", 94 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 95 | "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" 96 | }, 97 | "node_modules/whatwg-fetch": { 98 | "version": "3.6.20", 99 | "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", 100 | "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" 101 | }, 102 | "node_modules/whatwg-url": { 103 | "version": "5.0.0", 104 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 105 | "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 106 | "dependencies": { 107 | "tr46": "~0.0.3", 108 | "webidl-conversions": "^3.0.0" 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /examples/nodejs/http-stream/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@fanoutio/grip": "^4.3.0", 5 | "isomorphic-fetch": "^3.0.0" 6 | }, 7 | "scripts": { 8 | "start": "node ./index.js" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/nodejs/websocket/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /examples/nodejs/websocket/index.js: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import http from 'node:http'; 4 | import { 5 | parseGripUri, 6 | Publisher, 7 | encodeWebSocketEvents, 8 | WebSocketMessageFormat, 9 | isNodeReqWsOverHttp, 10 | getWebSocketContextFromNodeReq, 11 | } from '@fanoutio/grip'; 12 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 13 | import 'isomorphic-fetch'; // Only needed for Node < 16.15 14 | 15 | // Configure the Publisher. 16 | let gripConfig = 'http://127.0.0.1:5561/'; 17 | const gripUrl = process.env.GRIP_URL; 18 | if (gripUrl) { 19 | gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY }); 20 | } else { 21 | const fanoutServiceId = process.env.FANOUT_SERVICE_ID; 22 | const fanoutApiToken = process.env.FANOUT_API_TOKEN; 23 | if (fanoutServiceId != null && fanoutApiToken != null) { 24 | gripConfig = buildFanoutGripConfig({ 25 | serviceId: fanoutServiceId, 26 | apiToken: fanoutApiToken, 27 | }); 28 | } 29 | } 30 | const publisher = new Publisher(gripConfig); 31 | 32 | const server = http.createServer(async (req, res) => { 33 | 34 | const requestUrl = new URL(req.url, (req.socket.encrypted ? 'https://' : 'http://') + req.headers['host']); 35 | 36 | // Find whether we are behind GRIP 37 | const gripStatus = await publisher.validateGripSig(req.headers['grip-sig']); 38 | 39 | // Find whether we have a WebSocket context 40 | let wsContext = null; 41 | if (gripStatus.isProxied && isNodeReqWsOverHttp(req)) { 42 | wsContext = await getWebSocketContextFromNodeReq(req); 43 | } 44 | 45 | if (req.method === 'POST' && requestUrl.pathname === '/api/websocket') { 46 | // Make sure we're behind a GRIP proxy before we proceed 47 | if (!gripStatus.isProxied) { 48 | res.writeHead(200, { 49 | 'Content-Type': 'text/plain', 50 | }); 51 | res.end('[not proxied]\n'); 52 | return; 53 | } 54 | 55 | // Make sure we have a WebSocket context 56 | if (wsContext == null) { 57 | res.writeHead(400, { 58 | 'Content-Type': 'text/plain', 59 | }); 60 | res.end('[not a websocket request]\n'); 61 | return; 62 | } 63 | 64 | // If this is a new connection, accept it and subscribe it to a channel 65 | if (wsContext.isOpening()) { 66 | wsContext.accept(); 67 | wsContext.subscribe('test'); 68 | } 69 | 70 | // wsContext has a buffer of queued-up incoming WebSocket messages. 71 | 72 | // Iterate this queue 73 | while (wsContext.canRecv()) { 74 | const message = wsContext.recv(); 75 | 76 | if (message == null) { 77 | // If return value is undefined then connection is closed 78 | // Messages like this go into a queue of outgoing WebSocket messages 79 | wsContext.close(); 80 | break; 81 | } 82 | 83 | // Echo the message 84 | // This is also a message that goes into the queue of outgoing WebSocket messages. 85 | wsContext.send(message); 86 | } 87 | 88 | // Serialize the outgoing messages 89 | const events = wsContext.getOutgoingEvents(); 90 | const responseBody = encodeWebSocketEvents(events); 91 | 92 | // Write the response 93 | res.writeHead(200, wsContext.toHeaders()); 94 | res.end(responseBody); 95 | return; 96 | } 97 | 98 | if (req.method === 'POST' && requestUrl.pathname === '/api/broadcast') { 99 | // Only accept text bodies 100 | if (req.headers['content-type'].split(';')[0] !== 'text/plain') { 101 | res.writeHead(415, { 102 | 'Content-Type': 'text/plain', 103 | }); 104 | res.end('Body must be text/plain\n'); 105 | return; 106 | } 107 | 108 | // Read the body 109 | const body = await new Promise((resolve) => { 110 | const bodyParts = []; 111 | req.on('data', (chunk) => { 112 | bodyParts.push(chunk); 113 | }); 114 | req.on('end', () => { 115 | resolve(Buffer.concat(bodyParts).toString()); 116 | }); 117 | }); 118 | 119 | // Publish the body to GRIP clients that listen to ws-over-http format 120 | await publisher.publishFormats('test', new WebSocketMessageFormat(body)); 121 | 122 | // Write a success response 123 | res.writeHead(200, { 124 | 'Content-Type': 'text/plain', 125 | }); 126 | res.end('Ok\n'); 127 | return; 128 | } 129 | 130 | // Write an error response 131 | res.writeHead(404, { 132 | 'Content-Type': 'text/plain', 133 | }); 134 | res.end('Not found\n'); 135 | }); 136 | 137 | server.listen(3000, '0.0.0.0'); 138 | 139 | console.log('Server running...'); 140 | -------------------------------------------------------------------------------- /examples/nodejs/websocket/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "websocket", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@fanoutio/grip": "^4.3.0", 9 | "isomorphic-fetch": "^3.0.0" 10 | } 11 | }, 12 | "node_modules/@fanoutio/grip": { 13 | "version": "4.3.0", 14 | "resolved": "https://registry.npmjs.org/@fanoutio/grip/-/grip-4.3.0.tgz", 15 | "integrity": "sha512-aBAc84BmCUTV1BSV+nYlHMLzMlf4O9TI0DTe8RSYdj2iHvPudHrb6C74VMRbuOi82tWaad1bOgpYZj3dKsP9Lg==", 16 | "dependencies": { 17 | "debug": "^4.3.4", 18 | "jose": "^5.2.2", 19 | "jspack": "0.0.4" 20 | }, 21 | "engines": { 22 | "node": ">= 16" 23 | } 24 | }, 25 | "node_modules/debug": { 26 | "version": "4.3.4", 27 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", 28 | "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", 29 | "dependencies": { 30 | "ms": "2.1.2" 31 | }, 32 | "engines": { 33 | "node": ">=6.0" 34 | }, 35 | "peerDependenciesMeta": { 36 | "supports-color": { 37 | "optional": true 38 | } 39 | } 40 | }, 41 | "node_modules/isomorphic-fetch": { 42 | "version": "3.0.0", 43 | "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", 44 | "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", 45 | "dependencies": { 46 | "node-fetch": "^2.6.1", 47 | "whatwg-fetch": "^3.4.1" 48 | } 49 | }, 50 | "node_modules/jose": { 51 | "version": "5.2.2", 52 | "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz", 53 | "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==", 54 | "funding": { 55 | "url": "https://github.com/sponsors/panva" 56 | } 57 | }, 58 | "node_modules/jspack": { 59 | "version": "0.0.4", 60 | "resolved": "https://registry.npmjs.org/jspack/-/jspack-0.0.4.tgz", 61 | "integrity": "sha512-DC/lSTXYDDdYWzyY/9A1kMzp6Ov9mCRhZQ1cGg4te2w3y4/aKZTSspvbYN4LUsvSzMCb/H8z4TV9mYYW/bs3PQ==" 62 | }, 63 | "node_modules/ms": { 64 | "version": "2.1.2", 65 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 66 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 67 | }, 68 | "node_modules/node-fetch": { 69 | "version": "2.7.0", 70 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", 71 | "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", 72 | "dependencies": { 73 | "whatwg-url": "^5.0.0" 74 | }, 75 | "engines": { 76 | "node": "4.x || >=6.0.0" 77 | }, 78 | "peerDependencies": { 79 | "encoding": "^0.1.0" 80 | }, 81 | "peerDependenciesMeta": { 82 | "encoding": { 83 | "optional": true 84 | } 85 | } 86 | }, 87 | "node_modules/tr46": { 88 | "version": "0.0.3", 89 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 90 | "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" 91 | }, 92 | "node_modules/webidl-conversions": { 93 | "version": "3.0.1", 94 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 95 | "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" 96 | }, 97 | "node_modules/whatwg-fetch": { 98 | "version": "3.6.20", 99 | "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", 100 | "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==" 101 | }, 102 | "node_modules/whatwg-url": { 103 | "version": "5.0.0", 104 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 105 | "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", 106 | "dependencies": { 107 | "tr46": "~0.0.3", 108 | "webidl-conversions": "^3.0.0" 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /examples/nodejs/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@fanoutio/grip": "^4.3.0", 5 | "isomorphic-fetch": "^3.0.0" 6 | }, 7 | "scripts": { 8 | "start": "node ./index.js" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/remix/http-stream/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /examples/remix/http-stream/README.md: -------------------------------------------------------------------------------- 1 | # HTTP Streaming Example for Remix 2 | 3 | This example illustrates the use of GRIP to stream HTTP responses 4 | using a Remix application as the backend. 5 | 6 | ## Running the example 7 | 8 | For instructions on setting up and running the example, either locally or using 9 | Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../). 10 | 11 | This example also requires `curl`, which is included with most OSes. 12 | 13 | ## Testing the example 14 | 15 | After you have set up Pushpin and started the application, test the example 16 | following these steps. 17 | 18 | > NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but 19 | replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service. 20 | 21 | 1. Open a new terminal window, and type the following: 22 | 23 | ``` 24 | curl http://127.0.0.1:7999/api/stream 25 | ``` 26 | 27 | You should see the following response text, and then the response should hang open: 28 | ``` 29 | [stream open] 30 | ``` 31 | 32 | `curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`). 33 | 34 | 2. Open a separate terminal window, and type the following: 35 | 36 | ``` 37 | curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast" 38 | ``` 39 | 40 | This publishes the given message (to the channel `test`). You should see the message `Hello` 41 | appear in the stream held open by the first terminal. 42 | 43 | ## How it works 44 | 45 | For an explanation of the common startup and initialization code, as well as 46 | validating the GRIP header, refer to the [`README` file in the parent 47 | directory](../README.md#description-of-common-code-between-the-examples). 48 | 49 | The example exposes two API Routes (Resource Routes): 50 | 51 | 1. A `GET` request at `/api/stream` (File: `app/routes/api.stream.ts`) 52 | 53 | This endpoint is intended to be called through your configured GRIP proxy. 54 | 55 | The handler calls `publisher.validateGripSig` to validate this header, storing the result in 56 | the `gripStatus` variable. 57 | 58 | It checks `gripStatus.isProxied` to make sure we are being run behind a valid 59 | GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy, 60 | or if the signature validation failed. 61 | 62 | ```typescript 63 | if (!gripStatus.isProxied) { 64 | // emit an error 65 | } 66 | ``` 67 | 68 | If successful, then the handler goes on to set up a GRIP instruction. 69 | This instruction asks the GRIP proxy to hold the current connection open 70 | as a streaming connection, listening to the channel named `'test'`. 71 | 72 | ```typescript 73 | const gripInstruct = new GripInstruct('test'); 74 | gripInstruct.setHoldStream(); 75 | ``` 76 | 77 | Finally, a response is generated and returned, including the 78 | `gripInstruct` in the response headers. 79 | 80 | ```typescript 81 | return new Response( 82 | '[stream open]\n', 83 | { 84 | status: 200, 85 | headers: { 86 | ...gripInstruct.toHeaders(), 87 | 'Content-Type': 'text/plain', 88 | }, 89 | }, 90 | ); 91 | ``` 92 | 93 | That's all that's needed to hold a connection open. Note that the connection between 94 | your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the 95 | connection open with the client. 96 | 97 | 2. A `POST` request at `/api/publish` (File: `app/routes/api.publish.ts`) 98 | 99 | This handler starts by checking to make sure the method is in fact `POST` and that 100 | the content type header specifies that the body is of type `text/plain`. Afterward, 101 | the handler reads the request body into a string. 102 | 103 | ```typescript 104 | if (request.method !== 'POST') { 105 | // emit an error 106 | } 107 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 108 | // emit an error 109 | } 110 | const body = await request.text(); 111 | ``` 112 | 113 | Next, the handler proceeds to call `publisher.publishHttpStream` to send the 114 | body text as a message to the `test` channel. Any listener on this channel, 115 | such as those that have opened a stream through the endpoint described above, 116 | will receive the message. 117 | 118 | ```typescript 119 | await publisher.publishHttpStream('test', body + '\n'); 120 | ``` 121 | 122 | Finally, it returns a simple success response message and ends. 123 | 124 | ```typescript 125 | return new Response( 126 | 'Ok\n', 127 | { 128 | status: 200, 129 | headers: { 130 | 'Content-Type': 'text/plain', 131 | }, 132 | }, 133 | ); 134 | ``` 135 | -------------------------------------------------------------------------------- /examples/remix/http-stream/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Outlet, 3 | } from "@remix-run/react"; 4 | 5 | export default function App() { 6 | return ( 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /examples/remix/http-stream/app/routes/api.publish.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { ActionFunctionArgs } from '@remix-run/node'; 4 | import publisher from '~/utils/publisher'; 5 | 6 | export async function action({ request }: ActionFunctionArgs) { 7 | if (request.method !== 'POST') { 8 | return new Response( 9 | 'Method not allowed', 10 | { 11 | status: 405, 12 | }, 13 | ); 14 | } 15 | 16 | // Only accept text bodies 17 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 18 | return new Response( 19 | 'Body must be text/plain\n', { 20 | status: 415, 21 | headers: { 22 | 'Content-Type': 'text/plain', 23 | }, 24 | }, 25 | ); 26 | } 27 | 28 | // Read the body 29 | const body = await request.text(); 30 | 31 | // Publish the body to GRIP clients that listen to http-stream format 32 | await publisher.publishHttpStream('test', body + '\n'); 33 | 34 | // Return a success response 35 | return new Response( 36 | 'Ok\n', 37 | { 38 | status: 200, 39 | headers: { 40 | 'Content-Type': 'text/plain', 41 | }, 42 | }, 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /examples/remix/http-stream/app/routes/api.stream.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { LoaderFunctionArgs } from '@remix-run/node'; 4 | import { GripInstruct } from '@fanoutio/grip'; 5 | import publisher from '~/utils/publisher'; 6 | 7 | export async function loader({ request }: LoaderFunctionArgs) { 8 | 9 | // Find whether we are behind GRIP 10 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 11 | 12 | // Make sure we're behind a GRIP proxy before we proceed 13 | if (!gripStatus.isProxied) { 14 | return new Response( 15 | '[not proxied]\n', 16 | { 17 | status: 200, 18 | headers: { 19 | 'Content-Type': 'text/plain', 20 | }, 21 | }, 22 | ); 23 | } 24 | 25 | // Create some GRIP instructions and hold the stream 26 | const gripInstruct = new GripInstruct('test'); 27 | gripInstruct.setHoldStream(); 28 | 29 | // Write the response 30 | // Include the GRIP instructions in the response headers 31 | return new Response( 32 | '[stream open]\n', 33 | { 34 | status: 200, 35 | headers: { 36 | ...gripInstruct.toHeaders(), 37 | 'Content-Type': 'text/plain', 38 | }, 39 | }, 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /examples/remix/http-stream/app/utils/publisher.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, parseGripUri, Publisher } from '@fanoutio/grip'; 4 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 5 | 6 | // Configure the Publisher. 7 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 8 | const gripUrl = process.env.GRIP_URL; 9 | if (gripUrl) { 10 | gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY }); 11 | } else { 12 | const fanoutServiceId = process.env.FANOUT_SERVICE_ID; 13 | const fanoutApiToken = process.env.FANOUT_API_TOKEN; 14 | if (fanoutServiceId != null && fanoutApiToken != null) { 15 | gripConfig = buildFanoutGripConfig({ 16 | serviceId: fanoutServiceId, 17 | apiToken: fanoutApiToken, 18 | }); 19 | } 20 | } 21 | const publisher = new Publisher(gripConfig); 22 | 23 | export default publisher; 24 | -------------------------------------------------------------------------------- /examples/remix/http-stream/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev --manual", 9 | "start": "remix-serve ./build/index.js", 10 | "typecheck": "tsc" 11 | }, 12 | "dependencies": { 13 | "@fanoutio/grip": "^4.3.0", 14 | "@remix-run/node": "^2.8.0", 15 | "@remix-run/react": "^2.8.0", 16 | "@remix-run/serve": "^2.8.0", 17 | "isbot": "^4.1.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@remix-run/dev": "^2.8.0", 23 | "@types/react": "^18.2.20", 24 | "@types/react-dom": "^18.2.7", 25 | "typescript": "^5.1.6" 26 | }, 27 | "engines": { 28 | "node": ">=18.0.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/remix/http-stream/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | }; 4 | -------------------------------------------------------------------------------- /examples/remix/http-stream/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /examples/remix/http-stream/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": ["./app/*"] 18 | }, 19 | 20 | // Remix takes care of building everything in `remix build`. 21 | "noEmit": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/remix/websocket/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /examples/remix/websocket/app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Outlet, 3 | } from "@remix-run/react"; 4 | 5 | export default function App() { 6 | return ( 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /examples/remix/websocket/app/routes/api.broadcast.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { ActionFunctionArgs } from '@remix-run/node'; 4 | import publisher from '~/utils/publisher'; 5 | import { WebSocketMessageFormat } from '@fanoutio/grip'; 6 | 7 | export async function action({ request }: ActionFunctionArgs) { 8 | if (request.method !== 'POST') { 9 | return new Response( 10 | 'Method not allowed', 11 | { 12 | status: 405, 13 | }, 14 | ); 15 | } 16 | 17 | // Only accept text bodies 18 | if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') { 19 | return new Response( 20 | 'Body must be text/plain\n', { 21 | status: 415, 22 | headers: { 23 | 'Content-Type': 'text/plain', 24 | }, 25 | }, 26 | ); 27 | } 28 | 29 | // Read the body 30 | const body = await request.text(); 31 | 32 | // Publish the body to GRIP clients that listen to ws-over-http format 33 | await publisher.publishFormats('test', new WebSocketMessageFormat(body)); 34 | 35 | // Return a success response 36 | return new Response( 37 | 'Ok\n', 38 | { 39 | status: 200, 40 | headers: { 41 | 'Content-Type': 'text/plain', 42 | }, 43 | }, 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /examples/remix/websocket/app/routes/api.websocket.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { ActionFunctionArgs } from '@remix-run/node'; 4 | import { getWebSocketContextFromReq, isWsOverHttp, encodeWebSocketEvents } from '@fanoutio/grip'; 5 | import publisher from '~/utils/publisher'; 6 | 7 | export async function action({ request }: ActionFunctionArgs) { 8 | if (request.method !== 'POST') { 9 | return new Response( 10 | 'Method not allowed', 11 | { 12 | status: 405, 13 | }, 14 | ); 15 | } 16 | 17 | // Find whether we are behind GRIP 18 | const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig')); 19 | 20 | // Find whether we have a WebSocket context 21 | let wsContext = null; 22 | if (gripStatus.isProxied && isWsOverHttp(request)) { 23 | wsContext = await getWebSocketContextFromReq(request); 24 | } 25 | 26 | // Make sure we're behind a GRIP proxy before we proceed 27 | if (!gripStatus.isProxied) { 28 | return new Response( 29 | '[not proxied]\n', 30 | { 31 | status: 200, 32 | headers: { 33 | 'Content-Type': 'text/plain', 34 | }, 35 | }, 36 | ); 37 | } 38 | 39 | // Make sure we have a WebSocket context 40 | if (wsContext == null) { 41 | return new Response( 42 | '[not a websocket request]\n', 43 | { 44 | status: 400, 45 | headers: { 46 | 'Content-Type': 'text/plain', 47 | }, 48 | }, 49 | ); 50 | } 51 | 52 | // If this is a new connection, accept it and subscribe it to a channel 53 | if (wsContext.isOpening()) { 54 | wsContext.accept(); 55 | wsContext.subscribe('test'); 56 | } 57 | 58 | // wsContext has a buffer of queued-up incoming WebSocket messages. 59 | 60 | // Iterate this queue 61 | while (wsContext.canRecv()) { 62 | const message = wsContext.recv(); 63 | 64 | if (message == null) { 65 | // If return value is undefined then connection is closed 66 | // Messages like this go into a queue of outgoing WebSocket messages 67 | wsContext.close(); 68 | break; 69 | } 70 | 71 | // Echo the message 72 | // This is also a message that goes into the queue of outgoing WebSocket messages. 73 | wsContext.send(message); 74 | } 75 | 76 | // Serialize the outgoing messages 77 | const events = wsContext.getOutgoingEvents(); 78 | const responseBody = encodeWebSocketEvents(events); 79 | 80 | // Return the response 81 | return new Response( 82 | responseBody, 83 | { 84 | status: 200, 85 | headers: wsContext.toHeaders(), 86 | }, 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /examples/remix/websocket/app/utils/publisher.ts: -------------------------------------------------------------------------------- 1 | // noinspection DuplicatedCode 2 | 3 | import { type IGripConfig, parseGripUri, Publisher } from '@fanoutio/grip'; 4 | import { buildFanoutGripConfig } from '@fanoutio/grip/fastly-fanout'; 5 | 6 | // Configure the Publisher. 7 | let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/'; 8 | const gripUrl = process.env.GRIP_URL; 9 | if (gripUrl) { 10 | gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY }); 11 | } else { 12 | const fanoutServiceId = process.env.FANOUT_SERVICE_ID; 13 | const fanoutApiToken = process.env.FANOUT_API_TOKEN; 14 | if (fanoutServiceId != null && fanoutApiToken != null) { 15 | gripConfig = buildFanoutGripConfig({ 16 | serviceId: fanoutServiceId, 17 | apiToken: fanoutApiToken, 18 | }); 19 | } 20 | } 21 | const publisher = new Publisher(gripConfig); 22 | 23 | export default publisher; 24 | -------------------------------------------------------------------------------- /examples/remix/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-stream", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix build", 8 | "dev": "remix dev --manual", 9 | "start": "remix-serve ./build/index.js", 10 | "typecheck": "tsc" 11 | }, 12 | "dependencies": { 13 | "@fanoutio/grip": "^4.3.0", 14 | "@remix-run/node": "^2.8.0", 15 | "@remix-run/react": "^2.8.0", 16 | "@remix-run/serve": "^2.8.0", 17 | "isbot": "^4.1.0", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@remix-run/dev": "^2.8.0", 23 | "@types/react": "^18.2.20", 24 | "@types/react-dom": "^18.2.7", 25 | "typescript": "^5.1.6" 26 | }, 27 | "engines": { 28 | "node": ">=18.0.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/remix/websocket/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | }; 4 | -------------------------------------------------------------------------------- /examples/remix/websocket/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /examples/remix/websocket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "Bundler", 9 | "resolveJsonModule": true, 10 | "target": "ES2022", 11 | "strict": true, 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "~/*": ["./app/*"] 18 | }, 19 | 20 | // Remix takes care of building everything in `remix build`. 21 | "noEmit": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fanoutio/grip", 3 | "version": "4.3.1", 4 | "type": "module", 5 | "author": "Fastly ", 6 | "description": "GRIP Interface Library", 7 | "contributors": [ 8 | { 9 | "name": "Katsuyuki Omuro", 10 | "email": "komuro@fastly.com" 11 | }, 12 | { 13 | "name": "Konstantin Bokarius", 14 | "email": "kon@fanout.io" 15 | } 16 | ], 17 | "main": "./build/index.js", 18 | "types": "./build/index.d.ts", 19 | "exports": { 20 | ".": { 21 | "node": { 22 | "types": "./build/index-node.d.ts", 23 | "default": "./build/index-node.js" 24 | }, 25 | "default": { 26 | "types": "./build/index.d.ts", 27 | "default": "./build/index.js" 28 | } 29 | }, 30 | "./node": { 31 | "types": "./build/node/index.d.ts", 32 | "default": "./build/node/index.js" 33 | }, 34 | "./fastly-fanout": { 35 | "types": "./build/fastly-fanout/index.d.ts", 36 | "default": "./build/fastly-fanout/index.js" 37 | } 38 | }, 39 | "files": [ 40 | "build/**/*", 41 | "types/**/*" 42 | ], 43 | "scripts": { 44 | "prepack": "npm run build", 45 | "build": "npm run test && npm run build-package", 46 | "build-package": "npm run build-package:clean && npm run build-package:compile", 47 | "build-package:clean": "rimraf build", 48 | "build-package:compile": "tsc --build tsconfig.build.json", 49 | "coverage": "c8 npm test", 50 | "test": "npm run test:unit", 51 | "test:unit": "glob -c \"node --loader ts-node/esm --no-warnings=ExperimentalWarning --test\" \"./test/**/*.test.ts\"" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "https://github.com/fanout/js-grip.git" 56 | }, 57 | "readmeFilename": "README.md", 58 | "keywords": [ 59 | "grip", 60 | "fastly", 61 | "fanout", 62 | "fanoutpub", 63 | "realtime", 64 | "push", 65 | "pubcontrol", 66 | "publish" 67 | ], 68 | "license": "MIT", 69 | "devDependencies": { 70 | "@types/debug": "^4.1.5", 71 | "@types/node": "^20", 72 | "c8": "^8.0.1", 73 | "glob": "^10.3.10", 74 | "rimraf": "^3.0.2", 75 | "ts-node": "^10.9.2", 76 | "typescript": "^5.2.2" 77 | }, 78 | "dependencies": { 79 | "debug": "^4.3.4", 80 | "jose": "^5.2.2", 81 | "jspack": "0.0.4" 82 | }, 83 | "engines": { 84 | "node": ">= 16" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/auth/Basic.ts: -------------------------------------------------------------------------------- 1 | import { encodeBytesToBase64String } from '../utilities/index.js'; 2 | import { type IAuth } from './IAuth.js'; 3 | 4 | export class Basic implements IAuth { 5 | private readonly _user: string; 6 | private readonly _pass: string; 7 | 8 | constructor(user: string, pass: string) { 9 | // Initialize with a username and password. 10 | this._user = user; 11 | this._pass = pass; 12 | } 13 | 14 | getUser() { 15 | return this._user; 16 | } 17 | 18 | getPass() { 19 | return this._pass; 20 | } 21 | 22 | // Returns the auth header containing the username and password 23 | // in Basic auth format. 24 | async buildHeader() { 25 | const data = `${this._user}:${this._pass}`; 26 | const textEncoder = new TextEncoder(); 27 | const dataBase64 = encodeBytesToBase64String(textEncoder.encode(data)); 28 | return `Basic ${dataBase64}`; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/auth/Bearer.ts: -------------------------------------------------------------------------------- 1 | import { type IAuth } from './IAuth.js'; 2 | 3 | // Bearer authentication class used for building auth headers containing a literal token. 4 | export class Bearer implements IAuth { 5 | private readonly _token: string; 6 | 7 | constructor(token: string) { 8 | // Initialize with the specified literal token. 9 | this._token = token; 10 | } 11 | 12 | getToken() { 13 | return this._token; 14 | } 15 | 16 | // Returns the auth header containing the Bearer token. 17 | async buildHeader() { 18 | return `Bearer ${this._token}`; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/auth/IAuth.ts: -------------------------------------------------------------------------------- 1 | // The authorization interface for building auth headers in conjunction 2 | // with HTTP requests used for publishing messages. 3 | export interface IAuth { 4 | // This method should return the auth header in text format. 5 | buildHeader(): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/Jwt.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | import { isSymmetricSecret, JwkKey, PemKey } from '../utilities/index.js'; 3 | import { type IAuth } from './IAuth.js'; 4 | 5 | // JWT authentication class used for building auth headers containing 6 | // JSON web token information in the form of a claim and corresponding key. 7 | export class Jwt implements IAuth { 8 | private readonly _claim: Record; 9 | private readonly _key: Uint8Array | jose.KeyLike | PemKey | JwkKey; 10 | private readonly _alg?: string; 11 | 12 | constructor(claim: Record, key: Uint8Array | jose.KeyLike | PemKey | JwkKey, alg?: string) { 13 | // Initialize with the specified claim and key. 14 | this._claim = claim; 15 | this._key = key; 16 | this._alg = alg; 17 | } 18 | 19 | // Returns the auth header containing the JWT token in Bearer format. 20 | async buildHeader() { 21 | 22 | const key = this._key; 23 | let alg = this._alg ?? (isSymmetricSecret(key) ? 'HS256' : 'RS256'); 24 | 25 | let signKey: Uint8Array | jose.KeyLike; 26 | if (key instanceof JwkKey) { 27 | signKey = await key.getSecretOrKeyLike(alg); 28 | } else if (key instanceof PemKey) { 29 | signKey = await key.getKeyLike(alg); 30 | } else { 31 | signKey = key; 32 | } 33 | 34 | const signJwt = new jose.SignJWT(this._claim) 35 | .setProtectedHeader({ alg }) 36 | .setExpirationTime('10m'); 37 | const token = await signJwt.sign(signKey); 38 | 39 | return `Bearer ${token}`; 40 | } 41 | 42 | getClaim() { 43 | return this._claim; 44 | } 45 | 46 | getKey() { 47 | return this._key; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IAuth.js'; 2 | export * from './Basic.js'; 3 | export * from './Bearer.js'; 4 | export * from './Jwt.js'; 5 | -------------------------------------------------------------------------------- /src/data/Channel.ts: -------------------------------------------------------------------------------- 1 | import { type IExportedChannel } from './IExportedChannel.js'; 2 | 3 | // The Channel class is used to represent a channel in a GRIP proxy and 4 | // tracks the previous ID of the last message. 5 | 6 | export class Channel { 7 | public name: string; 8 | public prevId: string | null; 9 | 10 | constructor(name: string, prevId: string | null = null) { 11 | this.name = name; 12 | this.prevId = prevId; 13 | } 14 | 15 | // Export this channel instance into a dictionary containing the 16 | // name and previous ID value. 17 | export() { 18 | const obj: IExportedChannel = { name: this.name }; 19 | if (this.prevId != null) { 20 | obj.prevId = this.prevId; 21 | } 22 | return obj; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/data/Format.ts: -------------------------------------------------------------------------------- 1 | import { type IFormat } from './IFormat.js'; 2 | import { type IFormatExport } from './IFormatExport.js'; 3 | 4 | // The Format class is provided as a base class for all publishing 5 | // formats that are included in the Item class. Examples of format 6 | // implementations include HttpStreamFormat and HttpResponseFormat. 7 | 8 | // In pure TypeScript this would not be needed (implementations would 9 | // only need to implement IFormat), but since this needs to be consumable 10 | // from JavaScript, we are exporting this class. 11 | 12 | export abstract class Format implements IFormat { 13 | // The name of the format which should return a string. Examples 14 | // include 'json-object' and 'http-response' 15 | abstract name(): string; 16 | // The export method which should return a format-specific hash 17 | // containing the required format-specific data. 18 | abstract export(): IFormatExport; 19 | } 20 | -------------------------------------------------------------------------------- /src/data/GripInstruct.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from './Channel.js'; 2 | import { createGripChannelHeader, parseChannels } from '../utilities/index.js'; 3 | import { createKeepAliveHeader, createMetaHeader, createNextLinkHeader } from '../utilities/http.js'; 4 | 5 | export class GripInstruct { 6 | public status?: number; 7 | public hold?: string; 8 | public channels: Channel[] = []; 9 | public timeout = 0; 10 | public keepAlive?: Uint8Array | string; 11 | public keepAliveTimeout = 0; 12 | public nextLink?: string; 13 | public nextLinkTimeout = 0; 14 | public meta?: Record; // Intended to be modified/set directly 15 | 16 | public constructor(channels?: Channel | Channel[] | string | string[]) { 17 | if (channels != null) { 18 | this.addChannel(channels); 19 | } 20 | } 21 | 22 | public addChannel(channels: Channel | Channel[] | string | string[]) { 23 | this.channels.push(...parseChannels(channels)); 24 | } 25 | 26 | public setStatus(status: number) { 27 | this.status = status; 28 | } 29 | 30 | public setHoldLongPoll(timeout?: number) { 31 | this.hold = 'response'; 32 | if (timeout != null) { 33 | this.timeout = Math.floor(timeout); 34 | } 35 | } 36 | 37 | public setHoldStream() { 38 | this.hold = 'stream'; 39 | } 40 | 41 | public setKeepAlive(data: string | Uint8Array, timeout: number) { 42 | this.keepAlive = data; 43 | this.keepAliveTimeout = timeout; 44 | } 45 | 46 | public setNextLink(uri: string, timeout: number = 0) { 47 | this.nextLink = uri; 48 | this.nextLinkTimeout = timeout; 49 | } 50 | 51 | public toHeaders(additionalHeaders?: Record) { 52 | const headers: Record = {}; 53 | headers['Grip-Channel'] = createGripChannelHeader(this.channels); 54 | if (this.status != null) { 55 | headers['Grip-Status'] = `${this.status}`; // Convert to string 56 | } 57 | if (this.hold != null) { 58 | headers['Grip-Hold'] = this.hold; 59 | if (this.timeout > 0) { 60 | headers['Grip-Timeout'] = `${this.timeout}`; // Convert to string 61 | } 62 | if (this.keepAlive != null) { 63 | headers['Grip-Keep-Alive'] = createKeepAliveHeader(this.keepAlive, this.keepAliveTimeout); 64 | } 65 | if (this.meta != null && Object.entries(this.meta).length > 0) { 66 | headers['Grip-Set-Meta'] = createMetaHeader(this.meta); 67 | } 68 | } 69 | if (this.nextLink != null) { 70 | headers['Grip-Link'] = createNextLinkHeader(this.nextLink, this.nextLinkTimeout); 71 | } 72 | Object.assign(headers, additionalHeaders); 73 | return headers; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/data/IExportedChannel.ts: -------------------------------------------------------------------------------- 1 | export interface IExportedChannel { 2 | name: string; 3 | prevId?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/data/IFormat.ts: -------------------------------------------------------------------------------- 1 | import { type IFormatExport } from './IFormatExport.js'; 2 | 3 | export interface IFormat { 4 | name(): string; 5 | export(): IFormatExport; 6 | } 7 | -------------------------------------------------------------------------------- /src/data/IFormatExport.ts: -------------------------------------------------------------------------------- 1 | type JsonSerializablePrimitive = null | boolean | string | number; 2 | type JsonSerializableObject = { [key: string]: JSONSerializable }; 3 | type JSONSerializableArray = JSONSerializable[]; 4 | type JSONSerializable = JsonSerializablePrimitive | JsonSerializableObject | JSONSerializableArray; 5 | 6 | export interface IFormatExport { 7 | [format: string]: JSONSerializable; 8 | } 9 | -------------------------------------------------------------------------------- /src/data/IItem.ts: -------------------------------------------------------------------------------- 1 | import { type IItemExport } from './IItemExport.js'; 2 | 3 | export interface IItem { 4 | export(): IItemExport; 5 | } 6 | -------------------------------------------------------------------------------- /src/data/IItemExport.ts: -------------------------------------------------------------------------------- 1 | import { type IFormatExport } from './IFormatExport.js'; 2 | 3 | export interface IItemExport { 4 | channel?: string; 5 | id?: string; 6 | 'prev-id'?: string; 7 | formats: Record; 8 | } 9 | -------------------------------------------------------------------------------- /src/data/Item.ts: -------------------------------------------------------------------------------- 1 | import { type IFormat } from './IFormat.js'; 2 | import { type IItem } from './IItem.js'; 3 | import { type IItemExport } from './IItemExport.js'; 4 | 5 | // The Item class is a container used to contain one or more format 6 | // implementation instances where each implementation instance is of a 7 | // different type of format. An Item instance may not contain multiple 8 | // implementations of the same type of format. An Item instance is then 9 | // serialized into a hash that is used for publishing to clients. 10 | 11 | export class Item implements IItem { 12 | public formats: IFormat[]; 13 | public id?: string; 14 | public prevId?: string; 15 | 16 | constructor(formats: IFormat | IFormat[], id?: string, prevId?: string) { 17 | // The initialize method can accept either a single Format implementation 18 | // instance or an array of Format implementation instances. Optionally 19 | // specify an ID and/or previous ID to be sent as part of the message 20 | // published to the client. 21 | formats = Array.isArray(formats) ? formats : [formats]; 22 | this.formats = formats; 23 | if (arguments.length >= 3) { 24 | this.prevId = prevId; 25 | } 26 | if (arguments.length >= 2) { 27 | this.id = id; 28 | } 29 | } 30 | 31 | // The export method serializes all of the formats, ID, and previous ID 32 | // into a hash that is used for publishing to clients. If more than one 33 | // instance of the same type of Format implementation was specified then 34 | // an error will be raised. 35 | export(): IItemExport { 36 | const obj: IItemExport = { 37 | formats: {}, 38 | }; 39 | if (this.id != null) { 40 | obj.id = this.id; 41 | } 42 | if (this.prevId != null) { 43 | obj['prev-id'] = this.prevId; 44 | } 45 | 46 | const alreadyUsedFormatNames = new Set(); 47 | for (const format of this.formats) { 48 | const name = format.name(); 49 | if (alreadyUsedFormatNames.has(name)) { 50 | throw new Error(`More than one instance of ${name} specified`); 51 | } 52 | alreadyUsedFormatNames.add(name); 53 | obj.formats[name] = format.export(); 54 | } 55 | 56 | return obj; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/data/http/HttpResponseFormat.ts: -------------------------------------------------------------------------------- 1 | import { type IFormat } from '../IFormat.js'; 2 | import { type IFormatExport } from '../IFormatExport.js'; 3 | import { encodeBytesToBase64String } from '../../utilities/index.js'; 4 | 5 | type ExportedResponse = { 6 | code?: string; 7 | reason?: string; 8 | headers?: Record; 9 | body?: string; 10 | 'body-bin'?: string; 11 | }; 12 | 13 | export type ResponseParams = { 14 | code?: string | null, 15 | reason?: string | null, 16 | headers?: Record | null, 17 | body?: Uint8Array | string | null, 18 | } 19 | 20 | // The HttpResponseFormat class is the format used to publish messages to 21 | // HTTP response clients connected to a GRIP proxy. 22 | export class HttpResponseFormat implements IFormat { 23 | code: string | null; 24 | reason: string | null; 25 | headers: Record | null; 26 | body: Uint8Array | string | null; 27 | 28 | constructor(responseObject: ResponseParams); 29 | constructor( 30 | code?: string | null, 31 | reason?: string | null, 32 | headers?: Record | null, 33 | body?: Uint8Array | string | null, 34 | ); 35 | constructor( 36 | code: ResponseParams | string | null = null, 37 | reason: string | null = null, 38 | headers: Record | null = null, 39 | body: Uint8Array | string | null = null, 40 | ) { 41 | if (code !== null && typeof code !== 'string') { 42 | ({ code = null, reason = null, headers = null, body = null } = code); 43 | } 44 | this.code = code; 45 | this.reason = reason; 46 | this.headers = headers; 47 | this.body = body; 48 | } 49 | 50 | // Export this Response instance into a dictionary containing all 51 | // of the non-null data. If the body is set to a buffer then export 52 | // it as 'body-bin' (as opposed to 'body') and encode it as base64. 53 | export() { 54 | const obj: ExportedResponse = {}; 55 | if (this.code != null) { 56 | obj.code = this.code; 57 | } 58 | if (this.reason != null) { 59 | obj.reason = this.reason; 60 | } 61 | if (this.headers != null) { 62 | obj.headers = this.headers; 63 | } 64 | if (this.body != null) { 65 | if (this.body instanceof Uint8Array) { 66 | obj['body-bin'] = encodeBytesToBase64String(this.body); 67 | } else { 68 | obj['body'] = this.body.toString(); 69 | } 70 | } 71 | return obj as IFormatExport; 72 | } 73 | 74 | // The name used when publishing this format. 75 | name() { 76 | return 'http-response'; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/data/http/HttpStreamFormat.ts: -------------------------------------------------------------------------------- 1 | import { type IFormat } from '../IFormat.js'; 2 | import { type IFormatExport } from '../IFormatExport.js'; 3 | import { encodeBytesToBase64String } from '../../utilities/index.js'; 4 | 5 | // The HttpStreamFormat class is the format used to publish messages to 6 | // HTTP stream clients connected to a GRIP proxy. 7 | export class HttpStreamFormat implements IFormat { 8 | content: string | Uint8Array | null; 9 | close: boolean; 10 | 11 | constructor(content: string | Uint8Array | null = null, close = false) { 12 | // Initialize with either the message content or a boolean indicating that 13 | // the streaming connection should be closed. If neither the content nor 14 | // the boolean flag is set then an error will be thrown. 15 | if (content == null && !close) { 16 | throw new Error('HttpStreamFormat requires content.'); 17 | } 18 | this.content = content; 19 | this.close = close; 20 | } 21 | 22 | // The name used when publishing this format. 23 | name() { 24 | return 'http-stream'; 25 | } 26 | 27 | // Exports the message in the required format depending on whether the 28 | // message content is binary or not, or whether the connection should 29 | // be closed. 30 | export() { 31 | const obj: IFormatExport = {}; 32 | if (this.close) { 33 | obj['action'] = 'close'; 34 | obj['content'] = ''; 35 | } else { 36 | if (this.content instanceof Uint8Array) { 37 | obj['content-bin'] = encodeBytesToBase64String(this.content); 38 | } else if (this.content != null) { 39 | obj['content'] = this.content.toString(); 40 | } 41 | } 42 | return obj; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/data/http/index.ts: -------------------------------------------------------------------------------- 1 | export * from './HttpResponseFormat.js'; 2 | export * from './HttpStreamFormat.js'; 3 | -------------------------------------------------------------------------------- /src/data/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http/index.js'; 2 | export * from './websocket/index.js'; 3 | export * from './Channel.js'; 4 | export * from './Format.js'; 5 | export * from './GripInstruct.js'; 6 | export * from './IExportedChannel.js'; 7 | export * from './IFormat.js'; 8 | export * from './IFormatExport.js'; 9 | export * from './IItem.js'; 10 | export * from './IItemExport.js'; 11 | export * from './Item.js'; 12 | -------------------------------------------------------------------------------- /src/data/websocket/ConnectionIdMissingException.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketException } from './WebSocketException.js'; 2 | 3 | export class ConnectionIdMissingException extends WebSocketException { 4 | } 5 | -------------------------------------------------------------------------------- /src/data/websocket/IWebSocketEvent.ts: -------------------------------------------------------------------------------- 1 | export interface IWebSocketEvent { 2 | type: string; 3 | content: Uint8Array | string | null; 4 | 5 | getType(): string; 6 | getContent(): Uint8Array | string | null; 7 | } 8 | -------------------------------------------------------------------------------- /src/data/websocket/WebSocketDecodeEventException.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketException } from './WebSocketException.js'; 2 | 3 | export class WebSocketDecodeEventException extends WebSocketException { 4 | } 5 | -------------------------------------------------------------------------------- /src/data/websocket/WebSocketEvent.ts: -------------------------------------------------------------------------------- 1 | import { type IWebSocketEvent } from './IWebSocketEvent.js'; 2 | 3 | // The WebSocketEvent class represents WebSocket event information that is 4 | // used with the GRIP WebSocket-over-HTTP protocol. It includes information 5 | // about the type of event as well as an optional content field. 6 | export class WebSocketEvent implements IWebSocketEvent { 7 | type: string; 8 | content: Uint8Array | string | null; 9 | 10 | constructor(type: string, content: Uint8Array | string | null = null) { 11 | // Initialize with a specified event type and optional content information. 12 | this.type = type; 13 | this.content = content; 14 | } 15 | 16 | // Get the event type. 17 | getType() { 18 | return this.type; 19 | } 20 | 21 | // Get the event content. 22 | getContent() { 23 | return this.content; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/data/websocket/WebSocketException.ts: -------------------------------------------------------------------------------- 1 | export class WebSocketException extends Error { 2 | } 3 | 4 | -------------------------------------------------------------------------------- /src/data/websocket/WebSocketMessageFormat.ts: -------------------------------------------------------------------------------- 1 | import { type IFormat } from '../IFormat.js'; 2 | import { type IFormatExport } from '../IFormatExport.js'; 3 | import { encodeBytesToBase64String } from '../../utilities/index.js'; 4 | 5 | // The WebSocketMessageFormat class is the format used to publish data to 6 | // WebSocket clients connected to GRIP proxies. 7 | export class WebSocketMessageFormat implements IFormat { 8 | content: string | Uint8Array | null; 9 | close: boolean; 10 | code?: number; 11 | 12 | constructor(content: Uint8Array | string | null = null, close = false, code?: number) { 13 | // Initialize with either the message content or a boolean indicating that 14 | // the streaming connection should be closed. If neither the content nor 15 | // the boolean flag is set then an error will be thrown. 16 | if (content == null && !close) { 17 | throw new Error('WebSocketMessageFormat requires content.'); 18 | } 19 | this.content = content; 20 | this.close = close; 21 | this.code = code; 22 | } 23 | 24 | // The name used when publishing this format. 25 | name() { 26 | return 'ws-message'; 27 | } 28 | 29 | // Exports the message in the required format depending on whether the 30 | // message content is a buffer or not, or whether the connection should 31 | // be closed. 32 | export() { 33 | const obj: IFormatExport = {}; 34 | if (this.close) { 35 | obj['action'] = 'close'; 36 | if (this.code != null) { 37 | obj['code'] = this.code; 38 | } 39 | } else { 40 | if (this.content instanceof Uint8Array) { 41 | obj['content-bin'] = encodeBytesToBase64String(this.content); 42 | } else if (this.content != null) { 43 | obj['content'] = this.content.toString(); 44 | } 45 | } 46 | return obj; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/data/websocket/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConnectionIdMissingException.js'; 2 | export * from './IWebSocketEvent.js'; 3 | export * from './WebSocketContext.js'; 4 | export * from './WebSocketDecodeEventException.js'; 5 | export * from './WebSocketEvent.js'; 6 | export * from './WebSocketException.js'; 7 | export * from './WebSocketMessageFormat.js'; 8 | -------------------------------------------------------------------------------- /src/engine/IGripConfig.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | 3 | export interface IGripConfig { 4 | control_uri: string; 5 | control_iss?: string; 6 | user?: string; 7 | pass?: string; 8 | key?: string | JsonWebKey | Uint8Array | jose.KeyLike; 9 | verify_iss?: string; 10 | verify_key?: string | JsonWebKey | Uint8Array | jose.KeyLike; 11 | } 12 | -------------------------------------------------------------------------------- /src/engine/IPublisherClient.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | import * as Auth from '../auth/index.js'; 3 | import { type IItem } from '../data/index.js'; 4 | import { JwkKey, PemKey } from '../utilities/index.js'; 5 | 6 | export interface IPublisherClient { 7 | getAuth?(): Auth.IAuth | undefined; 8 | getVerifyIss?(): string | undefined; 9 | getVerifyKey?(): Uint8Array | jose.KeyLike | PemKey | JwkKey | undefined; 10 | 11 | publish(channel: string, item: IItem): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/engine/PublishException.ts: -------------------------------------------------------------------------------- 1 | import { type PublishContext } from './index.js'; 2 | 3 | export class PublishException { 4 | message: string; 5 | context: PublishContext; 6 | 7 | constructor(message: string, context: PublishContext) { 8 | this.message = message; 9 | this.context = context; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/engine/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IGripConfig.js'; 2 | export * from './IPublisherClient.js'; 3 | export * from './Publisher.js'; 4 | export * from './PublisherClient.js'; 5 | export * from './PublishException.js'; 6 | -------------------------------------------------------------------------------- /src/fastly-fanout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './keys.js'; 2 | export * from './utils.js'; 3 | -------------------------------------------------------------------------------- /src/fastly-fanout/keys.ts: -------------------------------------------------------------------------------- 1 | export const PUBLIC_KEY_FASTLY_FANOUT_PEM: string = `-----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAECKo5A1ebyFcnmVV8SE5On+8G81Jy 3 | BjSvcrx4VLetWCjuDAmppTo3xM/zz763COTCgHfp/6lPdCyYjjqc+GM7sw== 4 | -----END PUBLIC KEY-----`; 5 | 6 | export const PUBLIC_KEY_FASTLY_FANOUT_JWK: JsonWebKey = { 7 | 'kty': 'EC', 8 | 'crv': 'P-256', 9 | 'x': 'CKo5A1ebyFcnmVV8SE5On-8G81JyBjSvcrx4VLetWCg', 10 | 'y': '7gwJqaU6N8TP88--twjkwoB36f-pT3QsmI46nPhjO7M', 11 | }; 12 | -------------------------------------------------------------------------------- /src/fastly-fanout/utils.ts: -------------------------------------------------------------------------------- 1 | import { PUBLIC_KEY_FASTLY_FANOUT_JWK } from './keys.js'; 2 | import { parseGripUri } from '../utilities/grip.js'; 3 | 4 | export type BuildFanoutGripConfigParams = { 5 | serviceId: string, // Fastly service of GRIP proxy 6 | apiToken: string, // API token that has 'global' scope on above service 7 | baseUrl?: URL | string, // (optional) Base URL 8 | verifyIss?: string, // (optional) Verify Issuer 9 | verifyKey?: string | JsonWebKey, // (optional) Verify Key 10 | }; 11 | 12 | export function buildFanoutGripConfig(params: BuildFanoutGripConfigParams) { 13 | const gripUrl = buildFanoutGripUrl(params); 14 | return parseGripUri(gripUrl); 15 | } 16 | 17 | export function buildFanoutGripUrl(params: BuildFanoutGripConfigParams) { 18 | const { 19 | serviceId, 20 | apiToken, 21 | baseUrl, 22 | verifyIss, 23 | verifyKey, 24 | } = params; 25 | 26 | const url = new URL(baseUrl ?? `https://api.fastly.com/service/${serviceId}`); 27 | url.searchParams.set('key', apiToken); 28 | url.searchParams.set('verify-iss', verifyIss ?? `fastly:${serviceId}`); 29 | 30 | let verifyKeyValue: string; 31 | if (typeof verifyKey === 'string') { 32 | verifyKeyValue = verifyKey; 33 | } else if (verifyKey != null) { 34 | verifyKeyValue = JSON.stringify(verifyKey); 35 | } else { 36 | verifyKeyValue = JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK); 37 | } 38 | url.searchParams.set('verify-key', verifyKeyValue); 39 | 40 | return String(url); 41 | } 42 | -------------------------------------------------------------------------------- /src/index-node.ts: -------------------------------------------------------------------------------- 1 | export * from './index.js'; 2 | export * from './node/index.js'; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Flatten and export 2 | 3 | export * as Auth from './auth/index.js'; 4 | export * from './data/index.js'; 5 | export * from './engine/index.js'; 6 | export * from './utilities/index.js'; 7 | 8 | /** 9 | * @deprecated - Should import these from '@fanoutio/grip/fastly-fanout' 10 | */ 11 | export * from './fastly-fanout/keys.js'; 12 | -------------------------------------------------------------------------------- /src/node/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utilities/index.js'; 2 | -------------------------------------------------------------------------------- /src/node/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ws-over-http.js'; 2 | -------------------------------------------------------------------------------- /src/node/utilities/ws-over-http.ts: -------------------------------------------------------------------------------- 1 | import { type IncomingMessage, type IncomingHttpHeaders } from 'node:http'; 2 | import { 3 | getWebSocketContextImpl, 4 | isWsOverHttpImpl 5 | } from '../../utilities/index.js'; 6 | 7 | function headersFromNodeIncomingHttpHeaders(incomingHttpHeaders: IncomingHttpHeaders) { 8 | const headers = new Headers(); 9 | for (const [key, value] of Object.entries(incomingHttpHeaders)) { 10 | if (value == null) { 11 | continue; 12 | } 13 | if (!Array.isArray(value)) { 14 | headers.append(key, value); 15 | continue; 16 | } 17 | // Should only be for set-cookie 18 | // https://nodejs.org/api/http.html#messageheaders 19 | for (const entry of value) { 20 | headers.append(key, entry); 21 | } 22 | } 23 | 24 | return headers; 25 | } 26 | 27 | export function isNodeReqWsOverHttp(req: IncomingMessage) { 28 | return isWsOverHttpImpl(req.method, headersFromNodeIncomingHttpHeaders(req.headers)); 29 | } 30 | 31 | export async function getWebSocketContextFromNodeReq(req: IncomingMessage, prefix: string = '') { 32 | return getWebSocketContextImpl( 33 | headersFromNodeIncomingHttpHeaders(req.headers), 34 | async () => { 35 | return new Promise(resolve => { 36 | const bodyParts: Buffer[] = []; 37 | req.on('data', (chunk: string | Buffer) => { 38 | if (typeof chunk === 'string') { 39 | chunk = Buffer.from(chunk); 40 | } 41 | bodyParts.push(chunk); 42 | }); 43 | req.on('end', () => { 44 | const body = Buffer.concat(bodyParts); 45 | resolve(body); 46 | }); 47 | }); 48 | }, 49 | prefix 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/utilities/base64.ts: -------------------------------------------------------------------------------- 1 | export function encodeBytesToBase64String(bytes: Uint8Array) { 2 | const CHUNK_SIZE = 0x8000; 3 | const arr = []; 4 | for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { 5 | arr.push(String.fromCharCode.apply(null, [...bytes.subarray(i, i + CHUNK_SIZE)])); 6 | } 7 | return btoa(arr.join('')); 8 | } 9 | 10 | export function decodeBytesFromBase64String(str: string) { 11 | // If the base64 string contains '+', but the URL was built carelessly 12 | // without properly URL-encoding them to %2B, then at this point they 13 | // may have been replaced by ' '. 14 | // Turn them back into pluses before decoding from base64. 15 | str = str.replace(/ /g, '+'); 16 | 17 | // We also work with base64url 18 | str = str.replace(/_/g, '/'); 19 | str = str.replace(/-/g, '+'); 20 | 21 | let binary; 22 | try { 23 | binary = atob(str); 24 | } catch (ex) { 25 | throw new TypeError('Invalid base64 sequence', { cause: ex }); 26 | } 27 | const bytes = new Uint8Array(binary.length); 28 | for (let i = 0; i < binary.length; i++) { 29 | bytes[i] = binary.charCodeAt(i); 30 | } 31 | return bytes; 32 | } 33 | -------------------------------------------------------------------------------- /src/utilities/debug.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('js-grip'); 3 | export default debug; 4 | -------------------------------------------------------------------------------- /src/utilities/grip.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from '../data/index.js'; 2 | import { type IGripConfig } from '../engine/index.js'; 3 | 4 | // Method for parsing the specified parameter into an 5 | // array of Channel instances. The specified parameter can either 6 | // be a string, a Channel instance, or an array of Channel instances. 7 | export function parseChannels(inChannels: Channel | Channel[] | string | string[]) { 8 | const channels = !Array.isArray(inChannels) ? [inChannels] : inChannels; 9 | return channels.map((channel) => typeof channel === 'string' ? new Channel(channel) : channel); 10 | } 11 | 12 | // Parses the specified GRIP URI into a config object that can then be passed 13 | // to the Publisher class. The URI can include query parameters for authentication 14 | // during publishing as well as those used for verifying the signature of incoming 15 | // requests. 16 | // Additional values can be provided that get merged with query parameters 17 | // before parsing them. This is useful for values that get particularly long, 18 | // such as JWT_VERIFY_KEY. 19 | export function parseGripUri(uri: string, additional?: Record) { 20 | const parsedUrl = new URL(uri); 21 | const params = parsedUrl.searchParams; 22 | 23 | if (additional != null) { 24 | for (const [key, value] of Object.entries(additional)) { 25 | if (value === undefined) { 26 | continue; 27 | } 28 | params.set(key, value); 29 | } 30 | } 31 | 32 | let user: string | null = null; 33 | let pass: string | null = null; 34 | if (parsedUrl.username !== '') { 35 | user = parsedUrl.username; 36 | parsedUrl.username = ''; 37 | } 38 | if (parsedUrl.password !== '') { 39 | pass = parsedUrl.password; 40 | parsedUrl.password = ''; 41 | } 42 | 43 | let iss: string | null = null; 44 | let key: string | null = null; 45 | 46 | let verify_iss: string | null = null; 47 | let verify_key: string | null = null; 48 | 49 | if (params.has('iss')) { 50 | iss = params.get('iss'); 51 | params.delete('iss'); 52 | } 53 | if (params.has('key')) { 54 | key = params.get('key'); 55 | params.delete('key'); 56 | } 57 | if (params.has('verify-iss')) { 58 | verify_iss = params.get('verify-iss'); 59 | params.delete('verify-iss'); 60 | } 61 | if (params.has('verify-key')) { 62 | verify_key = params.get('verify-key'); 63 | params.delete('verify-key'); 64 | } 65 | 66 | if (parsedUrl.pathname.endsWith('/')) { 67 | parsedUrl.pathname = parsedUrl.pathname.slice(0, parsedUrl.pathname.length - 1); 68 | } 69 | let controlUri = parsedUrl.toString(); 70 | 71 | const gripConfig: IGripConfig = { control_uri: controlUri }; 72 | if (iss != null) { 73 | gripConfig['control_iss'] = iss; 74 | } 75 | if (user != null) { 76 | gripConfig['user'] = user; 77 | } 78 | if (pass != null) { 79 | gripConfig['pass'] = pass; 80 | } 81 | if (key != null) { 82 | gripConfig['key'] = key; 83 | } 84 | if (verify_iss != null) { 85 | gripConfig['verify_iss'] = verify_iss; 86 | } 87 | if (verify_key != null) { 88 | gripConfig['verify_key'] = verify_key; 89 | } 90 | 91 | return gripConfig; 92 | } 93 | 94 | // Create a GRIP channel header for the specified channels. The channels 95 | // parameter can be specified as a string representing the channel name, 96 | // a Channel instance, or an array of Channel instances. The returned GRIP 97 | // channel header is used when sending instructions to GRIP proxies via 98 | // HTTP headers. 99 | export function createGripChannelHeader(channels: Channel | Channel[] | string | string[]) { 100 | channels = parseChannels(channels); 101 | const parts = []; 102 | for (const channel of channels) { 103 | const channelExport = channel.export(); 104 | let s = channelExport.name; 105 | if (channelExport.prevId) { 106 | s += '; prev-id=' + channelExport.prevId; 107 | } 108 | parts.push(s); 109 | } 110 | return parts.join(', '); 111 | } 112 | -------------------------------------------------------------------------------- /src/utilities/http.ts: -------------------------------------------------------------------------------- 1 | import { encodeCString, escapeQuotes } from './string.js'; 2 | import { encodeBytesToBase64String } from './base64.js'; 3 | 4 | export function createKeepAliveHeader(data: string | Uint8Array, timeout: number) { 5 | let output = null; 6 | 7 | if (typeof data === 'string') { 8 | try { 9 | output = encodeCString(data) + '; format=cstring'; 10 | } catch (ex) { 11 | output = null; 12 | } 13 | } 14 | 15 | if (output == null) { 16 | const textEncoder = new TextEncoder(); 17 | const bytes = typeof data === 'string' ? textEncoder.encode(data) : data; 18 | output = encodeBytesToBase64String(bytes) + '; format=base64'; 19 | } 20 | 21 | output += `; timeout=${Math.floor(timeout)}`; 22 | 23 | return output; 24 | } 25 | 26 | export function createMetaHeader(data: Record) { 27 | return Object.entries(data) 28 | .map(([key, value]) => { 29 | return `${key}="${escapeQuotes(value)}"`; 30 | }) 31 | .join(', '); 32 | } 33 | 34 | export function createNextLinkHeader(uri: string, timeout: number = 0) { 35 | let output = `<${uri}>; rel=next`; 36 | if (timeout > 0) { 37 | output += `; timeout=${Math.floor(timeout)}`; 38 | } 39 | return output; 40 | } 41 | -------------------------------------------------------------------------------- /src/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base64.js'; 2 | export * from './grip.js'; 3 | export * from './webSocketEvents.js'; 4 | export * from './ws-over-http.js'; 5 | export * from './jwt.js'; 6 | export * from './keys.js'; 7 | export * from './string.js'; 8 | export * from './http.js'; 9 | export * from './typedarray.js'; 10 | -------------------------------------------------------------------------------- /src/utilities/jwt.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | import { isSymmetricSecret, loadKey, JwkKey, PemKey } from './keys.js'; 3 | 4 | // Validate the specified JWT token and key. This method is used to validate 5 | // the GRIP-SIG header coming from GRIP proxies such as Pushpin or Fastly Fanout. 6 | // Note that the token expiration is also verified. 7 | export async function validateSig(token: string, key: string | Uint8Array | jose.KeyLike | PemKey | JwkKey, iss?: string) { 8 | 9 | if (typeof key === 'string' || key instanceof Uint8Array) { 10 | key = loadKey(key); 11 | } 12 | 13 | let verifyKey: Uint8Array | jose.KeyLike; 14 | 15 | if (key instanceof JwkKey || key instanceof PemKey) { 16 | let header; 17 | try { 18 | header = jose.decodeProtectedHeader(token); 19 | } catch { 20 | header = {}; 21 | } 22 | const alg = header.alg ?? (isSymmetricSecret(key) ? 'HS256' : 'RS256'); 23 | if (key instanceof JwkKey) { 24 | verifyKey = await key.getSecretOrKeyLike(alg); 25 | } else { 26 | verifyKey = await key.getKeyLike(alg); 27 | } 28 | } else { 29 | verifyKey = key; 30 | } 31 | 32 | let claim: jose.JWTVerifyResult; 33 | try { 34 | claim = await jose.jwtVerify(token, verifyKey); 35 | } catch (e) { 36 | return false; 37 | } 38 | 39 | if (iss != null) { 40 | if (claim.payload.iss !== iss) { 41 | return false; 42 | } 43 | } 44 | 45 | return true; 46 | } 47 | -------------------------------------------------------------------------------- /src/utilities/keys.ts: -------------------------------------------------------------------------------- 1 | import * as jose from 'jose'; 2 | import { decodeBytesFromBase64String } from './base64.js'; 3 | 4 | const textEncoder = new TextEncoder(); 5 | const textDecoder = new TextDecoder(); 6 | 7 | export class PemKey { 8 | keyString: string; 9 | type: 'public' | 'private'; 10 | constructor(keyString: string) { 11 | if (keyString.startsWith('-----BEGIN PUBLIC KEY-----')) { 12 | this.type = 'public'; 13 | } else if ( 14 | keyString.startsWith('-----BEGIN PRIVATE KEY-----') 15 | ) { 16 | this.type = 'private'; 17 | } else { 18 | throw new TypeError('Attempt to construct PemKey with string that is evidently not a PEM'); 19 | } 20 | this.keyString = keyString; 21 | } 22 | 23 | async getKeyLike(alg: string) { 24 | if (this.keyString.indexOf('-----BEGIN PRIVATE KEY-----') === 0) { 25 | return jose.importPKCS8(this.keyString, alg); 26 | } 27 | if (this.keyString.indexOf('-----BEGIN PUBLIC KEY-----') === 0) { 28 | return jose.importSPKI(this.keyString, alg); 29 | } 30 | throw new Error('PEM type not supported.'); 31 | } 32 | } 33 | 34 | export class JwkKey { 35 | jwk: JsonWebKey 36 | constructor(jwk: JsonWebKey) { 37 | this.jwk = jwk; 38 | } 39 | 40 | async getSecretOrKeyLike(alg?: string) { 41 | return jose.importJWK(this.jwk as jose.JWK, alg); 42 | } 43 | } 44 | 45 | export function isSymmetricSecret(key: Uint8Array | jose.KeyLike | PemKey | JwkKey) { 46 | if (key instanceof Uint8Array) { 47 | return true; 48 | } else if (key instanceof PemKey) { 49 | return false; 50 | } else if (key instanceof JwkKey) { 51 | return key.jwk.kty === 'oct'; 52 | } 53 | 54 | return key.type === 'secret'; 55 | } 56 | 57 | function isPem(keyString: string) { 58 | return ( 59 | keyString.startsWith('-----BEGIN PUBLIC KEY-----') || 60 | keyString.startsWith('-----BEGIN PRIVATE KEY-----') 61 | ); 62 | } 63 | 64 | function isJsonWebKey(obj: unknown): obj is JsonWebKey { 65 | if (obj == null) { return false; } 66 | return (obj as JsonWebKey).kty != null; 67 | } 68 | 69 | export function loadKey(key: string | JsonWebKey | Uint8Array | jose.KeyLike): Uint8Array | jose.KeyLike | PemKey | JwkKey { 70 | 71 | let result: string | JsonWebKey | Uint8Array | jose.KeyLike | PemKey | JwkKey = key; 72 | 73 | if (typeof result === 'string' && result.startsWith('base64:')) { 74 | result = result.slice(7); 75 | result = decodeBytesFromBase64String(result); 76 | } 77 | 78 | // If the array starts with five hyphens, 79 | // it might be a PEM-encoded SPKI or PKCS#8 key 80 | if (result instanceof Uint8Array && result.at(0) === 45 && 81 | result.at(1) === 45 && 82 | result.at(2) === 45 && 83 | result.at(3) === 45 && 84 | result.at(4) === 45 85 | ) { 86 | let keyString: string | null = null; 87 | try { 88 | keyString = textDecoder.decode(result); 89 | } catch { 90 | } 91 | 92 | if (keyString != null) { 93 | if (isPem(keyString)) { 94 | result = new PemKey(keyString); 95 | } 96 | } 97 | } 98 | 99 | if (typeof result === 'string') { 100 | if (isPem(result)) { 101 | result = new PemKey(result); 102 | } 103 | } 104 | 105 | // '{' 106 | if (result instanceof Uint8Array && result.at(0) === 123) { 107 | let keyString: string | null = null; 108 | try { 109 | keyString = textDecoder.decode(result); 110 | } catch { 111 | } 112 | 113 | if (keyString != null) { 114 | let jsonObj: unknown = null; 115 | try { 116 | jsonObj = JSON.parse(keyString); 117 | } catch { 118 | } 119 | 120 | if (jsonObj != null && isJsonWebKey(jsonObj)) { 121 | result = new JwkKey(jsonObj); 122 | } 123 | } 124 | } 125 | 126 | if (typeof result === 'string') { 127 | if (result.startsWith('{')) { 128 | let jsonObj: unknown = null; 129 | try { 130 | jsonObj = JSON.parse(result); 131 | } catch { 132 | } 133 | 134 | if (jsonObj != null && isJsonWebKey(jsonObj)) { 135 | result = new JwkKey(jsonObj); 136 | } 137 | } 138 | } 139 | 140 | if (isJsonWebKey(result)) { 141 | result = new JwkKey(result); 142 | } 143 | 144 | if (typeof result === 'string') { 145 | result = textEncoder.encode(result); 146 | } 147 | 148 | return result; 149 | 150 | } 151 | -------------------------------------------------------------------------------- /src/utilities/string.ts: -------------------------------------------------------------------------------- 1 | export function encodeCString(str: string) { 2 | let output = ''; 3 | for (const c of str) { 4 | if (c === '\\') { 5 | output += '\\\\'; 6 | } else if (c === '\r') { 7 | output += '\\r'; 8 | } else if (c === '\n') { 9 | output += '\\n'; 10 | } else if (c === '\t') { 11 | output += '\\t'; 12 | } else if (c.charCodeAt(0) < 0x20) { 13 | throw new Error('can\'t encode'); 14 | } else { 15 | output += c; 16 | } 17 | } 18 | return output; 19 | } 20 | 21 | export function escapeQuotes(str: string) { 22 | let output = ''; 23 | for (const c of str) { 24 | if (c === '"') { 25 | output += '\\"'; 26 | } else { 27 | output += c; 28 | } 29 | } 30 | return output; 31 | } 32 | -------------------------------------------------------------------------------- /src/utilities/typedarray.ts: -------------------------------------------------------------------------------- 1 | export function concatUint8Arrays(...arrays: Uint8Array[]) { 2 | 3 | const combinedBuffer = new Uint8Array(arrays.reduce((acc, seg) => acc + seg.length, 0)); 4 | let offset = 0; 5 | for (const item of arrays) { 6 | combinedBuffer.set(item, offset); 7 | offset = offset + item.length; 8 | } 9 | 10 | return combinedBuffer; 11 | } 12 | -------------------------------------------------------------------------------- /src/utilities/webSocketEvents.ts: -------------------------------------------------------------------------------- 1 | import { type IWebSocketEvent, WebSocketEvent } from '../data/index.js'; 2 | import { concatUint8Arrays } from './typedarray.js'; 3 | 4 | const textEncoder = new TextEncoder(); 5 | const textDecoder = new TextDecoder(); 6 | 7 | // Encode the specified array of WebSocketEvent instances. The returned string 8 | // value should then be passed to a GRIP proxy in the body of an HTTP response 9 | // when using the WebSocket-over-HTTP protocol. 10 | export function encodeWebSocketEvents(events: IWebSocketEvent[]) { 11 | const eventSegments: Uint8Array[] = []; 12 | const bufferNewLine = new Uint8Array([13, 10]); 13 | for (const e of events) { 14 | let content = e.getContent(); 15 | if (content != null) { 16 | if (typeof content === 'string') { 17 | content = textEncoder.encode(content); 18 | } 19 | eventSegments.push( 20 | textEncoder.encode(e.getType() + ' ' + content.length.toString(16)), 21 | bufferNewLine, 22 | content, 23 | bufferNewLine, 24 | ); 25 | } else { 26 | eventSegments.push( 27 | textEncoder.encode(e.getType()), 28 | bufferNewLine, 29 | ); 30 | } 31 | } 32 | return concatUint8Arrays(...eventSegments); 33 | } 34 | 35 | // Decode the specified HTTP request body into an array of WebSocketEvent 36 | // instances when using the WebSocket-over-HTTP protocol. A RuntimeError 37 | // is raised if the format is invalid. 38 | export function decodeWebSocketEvents(body: Uint8Array | string): IWebSocketEvent[] { 39 | const out = []; 40 | let start = 0; 41 | let makeContentString = false; 42 | if (typeof body === 'string') { 43 | body = textEncoder.encode(body); 44 | makeContentString = true; 45 | } 46 | while (start < body.length) { 47 | let at = body.findIndex((val, x) => { 48 | if (x < start || x === body.length - 1) { 49 | return false; 50 | } 51 | if (val !== 13 || body.at(x+1) !== 10) { 52 | return false; 53 | } 54 | return true; 55 | }); 56 | if (at === -1) { 57 | throw new Error('bad format'); 58 | } 59 | const typeline = body.slice(start, at); 60 | start = at + 2; 61 | at = typeline.indexOf(32); 62 | let e = null; 63 | if (at !== -1) { 64 | const etype = typeline.slice(0, at); 65 | const clen = parseInt(textDecoder.decode(typeline.slice(at + 1)), 16); 66 | const content = body.slice(start, start + clen); 67 | start = start + clen + 2; 68 | if (makeContentString) { 69 | e = new WebSocketEvent(textDecoder.decode(etype), textDecoder.decode(content)); 70 | } else { 71 | e = new WebSocketEvent(textDecoder.decode(etype), content); 72 | } 73 | } else { 74 | e = new WebSocketEvent(textDecoder.decode(typeline)); 75 | } 76 | out.push(e); 77 | } 78 | return out; 79 | } 80 | 81 | // Generate a WebSocket control message with the specified type and optional 82 | // arguments. WebSocket control messages are passed to GRIP proxies and 83 | // example usage includes subscribing/unsubscribing a WebSocket connection 84 | // to/from a channel. 85 | export function createWebSocketControlMessage(type: string, args: Record | null = null) { 86 | const out = Object.assign({}, args, { type }); 87 | return JSON.stringify(out); 88 | } 89 | -------------------------------------------------------------------------------- /src/utilities/ws-over-http.ts: -------------------------------------------------------------------------------- 1 | import debug from './debug.js'; 2 | import { ConnectionIdMissingException, WebSocketContext, WebSocketDecodeEventException } from '../data/index.js'; 3 | import { decodeWebSocketEvents } from './webSocketEvents.js'; 4 | 5 | export const CONTENT_TYPE_APPLICATION = 'application'; 6 | export const CONTENT_SUBTYPE_WEBSOCKET_EVENTS = 'websocket-events'; 7 | 8 | export const CONTENT_TYPE_WEBSOCKET_EVENTS = `${CONTENT_TYPE_APPLICATION}/${CONTENT_SUBTYPE_WEBSOCKET_EVENTS}`; 9 | 10 | export function isWsOverHttpImpl(method: string | undefined, headers: Headers) { 11 | if (method !== 'POST') { 12 | return false; 13 | } 14 | 15 | let contentTypeHeader = headers.get('content-type'); 16 | if (contentTypeHeader != null) { 17 | const at = contentTypeHeader.indexOf(';'); 18 | if (at >= 0) { 19 | contentTypeHeader = contentTypeHeader.slice(0, at); 20 | } 21 | contentTypeHeader = contentTypeHeader.trim(); 22 | debug('content-type header', contentTypeHeader); 23 | } else { 24 | debug('content-type header not present'); 25 | } 26 | 27 | if (contentTypeHeader !== CONTENT_TYPE_WEBSOCKET_EVENTS) { 28 | return false; 29 | } 30 | 31 | const acceptTypesHeader = headers.get('accept'); 32 | 33 | if (acceptTypesHeader == null) { 34 | debug('accept header not present'); 35 | return false; 36 | } 37 | debug('accept header', acceptTypesHeader); 38 | 39 | for (let acceptType of acceptTypesHeader.split(',')) { 40 | const at = acceptType.indexOf(';'); 41 | if (at >= 0) { 42 | acceptType = acceptType.slice(0, at); 43 | } 44 | acceptType = acceptType.trim(); 45 | if ( 46 | acceptType === '*/*' || 47 | acceptType === `${CONTENT_TYPE_APPLICATION}/*` || 48 | acceptType === CONTENT_TYPE_WEBSOCKET_EVENTS 49 | ) { 50 | return true; 51 | } 52 | } 53 | 54 | return false; 55 | } 56 | 57 | export async function getWebSocketContextImpl(headers: Headers, getBody: () => Promise, prefix: string) { 58 | 59 | const cid = headers.get('connection-id'); 60 | if (cid == null) { 61 | throw new ConnectionIdMissingException(); 62 | } 63 | 64 | debug('Connection ID', cid); 65 | 66 | // Handle meta keys 67 | debug('Handling Meta - start'); 68 | const meta: Record = {}; 69 | for (const [key, value] of headers.entries()) { 70 | const lKey = key.toLowerCase(); 71 | if (lKey.startsWith('meta-')) { 72 | const k = lKey.slice(5); 73 | meta[k] = value; 74 | debug(k, '=', value); 75 | } 76 | } 77 | debug('Handling Meta - end'); 78 | 79 | const body = await getBody(); 80 | 81 | 82 | debug('Decode body - start'); 83 | let events = null; 84 | try { 85 | events = decodeWebSocketEvents(body); 86 | } catch (err) { 87 | throw new WebSocketDecodeEventException(); 88 | } 89 | debug('Decode body - end'); 90 | 91 | debug('Websocket Events', events); 92 | 93 | debug('Creating Websocket Context - start'); 94 | const wsContext = new WebSocketContext(cid, meta, events, prefix); 95 | debug('Creating Websocket Context - end'); 96 | 97 | return wsContext; 98 | } 99 | 100 | export function isWsOverHttp(req: Request) { 101 | return isWsOverHttpImpl(req.method, req.headers); 102 | } 103 | 104 | export async function getWebSocketContextFromReq(req: Request, prefix: string = '') { 105 | const getBody = async () => { 106 | const arrayBuffer = await req.arrayBuffer(); 107 | return new Uint8Array(arrayBuffer); 108 | }; 109 | return getWebSocketContextImpl(req.headers, getBody, prefix); 110 | } 111 | -------------------------------------------------------------------------------- /test/unit/auth/Auth.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import * as jose from 'jose'; 4 | import * as Auth from '../../../src/auth/index.js'; 5 | import { encodeBytesToBase64String } from '../../../src/index.js'; 6 | 7 | const textEncoder = new TextEncoder(); 8 | 9 | describe('auth', () => { 10 | describe('Basic', () => { 11 | it('can be instantiated and can generate an appropriate header', async () => { 12 | const authBasic = new Auth.Basic('user', 'pass'); 13 | assert.strictEqual(authBasic.getUser(), 'user'); 14 | assert.strictEqual(authBasic.getPass(), 'pass'); 15 | assert.strictEqual( 16 | await authBasic.buildHeader(), 17 | `Basic ${encodeBytesToBase64String(textEncoder.encode('user:pass'))}` 18 | ); 19 | }); 20 | }); 21 | describe('Bearer', () => { 22 | it('can be instantiated and can generate an appropriate header', async () => { 23 | let authBearer = new Auth.Bearer('token'); 24 | assert.strictEqual(authBearer.getToken(), 'token'); 25 | assert.strictEqual(await authBearer.buildHeader(), 'Bearer token'); 26 | }); 27 | }); 28 | describe('Jwt', () => { 29 | it('can be instantiated and can generate an appropriate header', async () => { 30 | const cl = {}; 31 | let authJwt = new Auth.Jwt(cl, textEncoder.encode('key')); 32 | assert.strictEqual(authJwt.getClaim(), cl); 33 | assert.deepStrictEqual(authJwt.getKey(), textEncoder.encode('key')); 34 | 35 | authJwt = new Auth.Jwt({ iss: 'hello' }, textEncoder.encode('key==')); 36 | const claim = await jose.jwtVerify( 37 | (await authJwt.buildHeader()).slice(7), 38 | textEncoder.encode('key==') 39 | ); 40 | 41 | assert.ok(claim.payload.exp != null); 42 | assert.strictEqual(claim.payload.iss, 'hello'); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/unit/data/Channel.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { Channel } from '../../../src/index.js'; 4 | 5 | describe('Channel', () => { 6 | 7 | describe('Initialize', () => { 8 | it('should allow creating a Channel with a name', () => { 9 | const ch = new Channel('name'); 10 | assert.strictEqual(ch.name, 'name'); 11 | assert.strictEqual(ch.prevId, null); 12 | }); 13 | it('should allow creating a Channel with both a name and a prevId', () => { 14 | const ch = new Channel('name', 'prev-id'); 15 | assert.strictEqual(ch.name, 'name'); 16 | assert.strictEqual(ch.prevId, 'prev-id'); 17 | }) 18 | }); 19 | 20 | describe('Export', () => { 21 | it('should allow exporting a Channel with a name', () => { 22 | const ch = new Channel('name'); 23 | assert.strictEqual(JSON.stringify(ch.export()), JSON.stringify({ 24 | name: 'name' 25 | })); 26 | }); 27 | it('should allow exporting a Chanel with a name and a prevId', () => { 28 | const ch = new Channel('name', 'prev-id'); 29 | assert.strictEqual(JSON.stringify(ch.export()), JSON.stringify({ 30 | name: 'name', prevId: 'prev-id' 31 | })); 32 | }); 33 | }); 34 | 35 | }); 36 | 37 | -------------------------------------------------------------------------------- /test/unit/data/Item.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { Item, Format } from '../../../src/index.js'; 4 | 5 | class TestFormat1 extends Format { 6 | content: string; 7 | constructor(content: string) { 8 | super(); 9 | this.content = content; 10 | } 11 | name() { 12 | return 'testformat1'; 13 | } 14 | export() { 15 | return { content: this.content }; 16 | } 17 | } 18 | 19 | class TestFormat2 extends Format { 20 | content: string; 21 | constructor(content: string) { 22 | super(); 23 | this.content = content; 24 | } 25 | name() { 26 | return 'testformat2'; 27 | } 28 | export() { 29 | return { content: this.content }; 30 | } 31 | } 32 | 33 | const fmt1a = new TestFormat1('body1a'); 34 | const fmt2a = new TestFormat2('body2a'); 35 | 36 | describe('Item', () => { 37 | describe('#constructor', () => { 38 | it('constructs with format', () => { 39 | const itm = new Item(fmt1a); 40 | assert.strictEqual(itm.formats[0], fmt1a); 41 | }); 42 | it('constructs with format and id', () => { 43 | const itm = new Item(fmt1a, 'id'); 44 | assert.strictEqual(itm.formats[0], fmt1a); 45 | assert.strictEqual(itm.id, 'id'); 46 | }); 47 | it('constructs with format, id, and prev-id', () => { 48 | const itm = new Item(fmt1a, 'id', 'prev-id'); 49 | assert.strictEqual(itm.formats[0], fmt1a); 50 | assert.strictEqual(itm.id, 'id'); 51 | assert.strictEqual(itm.prevId, 'prev-id'); 52 | }); 53 | it('constructs with multiple formats', () => { 54 | const itm = new Item([fmt1a, fmt2a]); 55 | assert.strictEqual(itm.formats[0], fmt1a); 56 | assert.strictEqual(itm.formats[1], fmt2a); 57 | }); 58 | }); 59 | describe('#export', () => { 60 | it('exports single format', () => { 61 | const itm = new Item(fmt1a); 62 | assert.ok(!('id' in itm.export())); 63 | assert.ok(!('prev-id' in itm.export())); 64 | assert.strictEqual( 65 | JSON.stringify(itm.export().formats['testformat1']), 66 | JSON.stringify({ content: 'body1a' }) 67 | ); 68 | }); 69 | it('exports multiple formats', () => { 70 | const itm = new Item([fmt1a, fmt2a]); 71 | assert.ok(!('id' in itm.export())); 72 | assert.ok(!('prev-id' in itm.export())); 73 | assert.strictEqual( 74 | JSON.stringify(itm.export().formats['testformat1']), 75 | JSON.stringify({ content: 'body1a' }) 76 | ); 77 | assert.strictEqual( 78 | JSON.stringify(itm.export().formats['testformat2']), 79 | JSON.stringify({ content: 'body2a' }) 80 | ); 81 | }); 82 | it('throws when same format appears multiple times', () => { 83 | assert.throws(() => { 84 | const itm = new Item([fmt1a, fmt1a]); 85 | itm.export(); 86 | }, Error); 87 | }); 88 | it('exports item with id and prev-id', () => { 89 | const itm = new Item(fmt1a, 'id', 'prev-id'); 90 | assert.strictEqual(itm.export()['id'], 'id'); 91 | assert.strictEqual(itm.export()['prev-id'], 'prev-id'); 92 | assert.strictEqual( 93 | JSON.stringify(itm.export().formats['testformat1']), 94 | JSON.stringify({ content: 'body1a' }) 95 | ); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/unit/data/http/HttpResponseFormat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { encodeBytesToBase64String, HttpResponseFormat } from '../../../../src/index.js'; 4 | 5 | const textEncoder = new TextEncoder(); 6 | 7 | describe('HttpResponseFormat', () => { 8 | describe('#constructor', () => { 9 | it('constructs with no parameters', () => { 10 | const hf = new HttpResponseFormat(); 11 | assert.strictEqual(hf.code, null); 12 | assert.strictEqual(hf.reason, null); 13 | assert.strictEqual(hf.headers, null); 14 | assert.strictEqual(hf.body, null); 15 | }); 16 | it('constructs with code, reason, headers, and body', () => { 17 | const hf = new HttpResponseFormat('code', 'reason', 18 | {'_': 'headers'}, 'body'); 19 | assert.strictEqual(hf.code, 'code'); 20 | assert.strictEqual(hf.reason, 'reason'); 21 | assert.deepStrictEqual(hf.headers, {'_': 'headers'}); 22 | assert.strictEqual(hf.body, 'body'); 23 | }); 24 | it('constructs with code, reason, headers, and body packed in the first parameter', () => { 25 | const hf = new HttpResponseFormat({ code: 'code', 26 | reason: 'reason', headers: {'_': 'headers'}, body: 'body' }); 27 | assert.strictEqual(hf.code, 'code'); 28 | assert.strictEqual(hf.reason, 'reason'); 29 | assert.deepStrictEqual(hf.headers, {'_': 'headers'}); 30 | assert.strictEqual(hf.body, 'body'); 31 | }); 32 | }); 33 | describe('#name', () => { 34 | it('returns the name \'http-response\'', () => { 35 | const hf = new HttpResponseFormat('body'); 36 | assert.strictEqual(hf.name(), 'http-response'); 37 | }); 38 | }); 39 | describe('#export', () => { 40 | it('exports an object with body only, and the other fields are left out', () => { 41 | const hf = new HttpResponseFormat({ body: 'body' }); 42 | assert.strictEqual(JSON.stringify(hf.export()), JSON.stringify({ body: 'body' })); 43 | }); 44 | it('exports an object with code, reason, headers, and body', () => { 45 | const hf = new HttpResponseFormat({ code: 'code', 46 | reason: 'reason', headers: {'_': 'headers'}, body: 'body' }); 47 | assert.strictEqual(JSON.stringify(hf.export()), JSON.stringify( 48 | { code: 'code', reason: 'reason', headers: {'_': 'headers'}, body: 'body' })); 49 | }); 50 | it('exports an object with code, reason, headers, and binary body', () => { 51 | const hf = new HttpResponseFormat({ code: 'code', 52 | reason: 'reason', headers: {'_': 'headers'}, body: textEncoder.encode('body') }); 53 | assert.strictEqual(JSON.stringify(hf.export()), JSON.stringify( 54 | { 55 | code: 'code', 56 | reason: 'reason', 57 | headers: {'_': 'headers'}, 58 | 'body-bin': encodeBytesToBase64String(textEncoder.encode('body')), 59 | })); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/unit/data/http/HttpStreamFormat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { encodeBytesToBase64String, HttpStreamFormat } from '../../../../src/index.js'; 4 | 5 | const textEncoder = new TextEncoder(); 6 | 7 | describe('HttpStreamFormat', () => { 8 | describe('#constructor', () => { 9 | it('can construct with content', () => { 10 | const hf = new HttpStreamFormat('content'); 11 | assert.strictEqual(hf.content, 'content'); 12 | assert.strictEqual(hf.close, false); 13 | }); 14 | it('can construct with content and close flag', () => { 15 | const hf = new HttpStreamFormat('content', true); 16 | assert.strictEqual(hf.content, 'content'); 17 | assert.strictEqual(hf.close, true); 18 | }); 19 | }); 20 | describe('#name', () => { 21 | it('returns name \'http-stream\'', () => { 22 | const hf = new HttpStreamFormat('content', true); 23 | assert.strictEqual(hf.name(), 'http-stream'); 24 | }); 25 | }); 26 | describe('#export', () => { 27 | it('can export a string message', () => { 28 | const hf = new HttpStreamFormat('message'); 29 | assert.strictEqual(hf.export()['content'], 'message'); 30 | }); 31 | it('can export a binary message', () => { 32 | const hf = new HttpStreamFormat(textEncoder.encode('message')); 33 | assert.deepStrictEqual( 34 | hf.export()['content-bin'], 35 | encodeBytesToBase64String(textEncoder.encode('message')), 36 | ); 37 | }); 38 | it('can export a message and close action', () => { 39 | const hf = new HttpStreamFormat(null, true); 40 | assert.strictEqual(hf.export()['action'], 'close'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/unit/data/websocket/WebSocketContext.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { jspack } from 'jspack'; 4 | import { WebSocketContext, WebSocketEvent } from '../../../../src/index.js'; 5 | 6 | const textEncoder = new TextEncoder(); 7 | const textDecoder = new TextDecoder(); 8 | 9 | describe('WebSocketContext', () => { 10 | describe('opening', () => { 11 | it('can accept accept a context with OPEN', () => { 12 | const ws = new WebSocketContext('conn-1', {}, [new WebSocketEvent('OPEN')]); 13 | assert.strictEqual(ws.id, 'conn-1'); 14 | assert.ok(ws.isOpening()); 15 | assert.ok(!ws.canRecv()); 16 | assert.ok(!ws.accepted); 17 | ws.accept(); 18 | assert.ok(ws.accepted); 19 | }); 20 | }); 21 | describe('receive', () => { 22 | it('can receive a message from context with TEXT', () => { 23 | const ws = new WebSocketContext('conn-1', {}, [new WebSocketEvent('TEXT', textEncoder.encode('hello'))]); 24 | assert.ok(!ws.isOpening()); 25 | assert.ok(ws.canRecv()); 26 | const msg = ws.recv(); 27 | assert.strictEqual(msg, 'hello'); 28 | assert.ok(!ws.canRecv()); 29 | }); 30 | }); 31 | describe('send', () => { 32 | it('can send messages through context', () => { 33 | const ws = new WebSocketContext('conn-1', {}, []); 34 | assert.ok(!ws.isOpening()); 35 | assert.ok(!ws.canRecv()); 36 | assert.strictEqual(ws.outEvents.length, 0); 37 | ws.send(textEncoder.encode('apple')); 38 | ws.send('banana'); 39 | ws.sendBinary(textEncoder.encode('cherry')); 40 | ws.sendBinary('date'); 41 | assert.strictEqual(ws.outEvents.length, 4); 42 | 43 | assert.strictEqual(ws.outEvents[0].getType(), 'TEXT'); 44 | let content = ws.outEvents[0].getContent(); 45 | assert.ok(content instanceof Uint8Array); 46 | assert.deepStrictEqual(content, textEncoder.encode('m:apple')); 47 | 48 | assert.strictEqual(ws.outEvents[1].getType(), 'TEXT'); 49 | content = ws.outEvents[1].getContent(); 50 | assert.ok(content instanceof Uint8Array); 51 | assert.deepStrictEqual(content, textEncoder.encode('m:banana')); 52 | 53 | assert.strictEqual(ws.outEvents[2].getType(), 'BINARY'); 54 | content = ws.outEvents[2].getContent(); 55 | assert.ok(content instanceof Uint8Array); 56 | assert.deepStrictEqual(content, textEncoder.encode('m:cherry')); 57 | 58 | assert.strictEqual(ws.outEvents[3].getType(), 'BINARY'); 59 | content = ws.outEvents[3].getContent(); 60 | assert.ok(content instanceof Uint8Array); 61 | assert.deepStrictEqual(content, textEncoder.encode('m:date')); 62 | }); 63 | }); 64 | describe('control', () => { 65 | it('can send control messages', () => { 66 | const ws = new WebSocketContext('conn-1', {}, []); 67 | assert.strictEqual(ws.outEvents.length, 0); 68 | ws.subscribe('foo'); 69 | ws.unsubscribe('bar'); 70 | assert.strictEqual(ws.outEvents.length, 2); 71 | 72 | assert.strictEqual(ws.outEvents[0].getType(), 'TEXT'); 73 | let content = ws.outEvents[0].getContent(); 74 | assert.ok(content instanceof Uint8Array); 75 | assert.ok(textDecoder.decode(content).startsWith('c:')); 76 | assert.deepEqual(JSON.parse(textDecoder.decode(content.slice(2))), 77 | {type: 'subscribe', channel: 'foo'}); 78 | 79 | assert.strictEqual(ws.outEvents[1].getType(), 'TEXT'); 80 | content = ws.outEvents[1].getContent(); 81 | assert.ok(content instanceof Uint8Array); 82 | assert.ok(textDecoder.decode(content).startsWith('c:')); 83 | assert.deepEqual(JSON.parse(textDecoder.decode(content.slice(2))), 84 | {type: 'unsubscribe', channel: 'bar'}); 85 | }); 86 | }); 87 | describe('close', () => { 88 | it('can handle a CLOSE message', () => { 89 | const data = jspack.Pack('>H', [100]); 90 | assert.ok(data); 91 | const ws = new WebSocketContext('conn-1', {}, [new WebSocketEvent('CLOSE', new Uint8Array(data))]); 92 | assert.ok(!ws.isOpening()); 93 | assert.ok(ws.canRecv()); 94 | const msg = ws.recv(); 95 | assert.ok(msg == null); 96 | assert.strictEqual(ws.closeCode, 100); 97 | }); 98 | it('can send a CLOSE message', () => { 99 | const ws = new WebSocketContext('conn-1', {}, []); 100 | assert.ok(!ws.isOpening()); 101 | assert.ok(!ws.canRecv()); 102 | assert.ok(!ws.closed); 103 | ws.close(100); 104 | assert.strictEqual(ws.outCloseCode, 100); 105 | }); 106 | }); 107 | describe('disconnect', () => { 108 | it('can send a DISCONNECT message', () => { 109 | const ws = new WebSocketContext('conn-5', {}, []); 110 | assert.strictEqual(ws.outEvents.length, 0); 111 | ws.disconnect(); 112 | assert.strictEqual(ws.outEvents.length, 1); 113 | assert.strictEqual(ws.outEvents[0].getType(), 'DISCONNECT'); 114 | assert.ok(ws.outEvents[0].getContent() === null, 'disconnect event has null content') 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/unit/data/websocket/WebSocketEvent.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { WebSocketEvent } from '../../../../src/index.js'; 4 | 5 | describe('WebSocketEvent', () => { 6 | describe('#constructor', () => { 7 | it('can construct with a type', () => { 8 | const we = new WebSocketEvent('type'); 9 | assert.strictEqual(we.type, 'type'); 10 | assert.strictEqual(we.content, null); 11 | }); 12 | it('can construct with a type and content', () => { 13 | const we = new WebSocketEvent('type', 'content'); 14 | assert.strictEqual(we.type, 'type'); 15 | assert.strictEqual(we.content, 'content'); 16 | }); 17 | }); 18 | describe('#getType', () => { 19 | it('returns the type', () => { 20 | const we = new WebSocketEvent('type'); 21 | assert.strictEqual(we.getType(), 'type'); 22 | }); 23 | }); 24 | describe('#getContent', () => { 25 | it('returns content', () => { 26 | const we = new WebSocketEvent('type', 'content'); 27 | assert.strictEqual(we.getContent(), 'content'); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/unit/data/websocket/WebSocketMessageFormat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { encodeBytesToBase64String, WebSocketMessageFormat } from '../../../../src/index.js'; 4 | 5 | const textEncoder = new TextEncoder(); 6 | 7 | describe('WebSocketMessageFormat', () => { 8 | describe('#constructor', () => { 9 | it('can construct with conetnt', () => { 10 | const ws = new WebSocketMessageFormat('content'); 11 | assert.strictEqual(ws.content, 'content'); 12 | }); 13 | }); 14 | describe('#name', () => { 15 | it('returns name \'ws-message\'', () => { 16 | const ws = new WebSocketMessageFormat('content'); 17 | assert.strictEqual(ws.name(), 'ws-message'); 18 | }); 19 | }); 20 | describe('#export', () => { 21 | it('returns content', () => { 22 | const ws = new WebSocketMessageFormat('message'); 23 | assert.strictEqual(ws.export()['content'], 'message'); 24 | }); 25 | it('returns binary content', () => { 26 | const ws = new WebSocketMessageFormat(textEncoder.encode('message')); 27 | assert.deepStrictEqual( 28 | ws.export()['content-bin'], 29 | encodeBytesToBase64String(textEncoder.encode('message')), 30 | ); 31 | }); 32 | it('returns close action and code', () => { 33 | const ws = new WebSocketMessageFormat(null, true, 1009); 34 | assert.strictEqual(ws.export()['action'], 'close'); 35 | assert.strictEqual(ws.export()['code'], 1009); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/unit/fastly-fanout/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | 4 | import { type IGripConfig } from '../../../src/index.js'; 5 | import { PUBLIC_KEY_FASTLY_FANOUT_JWK } from '../../../src/fastly-fanout/keys.js'; 6 | import { buildFanoutGripConfig, buildFanoutGripUrl } from '../../../src/fastly-fanout/utils.js'; 7 | 8 | describe('buildFanoutGripConfig', () => { 9 | 10 | it('can build a GRIP Config with just service ID and API token', () => { 11 | const gripConfig = buildFanoutGripConfig({ 12 | serviceId: 'SERVICE_ID', 13 | apiToken: 'API_TOKEN', 14 | }); 15 | const expected: IGripConfig = { 16 | control_uri: 'https://api.fastly.com/service/SERVICE_ID', 17 | key: 'API_TOKEN', 18 | verify_iss: 'fastly:SERVICE_ID', 19 | verify_key: JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK), 20 | }; 21 | assert.deepStrictEqual(gripConfig, expected); 22 | }); 23 | 24 | it('can build a GRIP Config with overridden baseURL', () => { 25 | const gripConfig = buildFanoutGripConfig({ 26 | baseUrl: 'https://www.example.com/path/to/base', 27 | serviceId: 'SERVICE_ID', 28 | apiToken: 'API_TOKEN', 29 | }); 30 | const expected: IGripConfig = { 31 | control_uri: 'https://www.example.com/path/to/base', 32 | key: 'API_TOKEN', 33 | verify_iss: 'fastly:SERVICE_ID', 34 | verify_key: JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK), 35 | }; 36 | assert.deepStrictEqual(gripConfig, expected); 37 | }); 38 | 39 | it('can build a GRIP Config with overridden verify_iss', () => { 40 | const gripConfig = buildFanoutGripConfig({ 41 | serviceId: 'SERVICE_ID', 42 | apiToken: 'API_TOKEN', 43 | verifyIss: 'foo', 44 | }); 45 | const expected: IGripConfig = { 46 | control_uri: 'https://api.fastly.com/service/SERVICE_ID', 47 | key: 'API_TOKEN', 48 | verify_iss: 'foo', 49 | verify_key: JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK), 50 | }; 51 | assert.deepStrictEqual(gripConfig, expected); 52 | }); 53 | 54 | it('can build a GRIP Config with overridden verify_key', () => { 55 | const gripConfig = buildFanoutGripConfig({ 56 | serviceId: 'SERVICE_ID', 57 | apiToken: 'API_TOKEN', 58 | verifyKey: 'foo', 59 | }); 60 | const expected: IGripConfig = { 61 | control_uri: 'https://api.fastly.com/service/SERVICE_ID', 62 | key: 'API_TOKEN', 63 | verify_iss: 'fastly:SERVICE_ID', 64 | verify_key: 'foo', 65 | }; 66 | assert.deepStrictEqual(gripConfig, expected); 67 | }); 68 | 69 | }); 70 | 71 | describe('buildFanoutGripUrl', () => { 72 | 73 | it('can build a GRIP_URL with just service ID and API token', () => { 74 | const gripUrl = buildFanoutGripUrl({ 75 | serviceId: 'SERVICE_ID', 76 | apiToken: 'API_TOKEN', 77 | }); 78 | const expected = 'https://api.fastly.com/service/SERVICE_ID' + 79 | '?key=API_TOKEN&verify-iss=fastly%3ASERVICE_ID' + 80 | '&verify-key=' + encodeURIComponent(JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK)); 81 | assert.strictEqual(gripUrl, expected); 82 | }); 83 | 84 | it('can build a GRIP_URL with overridden baseURL', () => { 85 | const gripUrl = buildFanoutGripUrl({ 86 | baseUrl: 'https://www.example.com/path/to/base', 87 | serviceId: 'SERVICE_ID', 88 | apiToken: 'API_TOKEN', 89 | }); 90 | const expected = 'https://www.example.com/path/to/base' + 91 | '?key=API_TOKEN&verify-iss=fastly%3ASERVICE_ID' + 92 | '&verify-key=' + encodeURIComponent(JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK)); 93 | assert.strictEqual(gripUrl, expected); 94 | }); 95 | 96 | it('can build a GRIP_URL with overridden verify_iss', () => { 97 | const gripUrl = buildFanoutGripUrl({ 98 | serviceId: 'SERVICE_ID', 99 | apiToken: 'API_TOKEN', 100 | verifyIss: 'foo', 101 | }); 102 | const expected = 'https://api.fastly.com/service/SERVICE_ID' + 103 | '?key=API_TOKEN&verify-iss=foo' + 104 | '&verify-key=' + encodeURIComponent(JSON.stringify(PUBLIC_KEY_FASTLY_FANOUT_JWK)); 105 | assert.strictEqual(gripUrl, expected); 106 | }); 107 | 108 | it('can build a GRIP_URL with overridden verify_key', () => { 109 | const gripUrl = buildFanoutGripUrl({ 110 | serviceId: 'SERVICE_ID', 111 | apiToken: 'API_TOKEN', 112 | verifyKey: 'foo', 113 | }); 114 | const expected = 'https://api.fastly.com/service/SERVICE_ID' + 115 | '?key=API_TOKEN&verify-iss=fastly%3ASERVICE_ID' + 116 | '&verify-key=foo'; 117 | assert.strictEqual(gripUrl, expected); 118 | }); 119 | 120 | }); 121 | -------------------------------------------------------------------------------- /test/unit/utilities/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { 4 | decodeBytesFromBase64String, 5 | encodeBytesToBase64String, 6 | } from '../../../src/index.js'; 7 | 8 | const textEncoder = new TextEncoder(); 9 | 10 | describe('encode()', () => { 11 | it('can encode a Uint8Array into a base64 string', () => { 12 | 13 | const bytes = new Uint8Array([0, 1, 2]); 14 | const str = encodeBytesToBase64String(bytes); 15 | assert.strictEqual(str, 'AAEC'); 16 | 17 | }); 18 | it('can decode a Uint8Array from a base64 string', () => { 19 | 20 | const bytes = decodeBytesFromBase64String('AAEC'); 21 | assert.deepStrictEqual(bytes, new Uint8Array([0, 1, 2])); 22 | 23 | }); 24 | it('can decode a Uint8Array from a base64url string', () => { 25 | 26 | const bytes = decodeBytesFromBase64String('Aag_7Ge-2Q=='); 27 | assert.deepStrictEqual(bytes, new Uint8Array([0x01, 0xa8, 0x3f, 0xec, 0x67, 0xbe, 0xd9])); 28 | 29 | }); 30 | it('can decode a Uint8Array from a base64 string that happens to have a space in it from bad encoding', () => { 31 | 32 | const bytes = decodeBytesFromBase64String('AEJ 3w=='); 33 | assert.deepStrictEqual(bytes, new Uint8Array([0x00, 0x42, 0x7e, 0xdf])); 34 | 35 | }); 36 | it('throws when trying to decode invalid base64 string', () => { 37 | 38 | assert.throws(() => { 39 | decodeBytesFromBase64String('geag121321='); 40 | }, err => { 41 | assert.ok(err instanceof TypeError); 42 | assert.strictEqual(err.message, 'Invalid base64 sequence'); 43 | return true; 44 | }); 45 | 46 | }); 47 | it('works with this dataset', () => { 48 | 49 | const dataset = { 50 | '': '', 51 | 'f': 'Zg==', 52 | 'fo': 'Zm8=', 53 | 'foo': 'Zm9v', 54 | 'foob': 'Zm9vYg==', 55 | 'fooba': 'Zm9vYmE=', 56 | 'foobar': 'Zm9vYmFy', 57 | } 58 | 59 | for (const [key, value] of Object.entries(dataset)) { 60 | const keyBytes = textEncoder.encode(key) 61 | 62 | assert.strictEqual(encodeBytesToBase64String(keyBytes), value); 63 | } 64 | 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/unit/utilities/http.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { createKeepAliveHeader, createMetaHeader, createNextLinkHeader } from '../../../src/index.js'; 4 | 5 | const textEncoder = new TextEncoder(); 6 | 7 | describe('utilities/http', () => { 8 | describe('#createKeepAliveHeader', () => { 9 | it('string input', () => { 10 | const header = createKeepAliveHeader('foo', 100); 11 | assert.strictEqual(header, 'foo; format=cstring; timeout=100'); 12 | }); 13 | it('buffer input', () => { 14 | const data = textEncoder.encode('foo'); 15 | const header = createKeepAliveHeader(data, 100); 16 | assert.strictEqual(header, 'Zm9v; format=base64; timeout=100'); 17 | }); 18 | }); 19 | describe('#createMetaHeader', () => { 20 | it('object', () => { 21 | const metas = { 22 | 'foo': 'bar', 23 | 'bar': '"quoted string"', 24 | }; 25 | const header = createMetaHeader(metas); 26 | assert.strictEqual(header, 'foo="bar", bar="\\"quoted string\\""'); 27 | }); 28 | }); 29 | describe('#createNextLinkHeader', () => { 30 | it('uri input', () => { 31 | const header = createNextLinkHeader('http://example.com/path/'); 32 | assert.strictEqual(header, '; rel=next'); 33 | }); 34 | it('uri input with timeout', () => { 35 | const header = createNextLinkHeader('http://example.com/path/', 100); 36 | assert.strictEqual(header, '; rel=next; timeout=100'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/unit/utilities/string.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { encodeCString, escapeQuotes, } from '../../../src/index.js'; 4 | 5 | describe('utilities/string', () => { 6 | describe('#encodeCString', () => { 7 | it('simple string', () => { 8 | const input = 'simple'; 9 | assert.strictEqual(encodeCString(input), 'simple'); 10 | }); 11 | it('backslashes in string', () => { 12 | // The \\ in the input string resolves to a single backslash 13 | const input = 'string\\with\\backslashes'; 14 | // The \\\\ in the test string resolves to a double backslash 15 | assert.strictEqual(encodeCString(input), 'string\\\\with\\\\backslashes'); 16 | }); 17 | it('carriage-return', () => { 18 | const input = 'multi\rline'; 19 | assert.strictEqual(encodeCString(input), 'multi\\rline'); 20 | }); 21 | it('new-line', () => { 22 | const input = 'multi\nline'; 23 | assert.strictEqual(encodeCString(input), 'multi\\nline'); 24 | }); 25 | it('string that cannot be encoded', () => { 26 | const input = 'unprintable' + String.fromCharCode(7) + 'string'; 27 | assert.throws(() => { 28 | encodeCString(input); 29 | }, err => { 30 | assert.ok(err instanceof Error); 31 | assert.strictEqual(err.message, 'can\'t encode'); 32 | return true; 33 | }); 34 | }); 35 | }); 36 | describe('#escapeQuotes', () => { 37 | it('simple string', () => { 38 | const input = 'simple string'; 39 | assert.strictEqual(escapeQuotes(input), 'simple string'); 40 | }); 41 | it('single-quoted string', () => { 42 | const input = '\'single-quoted string\''; 43 | assert.strictEqual(escapeQuotes(input), '\'single-quoted string\''); 44 | }); 45 | it('double-quoted string', () => { 46 | const input = '"double-quoted string"'; 47 | assert.strictEqual(escapeQuotes(input), '\\"double-quoted string\\"'); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/unit/utilities/typedarray.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { concatUint8Arrays } from '../../../src/index.js'; 4 | 5 | describe('concatTypedArrays', () => { 6 | it('combines arrays', () => { 7 | 8 | const a = new Uint8Array([100, 101, 102, 103]); 9 | const b = new Uint8Array([104, 105, 106]); 10 | const c = new Uint8Array([107, 108, 109]); 11 | 12 | const combined = concatUint8Arrays(a, b, c); 13 | 14 | assert.deepStrictEqual(combined, 15 | new Uint8Array([100, 101, 102, 103, 104, 105, 106, 107, 108, 109]) 16 | ); 17 | 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /test/unit/utilities/ws-over-http.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'node:test'; 2 | import assert from 'node:assert'; 3 | import { 4 | ConnectionIdMissingException, 5 | getWebSocketContextFromReq, 6 | isWsOverHttp, 7 | WebSocketDecodeEventException 8 | } from '../../../src/index.js'; 9 | 10 | describe('isWsOverHttp', () => { 11 | 12 | let req: Request; 13 | 14 | beforeEach(() => { 15 | req = new Request( 16 | 'https://www.example.com', 17 | { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/websocket-events', 21 | 'Accept': 'application/websocket-events', 22 | } 23 | }, 24 | ); 25 | }); 26 | 27 | it('returns true for normal case', () => { 28 | 29 | assert.ok(isWsOverHttp(req)); 30 | 31 | }); 32 | 33 | it('returns false if method isn\'t POST', () => { 34 | 35 | const getReq = new Request( 36 | req, 37 | { 38 | method: 'GET' 39 | } 40 | ); 41 | 42 | assert.ok(!isWsOverHttp(getReq)); 43 | 44 | const putReq = new Request( 45 | req, 46 | { 47 | method: 'PUT' 48 | } 49 | ); 50 | 51 | assert.ok(!isWsOverHttp(putReq)); 52 | 53 | }); 54 | 55 | it('returns false if Content-Type header is not \'application/websocket-events\'', () => { 56 | 57 | req.headers.set('content-type', 'text/plain'); 58 | assert.ok(!isWsOverHttp(req)); 59 | 60 | req.headers.set('content-type', 'text/html'); 61 | assert.ok(!isWsOverHttp(req)); 62 | 63 | req.headers.set('content-type', 'application/json'); 64 | assert.ok(!isWsOverHttp(req)); 65 | 66 | req.headers.set('content-type', 'image/jpeg'); 67 | assert.ok(!isWsOverHttp(req)); 68 | 69 | }); 70 | 71 | it('returns false if Accept header doesn\'t include \'application/websocket-events\'', () => { 72 | 73 | req.headers.set('accept', 'text/plain'); 74 | assert.ok(!isWsOverHttp(req)); 75 | 76 | req.headers.set('accept', 'text/html'); 77 | assert.ok(!isWsOverHttp(req)); 78 | 79 | req.headers.set('accept', 'application/json'); 80 | assert.ok(!isWsOverHttp(req)); 81 | 82 | req.headers.set('accept', 'image/jpeg'); 83 | assert.ok(!isWsOverHttp(req)); 84 | 85 | req.headers.set('accept', 'application/json, application/websocket-events'); 86 | assert.ok(isWsOverHttp(req)); 87 | 88 | req.headers.set('accept', 'text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8'); 89 | assert.ok(isWsOverHttp(req)); 90 | 91 | }); 92 | 93 | }); 94 | 95 | describe('getWebSocketContextFromReq', () => { 96 | let req: Request; 97 | 98 | beforeEach(() => { 99 | req = new Request( 100 | 'https://www.example.com', 101 | { 102 | method: 'POST', 103 | headers: { 104 | 'Connection-id': '1234', 105 | 'Content-Type': 'application/websocket-events', 106 | 'Accept': 'application/websocket-events', 107 | 'Meta-foo': 'bar', 108 | }, 109 | body: 'OPEN\r\nTEXT 5\r\nHello\r\nTEXT 0\r\n\r\nCLOSE\r\nTEXT\r\nCLOSE\r\n', 110 | } 111 | ); 112 | }); 113 | 114 | it('Can build a WebSocketContext', async () => { 115 | 116 | const ws = await getWebSocketContextFromReq(req); 117 | 118 | assert.strictEqual(ws.id, '1234'); 119 | assert.ok(ws.isOpening()); 120 | assert.ok(ws.canRecv()); 121 | assert.strictEqual(ws.meta['foo'], 'bar'); 122 | 123 | let msg: string | null; 124 | msg = ws.recv(); 125 | assert.strictEqual(msg, 'Hello'); 126 | msg = ws.recv(); 127 | assert.strictEqual(msg, ''); 128 | msg = ws.recv(); 129 | assert.strictEqual(msg, null); 130 | 131 | }); 132 | 133 | it('Fails if connection ID is not present', async () => { 134 | req.headers.delete('connection-id'); 135 | 136 | await assert.rejects(async () => { 137 | await getWebSocketContextFromReq(req); 138 | }, err => { 139 | assert.ok(err instanceof ConnectionIdMissingException); 140 | return true; 141 | }); 142 | }); 143 | 144 | it('Fails if body has bad format', async () => { 145 | 146 | // Should be possible to write this, but broken in node < 21: 147 | // req = new Request(req, { 148 | // body: 'foobar' 149 | // }); 150 | req = new Request( 151 | 'https://www.example.com', 152 | { 153 | method: 'POST', 154 | headers: { 155 | 'Connection-id': '1234', 156 | 'Content-Type': 'application/websocket-events', 157 | 'Accept': 'application/websocket-events', 158 | 'Meta-foo': 'bar', 159 | }, 160 | body: 'foobar', 161 | } 162 | ); 163 | await assert.rejects(async () => { 164 | await getWebSocketContextFromReq(req); 165 | }, err => { 166 | assert.ok(err instanceof WebSocketDecodeEventException); 167 | return true; 168 | }); 169 | }); 170 | 171 | }); 172 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "build" 6 | }, 7 | "include": [ 8 | "src/**/*.ts", 9 | ], 10 | "exclude": [ 11 | "node_modules", 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "esModuleInterop": true, 6 | "moduleResolution": "nodenext", 7 | "module": "nodenext", 8 | "rootDir": ".", 9 | "outDir": "build", 10 | "skipLibCheck": true, 11 | "target": "ESNext", 12 | "strict": true, 13 | "typeRoots": [ 14 | "./node_modules/@types", 15 | "./types" 16 | ], 17 | "types": [ 18 | "jspack", 19 | "node" 20 | ] 21 | }, 22 | "include": [ 23 | "src/**/*.ts", 24 | "test/**/*.ts" 25 | ], 26 | "exclude": [ 27 | "node_modules", 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /types/jspack.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jspack' { 2 | export const jspack: { 3 | Unpack(format: string, data: number[], offset?: number): number[]; 4 | Pack(format: string, data: number[]): number[] | false; 5 | }; 6 | } 7 | --------------------------------------------------------------------------------