diff --git a/.github/actions/actionsLib.mjs b/.github/actions/actionsLib.mjs index 686beb1dc88d..0ef01d2313b2 100644 --- a/.github/actions/actionsLib.mjs +++ b/.github/actions/actionsLib.mjs @@ -62,9 +62,9 @@ export function projectCopy(redwoodProjectCwd) { } /** - * @param {{ baseKeyPrefix: string, distKeyPrefix: string }} options + * @param {{ baseKeyPrefix: string, distKeyPrefix: string, canary: boolean }} options */ -export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { +export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix, canary }) { const baseKey = [ baseKeyPrefix, process.env.RUNNER_OS, @@ -76,7 +76,7 @@ export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { baseKey, 'dependencies', await hashFiles(['yarn.lock', '.yarnrc.yml'].join('\n')), - ].join('-') + ].join('-') + (canary ? '-canary' : '') const distKey = [ dependenciesKey, @@ -91,7 +91,7 @@ export async function createCacheKeys({ baseKeyPrefix, distKeyPrefix }) { 'lerna.json', 'packages', ].join('\n')) - ].join('-') + ].join('-') + (canary ? '-canary' : '') return { baseKey, diff --git a/.github/actions/set-up-test-project/action.yaml b/.github/actions/set-up-test-project/action.yaml index 9866507ab1db..0c0e623056a5 100644 --- a/.github/actions/set-up-test-project/action.yaml +++ b/.github/actions/set-up-test-project/action.yaml @@ -9,6 +9,9 @@ inputs: bundler: description: The bundler to use (vite or webpack) default: vite + canary: + description: Upgrade the project to canary? + default: "false" outputs: test-project-path: diff --git a/.github/actions/set-up-test-project/setUpTestProject.mjs b/.github/actions/set-up-test-project/setUpTestProject.mjs index a06c43f3c393..e8eb2da6a80b 100644 --- a/.github/actions/set-up-test-project/setUpTestProject.mjs +++ b/.github/actions/set-up-test-project/setUpTestProject.mjs @@ -25,8 +25,12 @@ core.setOutput('test-project-path', TEST_PROJECT_PATH) const bundler = core.getInput('bundler') +const canary = core.getInput('canary') === 'true' + + console.log({ bundler, + canary }) console.log() @@ -34,7 +38,7 @@ console.log() const { dependenciesKey, distKey -} = await createCacheKeys({ baseKeyPrefix: 'test-project', distKeyPrefix: bundler }) +} = await createCacheKeys({ baseKeyPrefix: 'test-project', distKeyPrefix: bundler, canary }) /** * @returns {Promise} @@ -54,7 +58,9 @@ async function main() { await sharedTasks() } else { console.log(`Cache not found for input keys: ${distKey}, ${dependenciesKey}`) - await setUpTestProject() + await setUpTestProject({ + canary: true + }) } await cache.saveCache([TEST_PROJECT_PATH], distKey) @@ -62,9 +68,10 @@ async function main() { } /** + * *@param {{canary: boolean}} options * @returns {Promise} */ -async function setUpTestProject() { +async function setUpTestProject({ canary }) { const TEST_PROJECT_FIXTURE_PATH = path.join( REDWOOD_FRAMEWORK_PATH, '__fixtures__', @@ -83,6 +90,12 @@ async function setUpTestProject() { await execInProject('yarn install') console.log() + if (canary) { + console.log(`Upgrading project to canary`) + await execInProject('yarn rw upgrade -t canary') + console.log() + } + await cache.saveCache([TEST_PROJECT_PATH], dependenciesKey) console.log(`Cache saved with key: ${dependenciesKey}`) diff --git a/.github/actions/ssr_related_changes/action.yml b/.github/actions/ssr_related_changes/action.yml new file mode 100644 index 000000000000..47a1122f2142 --- /dev/null +++ b/.github/actions/ssr_related_changes/action.yml @@ -0,0 +1,8 @@ +name: Streaming-SSR Related Changes +description: Determines if the PR makes any changes related to SSR or streaming +outputs: + rsc-related-changes: + description: If the PR makes any SSR related changes +runs: + using: node20 + main: ssr_related_changes.mjs diff --git a/.github/actions/ssr_related_changes/package.json b/.github/actions/ssr_related_changes/package.json new file mode 100644 index 000000000000..2b643b34dd1e --- /dev/null +++ b/.github/actions/ssr_related_changes/package.json @@ -0,0 +1,9 @@ +{ + "name": "ssr_related_changes", + "private": true, + "dependencies": { + "@actions/core": "1.10.0", + "@actions/exec": "1.1.1" + }, + "packageManager": "yarn@3.6.3" +} diff --git a/.github/actions/ssr_related_changes/ssr_related_changes.mjs b/.github/actions/ssr_related_changes/ssr_related_changes.mjs new file mode 100644 index 000000000000..b2d597b4b848 --- /dev/null +++ b/.github/actions/ssr_related_changes/ssr_related_changes.mjs @@ -0,0 +1,43 @@ +import core from '@actions/core' +import { exec, getExecOutput } from '@actions/exec' + +async function main() { + const branch = process.env.GITHUB_BASE_REF + + // If there is no branch, we're not in a pull request + if (!branch) { + core.setOutput('ssr-related-changes', false) + return + } + + await exec(`git fetch origin ${branch}`) + + const { stdout } = await getExecOutput( + `git diff origin/${branch} --name-only` + ) + + const changedFiles = stdout.toString().trim().split('\n').filter(Boolean) + + for (const changedFile of changedFiles) { + console.log('changedFile', changedFile) + + if ( + changedFile.startsWith('tasks/smoke-tests/streaming-ssr') || + changedFile.startsWith('tasks/smoke-tests/basePlaywright.config.ts') || + changedFile.startsWith('github/actions/ssr_related_changes/') || + changedFile.startsWith('packages/internal/') || + changedFile.startsWith('packages/project-config/') || + changedFile.startsWith('packages/web/') || + changedFile.startsWith('packages/router/') || + changedFile.startsWith('packages/web-server/') || + changedFile.startsWith('packages/vite/') + ) { + core.setOutput('ssr-related-changes', true) + return + } + } + + core.setOutput('ssr-related-changes', false) +} + +main() diff --git a/.github/actions/ssr_related_changes/yarn.lock b/.github/actions/ssr_related_changes/yarn.lock new file mode 100644 index 000000000000..b44c78774462 --- /dev/null +++ b/.github/actions/ssr_related_changes/yarn.lock @@ -0,0 +1,66 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8c0 + +"@actions/core@npm:1.10.0": + version: 1.10.0 + resolution: "@actions/core@npm:1.10.0" + dependencies: + "@actions/http-client": ^2.0.1 + uuid: ^8.3.2 + checksum: 9214d1e0cf5cf2a5d48b8f3b12488c6be9f6722ea60f2397409226e8410b5a3e12e558d9b66c93469d180399865ec20180119408a1770f026bd9ecac6965fcda + languageName: node + linkType: hard + +"@actions/exec@npm:1.1.1": + version: 1.1.1 + resolution: "@actions/exec@npm:1.1.1" + dependencies: + "@actions/io": ^1.0.1 + checksum: 4a09f6bdbe50ce68b5cf8a7254d176230d6a74bccf6ecc3857feee209a8c950ba9adec87cc5ecceb04110182d1c17117234e45557d72fde6229b7fd3a395322a + languageName: node + linkType: hard + +"@actions/http-client@npm:^2.0.1": + version: 2.0.1 + resolution: "@actions/http-client@npm:2.0.1" + dependencies: + tunnel: ^0.0.6 + checksum: b58987ba2f53d7988f612ede7ff834573a3360c21f8fdea9fea92f26ada0fd0efafb22aa7d83f49c18965a5b765775d5253e2edb8d9476d924c4b304ef726b67 + languageName: node + linkType: hard + +"@actions/io@npm:^1.0.1": + version: 1.1.2 + resolution: "@actions/io@npm:1.1.2" + checksum: 61c871bbee1cf58f57917d9bb2cf6bb7ea4dc40de3f65c7fb4ec619ceff57fc98f56be9cca2d476b09e7a96e1cba0d88cd125c4f690d384b9483935186f256c1 + languageName: node + linkType: hard + +"ssr_related_changes@workspace:.": + version: 0.0.0-use.local + resolution: "ssr_related_changes@workspace:." + dependencies: + "@actions/core": 1.10.0 + "@actions/exec": 1.1.1 + languageName: unknown + linkType: soft + +"tunnel@npm:^0.0.6": + version: 0.0.6 + resolution: "tunnel@npm:0.0.6" + checksum: e27e7e896f2426c1c747325b5f54efebc1a004647d853fad892b46d64e37591ccd0b97439470795e5262b5c0748d22beb4489a04a0a448029636670bfd801b75 + languageName: node + linkType: hard + +"uuid@npm:^8.3.2": + version: 8.3.2 + resolution: "uuid@npm:8.3.2" + bin: + uuid: dist/bin/uuid + checksum: bcbb807a917d374a49f475fae2e87fdca7da5e5530820ef53f65ba1d12131bd81a92ecf259cc7ce317cbe0f289e7d79fdfebcef9bfa3087c8c8a2fa304c9be54 + languageName: node + linkType: hard diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e809bd381b3c..8dbddbb7f4cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,31 @@ jobs: id: rsc-related-changes uses: ./.github/actions/rsc_related_changes + ssr-related-changes: + needs: check + if: github.repository == 'redwoodjs/redwood' + name: 🌤️ SSR related changes? + runs-on: ubuntu-latest + outputs: + ssr-related-changes: ${{ steps.ssr-related-changes.outputs.ssr-related-changes }} + steps: + - uses: actions/checkout@v3 + + - name: ⬢ Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: 🐈 Yarn install + working-directory: ./.github/actions/ssr_related_changes + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: 🌤️ SSR related changes? + id: ssr-related-changes + uses: ./.github/actions/ssr_related_changes + check: needs: only-doc-changes if: needs.only-doc-changes.outputs.only-doc-changes == 'false' @@ -232,6 +257,80 @@ jobs: steps: - run: echo "Only doc changes" + ssr-smoke-tests: + needs: ssr-related-changes + if: needs.ssr-related-changes.outputs.ssr-related-changes == 'true' + + strategy: + matrix: + os: [ubuntu-latest] + # [ubuntu-latest, windows-latest] disabled, because windows misbehaving + # waiting for help from main-man Josh + + name: 🔁 SSR Smoke tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + env: + REDWOOD_CI: 1 + REDWOOD_VERBOSE_TELEMETRY: 1 + + steps: + - uses: actions/checkout@v3 + + - name: ⬢ Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: 🐈 Set up yarn cache + uses: ./.github/actions/set-up-yarn-cache + + - name: 🐈 Yarn install + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: 🔨 Build + run: yarn build + + - name: 🌲 Set up test project + id: set-up-test-project + uses: ./.github/actions/set-up-test-project + with: + bundler: vite + canary: true + env: + REDWOOD_DISABLE_TELEMETRY: 1 + YARN_ENABLE_IMMUTABLE_INSTALLS: false + + - name: Run SSR codemods on test project + run: ./tasks/test-project/convert-to-ssr-fixture ${{ steps.set-up-test-project.outputs.test-project-path }} + env: + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: 🎭 Install playwright dependencies + run: npx playwright install --with-deps chromium + + - name: Run SSR [DEV] smoke tests + working-directory: ./tasks/smoke-tests/streaming-ssr-dev + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: Build for production + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} + run: yarn rw build --no-prerender + env: + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: Run SSR [PROD] smoke tests + working-directory: ./tasks/smoke-tests/streaming-ssr-prod + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + smoke-tests: needs: check diff --git a/.yarnrc.yml b/.yarnrc.yml index a46601216899..e57c81ecfa7a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -17,3 +17,4 @@ plugins: preferInteractive: true yarnPath: .yarn/releases/yarn-3.6.3.cjs + diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md index 6fafdacddafa..5f2d73d0a386 100644 --- a/docs/docs/cli-commands.md +++ b/docs/docs/cli-commands.md @@ -1904,6 +1904,19 @@ In order to use [Netlify Dev](https://www.netlify.com/products/dev/) you need to > Note: To detect the RedwoodJS framework, please use netlify-cli v3.34.0 or greater. +### setup mailer + +This command adds the necessary packages and files to get started using the RedwoodJS mailer. By default it also creates an example mail template which can be skipped with the `--skip-examples` flag. + +``` +yarn redwood setup mailer +``` + +| Arguments & Options | Description | +| :---------------------- | :----------------------------- | +| `--force, -f` | Overwrite existing files | +| `--skip-examples` | Do not include example content, such as a React email template | + ### setup package This command takes a published npm package that you specify, performs some compatibility checks, and then executes its bin script. This allows you to use third-party packages that can provide you with an easy-to-use setup command for the particular functionality they provide. diff --git a/docs/docs/realtime.md b/docs/docs/realtime.md new file mode 100644 index 000000000000..aa0a0d584485 --- /dev/null +++ b/docs/docs/realtime.md @@ -0,0 +1,709 @@ +# Realtime + +One of the most often asked questions of RedwoodJS before and after the launch of V1 was, “When will RedwoodJS support a realtime solution?” + +The answer is: **now**. + +## What is Realtime? + +RedwoodJS's initial real-time solution leverages on GraphQL and relies on a serverful deployment to maintain a long-running connection between the client and server. + +:::note +This means that your cannot currently use RedwoodJS when deployed to Netlify or Vercel. + +**More information about deploying a serverful RedwoodJS is forthcoming.** +::: + +RedwoodJS's GraphQL Server uses [GraphQL over Server-Sent Events](https://github.com/enisdenjo/graphql-sse/blob/master/PROTOCOL.md#distinct-connections-mode) spec "distinct connections mode" for subscriptions. + +Advantages of SSE over WebSockets include: + +* Transported over simple HTTP instead of a custom protocol +* Built in support for re-connection and event-id Simpler protocol +* No trouble with corporate firewalls doing packet inspection + +### Subscriptions and Live Queries + +In GraphQL, there are two options for real-time updates: **live queries** and **subscriptions**. + +Subscriptions are part of the GraphQL specification, whereas live queries are not. + +There are times where subscriptions are well-suited for a realtime problem — and in some cases live queries may be a better fit. Later we’ll explore the pros and cons of each approach and how best to decide that to use and when. + +### Defer and Stream + +[Stream and defer](https://the-guild.dev/graphql/yoga-server/docs/features/defer-stream) are directives that allow you to improve latency for clients by sending data the most important data as soon as it's ready. + +As applications grow, the GraphQL operation documents can get bigger. The server will only send the response back once all the data requested in the query is ready. However, not all requested data is of equal importance, and the client may not need all of the data at once. + +#### Using Defer + +The `@defer`` directive allows you to post-pone the delivery of one or more (slow) fields grouped in an inlined or spread fragment. + +#### Using Stream + +The '@stream' directive allows you to stream the individual items of a field of the list type as the items are available. + +:::info +The `@stream` directive is currently **not** supported by Apollo GraphQL client. +::: + +## Features + +RedwoodJS Realtime handles the hard parts of a GraphQL Realtime implementation by automatically: + +- allowing GraphQL Subscription operations to be handled +- merging in your subscriptions types and mapping their handler functions (subscribe, and resolve) to your GraphQL schema letting you keep your subscription logic organized and apart from services (your subscription my use a service to respond to an event) +- authenticating subscription requests using the same `@requireAuth` directives already protecting other queries and mutations (or you can implement your own validator directive) +- adding in the `@live` query directive to your GraphQL schema and setting up the `useLiveQuery` envelop plugin to handle requests, invalidation, and managing the storage mechanism needed +- creating and configuring in-memory and persisted Redis stores uses by the PubSub transport for subscriptions and Live Queries (and letting you switch between them in development and production) +- placing the pubSub transport and stores into the GraphQL context so you can use them in services, subscription resolvers, or elsewhere (like a webhook, function, or job) to publish an event or invalidate data +- typing you subscription channel event payloads +- support `@defer` and `@stream` directives + +It provides a first-class developer experience for real-time updates with GraphQL so you can easily + +- respond to an event (e.g. NewPost, NewUserNotification) +- respond to a data change (e.g. Post 123's title updated) + +and have the latest data reflected in your app. + +Lastly, the Redwood CLI has commands to + +- generate a boilerplate implementation and sample code needed to create your custom + - subscriptions + - live Queries + +Regardless of the implementation chosen, **a stateful server and store are needed** to track changes, invalidation, or who wants to be informed about the change. + +### What can I build with Realtime? + +- Application Alerts and Messages +- User Notifications +- Live Charts +- Location updates +- Auction bid updates +- Messaging +- OpenAI streaming responses + +## RedwoodJS Realtime Setup + +To setup Realtime in an existing RedwoodJS project, run the following commands: + +* `yarn rw exp setup-server-file` +* `yarn rw exp setup-realtime` + +You will get: + +* `api/server.ts` where you configure your Fastify server and GraphQL +* `api/lib/realtime.ts` where you consume your subscriptions and configure realtime with an in-memory or Redis store +* Usage examples for live queries, subscriptions, defer, and stream. You'll get sdl, services/subscriptions for each. +* The [`auction` live query](#auction-live-query-example) example +* The [`countdown timer` subscription](#countdown-timer-example) example +* The [`chat` subscription](#chatnew-message-example) examples +* The [`alphabet` stream](#alphabet-stream-example) example +* The [`slow and fast` field defer](#slow-and-fast-field-defer-example) example + +:::note +There is no UI setup for these examples. You can find information on how to try them out using the GraphiQL playground. +::: + +### GraphQL Configuration + +Now that how have a serverful project, you will configure your GraphQL server in the `api/server.ts` file. + +:::important +That means you **must** manually configure your GraphQL server accordingly +::: + +For example, you will have to setup any authentication and the realtime config: + +```ts + await fastify.register(redwoodFastifyGraphQLServer, { + // If authenticating, be sure to import and add in + // authDecoder, + // getCurrentUser, + loggerConfig: { + logger: logger, + options: { + query: true, + data: true, + operationName: true, + requestId: true, + }, + }, + graphiQLEndpoint: enableWeb ? '/.redwood/functions/graphql' : '/graphql', + sdls, + services, + directives, + allowIntrospection: true, + allowGraphiQL: true, + // Configure if using RedwoodJS Realtime + realtime, + }) +``` + +You can now remove the GraphQL handler function that resides in `api/functions/graphql.ts`. + +### Realtime Configuration + +By default, RedwoodJS realtime configures an in-memory store for the Pub Sub client used with subscriptions and live query invalidation. + +Realtime supports in-memory and Redis stores: + +- In-memory stores are useful for development and testing. +- Redis stores are useful for production. + +To enable defer and streaming, set `enableDeferStream` to true. + +Configure a Redis store and defer and stream in: + +```ts +// api/lib/realtime.ts +import { RedwoodRealtimeOptions } from '@redwoodjs/realtime' + +import subscriptions from 'src/subscriptions/**/*.{js,ts}' + +// if using a Redis store +// import { Redis } from 'ioredis' +// const publishClient = new Redis() +// const subscribeClient = new Redis() + +/** + * Configure RedwoodJS Realtime + * + * See https://redwoodjs.com/docs/realtime + * + * Realtime supports Live Queries and Subscriptions over GraphQL SSE. + * + * Live Queries are GraphQL queries that are automatically re-run when the data they depend on changes. + * + * Subscriptions are GraphQL queries that are run when a client subscribes to a channel. + * + * Redwood Realtime + * - uses a publish/subscribe model to broadcast data to clients. + * - uses a store to persist Live Query and Subscription data. + * + * Redwood Realtime supports in-memory and Redis stores: + * - In-memory stores are useful for development and testing. + * - Redis stores are useful for production. + * + */ +export const realtime: RedwoodRealtimeOptions = { + subscriptions: { + subscriptions, + store: 'in-memory', + // if using a Redis store + // store: { redis: { publishClient, subscribeClient } }, + }, + liveQueries: { + store: 'in-memory', + // if using a Redis store + // store: { redis: { publishClient, subscribeClient } }, + }, + // To enable defer and streaming, set to true. + // enableDeferStream: true, +} +``` + +#### PubSub and LiveQueryStore + +By setting up RedwoodJS Realtime, the GraphQL server adds two helpers on the context: + +* pubSub +* liveQueryStory + +With `context.pubSub` you can subscribe to and publish messages via `context.pubSub.publish('the-topic', id, id2)`. + +With `context.liveQueryStore.` you can `context.liveQueryStore.invalidate(key)` where your key may be a reference or schema coordinate: + +##### Reference +Where the query is: `auction(id: ID!): Auction @requireAuth`: + +* `"Auction:123"` + +##### Schema Coordinate +When the query is: `auctions: [Auction!]! @requireAuth`: + +* `"Query.auctions"` + +## Subscriptions + +RedwoodJS has a first-class developer experience for GraphQL subscriptions. + +#### Subscribe to Events + +- Granular information on what data changed +- Why has the data changed? +- Spec compliant + +### Chat/New Message Example + +```graphql +type Subscription { + newMessage(roomId: ID!): Message! @requireAuth +} +``` + +1. I subscribed to a "newMessage” in room “2” +2. Someone added a message to room “2” with a from and body +3. A "NewMessage" event to Room 2 gets published +4. I find out and see who the message is from and what they messaged (the body) + +### Countdown Timer Example + +Counts down from a starting values by an interval. + +```graphql +subscription CountdownFromInterval { + countdown(from: 100, interval: 10) +} +``` + +This example showcases how a subscription can yields its own response. + +## Live Queries + +RedwoodJS has made it super easy to add live queries to your GraphQL server! You can push new data to your clients automatically once the data selected by a GraphQL operation becomes stale by annotating your query operation with the `@live` directive. + +The invalidation mechanism is based on GraphQL ID fields and schema coordinates. Once a query operation has been invalidated, the query is re-executed, and the result is pushed to the client. + +##### Listen for Data Changes + +- I'm not interested in what exactly changed it. +- Just give me the data. +- This is not part of the GraphQL specification. +- There can be multiple root fields. + +### Auction Live Query Example + +```graphql +query GetCurrentAuctionBids @live { + auction(id: "1") { + bids { + amount + } + highestBid { + amount + } + id + title + } +} + +mutation MakeBid { + bid(input: { auctionId: "1", amount: 10 }) { + amount + } +} +``` + +1. I listen for changes to Auction 1 by querying the auction. +2. A bid was placed on Auction 1. +3. The information for Auction 1 is no longer valid. +4. My query automatically refetches the latest Auction and Bid details. + +## Defer Directive + +The `@defer` directive allows you to post-pone the delivery of one or more (slow) fields grouped in an inlined or spread fragment. + +### Slow and Fast Field Defer Example + +Here, the GraphQL schema defines two queries for a "fast" and a "slow" (ie, delayed) information. + +```graphql +export const schema = gql` + type Query { + """ + A field that resolves fast. + """ + fastField: String! @skipAuth + + """ + A field that resolves slowly. + Maybe you want to @defer this field ;) + """ + slowField(waitFor: Int! = 5000): String @skipAuth + } +` +``` + +The Redwood services for these queries return the `fastField` immediately and the `showField` after some delay. + +```ts +import { logger } from 'src/lib/logger' + +const wait = (time: number) => + new Promise((resolve) => setTimeout(resolve, time)) + +export const fastField = async () => { + return 'I am speedy' +} + +export const slowField = async (_, { waitFor = 5000 }) => { + logger.debug('deferring slowField until ...') + await wait(waitFor) + logger.debug('now!') + + return 'I am slow' +} +``` + +When making the query: + +```graphql +query SlowAndFastFieldWithDefer { + ... on Query @defer { + slowField + } + fastField +} +``` + +The response returns: + +```json +{ + "data": { + "fastField": "I am speedy" + } +} +``` + +and will await the deferred field to then present: + +```json +{ + "data": { + "fastField": "I am speedy", + "slowField": "I am slow" + } +} +``` + +## Stream Directive + +The `@stream` directive allows you to stream the individual items of a field of the list type as the items are available. + +### Alphabet Stream Example + +Here, the GraphQL schema defines a query to return the letters of the alphabet: + +```graphql +export const schema = gql` + type Query { + alphabet: [String!]! @skipAuth +` +``` + +The service uses `Repeater` to write a safe stream resolver. + +:::info +[AsyncGenerators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator) as declared via the `async *` keywords are prone to memory leaks and leaking timers. For real-world usage, use Repeater. +::: + +```ts +import { Repeater } from '@redwoodjs/realtime' + +import { logger } from 'src/lib/logger' + +export const alphabet = async () => { + return new Repeater(async (push, stop) => { + const values = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] + const publish = () => { + const value = values.shift() + + if (value) { + logger.debug({ value }, 'publishing') + + push(value) + } + + if (values.length === 0) { + stop() + } + } + + const interval = setInterval(publish, 1000) + + stop.then(() => { + logger.debug('cancel') + clearInterval(interval) + }) + + publish() + }) +} +``` + +### What does the incremental stream look like? + +Since Apollo Client does not yet support the `@stream` directive, you can use them in the GraphiQL Playground or see them in action via CURL. + +When making the request with the `@stream` directive: + +```bash +curl -g -X POST \ + -H "accept:multipart/mixed" \ + -H "content-type: application/json" \ + -d '{"query":"query StreamAlphabet { alphabet @stream }"}' \ + http://localhost:8911/graphql +``` + +Here you see the initial response has `[]` for alphabet data. + +Then on each push to the Repeater, an incremental update the the list of letters is sent. + +The stream ends when `hasNext` is false: + +```bash +* Connected to localhost (127.0.0.1) port 8911 (#0) +> POST /graphql HTTP/1.1 +> Host: localhost:8911 +> User-Agent: curl/8.1.2 +> accept:multipart/mixed +> content-type: application/json +> Content-Length: 53 +> +< HTTP/1.1 200 OK +< connection: keep-alive +< content-type: multipart/mixed; boundary="-" +< transfer-encoding: chunked +< +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 39 + +{"data":{"alphabet":[]},"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["a"],"path":["alphabet",0]}],"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["b"],"path":["alphabet",1]}],"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["c"],"path":["alphabet",2]}],"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["d"],"path":["alphabet",3]}],"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["e"],"path":["alphabet",4]}],"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["f"],"path":["alphabet",5]}],"hasNext":true} +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 70 + +{"incremental":[{"items":["g"],"path":["alphabet",6]}],"hasNext":true} +--- +... + +--- +Content-Type: application/json; charset=utf-8 +Content-Length: 17 + +{"hasNext":false} +----- +``` + +## How do I choose Subscriptions or Live Queries? + +![image](https://github.com/ahaywood/redwoodjs-streaming-realtime-demos/assets/1051633/e3c51908-434c-4396-856a-8bee7329bcdd) + +When deciding on how to offer realtime data updates in your RedwoodJS app, you’ll want to consider: + +- How frequently do your users require information updates? + - Determine the value of "real-time" versus "near real-time" to your users. Do they need to know in less than 1-2 seconds, or is 10, 30, or 60 seconds acceptable for them to receive updates? + - Consider the criticality of the data update. Is it low, such as a change in shipment status, or higher, such as a change in stock price for an investment app? + - Consider the cost of maintaining connections and tracking updates across your user base. Is the infrastructure cost justifiable? + - If you don't require "real" real-time, consider polling for data updates on a reasonable interval. According to Apollo, [in most cases](https://www.apollographql.com/docs/react/data/subscriptions/), your client should not use subscriptions to stay up to date with your backend. Instead, you should poll intermittently with queries or re-execute queries on demand when a user performs a relevant action, such as clicking a button. +- How are you deploying? Serverless or Serverful? + - Real-time options depend on your deployment method. + - If you are using a serverless architecture, your application cannot maintain a stateful connection to your users' applications. Therefore, it's not easy to "push," "publish," or "stream" data updates to the web client. + - In this case, you may need to look for third-party solutions that manage the infrastructure to maintain such stateful connections to your web client, such as [Supabase Realtime](https://supabase.com/realtime), [SendBird](https://sendbird.com/), [Pusher](https://pusher.com/), or consider creating your own [AWS SNS-based](https://docs.aws.amazon.com/sns/latest/dg/welcome.html) functionality. + + + +## Showcase Demos + +Please see our [showcase RedwoodJS Realtime app](https://realtime-demo.fly.dev) for exampes of subscriptions and live queries. It also demonstrates how you can handle streaming responses, like those used by OpenAI chat completions. + +### Chat Room (Subscription) + +Sends a message to one of four Chat Rooms. + +Each room subscribes to its new messages via the `NewMessage` channel aka topic. + +```ts +context.pubSub.publish('newMessage', roomId, { from, body }) +``` + +#### Simulate + +```bash +./scripts/simulate_chat.sh -h +Usage: ./scripts/simulate_chat.sh -r [roomId] -n [num_messages] + ./scripts/simulate_chat.sh -h + +Options: + -r roomId Specify the room ID (1-4) for sending chat messages. + -n num_messages Specify the number of chat messages to send. If not provided, the script will run with a random number of messages. +``` +#### Test + +```ts +/** + * To test this NewMessage subscription, run the following in one GraphQL Playground to subscribe: + * + * subscription ListenForNewMessagesInRoom { + * newMessage(roomId: "1") { + * body + * from + * } + * } + * + * + * And run the following in another GraphQL Playground to publish and send a message to the room: + * + * mutation SendMessageToRoom { + * sendMessage(input: {roomId: "1", from: "hello", body: "bob"}) { + * body + * from + * } + * } + */ + ``` + +### Auction Bids (Live Query) + +Bid on a fancy pair of new sneaks! + +When a bid is made, the auction updates via a Live Query due to the invalidation of the auction key. + +```ts + + const key = `Auction:${auctionId}` + context.liveQueryStore.invalidate(key) + ``` + +#### Simulate + +```bash +./scripts/simulate_bids.sh -h +Usage: ./scripts/simulate_bids.sh [options] + +Options: + -a Specify the auction ID (1-5) for which to send bids (optional). + -n Specify the number of bids to send (optional). + -h, --help Display this help message. + ``` + +#### Test + +```ts + +/** + * To test this live query, run the following in the GraphQL Playground: + * + * query GetCurrentAuctionBids @live { + * auction(id: "1") { + * bids { + * amount + * } + * highestBid { + * amount + * } + * id + * title + * } + * } + * + * And then make a bid with the following mutation: + * + * mutation MakeBid { + * bid(input: {auctionId: "1", amount: 10}) { + * amount + * } + * } + */ +``` + +### Countdown (Streaming Subscription) + +> It started slowly and I thought it was my heart +> But then I realised that this time it was for real + +Counts down from a starting values by an interval. + +This example showcases how a subscription can yields its own response. + +#### Test + +```ts +/** + * To test this Countdown subscription, run the following in the GraphQL Playground: + * + * subscription CountdownFromInterval { + * countdown(from: 100, interval: 10) + * } + */ +``` + +### Bedtime Story (Subscription with OpenAI Streaming) + +> Tell me a story about a happy, purple penguin that goes to a concert. + +Showcases how to use OpenAI to stream a chat completion via a prompt that writes a bedtime story: + +```ts +const PROMPT = `Write a short children's bedtime story about an Animal that is a given Color and that does a given Activity. + +Give the animal a cute descriptive and memorable name. + +The story should teach a lesson. + +The story should be told in a quality, style and feeling of the given Adjective. + +The story should be no longer than 3 paragraphs. + +Format the story using Markdown.` + +``` + +The story updates on each stream content delta via a `newStory` subscription topic event. + +```ts +context.pubSub.publish('newStory', id, story) +``` + +### Movie Mashup (Live Query with OpenAI Streaming) + +> It's Out of Africa meets Pretty Woman. + +> So it's a psychic, political, thriller comedy with a heart With a heart, not unlike Ghost meets Manchurian Candidate. + +-- The Player, 1992 + +Mashup some of your favorite movies to create something new and Netflix-worthy to watch. + +Powered by OpenAI, this movie tagline and treatment updates on each stream content delta via a Live Query bui invalidating the `MovieMashup key. + +```ts +context.liveQueryStore.invalidate(`MovieMashup:${id}`) +``` + diff --git a/docs/sidebars.js b/docs/sidebars.js index 3a2ee759d9c1..5d1b22eacc49 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -150,6 +150,7 @@ module.exports = { 'prerender', 'project-configuration-dev-test-build', 'redwoodrecord', + 'realtime', 'router', 'schema-relations', 'security', diff --git a/packages/api-server/src/plugins/withWebServer.ts b/packages/api-server/src/plugins/withWebServer.ts index f347e672c2b2..fa34797ce134 100644 --- a/packages/api-server/src/plugins/withWebServer.ts +++ b/packages/api-server/src/plugins/withWebServer.ts @@ -2,7 +2,7 @@ import fs from 'fs' import path from 'path' import fastifyStatic from '@fastify/static' -import type { FastifyInstance, FastifyReply } from 'fastify' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { getPaths } from '@redwoodjs/project-config' @@ -54,10 +54,21 @@ const withWebServer = async ( // For SPA routing fallback on unmatched routes // And let JS routing take over - fastify.setNotFoundHandler({}, function (_, reply: FastifyReply) { - reply.header('Content-Type', 'text/html; charset=UTF-8') - reply.sendFile(indexPath) - }) + fastify.setNotFoundHandler( + {}, + function (req: FastifyRequest, reply: FastifyReply) { + const requestedExtension = path.extname(req.url) + // If it's requesting some sort of asset, e.g. .js or .jpg files + // Html files should fallback to the index.html + if (requestedExtension !== '' && requestedExtension !== '.html') { + reply.code(404) + return reply.send('Not Found') + } + + reply.header('Content-Type', 'text/html; charset=UTF-8') + return reply.sendFile(indexPath) + } + ) return fastify } diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index 0d3e4b278db4..78cf1db0de46 100644 --- a/packages/auth-providers/dbAuth/api/src/shared.ts +++ b/packages/auth-providers/dbAuth/api/src/shared.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto' import type { APIGatewayProxyEvent } from 'aws-lambda' -import { getConfig } from '@redwoodjs/project-config' +import { getConfig, getConfigPath } from '@redwoodjs/project-config' import * as DbAuthError from './errors' @@ -27,6 +27,19 @@ const eventHeadersCookie = (event: APIGatewayProxyEvent) => { return event.headers.cookie || event.headers.Cookie } +const getPort = () => { + let configPath + + try { + configPath = getConfigPath() + } catch { + // If this throws, we're in a serverless environment, and the `redwood.toml` file doesn't exist. + return 8911 + } + + return getConfig(configPath).api.port +} + // When in development environment, check for cookie in the request extension headers // if user has generated graphiql headers const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent) => { @@ -224,7 +237,7 @@ export const legacyHashPassword = (text: string, salt?: string) => { } export const cookieName = (name: string | undefined) => { - const port = getConfig().api?.port || 8911 + const port = getPort() const cookieName = name?.replace('%port%', '' + port) ?? 'session' return cookieName diff --git a/packages/cli/package.json b/packages/cli/package.json index 69d03155fd5a..d149aada5bfc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ "camelcase": "6.3.0", "chalk": "4.1.2", "ci-info": "3.8.0", - "concurrently": "8.2.1", + "concurrently": "8.2.2", "configstore": "3.1.5", "core-js": "3.32.2", "cross-env": "7.0.3", diff --git a/packages/cli/src/commands/experimental/setupRealtimeHandler.js b/packages/cli/src/commands/experimental/setupRealtimeHandler.js index 97216209653e..814929c15056 100644 --- a/packages/cli/src/commands/experimental/setupRealtimeHandler.js +++ b/packages/cli/src/commands/experimental/setupRealtimeHandler.js @@ -246,6 +246,126 @@ export async function handler({ force, includeExamples, verbose }) { ] }, }, + + { + title: 'Adding Defer example queries ...', + enabled: () => includeExamples, + task: () => { + // sdl + + const exampleSdlTemplateContent = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'defer', + 'fastAndSlowFields', + `fastAndSlowFields.sdl.template` + ), + 'utf-8' + ) + + const sdlFile = path.join( + redwoodPaths.api.graphql, + `fastAndSlowFields.sdl.${isTypeScriptProject() ? 'ts' : 'js'}` + ) + + const sdlContent = ts + ? exampleSdlTemplateContent + : transformTSToJS(sdlFile, exampleSdlTemplateContent) + + // service + + const exampleServiceTemplateContent = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'defer', + 'fastAndSlowFields', + `fastAndSlowFields.ts.template` + ), + 'utf-8' + ) + const serviceFile = path.join( + redwoodPaths.api.services, + 'fastAndSlowFields', + `fastAndSlowFields.${isTypeScriptProject() ? 'ts' : 'js'}` + ) + + const serviceContent = ts + ? exampleServiceTemplateContent + : transformTSToJS(serviceFile, exampleServiceTemplateContent) + + // write all files + return [ + writeFile(sdlFile, sdlContent, { + overwriteExisting: force, + }), + writeFile(serviceFile, serviceContent, { + overwriteExisting: force, + }), + ] + }, + }, + + { + title: 'Adding Stream example queries ...', + enabled: () => includeExamples, + task: () => { + // sdl + + const exampleSdlTemplateContent = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'stream', + 'alphabet', + `alphabet.sdl.template` + ), + 'utf-8' + ) + + const sdlFile = path.join( + redwoodPaths.api.graphql, + `alphabet.sdl.${isTypeScriptProject() ? 'ts' : 'js'}` + ) + + const sdlContent = ts + ? exampleSdlTemplateContent + : transformTSToJS(sdlFile, exampleSdlTemplateContent) + + // service + + const exampleServiceTemplateContent = fs.readFileSync( + path.resolve( + __dirname, + 'templates', + 'stream', + 'alphabet', + `alphabet.ts.template` + ), + 'utf-8' + ) + const serviceFile = path.join( + redwoodPaths.api.services, + 'alphabet', + `alphabet.${isTypeScriptProject() ? 'ts' : 'js'}` + ) + + const serviceContent = ts + ? exampleServiceTemplateContent + : transformTSToJS(serviceFile, exampleServiceTemplateContent) + + // write all files + return [ + writeFile(sdlFile, sdlContent, { + overwriteExisting: force, + }), + writeFile(serviceFile, serviceContent, { + overwriteExisting: force, + }), + ] + }, + }, { title: 'Adding config to redwood.toml...', task: (_ctx, task) => { diff --git a/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js b/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js index 007e1bdf7b99..9c431b5a41f7 100644 --- a/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js +++ b/packages/cli/src/commands/experimental/setupStreamingSsrHandler.js @@ -3,6 +3,7 @@ import path from 'path' import { Listr } from 'listr2' +import { addWebPackages } from '@redwoodjs/cli-helpers' import { getConfigPath } from '@redwoodjs/project-config' import { errorTelemetry } from '@redwoodjs/telemetry' @@ -158,6 +159,9 @@ export const handler = async ({ force, verbose }) => { }) }, }, + addWebPackages([ + '@apollo/experimental-nextjs-app-support@0.0.0-commit-b8a73fe', + ]), { task: () => { printTaskEpilogue(command, description, EXPERIMENTAL_TOPIC_ID) diff --git a/packages/cli/src/commands/experimental/templates/defer/fastAndSlowFields/fastAndSlowFields.sdl.template b/packages/cli/src/commands/experimental/templates/defer/fastAndSlowFields/fastAndSlowFields.sdl.template new file mode 100644 index 000000000000..67cedf7119a1 --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/defer/fastAndSlowFields/fastAndSlowFields.sdl.template @@ -0,0 +1,14 @@ +export const schema = gql` + type Query { + """ + A field that resolves fast. + """ + fastField: String! @skipAuth + + """ + A field that resolves slowly. + Maybe you want to @defer this field ;) + """ + slowField(waitFor: Int! = 5000): String @skipAuth + } +` diff --git a/packages/cli/src/commands/experimental/templates/defer/fastAndSlowFields/fastAndSlowFields.ts.template b/packages/cli/src/commands/experimental/templates/defer/fastAndSlowFields/fastAndSlowFields.ts.template new file mode 100644 index 000000000000..d8e25c46de0c --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/defer/fastAndSlowFields/fastAndSlowFields.ts.template @@ -0,0 +1,14 @@ +import { logger } from 'src/lib/logger' + +const wait = (time: number) => + new Promise((resolve) => setTimeout(resolve, time)) + +export const fastField = async () => { + return 'I am fast' +} + +export const slowField = async (_, { waitFor = 5000 }) => { + logger.debug('waiting on slowField') + await wait(waitFor) + return 'I am slow' +} diff --git a/packages/cli/src/commands/experimental/templates/realtime.ts.template b/packages/cli/src/commands/experimental/templates/realtime.ts.template index 33028f34177a..db55b686b6c6 100644 --- a/packages/cli/src/commands/experimental/templates/realtime.ts.template +++ b/packages/cli/src/commands/experimental/templates/realtime.ts.template @@ -21,6 +21,8 @@ import subscriptions from 'src/subscriptions/**/*.{js,ts}' * Redwood Realtime * - uses a publish/subscribe model to broadcast data to clients. * - uses a store to persist Live Query and Subscription data. + * - and enable defer and stream directives to improve latency + * for clients by sending data the most important data as soon as it's ready. * * Redwood Realtime supports in-memory and Redis stores: * - In-memory stores are useful for development and testing. @@ -39,4 +41,6 @@ export const realtime: RedwoodRealtimeOptions = { // if using a Redis store // store: { redis: { publishClient, subscribeClient } }, }, + // To enable defer and streaming, set to true. + // enableDeferStream: true, } diff --git a/packages/cli/src/commands/experimental/templates/stream/alphabet/alphabet.sdl.template b/packages/cli/src/commands/experimental/templates/stream/alphabet/alphabet.sdl.template new file mode 100644 index 000000000000..44e478cb8486 --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/stream/alphabet/alphabet.sdl.template @@ -0,0 +1,9 @@ +export const schema = gql` + type Query { + """ + A field that spells out the letters of the alphabet + Maybe you want to @stream this field ;) + """ + alphabet: [String!]! @skipAuth + } +` diff --git a/packages/cli/src/commands/experimental/templates/stream/alphabet/alphabet.ts.template b/packages/cli/src/commands/experimental/templates/stream/alphabet/alphabet.ts.template new file mode 100644 index 000000000000..a3ffde7e1d05 --- /dev/null +++ b/packages/cli/src/commands/experimental/templates/stream/alphabet/alphabet.ts.template @@ -0,0 +1,31 @@ +import { Repeater } from '@redwoodjs/realtime' + +import { logger } from 'src/lib/logger' + +export const alphabet = async () => { + return new Repeater(async (push, stop) => { + const letters = 'abcdefghijklmnopqrstuvwxyz'.split('') + + const publish = () => { + const letter = letters.shift() + + if (letter) { + logger.debug({ letter }, 'publishing letter...') + push(letter) + } + + if (letters.length === 0) { + stop() + } + } + + const interval = setInterval(publish, 1000) + + stop.then(() => { + logger.debug('cancel') + clearInterval(interval) + }) + + publish() + }) +} diff --git a/packages/cli/src/commands/experimental/templates/subscriptions/countdown/countdown.ts.template b/packages/cli/src/commands/experimental/templates/subscriptions/countdown/countdown.ts.template index bf42145b1025..2c87356fcfdf 100644 --- a/packages/cli/src/commands/experimental/templates/subscriptions/countdown/countdown.ts.template +++ b/packages/cli/src/commands/experimental/templates/subscriptions/countdown/countdown.ts.template @@ -1,5 +1,9 @@ import gql from 'graphql-tag' +import { Repeater } from '@redwoodjs/realtime' + +import { logger } from 'src/lib/logger' + export const schema = gql` type Subscription { countdown(from: Int!, interval: Int!): Int! @requireAuth @@ -15,14 +19,39 @@ export const schema = gql` */ const countdown = { countdown: { - async *subscribe(_, { from = 100, interval = 10 }) { - while (from >= 0) { - yield { countdown: from } - // pause for 1/4 second - await new Promise((resolve) => setTimeout(resolve, 250)) - from -= interval + subscribe: ( + _, + { + from = 100, + interval = 10, + }: { + from: number + interval: number } - }, + ) => + new Repeater((push, stop) => { + function decrement() { + from -= interval + + if (from < 0) { + logger.debug({ from }, 'stopping as countdown is less than 0') + stop() + } + + logger.debug({ from }, 'pushing countdown value ...') + push(from) + } + + decrement() + + const delay = setInterval(decrement, 500) + + stop.then(() => { + clearInterval(delay) + logger.debug('stopping countdown') + }) + }), + resolve: (payload: number) => payload, }, } diff --git a/packages/cli/src/commands/setup/mailer/mailer.js b/packages/cli/src/commands/setup/mailer/mailer.js new file mode 100644 index 000000000000..3838ec7f9500 --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/mailer.js @@ -0,0 +1,31 @@ +import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' + +export const command = 'mailer' + +export const description = + 'Setup the redwood mailer. This will install the required packages and add the required initial configuration to your redwood app.' + +export const builder = (yargs) => { + yargs + .option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing configuration', + type: 'boolean', + }) + .option('skip-examples', { + default: false, + description: 'Only include required files and exclude any examples', + type: 'boolean', + }) +} + +export const handler = async (options) => { + recordTelemetryAttributes({ + command: 'setup mailer', + force: options.force, + skipExamples: options.skipExamples, + }) + const { handler } = await import('./mailerHandler.js') + return handler(options) +} diff --git a/packages/cli/src/commands/setup/mailer/mailerHandler.js b/packages/cli/src/commands/setup/mailer/mailerHandler.js new file mode 100644 index 000000000000..a25ce2966c18 --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/mailerHandler.js @@ -0,0 +1,119 @@ +import fs from 'fs' +import path from 'path' + +import { Listr } from 'listr2' + +import { addApiPackages } from '@redwoodjs/cli-helpers' +import { errorTelemetry } from '@redwoodjs/telemetry' + +import { getPaths, transformTSToJS, writeFile } from '../../../lib' +import c from '../../../lib/colors' +import { isTypeScriptProject } from '../../../lib/project' + +export const handler = async ({ force, skipExamples }) => { + const projectIsTypescript = isTypeScriptProject() + const redwoodVersion = + require(path.join(getPaths().base, 'package.json')).devDependencies[ + '@redwoodjs/core' + ] ?? 'latest' + + const tasks = new Listr( + [ + { + title: `Adding api/src/lib/mailer.${ + projectIsTypescript ? 'ts' : 'js' + }...`, + task: () => { + const templatePath = path.resolve( + __dirname, + 'templates', + 'mailer.ts.template' + ) + const templateContent = fs.readFileSync(templatePath, { + encoding: 'utf8', + flag: 'r', + }) + + const mailerPath = path.join( + getPaths().api.lib, + `mailer.${projectIsTypescript ? 'ts' : 'js'}` + ) + const mailerContent = projectIsTypescript + ? templateContent + : transformTSToJS(mailerPath, templateContent) + + return writeFile(mailerPath, mailerContent, { + overwriteExisting: force, + }) + }, + }, + { + title: 'Adding api/src/mail directory...', + task: () => { + const mailDir = path.join(getPaths().api.mail) + if (!fs.existsSync(mailDir)) { + fs.mkdirSync(mailDir) + } + }, + }, + { + title: `Adding example ReactEmail mail template`, + skip: () => skipExamples, + task: () => { + const templatePath = path.resolve( + __dirname, + 'templates', + 're-example.tsx.template' + ) + const templateContent = fs.readFileSync(templatePath, { + encoding: 'utf8', + flag: 'r', + }) + + const exampleTemplatePath = path.join( + getPaths().api.mail, + 'Example', + `Example.${projectIsTypescript ? 'tsx' : 'jsx'}` + ) + const exampleTemplateContent = projectIsTypescript + ? templateContent + : transformTSToJS(exampleTemplatePath, templateContent) + + return writeFile(exampleTemplatePath, exampleTemplateContent, { + overwriteExisting: force, + }) + }, + }, + { + // Add production dependencies + ...addApiPackages([ + `@redwoodjs/mailer-core@${redwoodVersion}`, + `@redwoodjs/mailer-handler-nodemailer@${redwoodVersion}`, + `@redwoodjs/mailer-renderer-react-email@${redwoodVersion}`, + `@react-email/components`, // NOTE: Unpinned dependency here + ]), + title: 'Adding production dependencies to your api side...', + }, + { + // Add development dependencies + ...addApiPackages([ + '-D', + `@redwoodjs/mailer-handler-in-memory@${redwoodVersion}`, + `@redwoodjs/mailer-handler-studio@${redwoodVersion}`, + ]), + title: 'Adding development dependencies to your api side...', + }, + ], + { + rendererOptions: { collapseSubtasks: false }, + } + ) + + try { + await tasks.run() + } catch (e) { + errorTelemetry(process.argv, e.message) + console.error(c.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/mailer/templates/mailer.ts.template b/packages/cli/src/commands/setup/mailer/templates/mailer.ts.template new file mode 100644 index 000000000000..eac5fed86efc --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/templates/mailer.ts.template @@ -0,0 +1,30 @@ +import { Mailer } from '@redwoodjs/mailer-core' +import { NodemailerMailHandler } from '@redwoodjs/mailer-handler-nodemailer' +import { ReactEmailRenderer } from '@redwoodjs/mailer-renderer-react-email' + +import { logger } from 'src/lib/logger' + +export const mailer = new Mailer({ + handling: { + handlers: { + // TODO: Update this handler config or switch it out for a different handler completely + nodemailer: new NodemailerMailHandler({ + transport: { + host: 'localhost', + port: 4319, + secure: false, + }, + }), + }, + default: 'nodemailer', + }, + + rendering: { + renderers: { + reactEmail: new ReactEmailRenderer(), + }, + default: 'reactEmail', + }, + + logger, +}) diff --git a/packages/cli/src/commands/setup/mailer/templates/re-example.tsx.template b/packages/cli/src/commands/setup/mailer/templates/re-example.tsx.template new file mode 100644 index 000000000000..9a13cf19eb1f --- /dev/null +++ b/packages/cli/src/commands/setup/mailer/templates/re-example.tsx.template @@ -0,0 +1,40 @@ +import React from 'react' + +import { + Html, + Text, + Hr, + Body, + Head, + Tailwind, + Preview, + Container, + Heading, +} from '@react-email/components' + +export function ExampleEmail( + { when }: { when: string } = { when: new Date().toLocaleString() } +) { + return ( + + + An example email + + + + + Example Email + + + This is an example email which you can customise to your needs. + +
+ + Message was sent on {when} + +
+ +
+ + ) +} diff --git a/packages/graphql-server/src/plugins/useRedwoodError.ts b/packages/graphql-server/src/plugins/useRedwoodError.ts index 27dd054213fd..e57a558c0afa 100644 --- a/packages/graphql-server/src/plugins/useRedwoodError.ts +++ b/packages/graphql-server/src/plugins/useRedwoodError.ts @@ -52,8 +52,12 @@ export const useRedwoodError = ( } }) + // be certain to return the complete result + // and not just the data or the errors + // because defer, stream and AsyncIterator results + // need to be returned as is setResult({ - data: result.data, + ...result, errors, extensions: result.extensions || {}, }) diff --git a/packages/internal/src/generate/graphqlSchema.ts b/packages/internal/src/generate/graphqlSchema.ts index d253057ef161..b4542780ec81 100644 --- a/packages/internal/src/generate/graphqlSchema.ts +++ b/packages/internal/src/generate/graphqlSchema.ts @@ -24,13 +24,16 @@ export const generateGraphQLSchema = async () => { 'subscriptions/**/*.{js,ts}': {}, } - // If we are serverful and the user is using realtime, we need to include the live directive for realtime support. + // If we're serverful and the user is using realtime, we need to include the live directive for realtime support. + // Note the `ERR_ prefix in`ERR_MODULE_NOT_FOUND`. Since we're using `await import`, + // if the package (here, `@redwoodjs/realtime`) can't be found, it throws this error, with the prefix. + // Whereas `require('@redwoodjs/realtime')` would throw `MODULE_NOT_FOUND`. if (resolveFile(`${getPaths().api.src}/server`)) { try { const { liveDirectiveTypeDefs } = await import('@redwoodjs/realtime') schemaPointerMap[liveDirectiveTypeDefs] = {} } catch (error) { - if ((error as { code: string }).code !== 'MODULE_NOT_FOUND') { + if ((error as { code: string }).code !== 'ERR_MODULE_NOT_FOUND') { throw error } } diff --git a/packages/mailer/core/src/__tests__/mailer.test.ts b/packages/mailer/core/src/__tests__/mailer.test.ts index b7dd65541818..dc10c3d2f869 100644 --- a/packages/mailer/core/src/__tests__/mailer.test.ts +++ b/packages/mailer/core/src/__tests__/mailer.test.ts @@ -325,33 +325,45 @@ describe('Uses the correct modes', () => { expect(console.warn).toBeCalledWith( 'The test handler is null, this will prevent mail from being processed in test mode' ) + }) + + test('development', () => { console.warn.mockClear() - const _mailer2 = new Mailer({ + const _mailer1 = new Mailer({ ...baseConfig, - test: { + development: { when: true, - handler: undefined, + handler: null, }, }) expect(console.warn).toBeCalledWith( - 'The test handler is null, this will prevent mail from being processed in test mode' + 'The development handler is null, this will prevent mail from being processed in development mode' ) }) + }) - test('development', () => { + describe('attempts to use fallback handlers', () => { + beforeAll(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + test('test', () => { console.warn.mockClear() - const _mailer1 = new Mailer({ + const _mailer = new Mailer({ ...baseConfig, - development: { + test: { when: true, - handler: null, + handler: undefined, }, }) expect(console.warn).toBeCalledWith( - 'The development handler is null, this will prevent mail from being processed in development mode' + "Automatically loaded the '@redwoodjs/mailer-handler-in-memory' handler, this will be used to process mail in test mode" ) + }) + + test('development', () => { console.warn.mockClear() - const _mailer2 = new Mailer({ + const _mailer = new Mailer({ ...baseConfig, development: { when: true, @@ -359,7 +371,7 @@ describe('Uses the correct modes', () => { }, }) expect(console.warn).toBeCalledWith( - 'The development handler is null, this will prevent mail from being processed in development mode' + "Automatically loaded the '@redwoodjs/mailer-handler-studio' handler, this will be used to process mail in development mode" ) }) }) @@ -804,7 +816,7 @@ describe('Uses the correct modes', () => { expect(mailerExplicitlyNullTestHandler.getTestHandler()).toBeNull() const mailerNoTestHandlerDefined = new Mailer(baseConfig) - expect(mailerNoTestHandlerDefined.getTestHandler()).toBeNull() + expect(mailerNoTestHandlerDefined.getTestHandler()).not.toBeNull() }) test('getDevelopmentHandler', () => { @@ -835,7 +847,9 @@ describe('Uses the correct modes', () => { ).toBeNull() const mailerNoDevelopmentHandlerDefined = new Mailer(baseConfig) - expect(mailerNoDevelopmentHandlerDefined.getDevelopmentHandler()).toBeNull() + expect( + mailerNoDevelopmentHandlerDefined.getDevelopmentHandler() + ).not.toBeNull() }) test('getDefaultProductionHandler', () => { diff --git a/packages/mailer/core/src/mailer.ts b/packages/mailer/core/src/mailer.ts index 4b565fdf77e3..de689b2cc399 100644 --- a/packages/mailer/core/src/mailer.ts +++ b/packages/mailer/core/src/mailer.ts @@ -1,5 +1,6 @@ import type { Logger } from '@redwoodjs/api/logger' +import type { AbstractMailHandler } from './handler' import type { MailerConfig, MailSendWithoutRenderingOptions, @@ -27,6 +28,11 @@ export class Mailer< public handlers: THandlers public renderers: TRenderers + // TODO: These would be better typed as the specific InMemoryMailHandler and StudioMailHandler classes + // However, that would require a circular dependency between this file and those files + private fallbackTestHandler?: AbstractMailHandler + private fallbackDevelopmentHandler?: AbstractMailHandler + constructor( public config: MailerConfig< THandlers, @@ -59,15 +65,18 @@ export class Mailer< // Validate handlers for test and development modes const testHandlerKey = this.config.test?.handler if (testHandlerKey === undefined) { - // TODO: Attempt to use a default in-memory handler if the required package is installed - // otherwise default to null and log a warning - this.config.test = { - ...this.config.test, - handler: null, + // Attempt to use a default in-memory handler if the required package is installed + try { + this.fallbackTestHandler = + new (require('@redwoodjs/mailer-handler-in-memory').InMemoryMailHandler)() + this.logger.warn( + "Automatically loaded the '@redwoodjs/mailer-handler-in-memory' handler, this will be used to process mail in test mode" + ) + } catch (_error) { + this.logger.warn( + "No test handler specified and could not load the '@redwoodjs/mailer-handler-in-memory' handler automatically, this will prevent mail from being processed in test mode" + ) } - this.logger.warn( - 'The test handler is null, this will prevent mail from being processed in test mode' - ) } else if (testHandlerKey === null) { this.logger.warn( 'The test handler is null, this will prevent mail from being processed in test mode' @@ -81,15 +90,18 @@ export class Mailer< } const developmentHandlerKey = this.config.development?.handler if (developmentHandlerKey === undefined) { - // TODO: Attempt to use a default studio handler if the required package is installed - // otherwise default to null and log a warning - this.config.development = { - ...this.config.development, - handler: null, + // Attempt to use a default studio handler if the required package is installed + try { + this.fallbackDevelopmentHandler = + new (require('@redwoodjs/mailer-handler-studio').StudioMailHandler)() + this.logger.warn( + "Automatically loaded the '@redwoodjs/mailer-handler-studio' handler, this will be used to process mail in development mode" + ) + } catch (_error) { + this.logger.warn( + "No development handler specified and could not load the '@redwoodjs/mailer-handler-studio' handler automatically, this will prevent mail from being processed in development mode" + ) } - this.logger.warn( - 'The development handler is null, this will prevent mail from being processed in development mode' - ) } else if (developmentHandlerKey === null) { this.logger.warn( 'The development handler is null, this will prevent mail from being processed in development mode' @@ -171,13 +183,6 @@ export class Mailer< // Handler is null, which indicates a no-op return {} } - if (handlerKey === undefined) { - throw new Error('No handler specified and no default handler configured') - } - const handler = this.handlers[handlerKey] - if (handler === undefined) { - throw new Error(`No handler found to match '${handlerKey.toString()}'`) - } const completedSendOptions = constructCompleteSendOptions( sendOptions, @@ -210,10 +215,23 @@ export class Mailer< } ) - const defaultedHandlerOptions = { - ...this.config.handling.options?.[handlerKeyForProduction], - ...handlerOptions, + const defaultedHandlerOptions = + handlerKey === undefined + ? handlerOptions + : { + ...this.config.handling.options?.[handlerKey], + ...handlerOptions, + } + + const handler = + handlerKey === undefined + ? this.getDefaultHandler() + : this.handlers[handlerKey] + if (handler === null || handler === undefined) { + // Handler is null or missing, which indicates a no-op + return {} } + const result = await handler.send( renderedContent, completedSendOptions, @@ -260,23 +278,29 @@ export class Mailer< // Handler is null, which indicates a no-op return {} } - if (handlerKey === undefined) { - throw new Error('No handler specified and no default handler configured') - } - const handler = this.handlers[handlerKey] - if (handler === undefined) { - throw new Error(`No handler found to match '${handlerKey.toString()}'`) - } const completedSendOptions = constructCompleteSendOptions( sendOptions, this.defaults ) - const defaultedHandlerOptions = { - ...this.config.handling.options?.[handlerKeyForProduction], - ...handlerOptions, + const defaultedHandlerOptions = + handlerKey === undefined + ? handlerOptions + : { + ...this.config.handling.options?.[handlerKey], + ...handlerOptions, + } + + const handler = + handlerKey === undefined + ? this.getDefaultHandler() + : this.handlers[handlerKey] + if (handler === null || handler === undefined) { + // Handler is null or missing, which indicates a no-op + return {} } + const result = await handler.send( content, completedSendOptions, @@ -322,17 +346,23 @@ export class Mailer< getTestHandler() { const handlerKey = this.config.test?.handler - if (handlerKey === undefined || handlerKey === null) { + if (handlerKey === null) { return handlerKey } + if (handlerKey === undefined) { + return this.fallbackTestHandler + } return this.handlers[handlerKey] } getDevelopmentHandler() { const handlerKey = this.config.development?.handler - if (handlerKey === undefined || handlerKey === null) { + if (handlerKey === null) { return handlerKey } + if (handlerKey === undefined) { + return this.fallbackDevelopmentHandler + } return this.handlers[handlerKey] } diff --git a/packages/realtime/package.json b/packages/realtime/package.json index 92e425088a37..b5b19511399f 100644 --- a/packages/realtime/package.json +++ b/packages/realtime/package.json @@ -26,6 +26,7 @@ "@envelop/live-query": "6.0.0", "@graphql-tools/schema": "10.0.0", "@graphql-tools/utils": "10.0.1", + "@graphql-yoga/plugin-defer-stream": "2.0.4", "@graphql-yoga/plugin-graphql-sse": "2.0.4", "@graphql-yoga/redis-event-target": "2.0.0", "@graphql-yoga/subscription": "4.0.0", diff --git a/packages/realtime/src/graphql/index.ts b/packages/realtime/src/graphql/index.ts index b85e2ecee3cc..2c6835f0ee0f 100644 --- a/packages/realtime/src/graphql/index.ts +++ b/packages/realtime/src/graphql/index.ts @@ -6,6 +6,7 @@ export { RedisLiveQueryStore, liveQueryStore, pubSub, + Repeater, } from './plugins/useRedwoodRealtime' export type { diff --git a/packages/realtime/src/graphql/plugins/useRedwoodRealtime.ts b/packages/realtime/src/graphql/plugins/useRedwoodRealtime.ts index ac18352059ca..a75736b45b25 100644 --- a/packages/realtime/src/graphql/plugins/useRedwoodRealtime.ts +++ b/packages/realtime/src/graphql/plugins/useRedwoodRealtime.ts @@ -2,6 +2,7 @@ import type { Plugin } from '@envelop/core' import { useLiveQuery } from '@envelop/live-query' import { mergeSchemas } from '@graphql-tools/schema' import { astFromDirective } from '@graphql-tools/utils' +import { useDeferStream } from '@graphql-yoga/plugin-defer-stream' import { useGraphQLSSE } from '@graphql-yoga/plugin-graphql-sse' import { createRedisEventTarget } from '@graphql-yoga/redis-event-target' import type { CreateRedisEventTargetArgs } from '@graphql-yoga/redis-event-target' @@ -12,6 +13,8 @@ import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store' import type { execute as defaultExecute } from 'graphql' import { print } from 'graphql' +export { Repeater } from 'graphql-yoga' + /** * We want SubscriptionsGlobs type to be an object with this shape: * @@ -60,6 +63,7 @@ export type SubscribeClientType = CreateRedisEventTargetArgs['subscribeClient'] * */ export type RedwoodRealtimeOptions = { + enableDeferStream?: boolean liveQueries?: { /** * @description Redwood Realtime supports in-memory and Redis stores. @@ -232,6 +236,9 @@ export const useRedwoodRealtime = (options: RedwoodRealtimeOptions): Plugin => { if (subscriptionsEnabled) { addPlugin(useGraphQLSSE() as Plugin) } + if (options.enableDeferStream) { + addPlugin(useDeferStream() as Plugin) + } }, onContextBuilding() { return ({ extendContext }) => { diff --git a/packages/realtime/src/index.ts b/packages/realtime/src/index.ts index 674f7c106beb..b2706a4958a5 100644 --- a/packages/realtime/src/index.ts +++ b/packages/realtime/src/index.ts @@ -6,6 +6,7 @@ export { RedisLiveQueryStore, liveQueryStore, pubSub, + Repeater, } from './graphql' export type { diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 6803a9a813a2..f394a266674e 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -10,6 +10,7 @@ import { getConfig, getPaths } from '@redwoodjs/project-config' import handleJsAsJsx from './plugins/vite-plugin-jsx-loader' import removeFromBundle from './plugins/vite-plugin-remove-from-bundle' +import swapApolloProvider from './plugins/vite-plugin-swap-apollo-provider' /** * Pre-configured vite plugin, with required config for Redwood apps. @@ -261,6 +262,8 @@ export default function redwoodPluginVite(): PluginOption[] { } }, }, + // We can remove when streaming is stable + rwConfig.experimental.streamingSsr.enabled && swapApolloProvider(), // ----------------- handleJsAsJsx(), // Remove the splash-page from the bundle. diff --git a/packages/vite/src/plugins/__tests__/swap-apollo-provider.test.mts b/packages/vite/src/plugins/__tests__/swap-apollo-provider.test.mts new file mode 100644 index 000000000000..35f352a1e8fc --- /dev/null +++ b/packages/vite/src/plugins/__tests__/swap-apollo-provider.test.mts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import plugin from '../vite-plugin-swap-apollo-provider.js' + +// @ts-expect-error node test not configured correctly +const swapApolloProvider = plugin.default + + +describe('excludeModule', () => { + it('should swap the import', async() => { + const plugin = swapApolloProvider() + + const output = await plugin.transform(`import ApolloProvider from '@redwoodjs/web/apollo'`, '/Users/dac09/Experiments/ssr-2354/web/src/App.tsx') + + assert.strictEqual(output, "import ApolloProvider from '@redwoodjs/web/dist/apollo/suspense'") +}) +}) diff --git a/packages/vite/src/plugins/vite-plugin-swap-apollo-provider.ts b/packages/vite/src/plugins/vite-plugin-swap-apollo-provider.ts new file mode 100644 index 000000000000..8f3742492b4c --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-swap-apollo-provider.ts @@ -0,0 +1,26 @@ +import type { PluginOption } from 'vite' + +/** + * + * Temporary plugin, that swaps the ApolloProvider import with the Suspense enabled one, + * until it becomes stable. + * + * import { RedwoodApolloProvider } from "@redwoodjs/web/apollo" -> + * import { RedwoodApolloProvider } from "@redwoodjs/web/dist/apollo/suspense" + * + */ +export default function swapApolloProvider(): PluginOption { + return { + name: 'redwood-swap-apollo-provider', + async transform(code: string, id: string) { + if (/web\/src\/App\.(ts|tsx|js|jsx)$/.test(id)) { + return code.replace( + '@redwoodjs/web/apollo', + '@redwoodjs/web/dist/apollo/suspense' + ) + } + + return code + }, + } +} diff --git a/packages/vite/src/streaming/registerGlobals.ts b/packages/vite/src/streaming/registerGlobals.ts index 1ace576f59b2..75d034636579 100644 --- a/packages/vite/src/streaming/registerGlobals.ts +++ b/packages/vite/src/streaming/registerGlobals.ts @@ -33,11 +33,11 @@ export const registerFwGlobals = () => { if (/^[a-zA-Z][a-zA-Z\d+\-.]*?:/.test(apiPath)) { return apiPath } else { - const proxiedApiUrl = - // NOTE: rwConfig.web.host defaults to "localhost", which is - // troublesome in regards to IPv6/IPv4. So all the more - // reason to set RWJS_EXP_SSR_GRAPHQL_ENDPOINT + // NOTE: rwConfig.web.host defaults to "localhost", which is + // When running in production, the api server does not listen on localhost + const proxiedApiUrl = swapLocalhostFor127( 'http://' + rwConfig.web.host + ':' + rwConfig.web.port + apiPath + ) if ( process.env.NODE_ENV === 'production' && @@ -47,9 +47,7 @@ export const registerFwGlobals = () => { console.warn() console.warn() - console.warn( - `You haven't configured your API absolute url. Localhost is unlikely to work in production` - ) + console.warn(`You haven't configured your API absolute url.`) console.warn(`Using ${proxiedApiUrl}`) console.warn() @@ -64,7 +62,7 @@ export const registerFwGlobals = () => { return proxiedApiUrl } - return ( + return swapLocalhostFor127( (process.env.RWJS_EXP_SSR_GRAPHQL_ENDPOINT as string) ?? proxiedApiUrl ) } @@ -76,3 +74,7 @@ export const registerFwGlobals = () => { REDWOOD_ENV_EDITOR: JSON.stringify(process.env.REDWOOD_ENV_EDITOR), } } + +function swapLocalhostFor127(hostString: string) { + return hostString.replace('localhost', '127.0.0.1') +} diff --git a/tasks/k6-test/run-k6-tests.mts b/tasks/k6-test/run-k6-tests.mts index 50780c296068..a6cf749849f9 100755 --- a/tasks/k6-test/run-k6-tests.mts +++ b/tasks/k6-test/run-k6-tests.mts @@ -45,11 +45,11 @@ const SETUPS_DIR = path.join(__dirname, 'setups') const TESTS_DIR = path.join(__dirname, 'tests') const API_SERVER_COMMANDS = [ { - cmd: `node ${path.resolve(REDWOODJS_FRAMEWORK_PATH, 'packages/cli/dist/index.js')} serve api`, + cmd: `node ${path.resolve(REDWOOD_PROJECT_DIRECTORY, 'node_modules/@redwoodjs/cli/dist/index.js')} serve api`, host: 'http://localhost:8911', }, { - cmd: `node ${path.resolve(REDWOODJS_FRAMEWORK_PATH, 'packages/api-server/dist/index.js')} api`, + cmd: `node ${path.resolve(REDWOOD_PROJECT_DIRECTORY, 'node_modules/@redwoodjs/api-server/dist/index.js')} api`, host: 'http://localhost:8911' }, ] diff --git a/tasks/k6-test/setups/context_magic_number/templates/benchmarks.ts b/tasks/k6-test/setups/context_magic_number/templates/benchmarks.ts index f638811bceb4..0d8be240ebad 100644 --- a/tasks/k6-test/setups/context_magic_number/templates/benchmarks.ts +++ b/tasks/k6-test/setups/context_magic_number/templates/benchmarks.ts @@ -2,25 +2,19 @@ import type { MutationResolvers } from 'types/graphql' import { setContext } from '@redwoodjs/graphql-server' -import { logger } from 'src/lib/logger' - export const magicNumber: MutationResolvers['magicNumber'] = async ({ value, }) => { setContext({ - // ...context, magicNumber: value, }) - // context.magicNumber = value const sleep = Math.random() * 200 - // logger.info(`Sleeping for ${sleep}ms`) await new Promise((resolve) => setTimeout(resolve, sleep)) const numberFromContext = (context.magicNumber ?? -1) as number if (value !== numberFromContext) { - logger.error(`Expected ${value} but got ${numberFromContext}`) - // throw new Error(`Expected ${value} but got ${numberFromContext}`) + throw new Error(`Expected ${value} but got ${numberFromContext}`) } return { value: numberFromContext } diff --git a/tasks/k6-test/setups/context_magic_number/templates/func.ts b/tasks/k6-test/setups/context_magic_number/templates/func.ts index 5e4b957cd351..6132e50942ef 100644 --- a/tasks/k6-test/setups/context_magic_number/templates/func.ts +++ b/tasks/k6-test/setups/context_magic_number/templates/func.ts @@ -2,8 +2,6 @@ import type { APIGatewayProxyEvent, Context } from 'aws-lambda' import { setContext } from '@redwoodjs/graphql-server' -import { logger } from 'src/lib/logger' - export const handler = async ( event: APIGatewayProxyEvent, _context: Context @@ -19,7 +17,7 @@ export const handler = async ( const numberFromContext = (context.magicNumber ?? -1) as number if (magicNumber !== numberFromContext) { - logger.error(`Expected ${magicNumber} but got ${numberFromContext}`) + throw new Error(`Expected ${magicNumber} but got ${numberFromContext}`) } return { diff --git a/tasks/k6-test/tests/context_functions.js b/tasks/k6-test/tests/context_functions.js index 96bf66036358..6c8f3b04da68 100644 --- a/tasks/k6-test/tests/context_functions.js +++ b/tasks/k6-test/tests/context_functions.js @@ -19,28 +19,7 @@ export const options = { export default function () { const magicNumber = Math.floor(Math.random() * 16000000) - - const payload = (value) => { - return JSON.stringify({ - query: `mutation MagicNumber($value: Int!) { - magicNumber(value: $value) { - value - } - }`, - variables: { - value, - }, - operationName: 'MagicNumber', - }) - } - - const url = `${__ENV.TEST_HOST}/func` - const params = { - headers: { - 'Content-Type': 'application/json', - }, - } - const res = http.post(url, payload(magicNumber), params) + const res = http.get(`${__ENV.TEST_HOST}/func?magicNumber=${magicNumber}`) const requestPassed = check(res, { 'status was 200': (r) => r.status == 200, @@ -51,7 +30,7 @@ export default function () { const contextPassed = check(res, { 'correct magic number': (r) => - r.body != null && r.body.includes(`"value":${magicNumber}}`), + r.body != null && r.body.includes(`"value":"${magicNumber}"}`), }) if (!contextPassed) { contextErrorCounter.add(1) diff --git a/tasks/smoke-tests/auth/tests/authChecks.spec.ts b/tasks/smoke-tests/auth/tests/authChecks.spec.ts index d5b8fa1c5632..b20171bbe277 100644 --- a/tasks/smoke-tests/auth/tests/authChecks.spec.ts +++ b/tasks/smoke-tests/auth/tests/authChecks.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test' -import { loginAsTestUser, signUpTestUser } from '../../common' +import { loginAsTestUser, signUpTestUser } from '../../shared/common' // Signs up a user before these tests diff --git a/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts b/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts index 216c49e97f48..280c2ad4c820 100644 --- a/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts +++ b/tasks/smoke-tests/auth/tests/rbacChecks.spec.ts @@ -3,10 +3,9 @@ import * as path from 'node:path' import { test, expect } from '@playwright/test' import type { PlaywrightTestArgs, Page } from '@playwright/test' -// @ts-expect-error - With `* as` you have to use .default() when calling execa import execa from 'execa' -import { loginAsTestUser, signUpTestUser } from '../../common' +import { loginAsTestUser, signUpTestUser } from '../../shared/common' // This is a special test that does the following // Signup a user (admin@bazinga.com), because salt/secrets won't match, we need to do this diff --git a/tasks/smoke-tests/dev/tests/dev.spec.ts b/tasks/smoke-tests/dev/tests/dev.spec.ts index c36dd405d595..17a5e003c9ef 100644 --- a/tasks/smoke-tests/dev/tests/dev.spec.ts +++ b/tasks/smoke-tests/dev/tests/dev.spec.ts @@ -1,5 +1,5 @@ import { test } from '@playwright/test' -import { smokeTest } from '../../common' +import { smokeTest } from '../../shared/common' test('Smoke test with dev server', smokeTest) diff --git a/tasks/smoke-tests/prerender/tests/prerender.spec.ts b/tasks/smoke-tests/prerender/tests/prerender.spec.ts index 77557ec35613..4dd89b0e32c5 100644 --- a/tasks/smoke-tests/prerender/tests/prerender.spec.ts +++ b/tasks/smoke-tests/prerender/tests/prerender.spec.ts @@ -7,9 +7,10 @@ import type { PlaywrightTestArgs, PlaywrightWorkerArgs, } from '@playwright/test' -// @ts-expect-error - With `* as` you have to use .default() when calling execa import execa from 'execa' +import { checkHomePageCellRender } from '../../shared/homePage' + let noJsBrowser: BrowserContext test.beforeAll(async ({ browser }: PlaywrightWorkerArgs) => { @@ -22,21 +23,7 @@ test('Check that homepage is prerendered', async () => { const pageWithoutJs = await noJsBrowser.newPage() await pageWithoutJs.goto('/') - const cellSuccessState = await pageWithoutJs.locator('main').innerHTML() - expect(cellSuccessState).toMatch(/Welcome to the blog!/) - expect(cellSuccessState).toMatch(/A little more about me/) - expect(cellSuccessState).toMatch(/What is the meaning of life\?/) - - const navTitle = await pageWithoutJs.locator('header >> h1').innerText() - expect(navTitle).toBe('Redwood Blog') - - const navLinks = await pageWithoutJs.locator('nav >> ul').innerText() - expect(navLinks.split('\n')).toEqual([ - 'About', - 'Contact Us', - 'Admin', - 'Log In', - ]) + checkHomePageCellRender(pageWithoutJs) pageWithoutJs.close() }) diff --git a/tasks/smoke-tests/serve/tests/serve.spec.ts b/tasks/smoke-tests/serve/tests/serve.spec.ts index 08fbb4cd3c82..715ee04b6c1d 100644 --- a/tasks/smoke-tests/serve/tests/serve.spec.ts +++ b/tasks/smoke-tests/serve/tests/serve.spec.ts @@ -1,5 +1,5 @@ import { test } from '@playwright/test' -import { smokeTest } from '../../common' +import { smokeTest } from '../../shared/common' test('Smoke test with rw serve', smokeTest) diff --git a/tasks/smoke-tests/common.ts b/tasks/smoke-tests/shared/common.ts similarity index 100% rename from tasks/smoke-tests/common.ts rename to tasks/smoke-tests/shared/common.ts diff --git a/tasks/smoke-tests/shared/delayedPage.ts b/tasks/smoke-tests/shared/delayedPage.ts new file mode 100644 index 000000000000..4b52aaf7c85f --- /dev/null +++ b/tasks/smoke-tests/shared/delayedPage.ts @@ -0,0 +1,47 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export async function checkDelayedPageRendering( + page: Page, + { expectedDelay }: { expectedDelay: number } +) { + const delayedLogStatements: { message: string; time: number }[] = [] + + page.on('console', (message) => { + if (message.type() === 'log') { + const messageText = message.text() + + if (messageText.includes('delayed by')) { + delayedLogStatements.push({ + message: messageText, + time: Date.now(), + }) + } + } + }) + + await page.goto('/delayed') + + expect(delayedLogStatements.length).toBe(4) + + delayedLogStatements.forEach((log, index) => { + if (index > 0) { + const timeDiff = log.time - delayedLogStatements[index - 1].time + // If we're not expecting a delay + // Check that timeDiff is less than 300ms (with margin of error) + if (expectedDelay === 0) { + expect(timeDiff).toBeLessThan(300) + } else { + // Allow a 300ms margin of error + expect(timeDiff).toBeGreaterThan(expectedDelay - 300) + expect(timeDiff).toBeLessThan(expectedDelay + 300) + } + } + }) + + // Check that its actually rendered on the page. Important when **not** progressively rendering + await expect(page.locator('[data-test-id="delayed-text-1"]')).toHaveCount(1) + await expect(page.locator('[data-test-id="delayed-text-2"]')).toHaveCount(1) + await expect(page.locator('[data-test-id="delayed-text-3"]')).toHaveCount(1) + await expect(page.locator('[data-test-id="delayed-text-4"]')).toHaveCount(1) +} diff --git a/tasks/smoke-tests/shared/homePage.ts b/tasks/smoke-tests/shared/homePage.ts new file mode 100644 index 000000000000..dc442192999c --- /dev/null +++ b/tasks/smoke-tests/shared/homePage.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test' +import type { Page } from '@playwright/test' + +export async function checkHomePageCellRender(page: Page) { + const cellSuccessState = await page.locator('main').innerHTML() + + expect(cellSuccessState).toMatch(/Welcome to the blog!/) + expect(cellSuccessState).toMatch(/A little more about me/) + expect(cellSuccessState).toMatch(/What is the meaning of life\?/) + + const navTitle = await page.locator('header >> h1').innerText() + expect(navTitle).toBe('Redwood Blog') + + const navLinks = await page.locator('nav >> ul').innerText() + expect(navLinks.split('\n')).toEqual([ + 'About', + 'Contact Us', + 'Admin', + 'Log In', + ]) +} diff --git a/tasks/smoke-tests/smoke-tests.mjs b/tasks/smoke-tests/smoke-tests.mjs index 0bfb1db7c46f..fddbe1b0c708 100644 --- a/tasks/smoke-tests/smoke-tests.mjs +++ b/tasks/smoke-tests/smoke-tests.mjs @@ -64,7 +64,7 @@ async function main() { .readdirSync(path.dirname(fileURLToPath(import.meta.url)), { withFileTypes: true, }) - .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => dirent.isDirectory() && dirent.name !== 'shared') .map((dirent) => dirent.name) if (smokeTest === undefined) { diff --git a/tasks/smoke-tests/streaming-ssr-dev/playwright.config.ts b/tasks/smoke-tests/streaming-ssr-dev/playwright.config.ts new file mode 100644 index 000000000000..b188c0d86434 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-dev/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + use: { + baseURL: 'http://localhost:8910', + // headless: false, + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood dev --no-generate --fwd="--no-open"', + cwd: process.env.REDWOOD_TEST_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/streaming-ssr-dev/tests/progressiveRendering.spec.ts b/tasks/smoke-tests/streaming-ssr-dev/tests/progressiveRendering.spec.ts new file mode 100644 index 000000000000..0f630a02bae9 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-dev/tests/progressiveRendering.spec.ts @@ -0,0 +1,85 @@ +import { setTimeout } from 'node:timers/promises' + +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' + +import { checkHomePageCellRender } from '../../shared/homePage' + +let pageWithClientBlocked: Page + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + // Disable loading of client-side JS + await page.route('**/entry.client.{js,tsx,ts,jsx}', (route) => route.abort()) + await page.route('**/App.{js,tsx,ts,jsx}', (route) => route.abort()) + await page.route('**/index.*.{js,tsx,ts,jsx}', (route) => route.abort()) + + pageWithClientBlocked = page +}) + +test.afterAll(() => { + pageWithClientBlocked.close() +}) + +test('Check that homepage has content rendered from the server (progressively)', async () => { + await pageWithClientBlocked.goto('/') + + // @NOTE: It shows loading when the fetch fails, so client side can recover. + const apiServerLoading = pageWithClientBlocked.getByText('Loading...') + + while (await apiServerLoading.isVisible()) { + await pageWithClientBlocked.reload() + await setTimeout(500) + } + + // Appears when Cell is successfully rendered + await pageWithClientBlocked.waitForSelector('article') + + await checkHomePageCellRender(pageWithClientBlocked) +}) + +test('Check delayed page has content progressively rendered', async () => { + const delayedLogStatements: { message: string; time: number }[] = [] + + pageWithClientBlocked.on('console', (message) => { + if (message.type() === 'log') { + const messageText = message.text() + + if (messageText.includes('delayed by')) { + delayedLogStatements.push({ + message: messageText, + time: Date.now(), + }) + } + } + }) + + await pageWithClientBlocked.goto('/delayed') + + expect(delayedLogStatements.length).toBe(4) + + delayedLogStatements.forEach((log, index) => { + if (index > 0) { + const timeDiff = log.time - delayedLogStatements[index - 1].time + + // With room for error, approximately 1 second + expect(timeDiff).toBeGreaterThan(600) + expect(timeDiff).toBeLessThan(1400) + } + }) + + // Check that its actually rendered on the page. Important when **not** progressively rendering + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-1"]') + ).toHaveCount(1) + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-2"]') + ).toHaveCount(1) + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-3"]') + ).toHaveCount(1) + await expect( + pageWithClientBlocked.locator('[data-test-id="delayed-text-4"]') + ).toHaveCount(1) +}) diff --git a/tasks/smoke-tests/streaming-ssr-prod/playwright.config.ts b/tasks/smoke-tests/streaming-ssr-prod/playwright.config.ts new file mode 100644 index 000000000000..29c14c8da4a3 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-prod/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + use: { + baseURL: 'http://localhost:8910', + // headless: false, + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood serve', + cwd: process.env.REDWOOD_TEST_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/streaming-ssr-prod/tests/botRendering.spec.ts b/tasks/smoke-tests/streaming-ssr-prod/tests/botRendering.spec.ts new file mode 100644 index 000000000000..439db2806448 --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-prod/tests/botRendering.spec.ts @@ -0,0 +1,42 @@ +import type { Page } from '@playwright/test' +import { test } from '@playwright/test' + +import { checkDelayedPageRendering } from '../../shared/delayedPage' +import { checkHomePageCellRender } from '../../shared/homePage' + +let botPageNoJs: Page + +test.beforeAll(async ({ browser }) => { + // UA taken from https://developers.google.com/search/docs/crawling-indexing/overview-google-crawlers + const botContext = await browser.newContext({ + userAgent: + 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36', + // @@MARK TODO awaiting react team feedback. I dont understand why React is still injecting JS instead of giving us + // a fully formed HTML page + // javaScriptEnabled: false, + }) + + const botPage = await botContext.newPage() + await botPage.route('**/*.*.{js,tsx,ts,jsx}', (route) => route.abort()) + + botPageNoJs = botPage +}) + +test.afterAll(() => { + botPageNoJs.close() +}) + +test('Check that homepage has content rendered from the server', async () => { + await botPageNoJs.goto('/') + + // Appears when Cell is successfully rendered + await botPageNoJs.waitForSelector('article') + + await checkHomePageCellRender(botPageNoJs) +}) + +test('Check delayed page is NOT progressively rendered', async () => { + await checkDelayedPageRendering(botPageNoJs, { + expectedDelay: 0, + }) +}) diff --git a/tasks/smoke-tests/streaming-ssr-prod/tests/progressiveRendering.spec.ts b/tasks/smoke-tests/streaming-ssr-prod/tests/progressiveRendering.spec.ts new file mode 100644 index 000000000000..f95495dc915c --- /dev/null +++ b/tasks/smoke-tests/streaming-ssr-prod/tests/progressiveRendering.spec.ts @@ -0,0 +1,37 @@ +import type { Page } from '@playwright/test' +import { test } from '@playwright/test' + +import { checkDelayedPageRendering } from '../../shared/delayedPage' +import { checkHomePageCellRender } from '../../shared/homePage' + +let pageWithClientBlocked: Page + +test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + + // Disable loading of client-side JS + // Note that we don't want to disable JS entirely, because progressive rendering + // requires JS injected in