Skip to content

Commit

Permalink
Add Remix examples
Browse files Browse the repository at this point in the history
  • Loading branch information
harmony7 committed Mar 25, 2024
1 parent 860826b commit 8473510
Show file tree
Hide file tree
Showing 24 changed files with 17,212 additions and 0 deletions.
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ each example.
* [`cloudflare-workers/`](./cloudflare-workers) - Usage with a Cloudflare Workers application as the origin.
* [`http-stream/`](./cloudflare-workers/http-stream) - HTTP streaming using GRIP.
* [`websocket/`](./cloudflare-workers/websocket) - WebSocket-over-HTTP using GRIP.

* [`remix/`](./remix) - Usage with a Remix application as the origin.
* [`http-stream/`](./remix/http-stream) - HTTP streaming using GRIP.
* [`websocket/`](./remix/websocket) - WebSocket-over-HTTP using GRIP.
164 changes: 164 additions & 0 deletions examples/remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Examples for Remix

The examples in this directory illustrate the use of GRIP using
a [Remix](https://remix.run) application as the backend.

* [`http-stream/`](./http-stream) - HTTP streaming using GRIP.
* [`websocket/`](./websocket) - WebSocket-over-HTTP using GRIP.

For details on each example, view the `README` file in its
respective directory.

## Running the examples locally

Each example can be run locally by running it alongside an instance of
[Pushpin](https://pushpin.org/).

To run the examples locally, you'll need:

* Node.js (v18 or newer)
* Pushpin - [installation instructions](https://pushpin.org/docs/install/)

> NOTE: Instead of local Pushpin, you can also run the examples using Fastly Fanout for the GRIP proxy.
See [Running the examples on Fastly Fanout](#running-the-examples-with-fastly-fanout-as-the-grip-proxy) below.

1. Set up Pushpin by modifying the `routes` file with the following content
(See [this page](https://pushpin.org/docs/configuration/) for details on
Pushpin configuration):

```
* 127.0.0.1:3000
```

2. Start Pushpin.

```
pushpin
```

By default, it will listen on port 7999, with a publishing
endpoint open on port 5561. Leave Pushpin running in that terminal window.

3. In a new terminal window, switch to the example's directory, and
install dependencies:

```
npm install
```

4. Start the example:

```
npm run start
```

This will invoke [`@remix-run/serve`](https://remix.run/docs/en/main/other-api/serve)
to start the local server to run the example application.

5. Go on to follow the steps under each example's `README` file.

## Description of common code between the examples

Each example has the same general structure in an `app/` directory that contains:
* `routes/` to define the API endpoints
* Checking GRIP status
* Handling (specific to the example)
* `utils/` directory
* Configuring GRIP and instantiating the `Publisher`

### API Routes

Following the format of [API Routes (Resource Routes)](https://remix.run/docs/en/main/guides/api-routes#resource-routes)
in Remix applications, these examples declare files in the `app/routes/` directory.

### Configuration of GRIP

Each example interfaces with GRIP using the `Publisher` class. The code for this
exists in the `app/utils/publisher.ts` file.

To configure `Publisher`, a GRIP configuration object `gripConfig` is used.
The example applications give it a default value of `http://127.0.0.1:5561/` to point to
local Pushpin.

```typescript
let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/';
```

It may be overridden using a `GRIP_URL`, which in the Remix backend application is set as
a [server environment variable](https://remix.run/docs/en/main/guides/envvars#server-environment-variables).
Additionally, in the example, the utility function `parseGripUri` is used to merge in the `GRIP_VERIFY_KEY`
if it's required by the proxy.

```typescript
let gripConfig: string | IGripConfig = 'http://127.0.0.1:5561/';
const gripUrl = process.env.GRIP_URL;
if (gripUrl) {
gripConfig = parseGripUri(gripUrl, { 'verify-key': process.env.GRIP_VERIFY_KEY });
}
```

Alternatively, the values for `FANOUT_SERVICE_ID` and `FANOUT_API_TOKEN` are checked, and if present,
they are used with the `buildFanoutGripConfig()` function to build the `gripConfig`.

```typescript
const fanoutServiceId = process.env.FANOUT_SERVICE_ID;
const fanoutApiToken = process.env.FANOUT_API_TOKEN;
if (fanoutServiceId != null && fanoutApiToken != null) {
gripConfig = buildFanoutGripConfig({
serviceId: fanoutServiceId,
apiToken: fanoutApiToken,
});
}
```

Finally, this `gripConfig` is used to instantiate `Publisher`.

```typescript
const publisher = new Publisher(gripConfig);
```

In the Remix example, this initialization happens in the `app/utils/publisher.ts` file,
and that single instance is reused among incoming requests.

### GRIP status

The backend application is intended to be called via a GRIP proxy. When the handler runs,
a GRIP proxy will have inserted a `Grip-Sig` header into the request, which it has
signed with a secret or key.

Route handlers that issue GRIP instructions call `publisher.validateGripSig` to validate
this header, storing the result in the `gripStatus` variable.
```typescript
const gripStatus = await publisher.validateGripSig(request.headers.get('grip-sig'));
```

This result can be checked for three fields:
`gripStatus.isProxied` - When `true`, indicates that the current request is behind
a GRIP proxy. If `needsSigned` is `true`, then this will only be `true` if the
signature validation has also succeeded.
`gripStatus.needsSigned` - When `true`, indicates that the GRIP proxy specified in the
configuration signs incoming requests.
`gripStatus.isSigned` - When `true`, indicates that the signature validation was successful.

### Handling Routes

The route handlers in each example handle requests in their respective ways. Refer
to the README in each project for details.

## Running the examples with Fastly Fanout as the GRIP proxy

By publishing these examples publicly, they can also be run behind
[Fastly Fanout](https://docs.fastly.com/products/fanout) to benefit from a global
network and holding client connections at the edge.

Aside from your backend application running publicly on the internet,
you will need a separate Fastly Compute service with Fanout enabled.
This Fastly service runs a small program at the edge that examines
each request and performs a "handoff" to Fanout for relevant requests,
allowing Fanout to hold client connections and interpret GRIP messages.

The [Fastly Fanout Forwarding Starter Kit (JavaScript)](https://github.com/fastly/compute-starter-kit-javascript-fanout-forward#readme)
can be used for this purpose. In many cases it can be used as is,
or as a starting point for further customization.

Back to [examples](../)
6 changes: 6 additions & 0 deletions examples/remix/http-stream/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules

/.cache
/build
/public/build
.env
134 changes: 134 additions & 0 deletions examples/remix/http-stream/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# HTTP Streaming Example for Remix

This example illustrates the use of GRIP to stream HTTP responses
using a Remix application as the backend.

## Running the example

For instructions on setting up and running the example, either locally or using
Fastly Fanout as the GRIP proxy, refer to the [`README` file in the parent directory](../).

This example also requires `curl`, which is included with most OSes.

## Testing the example

After you have set up Pushpin and started the application, test the example
following these steps.

> NOTE: If you are using Fastly Fanout as the GRIP proxy, follow these steps, but
replace `127.0.0.1:7999` with the public URL of your Fanout Forwarding service.

1. Open a new terminal window, and type the following:

```
curl http://127.0.0.1:7999/api/stream
```

You should see the following response text, and then the response should hang open:
```
[stream open]
```

`curl` now has an open HTTP stream, held open by Pushpin (listening on a channel internally called `test`).

2. Open a separate terminal window, and type the following:

```
curl -X POST -d "Hello" "https://127.0.0.1:7999/api/broadcast"
```

This publishes the given message (to the channel `test`). You should see the message `Hello`
appear in the stream held open by the first terminal.

## How it works

For an explanation of the common startup and initialization code, as well as
validating the GRIP header, refer to the [`README` file in the parent
directory](../README.md#description-of-common-code-between-the-examples).

The example exposes two API Routes (Resource Routes):

1. A `GET` request at `/api/stream` (File: `app/routes/api.stream.ts`)

This endpoint is intended to be called through your configured GRIP proxy.

The handler calls `publisher.validateGripSig` to validate this header, storing the result in
the `gripStatus` variable.

It checks `gripStatus.isProxied` to make sure we are being run behind a valid
GRIP proxy. This value will be `false` if the request did not come through a GRIP proxy,
or if the signature validation failed.

```typescript
if (!gripStatus.isProxied) {
// emit an error
}
```

If successful, then the handler goes on to set up a GRIP instruction.
This instruction asks the GRIP proxy to hold the current connection open
as a streaming connection, listening to the channel named `'test'`.

```typescript
const gripInstruct = new GripInstruct('test');
gripInstruct.setHoldStream();
```

Finally, a response is generated and returned, including the
`gripInstruct` in the response headers.

```typescript
return new Response(
'[stream open]\n',
{
status: 200,
headers: {
...gripInstruct.toHeaders(),
'Content-Type': 'text/plain',
},
},
);
```

That's all that's needed to hold a connection open. Note that the connection between
your backend and the GRIP proxy ends here. After this point, the GRIP proxy holds the
connection open with the client.

2. A `POST` request at `/api/publish` (File: `app/routes/api.publish.ts`)

This handler starts by checking to make sure the method is in fact `POST` and that
the content type header specifies that the body is of type `text/plain`. Afterward,
the handler reads the request body into a string.

```typescript
if (request.method !== 'POST') {
// emit an error
}
if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') {
// emit an error
}
const body = await request.text();
```

Next, the handler proceeds to call `publisher.publishHttpStream` to send the
body text as a message to the `test` channel. Any listener on this channel,
such as those that have opened a stream through the endpoint described above,
will receive the message.

```typescript
await publisher.publishHttpStream('test', body + '\n');
```

Finally, it returns a simple success response message and ends.

```typescript
return new Response(
'Ok\n',
{
status: 200,
headers: {
'Content-Type': 'text/plain',
},
},
);
```
9 changes: 9 additions & 0 deletions examples/remix/http-stream/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {
Outlet,
} from "@remix-run/react";

export default function App() {
return (
<Outlet />
);
}
44 changes: 44 additions & 0 deletions examples/remix/http-stream/app/routes/api.publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// noinspection DuplicatedCode

import { ActionFunctionArgs } from '@remix-run/node';
import publisher from '~/utils/publisher';

export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return new Response(
'Method not allowed',
{
status: 405,
},
);
}

// Only accept text bodies
if (request.headers.get('content-type')?.split(';')[0] !== 'text/plain') {
return new Response(
'Body must be text/plain\n', {
status: 415,
headers: {
'Content-Type': 'text/plain',
},
},
);
}

// Read the body
const body = await request.text();

// Publish the body to GRIP clients that listen to http-stream format
await publisher.publishHttpStream('test', body + '\n');

// Return a success response
return new Response(
'Ok\n',
{
status: 200,
headers: {
'Content-Type': 'text/plain',
},
},
);
}
Loading

0 comments on commit 8473510

Please sign in to comment.