Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add edge compatibility for custom frameworks and Next.JS #918

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
94138c5
Add support for polyfilling buffer and node for next
deepjyoti30Alt Aug 30, 2024
94300b9
Use querystringify instead of querystring for edge compatibility
deepjyoti30Alt Aug 30, 2024
5262e6b
Add support for parse implementation using URLSearchParams
deepjyoti30Alt Aug 30, 2024
7f9f61e
Add tests for parseParams
deepjyoti30Alt Aug 30, 2024
97d723e
Run build-pretty on all files
deepjyoti30Alt Aug 30, 2024
ab4f5f9
Add a workflow for testing Next.JS with edge runtime
deepjyoti30Alt Aug 30, 2024
a0145dc
Add support for gzip compression
deepjyoti30Alt Aug 30, 2024
aad18ad
Add support for br compression
deepjyoti30Alt Aug 30, 2024
028edd8
Disable brotli compression in edge
deepjyoti30Alt Aug 30, 2024
15ac624
Remove unnecessary log
deepjyoti30Alt Aug 31, 2024
5a9649d
Replace parseParams with one URLSearchParams alternative
deepjyoti30Alt Sep 2, 2024
a9d6535
Add support for throwing a proper error when brotli doesn't work
deepjyoti30Alt Sep 2, 2024
7cd8d97
Add support for CF workflow to test edge function compatibility
deepjyoti30Alt Sep 3, 2024
cbfbc63
Get rid of netflify edge test function
deepjyoti30Alt Sep 3, 2024
4a0b2c3
Remove newly added conflicting route from netlify next test wf
deepjyoti30Alt Sep 3, 2024
fcc69f4
Rename api/auth dynamic variable in next app router
deepjyoti30Alt Sep 3, 2024
39aaca4
Use ponyfilled process to refactor checking for test env
deepjyoti30Alt Sep 3, 2024
bac1e8f
Refactor process and expose an util function to make it accessible
deepjyoti30Alt Sep 3, 2024
476cda0
Add fallback implementation for accessing Buffer
deepjyoti30Alt Sep 3, 2024
a4645da
Refactor base64 encoding/decoding to use ponyfilled buffer
deepjyoti30Alt Sep 3, 2024
309c2e4
Reuse ponyfilled buffer at more places
deepjyoti30Alt Sep 3, 2024
66e3cdb
Remove polyfill functionality for buffer and process
deepjyoti30Alt Sep 3, 2024
043c05a
Remove unused test file
deepjyoti30Alt Sep 4, 2024
d2e4c32
Update some functions according to comments in PR
deepjyoti30Alt Sep 4, 2024
1627d3b
Update workflow to use more values from secrets
deepjyoti30Alt Sep 4, 2024
35a3748
Get rid of pages directory in next emailpassword example
deepjyoti30Alt Sep 4, 2024
df2ac22
Remove use of getBuffer in loopback framework
deepjyoti30Alt Sep 4, 2024
2f2fb62
Add error handling for possible malformed body in lambda request
deepjyoti30Alt Sep 4, 2024
58ab0e5
Feat/hono api example repo (#2)
deepjyoti30Alt Sep 4, 2024
a26942a
Merge branch '20.0' into feat/edge-compatibility-next
rishabhpoddar Sep 5, 2024
793925a
Address requested changes
deepjyoti30Alt Sep 5, 2024
5114804
Fix a typo regarding boxPrimitives check
deepjyoti30Alt Sep 5, 2024
d74b20d
Update changelog with details of changes
deepjyoti30Alt Sep 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/test-cf-worker-hono.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: "Test edge function compatibility for Hono on Cloudflare Workers"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
APP_URL: ${{ secrets.CLOUDFLARE_HONO_APP_URL }}
CLOUDFLARE_PROJECT_NAME: ${{ secrets.CLOUDFLARE_HONO_PROJECT_NAME }}
TEST_DEPLOYED_VERSION: true
defaults:
run:
working-directory: examples/cloudflare-workers/with-email-password-hono-be-only
steps:
- uses: actions/checkout@v2
- run: echo $GITHUB_SHA
- run: npm install git+https://github.com:supertokens/supertokens-node.git#$GITHUB_SHA
- run: npm install
- run: npm install [email protected] [email protected] puppeteer@^11.0.0 isomorphic-fetch@^3.0.0

- name: Replace APP_URL with deployed URL value
run: |
sed -i "s|process.env.REACT_APP_API_URL|\"${{ env.APP_URL }}\"|" config.ts
sed -i "s|process.env.REACT_APP_WEBSITE_URL|\"${{ env.APP_URL }}\"|" config.ts

- name: Deploy the changes
run: npx wrangler deploy --name ${{ env.CLOUDFLARE_PROJECT_NAME }} index.ts

- name: Run tests
run: |
( \
(echo "=========== Test attempt 1 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) || \
(echo "=========== Test attempt 2 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) || \
(echo "=========== Test attempt 3 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) \
)
- name: The job has failed
if: ${{ failure() }}
uses: actions/upload-artifact@v3
with:
name: screenshots
path: |
./**/*screenshot.jpeg
72 changes: 72 additions & 0 deletions .github/workflows/test-cf-worker-next.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: "Test edge function compatibility for Next.js on Cloudflare Workers"
on: push
jobs:
test:
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
APP_URL: ${{ secrets.CLOUDFLARE_APP_URL }}
CLOUDFLARE_PROJECT_NAME: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
TEST_DEPLOYED_VERSION: true
defaults:
run:
working-directory: examples/next/with-emailpassword
steps:
- uses: actions/checkout@v2
- run: echo $GITHUB_SHA
- run: npm install git+https://github.com:supertokens/supertokens-node.git#$GITHUB_SHA
- run: npm install
- run: npm install [email protected] [email protected] puppeteer@^11.0.0 isomorphic-fetch@^3.0.0

# Step to update the runtime to edge to all files in app/api/
- name: Add runtime export to API files
run: |
find app/api -type f \( -name "*.js" -o -name "*.ts" \) -exec sed -i '1s/^/export const runtime = "edge";\n/' {} +
echo 'export const runtime = "edge";' >> app/auth/[[...path]]/page.tsx

# Install next on pages to build the app
- name: Install next-on-pages
run: npm install --save-dev @cloudflare/next-on-pages

# Setup the compatibility flag to make non edge functions run
- name: Create a wrangler.toml
run: echo "compatibility_flags = [ "nodejs_compat" ]" >> wrangler.toml
deepjyoti30Alt marked this conversation as resolved.
Show resolved Hide resolved

- name: Replace APP_URL with deployed URL value
run: |
sed -i "s|process.env.APP_URL|\"${{ env.APP_URL }}\"|" config/appInfo.ts
- name: Build using next-on-pages
run: npx next-on-pages

- name: Publish to Cloudflare Pages
id: deploy
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ env.CLOUDFLARE_API_TOKEN }}
accountId: ${{ env.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ env.CLOUDFLARE_PROJECT_NAME }}
directory: "./examples/next/with-emailpassword/.vercel/output/static"
wranglerVersion: "3"
branch: "master"

- name: Extract deployment info and save to JSON
id: extract_deploy_info
run: |
DEPLOY_ID=${{ steps.deploy.outputs.id }}
DEPLOY_URL=${{ steps.deploy.outputs.url }}
echo "{\"deploy_url\": \"$DEPLOY_URL\", \"deploy_id\": \"$DEPLOY_ID\"}" > deployInfo.json
- name: Run tests
run: |
( \
(echo "=========== Test attempt 1 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) || \
(echo "=========== Test attempt 2 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) || \
(echo "=========== Test attempt 3 ===========" && npx mocha --no-config --timeout 80000 test/**/*.test.js) \
)
- name: The job has failed
if: ${{ failure() }}
uses: actions/upload-artifact@v3
with:
name: screenshots
path: |
./**/*screenshot.jpeg
1 change: 1 addition & 0 deletions .github/workflows/test-edge-function.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
- run: npm install git+https://github.com:supertokens/supertokens-node.git#$GITHUB_SHA
- run: npm install
- run: npm install [email protected] [email protected] puppeteer@^11.0.0 isomorphic-fetch@^3.0.0

- run: netlify deploy --alias 0 --build --json --auth=$NETLIFY_AUTH_TOKEN > deployInfo.json
- run: cat deployInfo.json
- run: |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [unreleased]

## [20.0.6] - 2024-09-05

- Add edge compatibility for custom frameworks and Next.JS

## [20.0.5] - 2024-09-02

- Optional form fields are now truly optional, can be omitted from the payload.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
![SuperTokens banner](https://raw.githubusercontent.com/supertokens/supertokens-logo/master/images/Artboard%20%E2%80%93%2027%402x.png)

# SuperTokens EmailPassword with Cloudflare Workers (HonoJS) on Edge Runtime

This demo app uses HonoJS with Cloudflare Workers for the backend server. We use [Wrangler](https://developers.cloudflare.com/workers/wrangler/) in the backend server to simulate the Cloudflare Worker runtime. This is a pure Edge runtime implementation (works without `nodejs_compat` flag).

## Project setup

Clone the repo, enter the directory, and use `npm` to install the project dependencies:

```bash
git clone https://github.com/supertokens/supertokens-node
cd supertokens-node/examples/cloudflare-workers/with-be-emailpassword
npm install
```

## Run the demo app

This compiles and serves the React app and starts the backend API server on port 3001.

```bash
npm run start
```

The app will start on `http://localhost:3000`
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Session from "supertokens-node/recipe/session";
import { TypeInput } from "supertokens-node/types";
import process from "process";

export const runtime = "edge";

export function getApiDomain() {
const apiPort = process.env.REACT_APP_API_PORT || 3001;
const apiUrl = process.env.REACT_APP_API_URL || `http://localhost:${apiPort}`;
return apiUrl;
}

export function getWebsiteDomain() {
const websitePort = process.env.REACT_APP_WEBSITE_PORT || 3000;
const websiteUrl = process.env.REACT_APP_WEBSITE_URL || `http://localhost:${websitePort}`;
return websiteUrl;
}

export const SuperTokensConfig: TypeInput = {
supertokens: {
// this is the location of the SuperTokens core.
connectionURI: "https://try.supertokens.com",
},
appInfo: {
appName: "SuperTokens Demo App",
apiDomain: getApiDomain(),
websiteDomain: getWebsiteDomain(),
},
// recipeList contains all the modules that you want to
// use from SuperTokens. See the full list here: https://supertokens.com/docs/guides
recipeList: [EmailPassword.init(), Session.init()],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { SessionContainer } from "supertokens-node/recipe/session";

declare module "hono" {
interface HonoRequest {
session?: SessionContainer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import supertokens from "supertokens-node";
import { middleware } from "./middleware";
import { getWebsiteDomain, SuperTokensConfig } from "./config";
import type { PageConfig } from "next";

export const config: PageConfig = {
runtime: "edge",
};

supertokens.init(SuperTokensConfig);

const app = new Hono();

app.use("*", async (c, next) => {
return await cors({
origin: getWebsiteDomain(),
credentials: true,
allowHeaders: ["Content-Type", ...supertokens.getAllCORSHeaders()],
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
})(c, next);
});

// This exposes all the APIs from SuperTokens to the client.
// and adds the session to the request object if one exists.
app.use("*", middleware());

// An example API that requires session verification
app.get("/sessioninfo", (c) => {
let session = c.req.session;
if (!session) {
return c.text("Unauthorized", 401);
}
return c.json({
sessionHandle: session!.getHandle(),
userId: session!.getUserId(),
accessTokenPayload: session!.getAccessTokenPayload(),
});
});

export default app;
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import {
CollectingResponse,
PreParsedRequest,
middleware as customMiddleware,
} from "supertokens-node/framework/custom";
import Session from "supertokens-node/recipe/session";
import { HTTPMethod } from "supertokens-node/types";
import { serialize } from "cookie";

export const runtime = "edge";

function setCookiesInHeaders(headers: Headers, cookies: CollectingResponse["cookies"]) {
for (const cookie of cookies) {
headers.append(
"Set-Cookie",
serialize(cookie.key, cookie.value, {
domain: cookie.domain,
expires: new Date(cookie.expires),
httpOnly: cookie.httpOnly,
path: cookie.path,
sameSite: cookie.sameSite,
secure: cookie.secure,
})
);
}
}

function copyHeaders(source: Headers, destination: Headers): void {
for (const [key, value] of source.entries()) {
destination.append(key, value);
}
}

export const middleware = () => {
return async function (c: Context, next: Next) {
const request = new PreParsedRequest({
method: c.req.method as HTTPMethod,
url: c.req.url,
query: Object.fromEntries(new URL(c.req.url).searchParams.entries()),
cookies: getCookie(c),
headers: c.req.raw.headers as Headers,
getFormBody: () => c.req.formData(),
getJSONBody: () => c.req.json(),
});
const baseResponse = new CollectingResponse();

const stMiddleware = customMiddleware(() => request);

const { handled, error } = await stMiddleware(request, baseResponse);

if (error) {
throw error;
}

if (handled) {
setCookiesInHeaders(baseResponse.headers, baseResponse.cookies);
return new Response(baseResponse.body, {
status: baseResponse.statusCode,
headers: baseResponse.headers,
});
}

// Add session to c.req if it exists
try {
c.req.session = await Session.getSession(request, baseResponse, {
sessionRequired: false,
});

await next();

// Add cookies that were set by `getSession` to response
setCookiesInHeaders(c.res.headers, baseResponse.cookies);
// Copy headers that were set by `getSession` to response
copyHeaders(baseResponse.headers, c.res.headers);
return c.res;
} catch (err) {
if (Session.Error.isErrorFromSuperTokens(err)) {
if (err.type === Session.Error.TRY_REFRESH_TOKEN || err.type === Session.Error.INVALID_CLAIMS) {
return new Response("Unauthorized", {
status: err.type === Session.Error.INVALID_CLAIMS ? 403 : 401,
});
}
}
}
};
};
Loading
Loading