From bb5d3e5743fd869d06be5575487738a847c5ac4f Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 3 Feb 2026 16:43:35 -0500 Subject: [PATCH 1/9] hono, vite, and little tweaks --- .prettierrc | 3 + docs/capabilities/blocks/blocks_payments.md | 22 +- .../blocks/optimize_performance.md | 70 +-- docs/capabilities/client/forms.mdx | 308 +++++++++++++ docs/capabilities/client/menu-actions.mdx | 387 +++++++++++----- .../devvit-web/devvit_web_configuration.md | 31 +- .../devvit-web/devvit_web_overview.mdx | 42 +- docs/capabilities/server/cache-helper.mdx | 71 +++ docs/capabilities/server/http-fetch.mdx | 94 ++-- .../view_modes_entry_points.md | 42 +- docs/capabilities/server/overview.md | 4 +- docs/capabilities/server/post-data.mdx | 108 +++++ docs/capabilities/server/redis.mdx | 203 +++++++- docs/capabilities/server/scheduler.md | 247 ---------- docs/capabilities/server/scheduler.mdx | 432 ++++++++++++++++++ .../server/settings-and-secrets.mdx | 129 ++++++ docs/capabilities/server/triggers.mdx | 192 +++++--- .../{userActions.md => userActions.mdx} | 73 +++ .../earn-money/payments/payments_add.mdx | 99 +++- .../earn-money/payments/payments_migrate.mdx | 32 +- docs/earn-money/payments/support_this_app.md | 16 +- docs/guides/launch/feature-guide.md | 4 +- docs/guides/tools/logs.md | 14 +- docs/guides/tools/playtest.md | 6 +- docs/guides/tools/vite.mdx | 94 +++- docs/quickstart/quickstart-gamemaker.mdx | 7 +- docs/quickstart/quickstart-mod-tool.md | 2 +- docs/quickstart/quickstart-unity.mdx | 56 ++- docs/quickstart/quickstart.md | 11 +- sidebars.ts | 3 +- src/css/custom.css | 78 +++- src/theme/Tabs/index.tsx | 21 + versioned_docs/version-0.11/changelog.md | 8 +- versioned_docs/version-0.11/debug.md | 14 +- versioned_docs/version-0.11/dev_guide.mdx | 6 +- versioned_docs/version-0.11/devvit_cli.md | 62 +-- .../devvit_web/devvit_web_overview.mdx | 2 +- .../devvit_web/how_devvit_web_works.mdx | 13 +- .../version-0.11/migration_guide.md | 109 ++--- versioned_docs/version-0.11/playtest.md | 6 +- .../capabilities/blocks/app_image_assets.md | 22 +- .../capabilities/blocks/blocks_payments.md | 22 +- .../blocks/optimize_performance.md | 70 +-- .../capabilities/client/forms.mdx | 308 +++++++++++++ .../capabilities/client/menu-actions.mdx | 387 +++++++++++----- .../devvit-web/devvit_web_configuration.md | 31 +- .../devvit-web/devvit_web_overview.mdx | 42 +- .../capabilities/server/cache-helper.mdx | 71 +++ .../capabilities/server/http-fetch.mdx | 94 ++-- .../view_modes_entry_points.md | 56 +-- .../capabilities/server/overview.md | 2 +- .../capabilities/server/post-data.mdx | 108 +++++ .../capabilities/server/redis.mdx | 203 +++++++- .../capabilities/server/scheduler.md | 247 ---------- .../capabilities/server/scheduler.mdx | 432 ++++++++++++++++++ .../server/settings-and-secrets.mdx | 129 ++++++ .../capabilities/server/triggers.mdx | 192 +++++--- .../{userActions.md => userActions.mdx} | 73 +++ .../earn-money/payments/payments_add.mdx | 99 +++- .../earn-money/payments/payments_migrate.mdx | 32 +- .../earn-money/payments/support_this_app.md | 16 +- .../{feature-guide.md => feature-guide.mdx} | 8 +- .../version-0.12/guides/tools/logs.md | 14 +- .../version-0.12/guides/tools/playtest.md | 6 +- .../version-0.12/guides/tools/vite.mdx | 26 +- .../quickstart/quickstart-gamemaker.mdx | 7 +- .../quickstart/quickstart-mod-tool.md | 2 +- .../quickstart/quickstart-unity.mdx | 7 +- .../version-0.12/quickstart/quickstart.md | 11 +- 69 files changed, 4408 insertions(+), 1430 deletions(-) create mode 100644 .prettierrc delete mode 100644 docs/capabilities/server/scheduler.md create mode 100644 docs/capabilities/server/scheduler.mdx rename docs/capabilities/server/{userActions.md => userActions.mdx} (76%) rename versioned_docs/version-0.12/earn-money/payments/payments_add.md => docs/earn-money/payments/payments_add.mdx (82%) rename versioned_docs/version-0.12/earn-money/payments/payments_migrate.md => docs/earn-money/payments/payments_migrate.mdx (61%) create mode 100644 src/theme/Tabs/index.tsx delete mode 100644 versioned_docs/version-0.12/capabilities/server/scheduler.md create mode 100644 versioned_docs/version-0.12/capabilities/server/scheduler.mdx rename versioned_docs/version-0.12/capabilities/server/{userActions.md => userActions.mdx} (76%) rename docs/earn-money/payments/payments_add.md => versioned_docs/version-0.12/earn-money/payments/payments_add.mdx (82%) rename docs/earn-money/payments/payments_migrate.md => versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx (61%) rename versioned_docs/version-0.12/guides/launch/{feature-guide.md => feature-guide.mdx} (97%) diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1ca87ab --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": false +} diff --git a/docs/capabilities/blocks/blocks_payments.md b/docs/capabilities/blocks/blocks_payments.md index d3c8000..4e535ea 100644 --- a/docs/capabilities/blocks/blocks_payments.md +++ b/docs/capabilities/blocks/blocks_payments.md @@ -3,7 +3,7 @@ You can use the payments template to build your app or add payment functionality to an existing app. :::note -[Devvit Web](../../capabilities/devvit-web/devvit_web_overview.mdx) is the recommended approach for all interactive experiences. We recommend [migrating your app](../../earn-money/payments/payments_migrate.md) to Devvit Web payments. +[Devvit Web](../../capabilities/devvit-web/devvit_web_overview.mdx) is the recommended approach for all interactive experiences. We recommend [migrating your app](../../earn-money/payments/payments_migrate.mdx) to Devvit Web payments. ::: To start with a template, select the payments template when you create a new project or run: @@ -161,28 +161,28 @@ Errors thrown within the payment handler automatically reject the order. To prov This example shows how to issue an "extra life" to a user when they purchase the "extra_life" product. ```ts -import { type Context } from '@devvit/public-api'; -import { addPaymentHandler } from '@devvit/payments'; -import { Devvit, useState } from '@devvit/public-api'; +import { type Context } from "@devvit/public-api"; +import { addPaymentHandler } from "@devvit/payments"; +import { Devvit, useState } from "@devvit/public-api"; Devvit.configure({ redis: true, redditAPI: true, }); -const GOD_MODE_SKU = 'god_mode'; +const GOD_MODE_SKU = "god_mode"; addPaymentHandler({ fulfillOrder: async (order, ctx) => { if (!order.products.some(({ sku }) => sku === GOD_MODE_SKU)) { - throw new Error('Unable to fulfill order: sku not found'); + throw new Error("Unable to fulfill order: sku not found"); } - if (order.status !== 'PAID') { - throw new Error('Becoming a god has a cost (in Reddit Gold)'); + if (order.status !== "PAID") { + throw new Error("Becoming a god has a cost (in Reddit Gold)"); } const redisKey = godModeRedisKey(ctx.postId, ctx.userId); - await ctx.redis.set(redisKey, 'true'); + await ctx.redis.set(redisKey, "true"); }, }); ``` @@ -204,14 +204,14 @@ Your app can acknowledge or reject the order. For example, for goods with limite Use the `useProducts` hook or `getProducts` function to fetch details about products. ```tsx -import { useProducts } from '@devvit/payments'; +import { useProducts } from "@devvit/payments"; export function ProductsList(context: Devvit.Context): JSX.Element { // Only query for products with the metadata "category" of value "powerup". // The metadata field can be empty - if it is, useProducts will not filter on metadata. const { products } = useProducts(context, { metadata: { - category: 'powerup', + category: "powerup", }, }); diff --git a/docs/capabilities/blocks/optimize_performance.md b/docs/capabilities/blocks/optimize_performance.md index f2718aa..031c699 100644 --- a/docs/capabilities/blocks/optimize_performance.md +++ b/docs/capabilities/blocks/optimize_performance.md @@ -39,7 +39,7 @@ Use `context.cache` to reduce the amount of requests to optimize performance and ### Leverage scheduled jobs to fetch or update data -Use [scheduler](../server/scheduler.md) to make large data requests in the background and store it in [Redis](../server/redis.mdx) for later use. You can also [fetch data for multiple users](#how-to-cache-data). +Use [scheduler](../server/scheduler.mdx) to make large data requests in the background and store it in [Redis](../server/redis.mdx) for later use. You can also [fetch data for multiple users](#how-to-cache-data). ### Batch API calls to make parallel requests @@ -62,7 +62,7 @@ In Devvit, the first render happens on the server side. Parallel fetch requests In the render function of this interactive post, the app fetches data about the post, the user, the weather, and the leaderboard stats. ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; render: (context) => { const [postInfo] = useState(async () => { @@ -103,7 +103,7 @@ The main difference between these two methods is that `useState` blocks render u This is the best choice for performance because it allows you to render parts of your application while others may still be loading. Here’s how the same example looks for useAsync: ```tsx -import { Devvit, useAsync } from '@devvit/public-api'; +import { Devvit, useAsync } from "@devvit/public-api"; const { data: postInfo, loading: postInfoLoading } = useAsync(async () => { return await getThreadInfo(context); @@ -117,9 +117,11 @@ const { data: weather, loading: weatherLoading } = useAsync(async () => { return await getTheWeather(context); }); -const { data: leaderboardStats, loading: leaderboardStatsLoading } = useAsync(async () => { - return await getLeaderboard(context); -}); +const { data: leaderboardStats, loading: leaderboardStatsLoading } = useAsync( + async () => { + return await getLeaderboard(context); + }, +); ``` #### useState @@ -127,7 +129,7 @@ const { data: leaderboardStats, loading: leaderboardStatsLoading } = useAsync(as This is the same example using useState. ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; render: (context) => { const [appState, setAppState] = useState(async () => { @@ -162,11 +164,11 @@ If you need to update one of the state props, you’ll need to do `setAppState({ The following example shows how unoptimized code for fetching data from an external resource, like a weather API, looks: ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; // naive, non-optimal way of fetching that kind of data const [externalData] = useState(async () => { - const response = await fetch('https://external.weather.com'); + const response = await fetch("https://external.weather.com"); return await response.json(); }); @@ -183,19 +185,19 @@ You can use a cache helper to make one request for data, save the response, and **Example: fetch weather data every 2 hours with cache helper** ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; // optimized, performant way of fetching that kind of data const [externalData] = useState(async () => { return context.cache( async () => { - const response = await fetch('https://external.weather.com'); + const response = await fetch("https://external.weather.com"); return await response.json(); }, { key: `weather_data`, ttl: 2 * 60 * 60 * 1000, // 2 hours in milliseconds - } + }, ); }); ``` @@ -206,35 +208,35 @@ Do not cache sensitive information. Cache helper randomly selects one user to ma ### Solution: schedule a job -Alternatively, you can use [scheduler](../server/scheduler.md) to make the request in background, save the response to [Redis](../server/redis.mdx), and avoid unnecessary requests to the external resource. +Alternatively, you can use [scheduler](../server/scheduler.mdx) to make the request in background, save the response to [Redis](../server/redis.mdx), and avoid unnecessary requests to the external resource. **Example: fetch weather data every 2 hours with a scheduled job** ```tsx -import { Devvit } from '@devvit/public-api'; +import { Devvit } from "@devvit/public-api"; Devvit.addSchedulerJob({ - name: 'fetch_weather_data', + name: "fetch_weather_data", onRun: async (_event, context) => { - const response = await fetch('https://external.weather.com'); + const response = await fetch("https://external.weather.com"); const responseData = await response.json(); - await context.redis.set('weather_data', JSON.stringify(responseData)); + await context.redis.set("weather_data", JSON.stringify(responseData)); }, }); Devvit.addTrigger({ - event: 'AppInstall', + event: "AppInstall", onEvent: async (_event, context) => { await context.scheduler.runJob({ - cron: '0 */2 * * *', // runs at the top of every second hour - name: 'fetch_weather_data', + cron: "0 */2 * * *", // runs at the top of every second hour + name: "fetch_weather_data", }); }, }); // inside the render method const [externalData] = useState(async () => { - return context.redis.get('fetch_weather_data'); + return context.redis.get("fetch_weather_data"); }); export default Devvit; @@ -254,9 +256,9 @@ Before using realtime, the leaderboard fetching code looked like this: ```tsx const getLeaderboard = async () => - await context.redis.zRange('leaderboard', 0, 5, { + await context.redis.zRange("leaderboard", 0, 5, { reverse: true, - by: 'rank', + by: "rank", }); const [leaderboard, setLeaderboard] = useState(async () => { @@ -274,7 +276,7 @@ leaderboardInterval.start(); And code for updating the leaderboard looked like this: ```tsx -await context.redis.zAdd('leaderboard', { member: username, score: gameScore }); +await context.redis.zAdd("leaderboard", { member: username, score: gameScore }); ``` ### With realtime​ @@ -285,9 +287,12 @@ This is the updated game completion code: ```tsx // stays as is -await context.redis.zAdd('leaderboard', { member: username, score: gameScore }); +await context.redis.zAdd("leaderboard", { member: username, score: gameScore }); // new code -context.realtime.send('leaderboard_updates', { member: username, score: gameScore }); +context.realtime.send("leaderboard_updates", { + member: username, + score: gameScore, +}); ``` Now replace the interval with the realtime subscription: @@ -298,7 +303,7 @@ const [leaderboard, setLeaderboard] = useState(async () => { }); // stays as is const channel = useChannel({ - name: 'leaderboard_updates', + name: "leaderboard_updates", onMessage: (newLeaderboardEntry) => { const newLeaderboard = [...leaderboard, newLeaderboardEntry] // append new entry .sort((a, b) => b.score - a.score) // sort by score @@ -342,9 +347,12 @@ To do this, you can add: ```tsx const [subscriberCount] = useState(async () => { const startSubscribersRequest = Date.now(); // a reference point for the request start - const devvitSubredditInfo = await context.reddit.getSubredditInfoByName('devvit'); + const devvitSubredditInfo = + await context.reddit.getSubredditInfoByName("devvit"); - console.log(`subscribers request took: ${Date.now() - startSubscribersRequest} milliseconds`); + console.log( + `subscribers request took: ${Date.now() - startSubscribersRequest} milliseconds`, + ); return devvitSubredditInfo.subscribersCount || 0; }); @@ -359,7 +367,9 @@ const [performanceStartRender] = useState(Date.now()); // a reference point for Add a console.log before the return statement: ```tsx -console.log(`Getting the data took: ${Date.now() - performanceStartRender} milliseconds`); +console.log( + `Getting the data took: ${Date.now() - performanceStartRender} milliseconds`, +); ``` All of that put together will look like this: diff --git a/docs/capabilities/client/forms.mdx b/docs/capabilities/client/forms.mdx index 4298196..e70f868 100644 --- a/docs/capabilities/client/forms.mdx +++ b/docs/capabilities/client/forms.mdx @@ -170,6 +170,78 @@ For forms that open from a menu item, you can use menu responses. This is useful ``` **Server endpoint that shows form via menu response:** + + + + + ```ts title="server/index.ts" + // Menu action that triggers menu response form + app.post('/internal/menu/start-workflow', async (c) => { + // Server processing before showing form + const userData = await fetchUserData(); + + return c.json({ + showForm: { + name: 'nameForm', + form: { + fields: [ + { + type: 'string', + name: 'name', + label: 'Name', + }, + ], + }, + data: { name: userData.name } // Pre-populate from server + } + }); + }); + + // Form submission handler that can chain to another form + app.post('/internal/form/name-submit', async (c) => { + const { name } = await c.req.json(); + + // Server processing + await saveUserName(name); + + // Show next form in workflow + return c.json({ + showForm: { + name: 'reviewForm', + form: { + fields: [ + { + type: 'paragraph', + name: 'review', + label: 'How was your experience?', + }, + ], + } + } + }); + }); + + app.post('/internal/form/review-submit', async (c) => { + const { review } = await c.req.json(); + + await saveReview(review); + + return c.json({ + showToast: 'Thank you for your feedback!' + }); + }); + ``` + + + + ```ts title="server/index.ts" import { UIResponse } from '@devvit/web/shared'; @@ -230,6 +302,9 @@ For forms that open from a menu item, you can use menu responses. This is useful }); ``` + + + @@ -591,6 +666,53 @@ Below is a collection of common use cases and patterns. } ``` + + + + ```ts title="server/index.ts" + // Endpoint that shows form with dynamic data + app.post('/internal/menu/show-dynamic-form', async (c) => { + const user = await reddit.getCurrentUser(); + + return c.json({ + showForm: { + name: 'dynamicForm', + form: { + fields: [ + { + type: 'string', + name: 'username', + label: 'Username', + }, + ], + }, + data: { + username: user?.username || '' + } + } + }); + }); + + // Form submission handler + app.post('/internal/form/dynamic-submit', async (c) => { + const { username } = await c.req.json(); + + return c.json({ + showToast: `Hello ${username}` + }); + }); + ``` + + + + ```ts title="server/index.ts" // Endpoint that shows form with dynamic data router.post("/internal/menu/show-dynamic-form", async (_req, res: Response) => { @@ -624,6 +746,9 @@ Below is a collection of common use cases and patterns. }); }); ``` + + + ```tsx @@ -753,6 +878,74 @@ Below is a collection of common use cases and patterns. } ``` + + + + ```ts title="server/index.ts" + // Step 1: Name form + app.post('/internal/form/step1-submit', async (c) => { + const { name } = await c.req.json(); + + return c.json({ + showForm: { + name: 'step2Form', + form: { + fields: [ + { + type: 'string', + name: 'food', + label: "What's your favorite food?", + required: true, + }, + ], + }, + data: { name } // Pass data to next step + } + }); + }); + + // Step 2: Food form + app.post('/internal/form/step2-submit', async (c) => { + const { name, food } = await c.req.json(); + + return c.json({ + showForm: { + name: 'step3Form', + form: { + fields: [ + { + type: 'string', + name: 'drink', + label: "What's your favorite drink?", + required: true, + }, + ], + }, + data: { name, food } // Pass accumulated data + } + }); + }); + + // Step 3: Final form + app.post('/internal/form/step3-submit', async (c) => { + const { name, food, drink } = await c.req.json(); + + return c.json({ + showToast: `Thanks ${name}! You like ${food} and ${drink}.` + }); + }); + ``` + + + + ```ts title="server/index.ts" // Step 1: Name form router.post("/internal/form/step1-submit", async (req, res: Response) => { @@ -807,6 +1000,9 @@ Below is a collection of common use cases and patterns. }); }); ``` + + + ```tsx @@ -979,6 +1175,87 @@ This example includes one of each of the [supported field types](#supported-fiel } ``` + + + + ```ts title="server/index.ts" + app.post('/internal/form/everything-submit', async (c) => { + const formValues = await c.req.json(); + console.log('Form values:', formValues); + + return c.json({ + showToast: 'Thanks!' + }); + }); + + // Example showing the form + app.post('/internal/menu/show-everything-form', async (c) => { + return c.json({ + showForm: { + name: 'everythingForm', + form: { + title: 'My favorites', + description: 'Tell us about your favorite food!', + fields: [ + { + type: 'string', + name: 'food', + label: 'What is your favorite food?', + helpText: 'Must be edible', + required: true, + }, + { + label: 'About that food', + type: 'group', + fields: [ + { + type: 'number', + name: 'times', + label: 'How many times a week do you eat it?', + defaultValue: 1, + }, + { + type: 'paragraph', + name: 'what', + label: 'What makes it your favorite?', + }, + { + type: 'select', + name: 'healthy', + label: 'Is it healthy?', + options: [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + { label: 'Maybe', value: 'maybe' }, + ], + defaultValue: ['maybe'], + }, + ], + }, + { + type: 'boolean', + name: 'again', + label: 'Can we ask again?', + }, + ], + acceptLabel: 'Submit', + cancelLabel: 'Cancel', + } + } + }); + }); + ``` + + + + ```ts title="server/index.ts" router.post("/internal/form/everything-submit", async (req, res: Response) => { console.log('Form values:', req.body); @@ -1045,6 +1322,9 @@ This example includes one of each of the [supported field types](#supported-fiel }); }); ``` + + + ```tsx @@ -1161,6 +1441,31 @@ This example includes one of each of the [supported field types](#supported-fiel } ``` + + + + ```ts title="server/index.ts" + app.post('/internal/form/image-submit', async (c) => { + const { myImage } = await c.req.json(); + // Use the mediaUrl to store in redis and display it in an block, or send to external service to modify + console.log('Image uploaded:', myImage); + + return c.json({ + showToast: 'Image uploaded successfully!' + }); + }); + ``` + + + + ```ts title="server/index.ts" router.post("/internal/form/image-submit", async (req, res: Response) => { const { myImage } = req.body; @@ -1172,6 +1477,9 @@ This example includes one of each of the [supported field types](#supported-fiel }); }); ``` + + + ```tsx diff --git a/docs/capabilities/client/menu-actions.mdx b/docs/capabilities/client/menu-actions.mdx index d178533..eaaf0bd 100644 --- a/docs/capabilities/client/menu-actions.mdx +++ b/docs/capabilities/client/menu-actions.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # Menu Actions @@ -16,30 +16,55 @@ Add an item to the three dot menu for posts, comments, or subreddits. Menu actio **Menu items defined in devvit.json:** - ```json title="devvit.json" - { - "menu": { - "items": [ - { - "description": "Show user information", - "endpoint": "/internal/menu/show-info", - "location": "post" - } - ] - } +```json title="devvit.json" +{ + "menu": { + "items": [ + { + "description": "Show user information", + "endpoint": "/internal/menu/show-info", + "location": "post" + } + ] } - ``` +} +``` - **Simple endpoint with direct client effects:** +**Simple endpoint with direct client effects:** + + + + +```ts title="server/index.ts" +app.post("/internal/menu/show-info", async (c) => { + // Simple actions don't need server processing + return c.json({ + showToast: "Menu action clicked!", + }); +}); +``` - ```ts title="server/index.ts" - router.post("/internal/menu/show-info", async (_req, res) => { - // Simple actions don't need server processing - res.json({ - showToast: 'Menu action clicked!' - }); + + + +```ts title="server/index.ts" +app.post("/internal/menu/show-info", async (_req, res) => { + // Simple actions don't need server processing + res.json({ + showToast: "Menu action clicked!", }); - ``` +}); +``` + + + @@ -47,44 +72,45 @@ Add an item to the three dot menu for posts, comments, or subreddits. Menu actio ```tsx import { Devvit } from '@devvit/public-api'; - // Simple menu action with direct client effects - Devvit.addMenuItem({ - label: 'Show user info', - location: 'post', // 'post', 'comment', 'subreddit', or array - onPress: async (event, context) => { - // Direct client effect - no server processing needed - context.ui.showToast('Menu action clicked!'); - }, - }); +// Simple menu action with direct client effects +Devvit.addMenuItem({ +label: 'Show user info', +location: 'post', // 'post', 'comment', 'subreddit', or array +onPress: async (event, context) => { +// Direct client effect - no server processing needed + context.ui.showToast('Menu action clicked!'); +}, +}); - // Menu action with form - const surveyForm = Devvit.createForm( - { - fields: [ - { - type: 'string', - name: 'feedback', - label: 'Your feedback', - }, - ], - }, - (event, context) => { - // onSubmit handler - context.ui.showToast({ text: `Thanks for the feedback: ${event.values.feedback}` }); - } - ); - - Devvit.addMenuItem({ - label: 'Quick survey', - location: 'subreddit', - forUserType: 'moderator', // Optional: restrict to moderators - onPress: async (event, context) => { - context.ui.showForm(surveyForm); - }, - }); - ``` +// Menu action with form +const surveyForm = Devvit.createForm( +{ +fields: [ +{ +type: 'string', +name: 'feedback', +label: 'Your feedback', +}, +], +}, +(event, context) => { +// onSubmit handler +context.ui.showToast({ text: `Thanks for the feedback: ${event.values.feedback}` }); +} +); + +Devvit.addMenuItem({ +label: 'Quick survey', +location: 'subreddit', +forUserType: 'moderator', // Optional: restrict to moderators +onPress: async (event, context) => { +context.ui.showForm(surveyForm); +}, +}); - +```` + + ## Supported Contexts @@ -106,89 +132,142 @@ For moderator permission security, when opening a form from a menu action with ` In Devvit Web, your menu item should respond with a client side effect to give feedback to users. This is available as a UIResponse as you do not have access to the `@devvit/web/client` library from your server endpoints. - - - **Menu items with server processing:** - - ```json title="devvit.json" - { - "menu": { - "items": [ - { - "label": "Process and validate data", - "endpoint": "/internal/menu/complex-action", - "forUserType": "moderator", - "location": "subreddit" - } - ] - } + + +**Menu items with server processing:** + +```json title="devvit.json" +{ + "menu": { + "items": [ + { + "label": "Process and validate data", + "endpoint": "/internal/menu/complex-action", + "forUserType": "moderator", + "location": "subreddit" + } + ] } - ``` +} +```` + + + + +```ts title="server/index.ts" +app.post("/internal/menu/complex-action", async (c) => { + try { + // Perform server-side processing + const userData = await validateAndProcessData(); + + // Show form with server-fetched data + return c.json({ + showForm: { + name: "processForm", + form: { + fields: [ + { + type: "string", + name: "processedData", + label: "Processed Data", + }, + ], + }, + data: { processedData: userData.processed }, + }, + }); + } catch (error) { + return c.json({ + showToast: "Processing failed. Please try again.", + }); + } +}); +``` - ```ts title="server/index.ts" - import { UIResponse } from '@devvit/web/shared'; - - router.post("/internal/menu/complex-action", async (_req, res: Response) => { + + + +```ts title="server/index.ts" +import { UIResponse } from "@devvit/web/shared"; + +app.post( + "/internal/menu/complex-action", + async (_req, res: Response) => { try { // Perform server-side processing const userData = await validateAndProcessData(); - + // Show form with server-fetched data res.json({ showForm: { - name: 'processForm', + name: "processForm", form: { fields: [ { - type: 'string', - name: 'processedData', - label: 'Processed Data', + type: "string", + name: "processedData", + label: "Processed Data", }, ], }, - data: { processedData: userData.processed } - } + data: { processedData: userData.processed }, + }, }); } catch (error) { res.json({ - showToast: 'Processing failed. Please try again.' + showToast: "Processing failed. Please try again.", }); } - }); - ``` + }, +); +``` + + + For Devvit Blocks, use the direct context approach even for complex workflows: - ```tsx - Devvit.addMenuItem({ - label: 'Process and validate data', - location: 'post', // 'post', 'comment', 'subreddit', or array - forUserType: 'moderator', // Optional: restrict to moderators - onPress: async (event, context) => { - try { - // Perform server-side processing - const userData = await validateAndProcessData(); - - // Show form with server-fetched data - const result = await context.ui.showForm({ +```tsx +Devvit.addMenuItem({ + label: "Process and validate data", + location: "post", // 'post', 'comment', 'subreddit', or array + forUserType: "moderator", // Optional: restrict to moderators + onPress: async (event, context) => { + try { + // Perform server-side processing + const userData = await validateAndProcessData(); + + // Show form with server-fetched data + const result = await context.ui.showForm( + { fields: [ { - type: 'string', - name: 'processedData', - label: 'Processed Data', + type: "string", + name: "processedData", + label: "Processed Data", }, ], - }, (values) => { + }, + (values) => { context.ui.showToast(`Processed: ${values.processedData}`); - }); - } catch (error) { - context.ui.showToast('Processing failed. Please try again.'); - } - }, - }); - ``` + }, + ); + } catch (error) { + context.ui.showToast("Processing failed. Please try again."); + } + }, +}); +``` + @@ -197,27 +276,100 @@ In Devvit Web, your menu item should respond with a client side effect to give f Menu responses can trigger any client effect after server processing: **Show toast after processing:** + + + + +```ts +return c.json({ + showToast: { + text: "Processing completed!", + appearance: "success", + }, +}); +``` + + + + ```ts res.json({ showToast: { - text: 'Processing completed!', - appearance: 'success' - } + text: "Processing completed!", + appearance: "success", + }, }); ``` + + + **Navigate after data fetching:** + + + + +```ts +const post = await reddit.getPostById(postId); +return c.json({ + navigateTo: post, +}); +``` + + + + ```ts const post = await reddit.getPostById(postId); res.json({ - navigateTo: post + navigateTo: post, }); ``` + + + **Chain multiple forms:** + + + + ```ts +// First form response leads to second form +return c.json({ + showForm: { + name: 'secondForm', + form: { fields: [...] }, + data: { fromStep1: processedData } + } +}); +``` + + +```ts // First form response leads to second form res.json({ showForm: { @@ -228,6 +380,9 @@ res.json({ }); ``` + + + ## Limitations - A sort order of actions in the context menu can't be specified. diff --git a/docs/capabilities/devvit-web/devvit_web_configuration.md b/docs/capabilities/devvit-web/devvit_web_configuration.md index 4e81e89..d4f3526 100644 --- a/docs/capabilities/devvit-web/devvit_web_configuration.md +++ b/docs/capabilities/devvit-web/devvit_web_configuration.md @@ -1,6 +1,6 @@ # Configure Your App -The devvit.json file serves as your app's configuration file. Use it to specify entry points, configure features like [event triggers](../server/triggers) and [scheduled actions](../server/scheduler.md), and enable app functionality such as [image uploads](../server/media-uploads.mdx). This page covers all available devvit.json configuration options. A complete devvit.json example file is provided [here](#complete-example). +The devvit.json file serves as your app's configuration file. Use it to specify entry points, configure features like [event triggers](../server/triggers) and [scheduled actions](../server/scheduler.mdx), and enable app functionality such as [image uploads](../server/media-uploads.mdx). This page covers all available devvit.json configuration options. A complete devvit.json example file is provided [here](#complete-example). ## devvit.json @@ -67,9 +67,10 @@ Additionally, you must include at least one of: ### Development -| Property | Type | Description | Required | -| -------- | ------ | ------------------------- | -------- | -| `dev` | object | Development configuration | No | +| Property | Type | Description | Required | +| --------- | ------ | --------------------------------------------------- | -------- | +| `dev` | object | Development configuration | No | +| `scripts` | object | Build commands run by the Devvit CLI (optional) | No | ## Detailed configuration @@ -281,6 +282,24 @@ Configure app presentation: - `icon` (string): Path to 1024x1024 PNG icon (required) +### Scripts configuration + +Configure build commands run by the Devvit CLI. These commands run relative to the `devvit.json` directory. + +```json +{ + "scripts": { + "dev": "vite build --watch", + "build": "vite build" + } +} +``` + +**Properties:** + +- `dev` (string): Command run by `devvit playtest` to build or watch your client/server artifacts +- `build` (string): Command run by `devvit upload` to build your client/server artifacts + ### Development configuration Configure development settings: @@ -384,6 +403,10 @@ The `devvit.json` configuration is validated against the JSON Schema at build ti }, "dev": { "subreddit": "my-test-sub" + }, + "scripts": { + "dev": "vite build --watch", + "build": "vite build" } } ``` diff --git a/docs/capabilities/devvit-web/devvit_web_overview.mdx b/docs/capabilities/devvit-web/devvit_web_overview.mdx index 90edf5b..9da13ae 100644 --- a/docs/capabilities/devvit-web/devvit_web_overview.mdx +++ b/docs/capabilities/devvit-web/devvit_web_overview.mdx @@ -1,4 +1,4 @@ -import DevvitWebArch from '../../assets/devvit_web/devvit_web_arch.png'; +import DevvitWebArch from "../../assets/devvit_web/devvit_web_arch.png"; # Devvit Web @@ -9,7 +9,7 @@ Devvit Web includes an easy way to build Devvit apps using a standard web stack. Devvit Web allows developers to build Devvit apps just like you would for the web. At the core, Devvit Web provides: - **A standard web app** that allows you to build with industry-standard frameworks and technologies (like React, Three.js, or Phaser). -- **Server endpoints** that you define to communicate between the webview client and the Devvit server, using industry-standard frameworks and technologies (like Express.js, Koa, etc.). +- **Server endpoints** that you define to communicate between the webview client and the Devvit server, using industry-standard frameworks and technologies (like Express.js, Hono, Koa, etc.). - **Devvit configuration** with a traditional client/server split. Devvit capabilities are now in one of three places: - A configuration file in devvit.json for defining app metadata, permissions, and capabilities - Client capabilities in the @devvit/client SDK @@ -23,21 +23,21 @@ In addition, since you’re working with standard web technologies your apps sho Visit [https://developers.reddit.com/new](https://developers.reddit.com/new) and choose one of our templates or take a look at the github repositories: -* [React](https://github.com/reddit/devvit-template-react) -* [Phaser](https://github.com/reddit/devvit-template-phaser) -* [Three.js](https://github.com/reddit/devvit-template-threejs) -* [Hello World](https://github.com/reddit/devvit-template-hello-world) +- [React](https://github.com/reddit/devvit-template-react) +- [Phaser](https://github.com/reddit/devvit-template-phaser) +- [Three.js](https://github.com/reddit/devvit-template-threejs) +- [Hello World](https://github.com/reddit/devvit-template-hello-world) ## Limitations As with most experimental features, there are some caveats. -| Limitation | What it means | -| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Serverless endpoints | The node server will run just long enough to execute your endpoint function and return a response, which means you can’t use packages that require long-running connections like streaming. | +| Limitation | What it means | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Serverless endpoints | The node server will run just long enough to execute your endpoint function and return a response, which means you can’t use packages that require long-running connections like streaming. | | Package limitations | Developers cannot use `fs` or external native packages. For now, we recommend using external services over the native dependencies, such as [StreamPot](https://streampot.io/) (instead of ffmpeg) and [OpenAI](https://platform.openai.com/docs/guides/embeddings) (instead of @xenova/transformers) . | -| Single request and single response handling only | Streaming or chunked responses and websockets are not supported. Long-polling is supported if it’s under the max request time. | -| No external requests from your client | You can’t have any external requests other than the app's webview domain. All backend responses are locked down to the webview domain via CSP. (Your backend can make external fetch requests though.) | +| Single request and single response handling only | Streaming or chunked responses and websockets are not supported. Long-polling is supported if it’s under the max request time. | +| No external requests from your client | You can’t have any external requests other than the app's webview domain. All backend responses are locked down to the webview domain via CSP. (Your backend can make external fetch requests though.) | Devvit Web still has the same technical requirements: @@ -47,7 +47,6 @@ Devvit Web still has the same technical requirements: - Max response size: 10MB - HTML/CSS/JS only - ## Devvit Web components Devvit Web uses endpoints between the client and server to make communication similar to standard web apps. A Devvit Web app has three components: @@ -58,11 +57,12 @@ Devvit Web uses endpoints between the client and server to make communication si Devvit Web templates all have the same file structure: -```tsx -- src - - client / // contains the webview code - - server / // endpoints for the client -- devvit.json; // the devvit config file +```text +. +├── src/ +│ ├── client/ # contains the webview code +│ └── server/ # endpoints for the client +└── devvit.json # the devvit config file ``` Now, instead of passing messages with postMessage (old way), you’ll define `/api/endpoints` (new way). @@ -75,7 +75,7 @@ When you want to make server-side calls, or use server-side capabilities, you’ ### Server folder -This folder includes server-side code. We provide a node server, and you can use typical node server frameworks like Koa or Express. This is where you can access key capabilities like [Redis](../server/redis.mdx), Reddit API client, and [fetch](../server/http-fetch.mdx). +This folder includes server-side code. We provide a node server, and you can use typical node server frameworks like Hono, Koa, or Express. This is where you can access key capabilities like [Redis](../server/redis.mdx), Reddit API client, and [fetch](../server/http-fetch.mdx). We also provide an authentication middleware so you don’t have to worry about authentication. @@ -83,7 +83,11 @@ We also provide an authentication middleware so you don’t have to worry about All server endpoints must start with `/api/` (e.g. `/api/get-something` or `/api/widgets/42`). ::: -devvit web architecture +devvit web architecture ### Configuration in `devvit.json` diff --git a/docs/capabilities/server/cache-helper.mdx b/docs/capabilities/server/cache-helper.mdx index 508eba6..ecd4065 100644 --- a/docs/capabilities/server/cache-helper.mdx +++ b/docs/capabilities/server/cache-helper.mdx @@ -63,6 +63,74 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t + + + + ```tsx title="server/index.ts" + import { Hono } from 'hono'; + import { cache, context, createServer, getServerPort, reddit } from '@devvit/web/server'; + + const app = new Hono(); + + app.get('/api/subreddit', async (c) => { + const { postId } = context; + + if (!postId) { + console.error('API Subreddit Error: postId not found in devvit context'); + return c.json( + { + status: 'error', + message: 'postId is required but missing from context', + }, + 400 + ); + } + + try { + const subredditName = await cache( + async () => { + const subreddit = await reddit.getCurrentSubreddit(); + if (!subreddit) { + throw new Error('Subreddit is required but missing from context'); + } + return subreddit.name; + }, + { + key: 'current_subreddit', + ttl: 24 * 60 * 60, // expire after one day. + } + ); + console.log(`Current subreddit: ${subredditName}`); + + return c.json({ + type: 'subreddit', + subreddit: subredditName, + }); + } catch (error) { + console.error(`API Subreddit Error for post ${postId}:`, error); + let errorMessage = 'Unknown error during subreddit retrieval'; + if (error instanceof Error) { + errorMessage = `Subreddit retrieval failed: ${error.message}`; + } + return c.json({ status: 'error', message: errorMessage }, 400); + } + }); + + const server = createServer(app); + server.on('error', (err) => console.error(`server error; ${err.stack}`)); + server.listen(getServerPort()); + ``` + + + + ```tsx title="server/index.ts" import express from "express"; import { @@ -138,6 +206,9 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t server.on("error", (err) => console.error(`server error; ${err.stack}`)); server.listen(getServerPort()); ``` + + + diff --git a/docs/capabilities/server/http-fetch.mdx b/docs/capabilities/server/http-fetch.mdx index 3b9463e..3ea13c2 100644 --- a/docs/capabilities/server/http-fetch.mdx +++ b/docs/capabilities/server/http-fetch.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # HTTP Fetch @@ -27,13 +27,13 @@ Your Devvit app can make network requests to access allow-listed external domain ```ts import { Devvit } from '@devvit/public-api'; - Devvit.configure({ - http: { - domains: ['my-site.com', 'another-domain.net'], - }, - }); +Devvit.configure({ +http: { +domains: ['my-site.com', 'another-domain.net'], +}, +}); -``` +```` @@ -66,13 +66,13 @@ Apps must request each individual domain that it intends to fetch, even if the d - + Devvit Web applications have two different contexts for using fetch: - + ### Server-side fetch - + Server-side fetch allows your app to make HTTP requests to allowlisted external domains from your server-side code (e.g., API routes, server actions): - + ```tsx title="server/index.ts" const response = await fetch('https://example.com/api/data', { method: 'GET', @@ -80,42 +80,43 @@ Apps must request each individual domain that it intends to fetch, even if the d 'Content-Type': 'application/json', }, }); - + const data = await response.json(); console.log('External API response:', data); - ``` - - ### Client-side fetch - - Client-side fetch has different restrictions and can only make requests to your own webview domain: - - **Client-side restrictions:** - - **Domain limitation**: Can only make requests to your own webview domain - - **Endpoint requirement**: All requests must target endpoints that end with `/api` - - **Authentication**: Handled automatically - no need to manage auth tokens - - **No external domains**: Cannot make requests to external domains from client-side code - +```` + +### Client-side fetch + +Client-side fetch has different restrictions and can only make requests to your own webview domain: + +**Client-side restrictions:** + +- **Domain limitation**: Can only make requests to your own webview domain +- **Endpoint requirement**: All requests must target endpoints that end with `/api` +- **Authentication**: Handled automatically - no need to manage auth tokens +- **No external domains**: Cannot make requests to external domains from client-side code + ```tsx title="client/index.ts" - const handleFetchData = async () => { - // ✅ Correct: Fetching your own webview's API endpoint - const response = await fetch('/api/user-data', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - console.log('API response:', data); - }; - - // ❌ Incorrect: Cannot fetch external domains from client-side - // const response = await fetch('https://external-api.com/data'); - - // ❌ Incorrect: Endpoint must end with /api - // const response = await fetch('/user-data'); - ``` - +const handleFetchData = async () => { + // ✅ Correct: Fetching your own webview's API endpoint + const response = await fetch("/api/user-data", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + console.log("API response:", data); +}; + +// ❌ Incorrect: Cannot fetch external domains from client-side +// const response = await fetch('https://external-api.com/data'); + +// ❌ Incorrect: Endpoint must end with /api +// const response = await fetch('/user-data'); +``` + @@ -158,7 +159,7 @@ Apps must request each individual domain that it intends to fetch, even if the d If you see the following error, it means HTTP Fetch requests are hitting the internal timeout limits. To resolve this: -- Use a queue or kick off an async request in your back end. You can use [Scheduler](./scheduler.md) to monitor the result. +- Use a queue or kick off an async request in your back end. You can use [Scheduler](./scheduler.mdx) to monitor the result. - Optimize the overall HTTP request latency if you have a self-hosted server. ```ts @@ -208,4 +209,3 @@ The following domains are globally allowed and can be fetched by any app: - pbs.org - i.giphy.com - chessboardjs.com - diff --git a/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md b/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md index 5b9eee5..6d68a0b 100644 --- a/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md +++ b/docs/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md @@ -20,7 +20,7 @@ Devvit apps support two view modes: ## Multiple Entry Points -Multiple entry points let the user start the game from different contexts or states. For example, you can have a button that launches into a leaderboard view and another for a specific game mode, each of these would be configured as an entry point for your app. You can define multiple entry points in your `devvit.json` and `src/client/vite.config.ts` to create different experiences: +Multiple entry points let the user start the game from different contexts or states. For example, you can have a button that launches into a leaderboard view and another for a specific game mode, each of these would be configured as an entry point for your app. Define multiple entry points in your `devvit.json`. If you use the [Devvit Vite plugin](../../../guides/tools/vite), it automatically infers the client build inputs from these entrypoints, so you don't need to maintain a custom Rollup `input` list. ```js title="devvit.json" { @@ -28,15 +28,15 @@ Multiple entry points let the user start the game from different contexts or sta "dir": "dist/client", "entrypoints": { "default": { - "entry": "preview.html", + "entry": "src/client/preview.html", "height": "regular", - "inline": true + "inline": true }, "game": { - "entry": "game.html" + "entry": "src/client/game.html" }, "leaderboard": { - "entry": "leaderboard.html" + "entry": "src/client/leaderboard.html" } } } @@ -44,32 +44,13 @@ Multiple entry points let the user start the game from different contexts or sta ``` ```ts title="vite.config.ts" -import { defineConfig } from 'vite'; -import tailwind from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwind from "@tailwindcss/vite"; +import { devvit } from "@devvit/start/vite"; -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), tailwind()], - build: { - outDir: '../../dist/client', - sourcemap: true, - rollupOptions: { - input: { - default: resolve(dirname(fileURLToPath(import.meta.url)), 'preview.html'), - game: resolve(dirname(fileURLToPath(import.meta.url)), 'game.html'), - leaderboard: resolve(dirname(fileURLToPath(import.meta.url)), 'leaderboard.html'), - }, - output: { - entryFileNames: '[name].js', - chunkFileNames: '[name].js', - assetFileNames: '[name][extname]', - sourcemapFileNames: '[name].js.map', - }, - }, - }, + plugins: [react(), tailwind(), devvit()], }); ``` @@ -78,6 +59,7 @@ export default defineConfig({ ```tsx your-app/ ├── devvit.json +├── vite.config.ts ├── src/ │ ├── server/ │ │ └── index.ts @@ -94,7 +76,7 @@ your-app/ └── styles.css ``` -The `dir` property specifies where your built client files are located. During development, your build process (e.g., Vite, webpack) typically compiles files from `src/client/` to `dist/client/`. The entry paths are relative to this `dir` location. +The `dir` property specifies where your built client files are located. With the Devvit Vite plugin, the `entry` values point at your source HTML files (for example `src/client/preview.html`), and the plugin outputs the matching files into `dist/client` during `vite build`. ### Creating Posts with Specific Entry Points diff --git a/docs/capabilities/server/overview.md b/docs/capabilities/server/overview.md index a36be05..d8d1997 100644 --- a/docs/capabilities/server/overview.md +++ b/docs/capabilities/server/overview.md @@ -20,7 +20,7 @@ Allows you to query information from Reddit such as comments, posts and upvotes. Allows you to store app data in a scalable database, free of charge. Limited to the installation scope of the application. -## [Scheduler](./scheduler.md) +## [Scheduler](./scheduler.mdx) Allows you to run automated server-side tasks on a schedule, for example, checking for updates every hour. @@ -32,7 +32,7 @@ Allows you to build an app where the moderator can store secret keys in a safe a Allows you to run automated server-side tasks when certain events happen on Reddit, for example: when a new post is created, or when a new comment is created. -## [User actions](./userActions.md) +## [User actions](./userActions.mdx) Allows you to execute some actions, like posting or commenting, on behalf of the user. This means that these new posts or comments will not show up as created by the app, but by the user that is currently using the app. Access to this feature is subject to review by Admins. diff --git a/docs/capabilities/server/post-data.mdx b/docs/capabilities/server/post-data.mdx index 1bb20f5..d92ba4c 100644 --- a/docs/capabilities/server/post-data.mdx +++ b/docs/capabilities/server/post-data.mdx @@ -19,6 +19,60 @@ When creating a post, include the `postData` parameter with your custom data obj + + + + ```ts title="server/index.ts" + import { context, reddit } from '@devvit/web/server'; + + app.post('/api/create-post', async (c) => { + const { subredditName } = context; + + if (!subredditName) { + return c.json({ error: 'Subreddit name is required' }, 400); + } + + const post = await reddit.submitCustomPost({ + subredditName, + title: 'Post with custom data', + entry: 'default', + postData: { + challengeNumber: 42, + totalGuesses: 0, + gameState: 'active', + pixels: [ + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] + ], + }, + }); + + return c.json({ + postId: post.id, + message: 'Post created successfully', + }); + }); + ``` + + + + ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; @@ -61,6 +115,9 @@ When creating a post, include the `postData` parameter with your custom data obj }); }); ``` + + + ```ts title="main.tsx" @@ -99,6 +156,54 @@ To update post data after creation, fetch the post and use the `setPostData()` m + + + + ```ts title="server/index.ts" + import { context, reddit } from '@devvit/web/server'; + + app.post('/api/update-post-data', async (c) => { + const { postId } = context; + const { favoriteColor, username } = await c.req.json(); + + if (!postId) { + return c.json({ error: 'Post ID is required' }, 400); + } + + try { + const post = await reddit.getPostById(postId); + + // Get existing post data to merge with updates + const currentData = context.postData || {}; + + await post.setPostData({ + ...currentData, + favoriteColor: favoriteColor || 'unknown', + lastUpdatedBy: username || 'anonymous', + lastUpdatedAt: new Date().toISOString(), + }); + + return c.json({ + success: true, + message: 'Post data updated successfully', + }); + } catch (error) { + console.error('Error updating post data:', error); + return c.json({ error: 'Failed to update post data' }, 500); + } + }); + ``` + + + + ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; @@ -137,6 +242,9 @@ To update post data after creation, fetch the post and use the `setPostData()` m } }); ``` + + + ```ts title="main.tsx" diff --git a/docs/capabilities/server/redis.mdx b/docs/capabilities/server/redis.mdx index 2796c21..bb806b1 100644 --- a/docs/capabilities/server/redis.mdx +++ b/docs/capabilities/server/redis.mdx @@ -44,16 +44,45 @@ All limits are applied at a per-installation granularity. ] } ``` + + + ```ts title="server/index.ts" - // Assumes Express.js import { redis } from '@devvit/redis'; + + app.post('/internal/menu/redis-test', async (c) => { + const key = 'hello'; + await redis.set(key, 'world'); + const value = await redis.get(key); + console.log(`${key}: ${value}`); + return c.json({ status: 'ok' }); + }); + ``` + + + + + ```ts title="server/index.ts" + import { redis } from '@devvit/redis'; + router.post("/internal/menu/redis-test", async (_req, res: Response) => { const key = 'hello'; await redis.set(key, 'world'); const value = await redis.get(key); console.log(`${key}: ${value}`); + res.json({ status: 'ok' }); }); ``` + + + ```ts @@ -916,7 +945,174 @@ Register your form handler, menu trigger, and scheduler endpoint here. } ``` -Add these route handlers to your Express app. +Add these route handlers to your server. + + + + +```ts +import { redis, scheduler } from '@devvit/web/server'; +// Import the compressed client +import { redisCompressed } from '@devvit/redis'; + +const MY_DATA_HASH_KEY = 'my:app:large:dataset'; + +// 1. Menu Endpoint: Returns the form definition +app.post('/internal/menu/ops/migrate-example', async (c) => { + return c.json({ + showForm: { + name: 'migrateExampleForm', // Must match key in devvit.json "forms" + form: { + title: 'Migrate Hash to Compression', + acceptLabel: 'Start Migration', + fields: [ + { + name: 'startCursor', + label: 'Start Cursor (0 for beginning)', + type: 'string', + defaultValue: '0', + }, + { + name: 'chunkSize', + label: 'Items per batch', + type: 'number', + defaultValue: 20000, + }, + ], + }, + }, + }); +}); + +// 2. Form Handler: Receives input and schedules the first job +app.post('/internal/form/ops/migrate-example', async (c) => { + const { startCursor, chunkSize } = await c.req.json().catch(() => ({})); + const cursor = startCursor || '0'; + const size = Number(chunkSize) || 20000; + + console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); + + // Kick off the first job in the chain + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), // Run immediately + data: { + cursor, + chunkSize: size, + processed: 0, + }, + }); + + return c.json({ + showToast: { + text: 'Migration started in background', + appearance: 'success', + }, + }); +}); + +// 3. Scheduler Endpoint: The recursive worker +app.post('/internal/scheduler/migrate-example-data', async (c) => { + const startTime = Date.now(); + + try { + const body = await c.req.json().catch(() => ({})); + const data = body.data ?? {}; + + let cursor = Number(data.cursor) || 0; + const chunkSize = Number(data.chunkSize) || 20000; + const processedTotal = Number(data.processed) || 0; + + console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); + + let keepRunning = true; + let processedInJob = 0; + const SCAN_COUNT = 250; // Internal batch size to keep event loop moving + + while (keepRunning) { + // Stop if we've processed enough items for this single execution + if (processedInJob >= chunkSize) { + break; + } + + const { cursor: nextCursor, fieldValues } = await redis.hScan( + MY_DATA_HASH_KEY, + cursor, + undefined, // match pattern + SCAN_COUNT + ); + + // Parallel Processing: + // We treat the batch as a set of promises to execute simultaneously. + // Promise.allSettled ensures one failure doesn't crash the whole job. + await Promise.allSettled( + fieldValues.map(async ({ field, value }) => { + // LOGIC: + // 1. We read the raw value. + // 2. We write it back using 'redisCompressed'. + // The proxy detects the write and compresses the string if beneficial. + if (value && value.length > 0) { + await redisCompressed.hSet(MY_DATA_HASH_KEY, { [field]: value }); + } + }) + ); + + processedInJob += fieldValues.length; + + // Cursor logic: 0 means iteration is complete + if (nextCursor === 0) { + cursor = 0; + keepRunning = false; + } else { + cursor = nextCursor; + } + + // Safety: Check execution time. + // If we are close to 30s (Devvit limit), stop early and requeue. + if (Date.now() - startTime > 20000) { + console.log('[Migration] Time limit approaching, stopping early.'); + keepRunning = false; + } + } + + const newTotal = processedTotal + processedInJob; + + // Daisy Chaining: + // If the cursor is not 0, we still have more data to scan. + // We schedule *this same job* to run again immediately. + if (cursor !== 0) { + console.log(`[Migration] Requeueing. Next cursor: ${cursor}. Processed so far: ${newTotal}`); + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), + data: { + cursor, + chunkSize, + processed: newTotal, + }, + }); + + return c.json({ status: 'requeued', processed: newTotal, cursor }); + } + + console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); + return c.json({ status: 'success', processed: newTotal }); + } catch (error) { + console.error('[Migration] Critical Job Error', error); + return c.json({ status: 'error', message: error.message }, 500); + } +}); +``` + + + ```ts import { redis, scheduler } from '@devvit/web/server'; @@ -1072,6 +1268,9 @@ app.post('/internal/scheduler/migrate-example-data', async (req, res) => { }); ``` + + + Note that the job may timeout, in which case you will need to find the last logged cursor to start the menu item action job again. Try adjusting the chunk size if you experience timeouts. You can monitor the migration progress using the logs command: diff --git a/docs/capabilities/server/scheduler.md b/docs/capabilities/server/scheduler.md deleted file mode 100644 index 8d9e0bb..0000000 --- a/docs/capabilities/server/scheduler.md +++ /dev/null @@ -1,247 +0,0 @@ -# Scheduler - -The scheduler allows your app to perform actions at specific times, such as sending private messages, tracking upvotes, or scheduling timeouts for user actions. You can schedule both recurring and one-off jobs using the scheduler. - ---- - -## Scheduling recurring jobs - -To create a regularly occurring event in your app, declare a task in your `devvit.json` and handle the event in your server logic. - -### 1. Add a recurring task to `devvit.json` - -Ensure the endpoint follows the format `/internal/.+` and specify a `cron` schedule: - -```json title="devvit.json" -"scheduler": { - "tasks": { - "regular-interval-example-task": { - "endpoint": "/internal/scheduler/regular-interval-task-example", - "cron": "*/1 * * * *" - } - } -}, -``` - -- The `cron` parameter uses the standard [UNIX cron format](https://en.wikipedia.org/wiki/Cron): - ``` - # * * * * * - # | | | | | - # | | | | day of the week (0–6, Sunday to Saturday; 7 is also Sunday on some systems) - # | | | month (1–12) - # | | day of the month (1–31) - # | hour (0–23) - # minute (0–59) - ``` -- We recommend using [Cronitor](https://crontab.guru/) to build cron strings. - -### 2. Handle the event in your server - -```ts title=/server/index.ts -router.post('/internal/scheduler/regular-interval-task-example', async (req, res) => { - console.log(`Handle event for cron example at ${new Date().toISOString()}!`); - // Handle the event here - res.status(200).json({ status: 'ok' }); -}); -``` - ---- - -## Scheduling one-off jobs at runtime - -One-off tasks must also be declared in `devvit.json`. - -### 1. Add the tasks to `devvit.json` - -```json title='devvit.json' -"scheduler": { - "tasks": { - "regular-interval-task-example": { - "endpoint": "/internal/scheduler/regular-interval-task-example", - "cron": "*/1 * * * *" - }, - "one-off-task-example": { - "endpoint": "/internal/scheduler/one-off-task-example" - } - } -} -``` - -### 2. Schedule a job at runtime - -Example usage: - -```ts -import { scheduler } from '@devvit/web/server'; - -// Handle the occurrence of the event -router.post('/internal/scheduler/one-off-task-example', async (req, res) => { - const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); - - let scheduledJob: ScheduledJob = { - id: `job-one-off-for-post${postId}`, - name: 'one-off-task-example', - data: { postId }, - runAt: oneMinuteFromNow, - }; - - let jobId = await scheduler.runJob(scheduledJob); - console.log(`Scheduled job ${jobId} for post ${postId}`); - console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); - // Handle the event here - res.status(200).json({ status: 'ok' }); -}); -``` - -## Cancel a scheduled job - -Use the job ID to cancel a scheduled action and remove it from your app. This example shows how to set up a moderator menu action to cancel a job. - -### 1. Add menu item to `devvit.json` - -```json title="devvit.json" -{ - "menu": { - "items": [ - { - "label": "Cancel Job", - "description": "Cancel a scheduled job", - "forUserType": "moderator", - "location": "post", - "endpoint": "/internal/menu/cancel-job" - } - ] - }, - "permissions": { - "redis": true - } -} -``` - -### 2. Handle the menu action in your server - -```ts title="server/index.ts" -import { redis } from '@devvit/redis'; -import { scheduler } from '@devvit/web/server'; - -router.post('/internal/menu/cancel-job', async (req, res) => { - try { - // Get the post ID from the menu action request - const postId = req.body.targetId; - - // Retrieve the job ID from Redis (stored when the job was created) - const jobId = await redis.get(`job:${postId}`); - - if (!jobId) { - return res.json({ - showToast: { - text: 'No scheduled job found for this post', - appearance: 'neutral', - }, - }); - } - - // Cancel the scheduled job - await scheduler.cancelJob(jobId); - - // Clean up the stored job ID - await redis.del(`job:${postId}`); - - res.json({ - showToast: { - text: 'Successfully cancelled the scheduled job', - appearance: 'success', - }, - }); - } catch (error) { - console.error('Error cancelling job:', error); - res.json({ - showToast: { - text: 'Failed to cancel job', - appearance: 'neutral', - }, - }); - } -}); -``` - -### Example: Storing a job ID when creating a job - -When you create a scheduled job, store its ID in Redis so you can reference it later - -```ts title="server/index.ts" -router.post('/api/schedule-action', async (req, res) => { - const { postId, delayMinutes } = req.body; - const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); - - const scheduledJob: ScheduledJob = { - id: `job-${postId}-${Date.now()}`, - name: 'one-off-task-example', - data: { postId }, - runAt, - }; - - const jobId = await scheduler.runJob(scheduledJob); - - // Store the job ID in Redis for later cancellation - await redis.set(`job:${postId}`, jobId); - - res.json({ - jobId, - message: 'Job scheduled successfully', - }); -}); -``` - -## List jobs - -This example shows how to handle a request within your server/index.ts to list your scheduled jobs and return them to the client. - -```ts title="server/index.ts" -router.get("/api/list-jobs", async (_req, res): Promise => { - try { - const jobs: (ScheduledJob | ScheduledCronJob)[] = await scheduler.listJobs(); - - console.log(`[LIST] Found ${jobs.length} scheduled jobs`); - - res.json({ - status: "success", - jobs: jobs, - count: jobs.length - }); - } catch (error) { - console.error(`[LIST] Error listing jobs:`, error); - res.status(500).json({ - status: "error", - message: error instanceof Error ? error.message : "Failed to list jobs" - }); - } -``` - -## Faster scheduler - -:::note -This feature is experimental, which means the design is not final but it's still available for you to use. -::: - -Scheduled jobs currently perform one scheduled run per minute. To go faster, you can now run jobs every second by adding seconds granularity to your cron expression. - -```tsx -await scheduler.runJob({ - name: 'run_every_30_seconds', - cron: '*/30 * * * * *', -}); -``` - -How frequent a scheduled job runs will depend on how long the job takes to complete and how many jobs are running in parallel. This means a job may take a bit longer than scheduled, but the overall resolution should be better than a minute. - ---- - -## Limitations - -_Limits are per installation of an app:_ - -1. An installation can have up to **10 live recurring actions**. -2. The `runJob()` method enforces two rate limits when creating actions: - - **Creation rate:** Up to 60 calls to `runJob()` per minute - - **Delivery rate:** Up to 60 deliveries per minute diff --git a/docs/capabilities/server/scheduler.mdx b/docs/capabilities/server/scheduler.mdx new file mode 100644 index 0000000..c755a75 --- /dev/null +++ b/docs/capabilities/server/scheduler.mdx @@ -0,0 +1,432 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# Scheduler + +The scheduler allows your app to perform actions at specific times, such as sending private messages, tracking upvotes, or scheduling timeouts for user actions. You can schedule both recurring and one-off jobs using the scheduler. + +--- + +## Scheduling recurring jobs + +To create a regularly occurring event in your app, declare a task in your `devvit.json` and handle the event in your server logic. + +### 1. Add a recurring task to `devvit.json` + +Ensure the endpoint follows the format `/internal/.+` and specify a `cron` schedule: + +```json title="devvit.json" +"scheduler": { + "tasks": { + "regular-interval-example-task": { + "endpoint": "/internal/scheduler/regular-interval-task-example", + "cron": "*/1 * * * *" + } + } +}, +``` + +- The `cron` parameter uses the standard [UNIX cron format](https://en.wikipedia.org/wiki/Cron): + ``` + # * * * * * + # | | | | | + # | | | | day of the week (0–6, Sunday to Saturday; 7 is also Sunday on some systems) + # | | | month (1–12) + # | | day of the month (1–31) + # | hour (0–23) + # minute (0–59) + ``` +- We recommend using [Cronitor](https://crontab.guru/) to build cron strings. + +### 2. Handle the event in your server + + + + +```ts title="/server/index.ts" +app.post("/internal/scheduler/regular-interval-task-example", async (c) => { + console.log(`Handle event for cron example at ${new Date().toISOString()}!`); + // Handle the event here + return c.json({ status: "ok" }, 200); +}); +``` + + + + +```ts title="/server/index.ts" +app.post( + "/internal/scheduler/regular-interval-task-example", + async (_req, res) => { + console.log( + `Handle event for cron example at ${new Date().toISOString()}!`, + ); + // Handle the event here + res.status(200).json({ status: "ok" }); + }, +); +``` + + + + +--- + +## Scheduling one-off jobs at runtime + +One-off tasks must also be declared in `devvit.json`. + +### 1. Add the tasks to `devvit.json` + +```json title='devvit.json' +"scheduler": { + "tasks": { + "regular-interval-task-example": { + "endpoint": "/internal/scheduler/regular-interval-task-example", + "cron": "*/1 * * * *" + }, + "one-off-task-example": { + "endpoint": "/internal/scheduler/one-off-task-example" + } + } +} +``` + +### 2. Schedule a job at runtime + +Example usage: + + + + +```ts title="/server/index.ts" +app.post("/internal/scheduler/one-off-task-example", async (c) => { + const { postId } = await c.req.json(); + const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); + + const scheduledJob: ScheduledJob = { + id: `job-one-off-for-post${postId}`, + name: "one-off-task-example", + data: { postId }, + runAt: oneMinuteFromNow, + }; + + const jobId = await scheduler.runJob(scheduledJob); + console.log(`Scheduled job ${jobId} for post ${postId}`); + console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); + // Handle the event here + return c.json({ status: "ok" }, 200); +}); +``` + + + + +```ts title="/server/index.ts" +app.post("/internal/scheduler/one-off-task-example", async (req, res) => { + const { postId } = req.body; + const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); + + const scheduledJob: ScheduledJob = { + id: `job-one-off-for-post${postId}`, + name: "one-off-task-example", + data: { postId }, + runAt: oneMinuteFromNow, + }; + + const jobId = await scheduler.runJob(scheduledJob); + console.log(`Scheduled job ${jobId} for post ${postId}`); + console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); + // Handle the event here + res.status(200).json({ status: "ok" }); +}); +``` + + + + +## Cancel a scheduled job + +Use the job ID to cancel a scheduled action and remove it from your app. This example shows how to set up a moderator menu action to cancel a job. + +### 1. Add menu item to `devvit.json` + +```json title="devvit.json" +{ + "menu": { + "items": [ + { + "label": "Cancel Job", + "description": "Cancel a scheduled job", + "forUserType": "moderator", + "location": "post", + "endpoint": "/internal/menu/cancel-job" + } + ] + }, + "permissions": { + "redis": true + } +} +``` + +### 2. Handle the menu action in your server + + + + +```ts title="/server/index.ts" +app.post("/internal/menu/cancel-job", async (c) => { + try { + // Get the post ID from the menu action request + const { targetId: postId } = await c.req.json(); + + // Retrieve the job ID from Redis (stored when the job was created) + const jobId = await redis.get(`job:${postId}`); + + if (!jobId) { + return c.json({ + showToast: { + text: "No scheduled job found for this post", + appearance: "neutral", + }, + }); + } + + // Cancel the scheduled job + await scheduler.cancelJob(jobId); + + // Clean up the stored job ID + await redis.del(`job:${postId}`); + + return c.json({ + showToast: { + text: "Successfully cancelled the scheduled job", + appearance: "success", + }, + }); + } catch (error) { + console.error("Error cancelling job:", error); + return c.json({ + showToast: { + text: "Failed to cancel job", + appearance: "neutral", + }, + }); + } +}); +``` + + + + +```ts title="/server/index.ts" +app.post("/internal/menu/cancel-job", async (req, res) => { + try { + // Get the post ID from the menu action request + const postId = req.body.targetId; + + // Retrieve the job ID from Redis (stored when the job was created) + const jobId = await redis.get(`job:${postId}`); + + if (!jobId) { + return res.json({ + showToast: { + text: "No scheduled job found for this post", + appearance: "neutral", + }, + }); + } + + // Cancel the scheduled job + await scheduler.cancelJob(jobId); + + // Clean up the stored job ID + await redis.del(`job:${postId}`); + + return res.json({ + showToast: { + text: "Successfully cancelled the scheduled job", + appearance: "success", + }, + }); + } catch (error) { + console.error("Error cancelling job:", error); + return res.json({ + showToast: { + text: "Failed to cancel job", + appearance: "neutral", + }, + }); + } +}); +``` + + + + +### Example: Storing a job ID when creating a job + +When you create a scheduled job, store its ID in Redis so you can reference it later + + + + +```ts title="/server/index.ts" +app.post("/api/schedule-action", async (c) => { + const { postId, delayMinutes } = await c.req.json(); + const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); + + const scheduledJob: ScheduledJob = { + id: `job-${postId}-${Date.now()}`, + name: "one-off-task-example", + data: { postId }, + runAt, + }; + + const jobId = await scheduler.runJob(scheduledJob); + + // Store the job ID in Redis for later cancellation + await redis.set(`job:${postId}`, jobId); + + return c.json({ + jobId, + message: "Job scheduled successfully", + }); +}); +``` + + + + +```ts title="/server/index.ts" +app.post("/api/schedule-action", async (req, res) => { + const { postId, delayMinutes } = req.body; + const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); + + const scheduledJob: ScheduledJob = { + id: `job-${postId}-${Date.now()}`, + name: "one-off-task-example", + data: { postId }, + runAt, + }; + + const jobId = await scheduler.runJob(scheduledJob); + + // Store the job ID in Redis for later cancellation + await redis.set(`job:${postId}`, jobId); + + return res.json({ + jobId, + message: "Job scheduled successfully", + }); +}); +``` + + + + +## List jobs + +This example shows how to handle a request within your server/index.ts to list your scheduled jobs and return them to the client. + + + + +```ts title="/server/index.ts" +app.get("/api/list-jobs", async (c) => { + try { + const jobs: (ScheduledJob | ScheduledCronJob)[] = + await scheduler.listJobs(); + + console.log(`[LIST] Found ${jobs.length} scheduled jobs`); + + return c.json({ + status: "success", + jobs, + count: jobs.length, + }); + } catch (error) { + console.error(`[LIST] Error listing jobs:`, error); + return c.json( + { + status: "error", + message: error instanceof Error ? error.message : "Failed to list jobs", + }, + 500, + ); + } +}); +``` + + + + +```ts title="/server/index.ts" +app.get("/api/list-jobs", async (_req, res): Promise => { + try { + const jobs: (ScheduledJob | ScheduledCronJob)[] = + await scheduler.listJobs(); + + console.log(`[LIST] Found ${jobs.length} scheduled jobs`); + + res.json({ + status: "success", + jobs, + count: jobs.length, + }); + } catch (error) { + console.error(`[LIST] Error listing jobs:`, error); + res.status(500).json({ + status: "error", + message: error instanceof Error ? error.message : "Failed to list jobs", + }); + } +}); +``` + + + + +## Faster scheduler + +:::note +This feature is experimental, which means the design is not final but it's still available for you to use. +::: + +Scheduled jobs currently perform one scheduled run per minute. To go faster, you can now run jobs every second by adding seconds granularity to your cron expression. + +```tsx +await scheduler.runJob({ + name: "run_every_30_seconds", + cron: "*/30 * * * * *", +}); +``` + +How frequent a scheduled job runs will depend on how long the job takes to complete and how many jobs are running in parallel. This means a job may take a bit longer than scheduled, but the overall resolution should be better than a minute. + +--- + +## Limitations + +_Limits are per installation of an app:_ + +1. An installation can have up to **10 live recurring actions**. +2. The `runJob()` method enforces two rate limits when creating actions: + - **Creation rate:** Up to 60 calls to `runJob()` per minute + - **Delivery rate:** Up to 60 deliveries per minute diff --git a/docs/capabilities/server/settings-and-secrets.mdx b/docs/capabilities/server/settings-and-secrets.mdx index 98a7654..f7359a0 100644 --- a/docs/capabilities/server/settings-and-secrets.mdx +++ b/docs/capabilities/server/settings-and-secrets.mdx @@ -190,6 +190,47 @@ Settings can be retrieved from within your app. + + + + ```tsx title="server/index.ts" + import { settings } from '@devvit/web/server'; + + // Get a single setting + const apiKey = await settings.get('apiKey'); + + // Get multiple settings + const [welcomeMessage, features] = await Promise.all([ + settings.get('welcomeMessage'), + settings.get('enabledFeatures') + ]); + + // Use in an endpoint + app.post('/api/process', async (c) => { + const apiKey = await settings.get('apiKey'); + const environment = await settings.get('environment'); + + const response = await fetch('https://api.example.com/endpoint', { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'X-Environment': environment + } + }); + + return c.json({ success: true }); + }); + ``` + + + + ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; @@ -217,6 +258,9 @@ Settings can be retrieved from within your app. res.json({ success: true }); }); ``` + + + ```tsx title="main.tsx" @@ -289,6 +333,43 @@ Validate user input to ensure it meets your requirements before saving. } ``` + + + + ```tsx title="server/index.ts" + import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; + + app.post('/internal/settings/validate-age', async (c) => { + const { value } = await c.req.json(); + + if (!value || value < 0) { + return c.json({ + success: false, + error: 'Age must be a positive number', + }); + } + + if (value > 365) { + return c.json({ + success: false, + error: 'Maximum age is 365 days', + }); + } + + return c.json({ success: true }); + }); + ``` + + + + ```tsx title="server/index.ts" import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; @@ -320,6 +401,9 @@ Validate user input to ensure it meets your requirements before saving. } ); ``` + + + Add an `onValidate` handler to your setting definition: @@ -391,6 +475,48 @@ Here's a complete example showing both secrets and subreddit settings in action: } ``` + + + + ```tsx title="server/index.ts" + import { settings } from '@devvit/web/server'; + + app.post('/api/generate', async (c) => { + const [apiKey, model, maxTokens] = await Promise.all([ + settings.get('openaiApiKey'), + settings.get('aiModel'), + settings.get('maxTokens') + ]); + const { messages } = await c.req.json(); + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + messages, + }), + }); + + const data = await response.json(); + return c.json(data); + }); + ``` + + + + ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; @@ -418,6 +544,9 @@ Here's a complete example showing both secrets and subreddit settings in action: res.json(data); }); ``` + + + ```tsx title="main.tsx" diff --git a/docs/capabilities/server/triggers.mdx b/docs/capabilities/server/triggers.mdx index e923baf..7928af0 100644 --- a/docs/capabilities/server/triggers.mdx +++ b/docs/capabilities/server/triggers.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # Triggers @@ -43,26 +43,28 @@ A full list of events and their payloads can be found in the [EventTypes documen Declare the triggers and their corresponding endpoints in your `devvit.json`: - ```json - "triggers": { - "onAppUpgrade": "/internal/on-app-upgrade", - "onCommentCreate": "/internal/on-comment-create", - "onPostSubmit": "/internal/on-post-submit" - } - ``` +```json +"triggers": { + "onAppUpgrade": "/internal/on-app-upgrade", + "onCommentCreate": "/internal/on-comment-create", + "onPostSubmit": "/internal/on-post-submit" +} +``` + Declare the triggers in your `devvit.json`: - ```json - { - "name": "your-app-name", - "blocks": { - "entry": "src/main.tsx", - "triggers": ["onPostCreate"] - } +```json +{ + "name": "your-app-name", + "blocks": { + "entry": "src/main.tsx", + "triggers": ["onPostCreate"] } - ``` +} +``` + @@ -72,65 +74,113 @@ A full list of events and their payloads can be found in the [EventTypes documen Listen for the events in your server and access the data passed into the request: + + + +```tsx title="server/index.ts" +app.post('/internal/on-app-upgrade', async (c) => { + console.log('Handle event for on-app-upgrade!'); + const body = await c.req.json(); + const installer = body.installer; + console.log('Installer:', JSON.stringify(installer, null, 2)); + return c.json({ status: 'ok' }); +}); + +app.post('/internal/on-comment-create', async (c) => { + console.log('Handle event for on-comment-create!'); + const body = await c.req.json(); + const comment = body.comment; + const author = body.author; + console.log('Comment:', JSON.stringify(comment, null, 2)); + console.log('Author:', JSON.stringify(author, null, 2)); + return c.json({ status: 'ok' }); +}); + +app.post('/internal/on-post-submit', async (c) => { + console.log('Handle event for on-post-submit!'); + const body = await c.req.json(); + const post = body.post; + const author = body.author; + console.log('Post:', JSON.stringify(post, null, 2)); + console.log('Author:', JSON.stringify(author, null, 2)); + return c.json({ status: 'ok' }); +}); +``` + + + + ```tsx title="server/index.ts" - const router = express.Router(); - - // .. - - router.post('/internal/on-app-upgrade', async (req, res) => { - console.log(`Handle event for on-app-upgrade!`); - const installer = req.body.installer; - console.log('Installer:', JSON.stringify(installer, null, 2)); - res.status(200).json({ status: 'ok' }); - }); - - router.post('/internal/on-comment-create', async (req, res) => { - console.log(`Handle event for on-comment-create!`); - const comment = req.body.comment; - const author = req.body.author; - console.log('Comment:', JSON.stringify(comment, null, 2)); - console.log('Author:', JSON.stringify(author, null, 2)); - res.status(200).json({ status: 'ok' }); - }); - - router.post('/internal/on-post-submit', async (req, res) => { - console.log(`Handle event for on-post-submit!`); - const post = req.body.post; - const author = req.body.author; - console.log('Post:', JSON.stringify(post, null, 2)); - console.log('Author:', JSON.stringify(author, null, 2)); - res.status(200).json({ status: 'ok' }); - }); - ``` +const router = express.Router(); + +// .. + +router.post("/internal/on-app-upgrade", async (req, res) => { + console.log(`Handle event for on-app-upgrade!`); + const installer = req.body.installer; + console.log("Installer:", JSON.stringify(installer, null, 2)); + res.status(200).json({ status: "ok" }); +}); + +router.post("/internal/on-comment-create", async (req, res) => { + console.log(`Handle event for on-comment-create!`); + const comment = req.body.comment; + const author = req.body.author; + console.log("Comment:", JSON.stringify(comment, null, 2)); + console.log("Author:", JSON.stringify(author, null, 2)); + res.status(200).json({ status: "ok" }); +}); + +router.post("/internal/on-post-submit", async (req, res) => { + console.log(`Handle event for on-post-submit!`); + const post = req.body.post; + const author = req.body.author; + console.log("Post:", JSON.stringify(post, null, 2)); + console.log("Author:", JSON.stringify(author, null, 2)); + res.status(200).json({ status: "ok" }); +}); +``` + + + + Handle trigger events in your main file. Example (`src/main.tsx`): - ```tsx - import { Devvit } from '@devvit/public-api'; - - // Handling a PostSubmit event - Devvit.addTrigger({ - event: 'PostSubmit', // Event name from above - onEvent: async (event) => { - console.log(`Received OnPostSubmit event:\n${JSON.stringify(event)}`); - }, - }); - - // Handling multiple events: PostUpdate and PostReport - Devvit.addTrigger({ - events: ['PostUpdate', 'PostReport'], // An array of events - onEvent: async (event) => { - if (event.type == 'PostUpdate') { - console.log(`Received OnPostUpdate event:\n${JSON.stringify(request)}`); - } else if (event.type === 'PostReport') { - console.log(`Received OnPostReport event:\n${JSON.stringify(request)}`); - } - }, - }); - - export default Devvit; - ``` +```tsx +import { Devvit } from "@devvit/public-api"; + +// Handling a PostSubmit event +Devvit.addTrigger({ + event: "PostSubmit", // Event name from above + onEvent: async (event) => { + console.log(`Received OnPostSubmit event:\n${JSON.stringify(event)}`); + }, +}); + +// Handling multiple events: PostUpdate and PostReport +Devvit.addTrigger({ + events: ["PostUpdate", "PostReport"], // An array of events + onEvent: async (event) => { + if (event.type == "PostUpdate") { + console.log(`Received OnPostUpdate event:\n${JSON.stringify(request)}`); + } else if (event.type === "PostReport") { + console.log(`Received OnPostReport event:\n${JSON.stringify(request)}`); + } + }, +}); + +export default Devvit; +``` + diff --git a/docs/capabilities/server/userActions.md b/docs/capabilities/server/userActions.mdx similarity index 76% rename from docs/capabilities/server/userActions.md rename to docs/capabilities/server/userActions.mdx index 0416b33..0c89a11 100644 --- a/docs/capabilities/server/userActions.md +++ b/docs/capabilities/server/userActions.mdx @@ -1,3 +1,6 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # User Actions User actions allow your app to submit posts, submit comments, and subscribe to the current subreddit on behalf of the logged in user. These actions occur on the logged in user's account instead of the app account. This enables stronger user engagement while ensuring user control and transparency. @@ -85,6 +88,44 @@ Apps that use `submitPost()` with `runAs: 'USER'` require `userGeneratedContent` This example uses a form to prompt the user for input and then submits a post as the user. + + + +```tsx title="server/index.ts" +import { reddit } from '@devvit/web/server'; + +// ... + +app.post('/internal/post-create', async (c) => { + const { subredditName } = context; + if (!subredditName) { + return c.json({ status: 'error', message: 'subredditName is required' }, 400); + } + + reddit.submitPost({ + runAs: 'USER', + userGeneratedContent: { + text: "Hello there! This is a new post from the user's account", + }, + subredditName, + title: 'Post Title', + entry: 'default', + }); + + return c.json({ status: 'success', message: `Post created in subreddit ${subredditName}` }); +}); +``` + + + + ```tsx title="server/index.ts" import { reddit } from '@devvit/web/server'; @@ -111,12 +152,41 @@ router.post('/internal/post-create', async (_req, res) => { }); ``` + + + --- ## Example: Subscribe to current subreddit The [subscribeToCurrentSubreddit()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#subscribetocurrentsubreddit) API does not take a `runAs` parameter; it subscribes as the user by default (if specified in `devvit.json` and approved). + + + +```ts +import { reddit } from '@devvit/web/server'; + +app.post('/api/subscribe', async (c) => { + try { + await reddit.subscribeToCurrentSubreddit(); + return c.json({ status: 'success' }); + } catch (error) { + return c.json({ status: 'error', message: 'Failed to subscribe' }, 500); + } +}); +``` + + + + ```ts import { reddit } from '@devvit/web/server'; @@ -130,4 +200,7 @@ router.post('/api/subscribe', async (_req, res) => { }); ``` + + + For user privacy there is no API to check if the user is already subscribed to the current subreddit. You may want to store the subscription state in Redis to provide contextually aware UI. diff --git a/versioned_docs/version-0.12/earn-money/payments/payments_add.md b/docs/earn-money/payments/payments_add.mdx similarity index 82% rename from versioned_docs/version-0.12/earn-money/payments/payments_add.md rename to docs/earn-money/payments/payments_add.mdx index b1e4e7f..bdca007 100644 --- a/versioned_docs/version-0.12/earn-money/payments/payments_add.md +++ b/docs/earn-money/payments/payments_add.mdx @@ -1,9 +1,12 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + # Add Payments The Devvit payments API is available in Devvit Web. Keep reading to learn how to configure your products and accept payments. :::note -Devvit Web is recommended for payments. Check out how to [migrate blocks apps](./payments_migrate.md) if you're app is currently using a blocks version of payments. +Devvit Web is recommended for payments. Check out how to [migrate blocks apps](./payments_migrate.mdx) if you're app is currently using a blocks version of payments. ::: To start with a template, select the payments template when you create a new project or run: @@ -45,15 +48,42 @@ You can reference an external `products.json` file, or define products directly. Create endpoints to fulfill and optionally revoke purchases. + + + +```tsx title="server/index.ts" +import type { PaymentHandlerResponse } from "@devvit/web/server"; + +app.post("/internal/payments/fulfill", async (c) => { + // Fulfill the order (grant entitlements, record delivery, etc.) + return c.json({ success: true } satisfies PaymentHandlerResponse); +}); + +app.post("/internal/payments/refund", async (c) => { + // Optionally revoke entitlements for a refunded order + return c.json({ success: true } satisfies PaymentHandlerResponse); +}); +``` + + + + ```tsx title="server/index.ts" -import type { PaymentHandlerResponse } from '@devvit/web/server'; +import type { PaymentHandlerResponse } from "@devvit/web/server"; -router.post('/internal/payments/fulfill', async (req, res) => { +router.post("/internal/payments/fulfill", async (req, res) => { // Fulfill the order (grant entitlements, record delivery, etc.) res.json({ success: true } satisfies PaymentHandlerResponse); }); -router.post('/internal/payments/refund', async (req, res) => { +router.post("/internal/payments/refund", async (req, res) => { // Optionally revoke entitlements for a refunded order res.json({ success: true } satisfies PaymentHandlerResponse); }); @@ -61,24 +91,55 @@ router.post('/internal/payments/refund', async (req, res) => { export default router; ``` + + + ### Server: Fetch products On the server, use `payments.getProducts()` and `payments.getOrders()`. If the client needs product metadata, expose it via your own `/api/` endpoint. + + + ```tsx title="server/index.ts" // Example: expose products for client display -import { payments } from '@devvit/web/server'; +import { payments } from "@devvit/web/server"; -const products = await payments.getProducts(); -res.json(products); +app.get("/api/products", async (c) => { + const products = await payments.getProducts(); + return c.json(products); +}); ``` + + + +```tsx title="server/index.ts" +// Example: expose products for client display +import { payments } from "@devvit/web/server"; + +app.get("/api/products", async (_req, res) => { + const products = await payments.getProducts(); + res.json(products); +}); +``` + + + + ### Client: trigger checkout Use `purchase()` from `@devvit/web/client` with a product SKU (or array of SKUs). ```tsx title="client/index.ts" -import { purchase, OrderResultStatus } from '@devvit/web/client'; +import { purchase, OrderResultStatus } from "@devvit/web/client"; export async function buy(sku: string) { const result = await purchase(sku); @@ -224,33 +285,33 @@ Use a consistent and clear product component to display paid goods or services t Use `addPaymentHandler` to specify the function that is called during the order flow. This customizes how your app fulfills product orders and provides the ability for you to reject an order. -Errors thrown within the payment handler automatically reject the order. To provide a custom error message to the frontend of your application, you can return {success: false, reason: } with a reason for the order rejection. +Errors thrown within the payment handler automatically reject the order. To provide a custom error message to the frontend of your application, you can return `{success: false, reason: }` with a reason for the order rejection. This example shows how to issue an "extra life" to a user when they purchase the "extra_life" product. ```ts -import { type Context } from '@devvit/public-api'; -import { addPaymentHandler } from '@devvit/payments'; -import { Devvit, useState } from '@devvit/public-api'; +import { type Context } from "@devvit/public-api"; +import { addPaymentHandler } from "@devvit/payments"; +import { Devvit, useState } from "@devvit/public-api"; Devvit.configure({ redis: true, redditAPI: true, }); -const GOD_MODE_SKU = 'god_mode'; +const GOD_MODE_SKU = "god_mode"; addPaymentHandler({ fulfillOrder: async (order, ctx) => { if (!order.products.some(({ sku }) => sku === GOD_MODE_SKU)) { - throw new Error('Unable to fulfill order: sku not found'); + throw new Error("Unable to fulfill order: sku not found"); } - if (order.status !== 'PAID') { - throw new Error('Becoming a god has a cost (in Reddit Gold)'); + if (order.status !== "PAID") { + throw new Error("Becoming a god has a cost (in Reddit Gold)"); } const redisKey = godModeRedisKey(ctx.postId, ctx.userId); - await ctx.redis.set(redisKey, 'true'); + await ctx.redis.set(redisKey, "true"); }, }); ``` @@ -272,14 +333,14 @@ Your app can acknowledge or reject the order. For example, for goods with limite Use the `useProducts` hook or `getProducts` function to fetch details about products. ```tsx -import { useProducts } from '@devvit/payments'; +import { useProducts } from "@devvit/payments"; export function ProductsList(context: Devvit.Context): JSX.Element { // Only query for products with the metadata "category" of value "powerup". // The metadata field can be empty - if it is, useProducts will not filter on metadata. const { products } = useProducts(context, { metadata: { - category: 'powerup', + category: "powerup", }, }); diff --git a/versioned_docs/version-0.12/earn-money/payments/payments_migrate.md b/docs/earn-money/payments/payments_migrate.mdx similarity index 61% rename from versioned_docs/version-0.12/earn-money/payments/payments_migrate.md rename to docs/earn-money/payments/payments_migrate.mdx index 317e2b6..0953d86 100644 --- a/versioned_docs/version-0.12/earn-money/payments/payments_migrate.md +++ b/docs/earn-money/payments/payments_migrate.mdx @@ -1,3 +1,6 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + # Migrate Blocks Payments If you already have payments set up on a Blocks app, use the following steps to migrate your payments functionality. @@ -25,15 +28,40 @@ Reference your `products.json` and declare endpoints. - Blocks: `addPaymentHandler({ fulfillOrder, refundOrder })` - Devvit Web: implement `/internal/payments/fulfill` and `/internal/payments/refund` + + + +```tsx +import type { PaymentHandlerResponse } from "@devvit/web/server"; + +app.post("/internal/payments/fulfill", async (c) => { + // migrate your old fulfillOrder logic here + return c.json({ success: true } satisfies PaymentHandlerResponse); +}); +``` + + + + ```tsx -import type { PaymentHandlerResponse } from '@devvit/web/server'; +import type { PaymentHandlerResponse } from "@devvit/web/server"; -router.post('/internal/payments/fulfill', async (req, res) => { +router.post("/internal/payments/fulfill", async (req, res) => { // migrate your old fulfillOrder logic here res.json({ success: true } satisfies PaymentHandlerResponse); }); ``` + + + 3. Update client purchase calls - Blocks: `usePayments().purchase(sku)` diff --git a/docs/earn-money/payments/support_this_app.md b/docs/earn-money/payments/support_this_app.md index 3f7ec4f..42732df 100644 --- a/docs/earn-money/payments/support_this_app.md +++ b/docs/earn-money/payments/support_this_app.md @@ -5,13 +5,13 @@ You can ask users to contribute to your app’s development by adding the “sup ## Requirements 1. You must give something in return to users who support your app. This could be unique custom user flair, an honorable mention in a thank you post, or another creative way to show your appreciation. -2. The “Support this App” purchase button must meet the Developer Platform’s [design guidelines](./payments_add.md#design-guidelines). +2. The “Support this App” purchase button must meet the Developer Platform’s [design guidelines](./payments_add.mdx#design-guidelines). ## How to integrate app support ### Create the product -Use the Devvit CLI to generate the [product configuration](./payments_add.md#register-products). +Use the Devvit CLI to generate the [product configuration](./payments_add.mdx#register-products). ```tsx devvit products add support-app @@ -19,24 +19,24 @@ devvit products add support-app ### Add a payment handler -The [payment handler](./payments_add.md#complete-the-payment-flow) is where you award the promised incentive to your supporters. For example, this is how you can award custom user flair: +The [payment handler](./payments_add.mdx#complete-the-payment-flow) is where you award the promised incentive to your supporters. For example, this is how you can award custom user flair: ```tsx addPaymentHandler({ fulfillOrder: async (order, context) => { const username = await context.reddit.getCurrentUsername(); if (!username) { - throw new Error('User not found'); + throw new Error("User not found"); } const subredditName = await context.reddit.getCurrentSubredditName(); await context.reddit.setUserFlair({ - text: 'Super Duper User', + text: "Super Duper User", subredditName, username, - backgroundColor: '#ffbea6', - textColor: 'dark', + backgroundColor: "#ffbea6", + textColor: "dark", }); }, }); @@ -47,7 +47,7 @@ addPaymentHandler({ Next you need to provide a way for users to support your app: - If you use Devvit blocks, you can use the ProductButton helper to render a purchase button. -- If you use webviews, make sure that your design follows the [design guidelines](./payments_add.md#design-guidelines) to [initiate purchases](./payments_add.md#initiate-orders). +- If you use webviews, make sure that your design follows the [design guidelines](./payments_add.mdx#design-guidelines) to [initiate purchases](./payments_add.mdx#initiate-orders). ![Support App Example](../../assets/support_this_app.png) diff --git a/docs/guides/launch/feature-guide.md b/docs/guides/launch/feature-guide.md index 8349e8f..c0df2c8 100644 --- a/docs/guides/launch/feature-guide.md +++ b/docs/guides/launch/feature-guide.md @@ -34,9 +34,9 @@ Games that see organic growth are also likely to be scouted by our team for feat Reddit features games and developers across multiple discovery surfaces to help players find new favorites: -- **Games Feed.** The Games Feed showcases playable experiences directly within Reddit. When featured, games are rotated into a list of games that is algorythmically served to users visiting the feed. +- **Games Feed.** The Games Feed showcases playable experiences directly within Reddit. When featured, games are rotated into a list of games that is algorithmically served to users visiting the feed. -![Featured games](../../assets/featured_games.png) +Featured games - **Community Drawer.** Our lefthand drawer provides an easy access point for any redditor to see a mix of recently played games and curated popular games. diff --git a/docs/guides/tools/logs.md b/docs/guides/tools/logs.md index 7628e20..83bb9e1 100644 --- a/docs/guides/tools/logs.md +++ b/docs/guides/tools/logs.md @@ -9,13 +9,13 @@ Any logs sent to `console` will be available via `devvit logs` for installed app The following example creates a basic app that simply creates a single log. ```typescript title="main.tsx" -import { Context, Devvit } from '@devvit/public-api'; +import { Context, Devvit } from "@devvit/public-api"; Devvit.addMenuItem({ - location: 'post', - label: 'Create a log!', + location: "post", + label: "Create a log!", onPress: (event, context) => { - console.log('Action called!'); + console.log("Action called!"); context.ui.showToast(`Successfully logged!`); }, }); @@ -28,13 +28,13 @@ export default Devvit; To stream logs for an installed app, open a terminal and navigate to your project directory and run: ```bash -$ devvit logs +devvit logs ``` You can also specify the app name to stream logs for from another folder. ```bash -$ devvit logs +devvit logs ``` You should now see logs streaming onto your console: @@ -70,7 +70,7 @@ You can view historical logs by using the `--since=XX` flag. You can use the fol The following example will show logs from `my-app` on `my-subreddit` in the past day. ```bash -$ devvit logs --since=1d +devvit logs --since=1d ``` You will now see historical logs created by your app on this subreddit: diff --git a/docs/guides/tools/playtest.md b/docs/guides/tools/playtest.md index fb18fa4..d5e6853 100644 --- a/docs/guides/tools/playtest.md +++ b/docs/guides/tools/playtest.md @@ -63,13 +63,13 @@ Exiting the playtest does not uninstall the playtest version or revert your app If you want to revert back to the latest non-playtest version of the app, run the following command from within your project directory: ```bash -$ devvit install +devvit install ``` If you want to revert to a different version of your pre-playtest app, you can specify which version using the `install` command. Entering app name is optional if you are running this command from within your project directory. ```bash -$ devvit install [@version] +devvit install [@version] ``` ## Upload your app @@ -77,7 +77,7 @@ $ devvit install [@version] If you’re satisfied with your playtest app and want to upload an installable version, run: ```bash -$ devvit upload +devvit upload ``` This will automatically bump your app version to the next patch release. For example, if your playtest version is 0.0.1.6, the upload command will remove the playtest version increment and change your app version to 0.0.2. diff --git a/docs/guides/tools/vite.mdx b/docs/guides/tools/vite.mdx index 8acfcea..2dd3fda 100644 --- a/docs/guides/tools/vite.mdx +++ b/docs/guides/tools/vite.mdx @@ -6,11 +6,7 @@ sidebar_label: Vite Plugin # Build with the Devvit Vite plugin -::::warning Experimental -The Devvit Vite plugin is experimental and subject to breaking changes. -:::: - -The Devvit [Vite](https://vite.dev/) plugin is a 100% optional plugin for Devvit Web that unifies your client and server builds into a single command using [Vite's Environment API](https://vite.dev/guide/api-environment). +The Devvit [Vite](https://vite.dev/) plugin is an opinionated (and 100% optional) plugin for Devvit Web that unifies your client and server builds into a single command using [Vite's Environment API](https://vite.dev/guide/api-environment). Features: @@ -53,29 +49,49 @@ The plugin uses your `devvit.json` as the source of truth for client entry point "entrypoints": { "default": { "inline": true, - "entry": "src/splash.html" + "entry": "splash.html" } } + }, + "server": { + "dir": "dist/server", + "entry": "index.ts" } } ``` +The plugin reads `devvit.json` from the project root (the current working directory unless you set `root` in `vite.config.ts`). If it can’t find a valid config, the build fails. + +
+Why your client output can look nested + +By default, the Devvit Vite plugin sets the Vite root to `src/client` when that folder exists (unless you explicitly set `root` in `vite.config.ts`). That keeps client output flat, like `dist/client/index.html`. + +If you explicitly set `root` to the repo root or include `src/` in `post.entrypoints.*.entry`, Vite will preserve that path in the output, leading to nested paths like `dist/client/src/client/index.html`. Keep entry paths relative to the client root (no `src/` prefix) to preserve the flat layout. + +Server builds use a fixed entry point and are not affected by this behavior. + +
+ For the server build, the plugin looks for one of these files: - `src/server/index.ts` - `src/api/index.ts` +- `src/index.ts` If neither file exists, the build fails with a clear error message. ## What it builds -Out of the box, the plugin configures two environments: +Out of the box, the plugin configures two environments depending on what's defined in your `devvit.json`: - **Client build** outputs to `dist/client` and uses the entry points from `devvit.json`. - **Server build** outputs to `dist/server` as `index.cjs` > Note that Devvit requires a single CJS bundle to run the server code. Please do not mark server dependencies as `external` as it will break your server build. This may change in the future! +> The plugin currently always writes to `dist/client` and `dist/server`, regardless of `post.dir` or `server.dir` in `devvit.json`. Those values are used by Devvit, but Vite’s output paths are fixed in the plugin. + ## Customize the build The plugin accepts a small options object that lets you tweak both environments without redoing the whole config. Each option is merged into the generated Vite environment config. @@ -124,7 +140,69 @@ src/ formatScore.ts # safe to import from client + server ``` +## Migrating Old Templates + +If you started with a template before this plugin, migrating it is simple! + +1. Run the installation command + +```bash +npm install @devvit/start +``` + +2. Add a `vite.config` to the root of your project. For example, this is how you would migrate a React app: + +```ts title="vite.config.ts" +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwind from "@tailwindcss/vite"; +import { devvit } from "@devvit/start/vite"; + +export default defineConfig({ + plugins: [react(), tailwind(), devvit()], +}); +``` + +3. Remove `src/client/vite.config.ts` and `src/server/vite.config.ts` + +Technically, that's all you need to do! However, you can also make your development experience a lot nicer by utilizing the new `scripts` field in your `devvit.json` file. + +4. Add the following to your `devvit.json` file: + +```json title="devvit.json" +{ + "scripts": { + "dev": "vite build --watch", + "build": "vite build" + } +} +``` + +5. Update your `package.json` commands to look like the following: + +```patch title="package.json" +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -7,14 +7,6 @@ + "scripts": { +- "build:client": "cd src/client && vite build", +- "build:server": "cd src/server && vite build", +- "build": "npm run build:client && npm run build:server", +- "dev": "concurrently -k -p \"[{name}]\" -n \"CLIENT,SERVER,DEVVIT\" -c \"blue,green,magenta\" \"npm run dev:client\" \"npm run dev:server\" \"npm run dev:devvit\"", +- "dev:client": "cd src/client && vite build --watch", +- "dev:devvit": "devvit playtest", +- "dev:server": "cd src/server && vite build --watch", +- "dev:vite": "cd src/client && vite --port 7474", ++ "build": "vite build", ++ "dev": "devvit playtest --verbose", +``` + +You can also remove the `concurrently` dependency after this switch. + +6. Run `npm run dev` to make sure everything is working. + ## Limitations and gotchas - **Build-only:** The plugin only supports `vite build`. There is no support for `vite dev` or Hot Module Replacement (HMR) at this time. This is because `devvit playtest` works by uploading your build to our servers and running it on Reddit.com. Instead, use `vite build --watch` as your dev command. -- **Entry points are required:** Building server apps only is not yet supported. If you'd like to request this feature, please [open an issue](https://github.com/reddit/devvit/issues/new). +- **Public dir resolution:** The plugin auto-detects a `public/` folder at the repo root or inside `src/client`. If both exist, the build fails—keep a single public directory. diff --git a/docs/quickstart/quickstart-gamemaker.mdx b/docs/quickstart/quickstart-gamemaker.mdx index d8a3055..7fa4411 100644 --- a/docs/quickstart/quickstart-gamemaker.mdx +++ b/docs/quickstart/quickstart-gamemaker.mdx @@ -41,7 +41,12 @@ Cutting the template to the target directory... To run your app, navigate to your project directory with `cd my-app` and run `npm run dev`. You should see logs that conclude with: ``` -https://www.reddit.com/r/my-app_dev?playtest=my-app +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ✓ Playtest ready │ +│ ➜ URL: https://www.reddit.com/r/my-app_dev/?playtest=my-app │ +│ ➜ Version: v0.0.0.1 │ +│ ➜ Open the URL above and refresh to see your latest changes. │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` Follow this link to see your app. diff --git a/docs/quickstart/quickstart-mod-tool.md b/docs/quickstart/quickstart-mod-tool.md index ef28a43..94864aa 100644 --- a/docs/quickstart/quickstart-mod-tool.md +++ b/docs/quickstart/quickstart-mod-tool.md @@ -12,7 +12,7 @@ This tutorial should take about 10 minutes to complete. Once complete, you'll be ## Environment setup 1. Install Node.JS and NPM ([instructions](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)) -2. Go to `https://developers.reddit.com/new` and choose Mod Tool under Other templates. +2. Go to [https://developers.reddit.com/new](https://developers.reddit.com/new) and choose Mod Tool under Other templates. 3. Go through the wizard. You will need to create a Reddit account and connect it to Reddit developers. 4. Follow the instructions on your terminal. diff --git a/docs/quickstart/quickstart-unity.mdx b/docs/quickstart/quickstart-unity.mdx index 369eef4..b8f61c9 100644 --- a/docs/quickstart/quickstart-unity.mdx +++ b/docs/quickstart/quickstart-unity.mdx @@ -8,7 +8,16 @@ Many great Unity games are already running on Devvit, including [Blokkit](https: This starter template creates a simple Unity game and demonstrates data exchange between Reddit and Unity. The example game can be played [here.](https://www.reddit.com/r/unity_starter_dev/comments/1p2fm6y/unitystarter/) -Unity example +Unity example ## What you'll need @@ -45,10 +54,15 @@ The Devvit Unity Template includes a pre-built Unity project. The full Unity pro To run your app, navigate to your project directory with `cd my-app` and run `npm run dev`. You should see logs that conclude with: ``` -✨ https://www.reddit.com/r/my-app_dev?playtest=my-app +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ✓ Playtest ready │ +│ ➜ URL: https://www.reddit.com/r/my-app_dev/?playtest=my-app │ +│ ➜ Version: v0.0.0.1 │ +│ ➜ Open the URL above and refresh to see your latest changes. │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` -Follow this link to see your app. +Follow this link to see your app. ## Devvit project structure @@ -78,14 +92,13 @@ Contains the code for `splash.html`, the first screen users see when your post a **`src/client/script.ts`:** Contains the code to load your Unity instance. This script powers `index.html`. - ## Updating your Unity project The starter template includes 4 files in the `src/client/public/Build` folder. You'll need to replace these 3 files from your export: - - - SampleGame.data.unityweb - - SampleGame.framework.js - - SampleGame.wasm.unityweb + +- SampleGame.data.unityweb +- SampleGame.framework.js +- SampleGame.wasm.unityweb To replace these files, export your project from Unity twice: once with compression and once without. @@ -95,28 +108,29 @@ To replace these files, export your project from Unity twice: once with compress 4. Build your project twice: a. Set **Compression Format** to **GZip** and select **Build** in the Build Profiles window - b. Copy these files into your `src/client/Public/Build` folder: - - `exportName.data.unityweb` - - `exportName.wasm.unityweb` + b. Copy these files into your `src/client/Public/Build` folder: + + - `exportName.data.unityweb` + - `exportName.wasm.unityweb` c. In Publishing Settings, set **Compression Format** to **Disabled** and select **Build** again - d. Copy this file into your `src/client/Public/Build` folder: - - `exportName.framework.js` + d. Copy this file into your `src/client/Public/Build` folder: + + - `exportName.framework.js` 5. If you used a name other than `SampleGame`, update `src/client/script.ts` lines 29-34 to point to your new files 6. Run `npm run dev` in your Devvit project to see your Unity app running on Reddit - -:::warning +:::warning File uploads have a 100 MB size limit and a 30-second timeout. We’re working to improve these limits. If you encounter issues, try splitting large files or using a faster network connection. ::: -## Communicate between Unity and Reddit +## Communicate between Unity and Reddit The Unity app includes a [DevvitBridge.cs](https://github.com/reddit/devvit-unity-project/blob/main/Assets/Scripts/DevvitBridge.cs) file that uses UnityWebRequests to communicate with the `src/server/index.ts` file. -For example, the following sends a message to the server, which receives the event, loads data from Reddit, and replies with the specified data. +For example, the following sends a message to the server, which receives the event, loads data from Reddit, and replies with the specified data. ```csharp UnityWebRequest request = UnityWebRequest.Get("/api/init"); @@ -131,7 +145,7 @@ Be sure that the object structure for the response type (such as `InitResponse` This starter project also includes an example of saving the completion time to Reddit through the `LevelCompletedRequest`. For Devvit apps, data is stored in [Redis](../capabilities/server/redis). - ```ts - const redisKey = `${postId}:${username}`; - await redis.set(redisKey, time); - ``` +```ts +const redisKey = `${postId}:${username}`; +await redis.set(redisKey, time); +``` diff --git a/docs/quickstart/quickstart.md b/docs/quickstart/quickstart.md index 4c6c78f..b0bd539 100644 --- a/docs/quickstart/quickstart.md +++ b/docs/quickstart/quickstart.md @@ -37,7 +37,12 @@ Cutting the template to the target directory... To run your app, `cd my-app` and then run `npm run dev`. You should see some logs start up that finish with: ``` -✨ https://www.reddit.com/r/my-app_dev?playtest=my-app +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ✓ Playtest ready │ +│ ➜ URL: https://www.reddit.com/r/my-app_dev/?playtest=my-app │ +│ ➜ Version: v0.0.0.1 │ +│ ➜ Open the URL above and refresh to see your latest changes. │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` The dev command automatically creates a development subreddit for your app and a test post for you to develop against. When you visit the url, it should look something like this. @@ -64,12 +69,12 @@ This special file in the root of the project contains configurations for many of ## Testing your app on a specific subreddit -You need to test your app on a subreddit. Your backend calls will not work when testing the app locally. For that we will be leveraging Devvit's Playtest tool. If you have a preference for a specific subreddit to playtest, change the `package.json` file to include your subreddit name in `dev:devvit`: +You need to test your app on a subreddit. Your backend calls will not work when testing the app locally. For that we will be leveraging Devvit's Playtest tool. If you have a preference for a specific subreddit to playtest, change the `package.json` file to include your subreddit name in `dev`: ```javascript title="package.json" "scripts": { //... - "dev:devvit": "devvit playtest r/MY_PREFERRED_SUBREDDIT", + "dev": "devvit playtest r/MY_PREFERRED_SUBREDDIT", //... } ``` diff --git a/sidebars.ts b/sidebars.ts index c6682b0..875f491 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -286,7 +286,8 @@ const sidebars: SidebarsConfig = { "guides/tools/logs", "guides/tools/playtest", "guides/tools/ui_simulator", - "guides/tools/multiple_developers" + "guides/tools/multiple_developers", + "guides/tools/vite" ], }, { diff --git a/src/css/custom.css b/src/css/custom.css index 5bd3f6b..f8395f7 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -25,7 +25,7 @@ } /* For readability concerns, you should choose a lighter palette in dark mode. */ -[data-theme='dark'] { +[data-theme="dark"] { --ifm-color-primary: #fe7c53; --ifm-color-primary-dark: #d93a00; --ifm-color-primary-darker: #962900; @@ -62,7 +62,7 @@ background-color: var(--ifm-background-color); } -[data-theme='dark'] .heading { +[data-theme="dark"] .heading { background-color: #0f1a1c; } @@ -70,7 +70,7 @@ justify-content: center; } -[data-theme='dark'] .features_src-components-HomepageFeatures-styles-module { +[data-theme="dark"] .features_src-components-HomepageFeatures-styles-module { background-color: #0f1a1c; } @@ -81,7 +81,7 @@ background-color: #ffffff; } -[data-theme='dark'] .feature { +[data-theme="dark"] .feature { background-color: #1a282d; border: 1px solid #ffffff33; } @@ -96,7 +96,7 @@ --ifm-footer-color: var(--ifm-font-color-base-inverse); } -[data-theme='dark'] footer { +[data-theme="dark"] footer { background-color: #131f23; color: #ffede5; } @@ -115,7 +115,7 @@ color: var(--ifm-font-color-base-inverse); } -[data-theme='dark'] #__docusaurus { +[data-theme="dark"] #__docusaurus { background-color: #0b1416; } @@ -124,7 +124,7 @@ } .language-bash span.token-line::before { - content: '$'; + content: "$"; padding: 0 0.7em 0 0; color: var(--ifm-color-emphasis-400); } @@ -173,22 +173,24 @@ table tbody tr:nth-of-type(even) { } div.themed-col { - transition: transform 0.25s cubic-bezier(0.4,0.2,0.2,1), box-shadow 0.25s cubic-bezier(0.4,0.2,0.2,1); - box-shadow: 0 2px 8px 0 rgba(0,0,0,0.06); + transition: + transform 0.25s cubic-bezier(0.4, 0.2, 0.2, 1), + box-shadow 0.25s cubic-bezier(0.4, 0.2, 0.2, 1); + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.06); } div.themed-col:hover { transform: scale(1.04) translateY(-4px); - box-shadow: 0 8px 32px 0 rgba(0,0,0,0.18); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.18); z-index: 2; } /* Sidebar custom styling */ -.sidebar-icon-link a::before{ +.sidebar-icon-link a::before { background-position: center; background-repeat: no-repeat; background-size: contain; - content: ''; + content: ""; display: inline-block; max-width: 20px; max-height: 20px; @@ -198,16 +200,16 @@ div.themed-col:hover { vertical-align: middle; } -.discord-link a::before{ - background-image: url('../../static/img/icon-discord.png'); +.discord-link a::before { + background-image: url("../../static/img/icon-discord.png"); } -.subreddit-link a::before{ - background-image: url('../../static/img/icon-reddit.png'); +.subreddit-link a::before { + background-image: url("../../static/img/icon-reddit.png"); } -.kiro-link a::before{ - background-image: url('../../static/img/icon-kiro.png'); +.kiro-link a::before { + background-image: url("../../static/img/icon-kiro.png"); } /* Sidebar divider styling */ @@ -218,7 +220,7 @@ div.themed-col:hover { } /* Dark mode support for sidebar divider */ -[data-theme='dark'] .sidebar-divider hr { +[data-theme="dark"] .sidebar-divider hr { border-top-color: rgba(255, 255, 255, 0.1); } @@ -232,7 +234,7 @@ div.themed-col:hover { } /* Dark mode support for section headers */ -[data-theme='dark'] .sidebar-section-header span { +[data-theme="dark"] .sidebar-section-header span { color: var(--ifm-color-emphasis-500); } @@ -240,3 +242,39 @@ div.themed-col:hover { .dropdown__menu a[href*="/next/"] { display: none; } + +/* Compact tabs for inline switchers (e.g. Hono/Express) */ +.tabs.tabs--compact { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.2rem; + border-radius: 999px; + background: var(--ifm-color-emphasis-100); + border: 1px solid var(--ifm-color-emphasis-200); + font-weight: 600; +} + +.tabs.tabs--compact .tabs__item { + margin: 0; + padding: 0.2rem 0.6rem; + border-radius: 999px; + border: none; + border-bottom: none; + font-size: 0.8rem; + line-height: 1.2; + color: var(--ifm-color-emphasis-700); + background: transparent; +} + +.tabs.tabs--compact .tabs__item:hover { + color: var(--ifm-color-emphasis-900); + background: var(--ifm-color-emphasis-200); +} + +.tabs.tabs--compact .tabs__item--active, +.tabs.tabs--compact .tabs__item--active:hover { + color: var(--ifm-color-primary-lightest); + background: var(--ifm-color-primary); + border-bottom: none; +} diff --git a/src/theme/Tabs/index.tsx b/src/theme/Tabs/index.tsx new file mode 100644 index 0000000..ceedd64 --- /dev/null +++ b/src/theme/Tabs/index.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import Tabs from "@theme-original/Tabs"; +import type TabsType from "@theme/Tabs"; +import type { WrapperProps } from "@docusaurus/types"; + +type TabsProps = WrapperProps & { + variant?: "pill" | "default"; +}; + +export default function TabsWrapper({ + variant, + className, + ...rest +}: TabsProps) { + const variantClassName = variant === "pill" ? "tabs--compact" : undefined; + const mergedClassName = [className, variantClassName] + .filter(Boolean) + .join(" "); + + return ; +} diff --git a/versioned_docs/version-0.11/changelog.md b/versioned_docs/version-0.11/changelog.md index 8b5fb45..8ab9eac 100644 --- a/versioned_docs/version-0.11/changelog.md +++ b/versioned_docs/version-0.11/changelog.md @@ -56,11 +56,11 @@ Special thanks to u/antboiy for updating `Comment.ApprovedAt` in our typedoc. We We’ve simplified how to request new domains for HTTP fetch (no more forms!). Now you can just add domains in your app’s configuration, and when you playtest or upload the app, the domain is automatically submitted for approval. ```tsx -import { Devvit } from '@devvit/public-api'; +import { Devvit } from "@devvit/public-api"; Devvit.configure({ http: { - domains: ['my-site.com', 'another-domain.net'], + domains: ["my-site.com", "another-domain.net"], }, }); ``` @@ -140,7 +140,7 @@ In this release, Devvit introduces a new way for apps to let users create conten - **Saves the user time and effort**. It’s easy for users to jump into the conversation. - **Improves retention**. When people interact with your app, they’re more likely to stick around, and continued user engagement helps your app reach new people. Total positive feedback loop! -Check out our new [user action API](./capabilities/userActions.md) to see how you can add this to your own app. +Check out our new [user action API](./capabilities/userActions.mdx) to see how you can add this to your own app. Also in this release, we’ve streamlined developer communication. New devs will get automatic email notifications when they [upload](./dev_guide.mdx) their first app and every time an app is approved for [publishing](./publishing.md). @@ -312,7 +312,7 @@ Release 0.11.4 introduces [payments](./payments/payments_overview.md)! This pilo Since this is a pilot program, you'll need to submit an [enrollment form](https://forms.gle/TuTV5jbUwFKTcerUA) before developing and playtesting payments in your app. Before you publish your app, you’ll need to complete additional steps outlined in the payments documentation. -We’ve also added a new [template](./payments/payments_add.md) to help you set up payments functionality without needing to code a full app from scratch. +We’ve also added a new [template](./payments/payments_add.mdx) to help you set up payments functionality without needing to code a full app from scratch. **New features** This release also includes: diff --git a/versioned_docs/version-0.11/debug.md b/versioned_docs/version-0.11/debug.md index 90f3ce8..c5843db 100644 --- a/versioned_docs/version-0.11/debug.md +++ b/versioned_docs/version-0.11/debug.md @@ -9,13 +9,13 @@ Any logs sent to `console` will be available via `devvit logs` for installed app The following example creates a basic app that simply creates a single log. ```typescript title="main.tsx" -import { Context, Devvit } from '@devvit/public-api'; +import { Context, Devvit } from "@devvit/public-api"; Devvit.addMenuItem({ - location: 'post', - label: 'Create a log!', + location: "post", + label: "Create a log!", onPress: (event, context) => { - console.log('Action called!'); + console.log("Action called!"); context.ui.showToast(`Successfully logged!`); }, }); @@ -28,13 +28,13 @@ export default Devvit; To stream logs for an installed app, open a terminal and navigate to your project directory and run: ```bash -$ devvit logs +devvit logs ``` You can also specify the app name to stream logs for from another folder. ```bash -$ devvit logs +devvit logs ``` You should now see logs streaming onto your console: @@ -70,7 +70,7 @@ You can view historical logs by using the `--since=XX` flag. You can use the fol The following example will show logs from `my-app` on `my-subreddit` in the past day. ```bash -$ devvit logs --since=1d +devvit logs --since=1d ``` You will now see historical logs created by your app on this subreddit: diff --git a/versioned_docs/version-0.11/dev_guide.mdx b/versioned_docs/version-0.11/dev_guide.mdx index b88f1fa..e28f32c 100644 --- a/versioned_docs/version-0.11/dev_guide.mdx +++ b/versioned_docs/version-0.11/dev_guide.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # Building an app @@ -118,7 +118,7 @@ You’ll be prompted to open an authorization page in your browser to allow the Pick a project name that is six or more characters, all lowercase, using kebab-case. Your project name must start with a letter and can include the numbers 0 - 9. Use the following command to make your project: ```bash -$ devvit new +devvit new ``` ## 5. Choose a template diff --git a/versioned_docs/version-0.11/devvit_cli.md b/versioned_docs/version-0.11/devvit_cli.md index 9fe6cdd..ee5c80e 100644 --- a/versioned_docs/version-0.11/devvit_cli.md +++ b/versioned_docs/version-0.11/devvit_cli.md @@ -13,7 +13,7 @@ Bundles all `SVG` files in the `/assets` folder into a new file (`src/icons.ts` #### Usage ```bash -$ devvit create icons [output-file] +devvit create icons [output-file] ``` #### Optional argument @@ -25,7 +25,7 @@ $ devvit create icons [output-file] #### Generating the SVG bundle file ```bash -$ devvit create icons +devvit create icons $ devvit create icons "src/my-icons.ts" ``` @@ -33,15 +33,19 @@ $ devvit create icons "src/my-icons.ts" #### Using the SVG files in app code ```tsx -import { Devvit } from '@devvit/public-api'; -import Icons from './my-icons.ts'; +import { Devvit } from "@devvit/public-api"; +import Icons from "./my-icons.ts"; Devvit.addCustomPostType({ - name: 'my-custom-post', + name: "my-custom-post", render: (_context) => { return ( - + ); }, @@ -57,7 +61,7 @@ Display help for devvit #### Usage ```bash -$ devvit help +devvit help ``` ## devvit install @@ -67,7 +71,7 @@ Install an app from the Apps directory to a subreddit that you moderate. You can #### Usage ```bash -$ devvit install [app-name]@[version] +devvit install [app-name]@[version] ``` #### Required arguments @@ -89,7 +93,7 @@ $ devvit install [app-name]@[version] #### Examples ```bash -$ devvit install r/mySubreddit +devvit install r/mySubreddit $ devvit install mySubreddit my-app @@ -105,7 +109,7 @@ To see a list of apps you've published #### Usage ```bash -$ devvit list apps +devvit list apps ``` ## devvit list installs @@ -117,7 +121,7 @@ If no subreddit is specified, you'll get a list of all apps installed by you. #### Usage ```bash -$ devvit list installs [subreddit] +devvit list installs [subreddit] ``` #### Optional argument @@ -129,7 +133,7 @@ $ devvit list installs [subreddit] #### Examples ```bash -$ devvit list installs +devvit list installs $ devvit list installs mySubreddit @@ -143,7 +147,7 @@ Login to Devvit with your Reddit account in the browser. #### Usage ```bash -$ devvit login [--copy-paste] +devvit login [--copy-paste] ``` #### Optional argument @@ -159,7 +163,7 @@ Logs the current user out of Devvit. #### Usage ```bash -$ devvit logout +devvit logout ``` ## devvit logs @@ -169,7 +173,7 @@ Stream logs for an installation within a specified subreddit. You can see 5,000 #### Usage ```bash -$ devvit logs [app-name] [-d ] [-j] [-s ] [--verbose] +devvit logs [app-name] [-d ] [-j] [-s ] [--verbose] ``` #### Required arguments @@ -213,7 +217,7 @@ $ devvit logs [app-name] [-d ] [-j] [-s ] [--verbose] #### Examples ```bash -$ devvit logs r/mySubreddit +devvit logs r/mySubreddit $ devvit logs mySubreddit my-app @@ -229,7 +233,7 @@ Create a new app. #### Usage ```bash -$ devvit new [directory-name] [--here] +devvit new [directory-name] [--here] ``` #### Optional arguments @@ -245,7 +249,7 @@ $ devvit new [directory-name] [--here] #### Examples ```bash -$ devvit new +devvit new $ devvit new tic-tac-toe @@ -259,7 +263,7 @@ Installs your app to your test subreddit and starts a playtest session. A new ve #### Usage ```bash -$ devvit playtest +devvit playtest ``` #### Optional argument @@ -282,7 +286,7 @@ List settings for your app. These settings exist at the global app-scope and are #### Usage ```bash -$ devvit settings list +devvit settings list ``` ## devvit settings set @@ -292,13 +296,13 @@ Create and update settings for your app. These settings will be added at the glo #### Usage ```bash -$ devvit settings set +devvit settings set ``` #### Example ```bash -$ devvit settings set my-feature-flag +devvit settings set my-feature-flag ``` ## devvit uninstall @@ -308,7 +312,7 @@ Uninstall an app from a specified subreddit. #### Usage ```bash -$ devvit uninstall [app-name] +devvit uninstall [app-name] ``` #### Required argument @@ -324,7 +328,7 @@ $ devvit uninstall [app-name] #### Examples ```bash -$ devvit uninstall r/mySubreddit +devvit uninstall r/mySubreddit $ devvit uninstall mySubreddit @@ -338,7 +342,7 @@ Update @devvit project dependencies to the currently installed CLI's version #### Usage ```bash -$ devvit update app +devvit update app ``` ## devvit upload @@ -348,7 +352,7 @@ Upload an app to the App directory. By default the app is private and visible on #### Usage ```bash -$ devvit upload [--bump major|minor|patch|prerelease] [--copyPaste] +devvit upload [--bump major|minor|patch|prerelease] [--copyPaste] ``` #### Optional arguments @@ -368,7 +372,7 @@ Get the version of the locally installed Devvit CLI. #### Usage ```bash -$ devvit version +devvit version ``` ## devvit view @@ -378,7 +382,7 @@ Shows you the latest version of your app and some data about uploads. Includes a #### Usage​ ```bash -$ devvit view [APPSLUG[@VERSION]] [--json] [version] +devvit view [APPSLUG[@VERSION]] [--json] [version] ``` ## devvit whoami @@ -388,5 +392,5 @@ Display the currently logged in Reddit user. #### Usage ```bash -$ devvit whoami +devvit whoami ``` diff --git a/versioned_docs/version-0.11/devvit_web/devvit_web_overview.mdx b/versioned_docs/version-0.11/devvit_web/devvit_web_overview.mdx index 011172f..e90ece5 100644 --- a/versioned_docs/version-0.11/devvit_web/devvit_web_overview.mdx +++ b/versioned_docs/version-0.11/devvit_web/devvit_web_overview.mdx @@ -15,7 +15,7 @@ or [Discord](https://developers.reddit.com/discord). Devvit Web allows developers to build Devvit apps just like you would for the web. At the core, Devvit Web provides: - **A standard web app** that allows you to build with industry-standard frameworks and technologies (like React, Three.js, or Phaser). -- **Server endpoints** that you define to communicate between the webview client and the Devvit server, using industry-standard frameworks and technologies (like Express.js, Koa, etc.). +- **Server endpoints** that you define to communicate between the webview client and the Devvit server, using industry-standard frameworks and technologies (like Express.js, Hono, Koa, etc.). - **Devvit configuration** with a traditional client/server split. Devvit capabilities are now in one of three places: - Client capabilities in the @devvit/client SDK - Server capabilities, like Redis and Reddit API with the @devvit/server SDK diff --git a/versioned_docs/version-0.11/devvit_web/how_devvit_web_works.mdx b/versioned_docs/version-0.11/devvit_web/how_devvit_web_works.mdx index e842bc9..0528e75 100644 --- a/versioned_docs/version-0.11/devvit_web/how_devvit_web_works.mdx +++ b/versioned_docs/version-0.11/devvit_web/how_devvit_web_works.mdx @@ -8,11 +8,12 @@ Devvit Web uses endpoints between the client and server to make communication si Devvit Web [templates](../devvit_web/devvit_web_templates) all have the same file structure: -```tsx --src - - client / // contains the webview code - -server / // endpoints for the client - -devvit.json; // the devvit config file +```text +. +├── src/ +│ ├── client/ # contains the webview code +│ └── server/ # endpoints for the client +└── devvit.json # the devvit config file ``` Now, instead of passing messages with postMessage (old way), you’ll define /api/endpoints (new way). @@ -25,7 +26,7 @@ When you want to make server-side calls, or use server-side capabilities, you’ ## Server folder -This folder is for server-side code. We provide a node server, and you can use typical node server frameworks like Koa or Express. This is where you can access key capabilities like [Redis](../capabilities/redis), Reddit API client, and [fetch](../capabilities/http-fetch), and you no longer need to add these to a devvit.configure object in order for them to work. +This folder is for server-side code. We provide a node server, and you can use typical node server frameworks like Hono, Koa, or Express. This is where you can access key capabilities like [Redis](../capabilities/redis), Reddit API client, and [fetch](../capabilities/http-fetch), and you no longer need to add these to a devvit.configure object in order for them to work. We also provide an authentication middleware so you don’t have to worry about authentication. diff --git a/versioned_docs/version-0.11/migration_guide.md b/versioned_docs/version-0.11/migration_guide.md index 25a53e5..4c48346 100644 --- a/versioned_docs/version-0.11/migration_guide.md +++ b/versioned_docs/version-0.11/migration_guide.md @@ -23,27 +23,30 @@ Menu items have been simplified: ```ts title="main.tsx" Devvit.addMenuItem({ - label: 'Remind me later', - location: 'post', + label: "Remind me later", + location: "post", onPress: (event, context) => { // if you want to show a form context.ui.showForm(remindMeForm); // if you want to show a toast - context.ui.showToast('hello'); + context.ui.showToast("hello"); }, }); const remindMeForm = Devvit.createForm( { - fields: [{ name: 'when', label: 'When?', type: 'string' }], - title: 'Remind me', - acceptLabel: 'Schedule', + fields: [{ name: "when", label: "When?", type: "string" }], + title: "Remind me", + acceptLabel: "Schedule", }, - remindMeHandler + remindMeHandler, ); -async function remindMeHandler(event: FormOnSubmitEvent, context: Devvit.Context) { +async function remindMeHandler( + event: FormOnSubmitEvent, + context: Devvit.Context, +) { // remind me code here to do something after submitting form } ``` @@ -53,7 +56,7 @@ async function remindMeHandler(event: FormOnSubmitEvent, context: Devvit.Context Because we allow data as an argument in createForm, you can now create dynamic forms. ```ts title="main.tsx" -import { Devvit } from '@devvit/public-api'; +import { Devvit } from "@devvit/public-api"; const dynamicForm = Devvit.createForm( (data) => { @@ -62,24 +65,24 @@ const dynamicForm = Devvit.createForm( return { fields: [ { - name: 'when', + name: "when", label: `a string (default: ${data.text})`, - type: 'string', + type: "string", defaultValue: data.text, }, ], - title: 'Rule Form', - acceptLabel: 'Send Rule', + title: "Rule Form", + acceptLabel: "Send Rule", }; }, ({ values }, ctx) => { return ctx.ui.showToast(`You sent ${values.when}`); - } + }, ); Devvit.addMenuItem({ - label: 'Show a dynamic form', - location: 'post', + label: "Show a dynamic form", + location: "post", onPress: async (_event, { ui }) => { const randomString = Math.random().toString(36).substring(7); @@ -101,7 +104,7 @@ export default Devvit; ```ts title="main.tsx" Devvit.addTrigger({ - event: 'PostSubmit', + event: "PostSubmit", onEvent: (event, context) => { context.scheduler.runJob({ job: test - job }); }, @@ -118,23 +121,23 @@ Devvit.addTrigger({ ```ts title="main.tsx" Devvit.addSchedulerJob({ - name: 'daily-thread', + name: "daily-thread", onRun: async (job, context) => { const subreddit = await context.reddit.getCurrentSubreddit(); const resp = await context.reddit.submitPost({ subredditName: subreddit.name, - title: 'Daily Thread', - text: 'This is a daily thread, commment here!', + title: "Daily Thread", + text: "This is a daily thread, commment here!", }); }, }); // run job once (within a menu item or trigger) -context.scheduler.runJob({ when: now, job: 'test-job' }); +context.scheduler.runJob({ when: now, job: "test-job" }); // run recurring job (within a menu item or trigger) // for tips on cron syntax use https://crontab.guru/ -context.scheduler.runJob({ cron: '* 12 * * * ', job: 'test-job' }); +context.scheduler.runJob({ cron: "* 12 * * * ", job: "test-job" }); ``` ## Plugins @@ -161,30 +164,30 @@ Devvit.configure({ }); Devvit.addMenuItem({ - label: 'Reply to post', - location: 'post', + label: "Reply to post", + location: "post", onPress: async (event, context) => { const response = await context.reddit.submitComment({ id: `t3_${context.postId}`, - text: 'hello world!', + text: "hello world!", }); // if you want to show a toast - context.ui.showToast('Successfully replied!'); + context.ui.showToast("Successfully replied!"); }, }); // alernative w/ deconstruction Devvit.addMenuItem({ - label: 'Reply to post', - location: 'post', + label: "Reply to post", + location: "post", onPress: (event, { reddit }) => { await reddit.submitComment({ id: `t3_${context.postId}`, - text: 'hello world!', + text: "hello world!", }); // if you want to show a toast - context.ui.showToast('Successfully replied!'); + context.ui.showToast("Successfully replied!"); }, }); ``` @@ -197,13 +200,13 @@ Devvit.configure({ }); Devvit.addMenuItem({ - label: 'Fetch a response', - location: 'post', + label: "Fetch a response", + location: "post", onPress: async (event, context) => { - const response = await fetch('https://example.com', { - method: 'post', + const response = await fetch("https://example.com", { + method: "post", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ content: comment?.body }), }); @@ -221,10 +224,10 @@ Devvit.configure({ }); Devvit.addMenuItem({ - label: 'Get from kv store', - location: 'post', + label: "Get from kv store", + location: "post", onPress: async (event, context) => { - const value = await context.kvStore.get('test-key'); + const value = await context.kvStore.get("test-key"); context.ui.showToast(value); }, }); @@ -236,12 +239,12 @@ Devvit.addMenuItem({ // set app configurations Devvit.addSettings([ { - type: 'string', - name: 'apiKey', - label: 'API Key', + type: "string", + name: "apiKey", + label: "API Key", onValidate: ({ value }) => { if (!value || value.length < 10) { - return 'API Key must be at least 10 characters long'; + return "API Key must be at least 10 characters long"; } }, }, @@ -250,16 +253,16 @@ Devvit.addSettings([ // retreiving app configurations Devvit.addMenuItem({ - label: 'Get settings values', - location: 'post', + label: "Get settings values", + location: "post", onPress: async (event, context) => { - console.log('event: ', event); + console.log("event: ", event); - const singleSetting = await context.settings.get('apiKey'); + const singleSetting = await context.settings.get("apiKey"); const allSettings = await context.settings.getAll(); - console.log('All settings: ', allSettings); - console.log('Single setting: ', singleSetting); + console.log("All settings: ", allSettings); + console.log("Single setting: ", singleSetting); }, }); ``` @@ -281,7 +284,7 @@ $ npm i -g devvit To update dependencies in an existing project: ```bash -$ devvit update app +devvit update app ``` ### Moving from public-api-next @@ -302,15 +305,15 @@ Since the context object has different objects attached to it, you can use types ```ts title="main.tsx" // alernative w/ deconstruction Devvit.addMenuItem({ - label: 'Reply to post', - location: 'post', + label: "Reply to post", + location: "post", onPress: (event, { reddit, ui }) => { await reddit.submitComment({ id: `t3_${context.postId}`, - text: 'hello world!', + text: "hello world!", }); // if you want to show a toast - ui.showToast('Successfully replied!'); + ui.showToast("Successfully replied!"); }, }); ``` diff --git a/versioned_docs/version-0.11/playtest.md b/versioned_docs/version-0.11/playtest.md index 53b2e5d..bfd010e 100644 --- a/versioned_docs/version-0.11/playtest.md +++ b/versioned_docs/version-0.11/playtest.md @@ -63,13 +63,13 @@ Exiting the playtest does not uninstall the playtest version or revert your app If you want to revert back to the latest non-playtest version of the app, run the following command from within your project directory: ```bash -$ devvit install +devvit install ``` If you want to revert to a different version of your pre-playtest app, you can specify which version using the `install` command. Entering app name is optional if you are running this command from within your project directory. ```bash -$ devvit install [@version] +devvit install [@version] ``` ## Upload your app @@ -77,7 +77,7 @@ $ devvit install [@version] If you’re satisfied with your playtest app and want to upload an installable version, run: ```bash -$ devvit upload +devvit upload ``` This will automatically bump your app version to the next patch release. For example, if your playtest version is 0.0.1.6, the upload command will remove the playtest version increment and change your app version to 0.0.2. diff --git a/versioned_docs/version-0.12/capabilities/blocks/app_image_assets.md b/versioned_docs/version-0.12/capabilities/blocks/app_image_assets.md index 21ce018..0d87766 100644 --- a/versioned_docs/version-0.12/capabilities/blocks/app_image_assets.md +++ b/versioned_docs/version-0.12/capabilities/blocks/app_image_assets.md @@ -35,7 +35,7 @@ The imageWidth and imageHeight attributes are in device independent pixels (DIPs 2. Use the URL string to get the image’s public URL from the code. ```ts -context.assets.getURL('imageName.jpg'); +context.assets.getURL("imageName.jpg"); ``` :::note @@ -66,20 +66,22 @@ const render: Devvit.CustomPostComponent = () => { }; Devvit.addCustomPostType({ - name: 'My custom post', - description: 'Test custom post for showing a custom asset!', + name: "My custom post", + description: "Test custom post for showing a custom asset!", render, }); Devvit.addMenuItem({ - location: 'subreddit', - label: 'Make custom post with image asset', + location: "subreddit", + label: "Make custom post with image asset", onPress: async (event, context) => { - const subreddit = await context.reddit.getSubredditById(context.subredditId); + const subreddit = await context.reddit.getSubredditById( + context.subredditId, + ); await context.reddit.submitPost({ subredditName: subreddit.name, - title: 'Custom post!', + title: "Custom post!", preview: render(context), }); }, @@ -100,10 +102,10 @@ Devvit.addMenuItem({ ```ts Devvit.addMenuItem({ - location: 'subreddit', - label: 'Get image URL', + location: "subreddit", + label: "Get image URL", onPress: async (event, context) => { - const url = await context.assets.getURL('hello.png'); + const url = await context.assets.getURL("hello.png"); context.ui.showToast(url); // should show 'https://i.redd.it/.png' // and if you go to the URL it showed, it should be your art // Note, it doesn't display the image this way, just the URL as text! diff --git a/versioned_docs/version-0.12/capabilities/blocks/blocks_payments.md b/versioned_docs/version-0.12/capabilities/blocks/blocks_payments.md index d3c8000..4e535ea 100644 --- a/versioned_docs/version-0.12/capabilities/blocks/blocks_payments.md +++ b/versioned_docs/version-0.12/capabilities/blocks/blocks_payments.md @@ -3,7 +3,7 @@ You can use the payments template to build your app or add payment functionality to an existing app. :::note -[Devvit Web](../../capabilities/devvit-web/devvit_web_overview.mdx) is the recommended approach for all interactive experiences. We recommend [migrating your app](../../earn-money/payments/payments_migrate.md) to Devvit Web payments. +[Devvit Web](../../capabilities/devvit-web/devvit_web_overview.mdx) is the recommended approach for all interactive experiences. We recommend [migrating your app](../../earn-money/payments/payments_migrate.mdx) to Devvit Web payments. ::: To start with a template, select the payments template when you create a new project or run: @@ -161,28 +161,28 @@ Errors thrown within the payment handler automatically reject the order. To prov This example shows how to issue an "extra life" to a user when they purchase the "extra_life" product. ```ts -import { type Context } from '@devvit/public-api'; -import { addPaymentHandler } from '@devvit/payments'; -import { Devvit, useState } from '@devvit/public-api'; +import { type Context } from "@devvit/public-api"; +import { addPaymentHandler } from "@devvit/payments"; +import { Devvit, useState } from "@devvit/public-api"; Devvit.configure({ redis: true, redditAPI: true, }); -const GOD_MODE_SKU = 'god_mode'; +const GOD_MODE_SKU = "god_mode"; addPaymentHandler({ fulfillOrder: async (order, ctx) => { if (!order.products.some(({ sku }) => sku === GOD_MODE_SKU)) { - throw new Error('Unable to fulfill order: sku not found'); + throw new Error("Unable to fulfill order: sku not found"); } - if (order.status !== 'PAID') { - throw new Error('Becoming a god has a cost (in Reddit Gold)'); + if (order.status !== "PAID") { + throw new Error("Becoming a god has a cost (in Reddit Gold)"); } const redisKey = godModeRedisKey(ctx.postId, ctx.userId); - await ctx.redis.set(redisKey, 'true'); + await ctx.redis.set(redisKey, "true"); }, }); ``` @@ -204,14 +204,14 @@ Your app can acknowledge or reject the order. For example, for goods with limite Use the `useProducts` hook or `getProducts` function to fetch details about products. ```tsx -import { useProducts } from '@devvit/payments'; +import { useProducts } from "@devvit/payments"; export function ProductsList(context: Devvit.Context): JSX.Element { // Only query for products with the metadata "category" of value "powerup". // The metadata field can be empty - if it is, useProducts will not filter on metadata. const { products } = useProducts(context, { metadata: { - category: 'powerup', + category: "powerup", }, }); diff --git a/versioned_docs/version-0.12/capabilities/blocks/optimize_performance.md b/versioned_docs/version-0.12/capabilities/blocks/optimize_performance.md index f2718aa..031c699 100644 --- a/versioned_docs/version-0.12/capabilities/blocks/optimize_performance.md +++ b/versioned_docs/version-0.12/capabilities/blocks/optimize_performance.md @@ -39,7 +39,7 @@ Use `context.cache` to reduce the amount of requests to optimize performance and ### Leverage scheduled jobs to fetch or update data -Use [scheduler](../server/scheduler.md) to make large data requests in the background and store it in [Redis](../server/redis.mdx) for later use. You can also [fetch data for multiple users](#how-to-cache-data). +Use [scheduler](../server/scheduler.mdx) to make large data requests in the background and store it in [Redis](../server/redis.mdx) for later use. You can also [fetch data for multiple users](#how-to-cache-data). ### Batch API calls to make parallel requests @@ -62,7 +62,7 @@ In Devvit, the first render happens on the server side. Parallel fetch requests In the render function of this interactive post, the app fetches data about the post, the user, the weather, and the leaderboard stats. ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; render: (context) => { const [postInfo] = useState(async () => { @@ -103,7 +103,7 @@ The main difference between these two methods is that `useState` blocks render u This is the best choice for performance because it allows you to render parts of your application while others may still be loading. Here’s how the same example looks for useAsync: ```tsx -import { Devvit, useAsync } from '@devvit/public-api'; +import { Devvit, useAsync } from "@devvit/public-api"; const { data: postInfo, loading: postInfoLoading } = useAsync(async () => { return await getThreadInfo(context); @@ -117,9 +117,11 @@ const { data: weather, loading: weatherLoading } = useAsync(async () => { return await getTheWeather(context); }); -const { data: leaderboardStats, loading: leaderboardStatsLoading } = useAsync(async () => { - return await getLeaderboard(context); -}); +const { data: leaderboardStats, loading: leaderboardStatsLoading } = useAsync( + async () => { + return await getLeaderboard(context); + }, +); ``` #### useState @@ -127,7 +129,7 @@ const { data: leaderboardStats, loading: leaderboardStatsLoading } = useAsync(as This is the same example using useState. ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; render: (context) => { const [appState, setAppState] = useState(async () => { @@ -162,11 +164,11 @@ If you need to update one of the state props, you’ll need to do `setAppState({ The following example shows how unoptimized code for fetching data from an external resource, like a weather API, looks: ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; // naive, non-optimal way of fetching that kind of data const [externalData] = useState(async () => { - const response = await fetch('https://external.weather.com'); + const response = await fetch("https://external.weather.com"); return await response.json(); }); @@ -183,19 +185,19 @@ You can use a cache helper to make one request for data, save the response, and **Example: fetch weather data every 2 hours with cache helper** ```tsx -import { Devvit, useState } from '@devvit/public-api'; +import { Devvit, useState } from "@devvit/public-api"; // optimized, performant way of fetching that kind of data const [externalData] = useState(async () => { return context.cache( async () => { - const response = await fetch('https://external.weather.com'); + const response = await fetch("https://external.weather.com"); return await response.json(); }, { key: `weather_data`, ttl: 2 * 60 * 60 * 1000, // 2 hours in milliseconds - } + }, ); }); ``` @@ -206,35 +208,35 @@ Do not cache sensitive information. Cache helper randomly selects one user to ma ### Solution: schedule a job -Alternatively, you can use [scheduler](../server/scheduler.md) to make the request in background, save the response to [Redis](../server/redis.mdx), and avoid unnecessary requests to the external resource. +Alternatively, you can use [scheduler](../server/scheduler.mdx) to make the request in background, save the response to [Redis](../server/redis.mdx), and avoid unnecessary requests to the external resource. **Example: fetch weather data every 2 hours with a scheduled job** ```tsx -import { Devvit } from '@devvit/public-api'; +import { Devvit } from "@devvit/public-api"; Devvit.addSchedulerJob({ - name: 'fetch_weather_data', + name: "fetch_weather_data", onRun: async (_event, context) => { - const response = await fetch('https://external.weather.com'); + const response = await fetch("https://external.weather.com"); const responseData = await response.json(); - await context.redis.set('weather_data', JSON.stringify(responseData)); + await context.redis.set("weather_data", JSON.stringify(responseData)); }, }); Devvit.addTrigger({ - event: 'AppInstall', + event: "AppInstall", onEvent: async (_event, context) => { await context.scheduler.runJob({ - cron: '0 */2 * * *', // runs at the top of every second hour - name: 'fetch_weather_data', + cron: "0 */2 * * *", // runs at the top of every second hour + name: "fetch_weather_data", }); }, }); // inside the render method const [externalData] = useState(async () => { - return context.redis.get('fetch_weather_data'); + return context.redis.get("fetch_weather_data"); }); export default Devvit; @@ -254,9 +256,9 @@ Before using realtime, the leaderboard fetching code looked like this: ```tsx const getLeaderboard = async () => - await context.redis.zRange('leaderboard', 0, 5, { + await context.redis.zRange("leaderboard", 0, 5, { reverse: true, - by: 'rank', + by: "rank", }); const [leaderboard, setLeaderboard] = useState(async () => { @@ -274,7 +276,7 @@ leaderboardInterval.start(); And code for updating the leaderboard looked like this: ```tsx -await context.redis.zAdd('leaderboard', { member: username, score: gameScore }); +await context.redis.zAdd("leaderboard", { member: username, score: gameScore }); ``` ### With realtime​ @@ -285,9 +287,12 @@ This is the updated game completion code: ```tsx // stays as is -await context.redis.zAdd('leaderboard', { member: username, score: gameScore }); +await context.redis.zAdd("leaderboard", { member: username, score: gameScore }); // new code -context.realtime.send('leaderboard_updates', { member: username, score: gameScore }); +context.realtime.send("leaderboard_updates", { + member: username, + score: gameScore, +}); ``` Now replace the interval with the realtime subscription: @@ -298,7 +303,7 @@ const [leaderboard, setLeaderboard] = useState(async () => { }); // stays as is const channel = useChannel({ - name: 'leaderboard_updates', + name: "leaderboard_updates", onMessage: (newLeaderboardEntry) => { const newLeaderboard = [...leaderboard, newLeaderboardEntry] // append new entry .sort((a, b) => b.score - a.score) // sort by score @@ -342,9 +347,12 @@ To do this, you can add: ```tsx const [subscriberCount] = useState(async () => { const startSubscribersRequest = Date.now(); // a reference point for the request start - const devvitSubredditInfo = await context.reddit.getSubredditInfoByName('devvit'); + const devvitSubredditInfo = + await context.reddit.getSubredditInfoByName("devvit"); - console.log(`subscribers request took: ${Date.now() - startSubscribersRequest} milliseconds`); + console.log( + `subscribers request took: ${Date.now() - startSubscribersRequest} milliseconds`, + ); return devvitSubredditInfo.subscribersCount || 0; }); @@ -359,7 +367,9 @@ const [performanceStartRender] = useState(Date.now()); // a reference point for Add a console.log before the return statement: ```tsx -console.log(`Getting the data took: ${Date.now() - performanceStartRender} milliseconds`); +console.log( + `Getting the data took: ${Date.now() - performanceStartRender} milliseconds`, +); ``` All of that put together will look like this: diff --git a/versioned_docs/version-0.12/capabilities/client/forms.mdx b/versioned_docs/version-0.12/capabilities/client/forms.mdx index 4298196..e70f868 100644 --- a/versioned_docs/version-0.12/capabilities/client/forms.mdx +++ b/versioned_docs/version-0.12/capabilities/client/forms.mdx @@ -170,6 +170,78 @@ For forms that open from a menu item, you can use menu responses. This is useful ``` **Server endpoint that shows form via menu response:** + + + + + ```ts title="server/index.ts" + // Menu action that triggers menu response form + app.post('/internal/menu/start-workflow', async (c) => { + // Server processing before showing form + const userData = await fetchUserData(); + + return c.json({ + showForm: { + name: 'nameForm', + form: { + fields: [ + { + type: 'string', + name: 'name', + label: 'Name', + }, + ], + }, + data: { name: userData.name } // Pre-populate from server + } + }); + }); + + // Form submission handler that can chain to another form + app.post('/internal/form/name-submit', async (c) => { + const { name } = await c.req.json(); + + // Server processing + await saveUserName(name); + + // Show next form in workflow + return c.json({ + showForm: { + name: 'reviewForm', + form: { + fields: [ + { + type: 'paragraph', + name: 'review', + label: 'How was your experience?', + }, + ], + } + } + }); + }); + + app.post('/internal/form/review-submit', async (c) => { + const { review } = await c.req.json(); + + await saveReview(review); + + return c.json({ + showToast: 'Thank you for your feedback!' + }); + }); + ``` + + + + ```ts title="server/index.ts" import { UIResponse } from '@devvit/web/shared'; @@ -230,6 +302,9 @@ For forms that open from a menu item, you can use menu responses. This is useful }); ``` + + +
@@ -591,6 +666,53 @@ Below is a collection of common use cases and patterns. } ``` + + + + ```ts title="server/index.ts" + // Endpoint that shows form with dynamic data + app.post('/internal/menu/show-dynamic-form', async (c) => { + const user = await reddit.getCurrentUser(); + + return c.json({ + showForm: { + name: 'dynamicForm', + form: { + fields: [ + { + type: 'string', + name: 'username', + label: 'Username', + }, + ], + }, + data: { + username: user?.username || '' + } + } + }); + }); + + // Form submission handler + app.post('/internal/form/dynamic-submit', async (c) => { + const { username } = await c.req.json(); + + return c.json({ + showToast: `Hello ${username}` + }); + }); + ``` + + + + ```ts title="server/index.ts" // Endpoint that shows form with dynamic data router.post("/internal/menu/show-dynamic-form", async (_req, res: Response) => { @@ -624,6 +746,9 @@ Below is a collection of common use cases and patterns. }); }); ``` + + + ```tsx @@ -753,6 +878,74 @@ Below is a collection of common use cases and patterns. } ``` + + + + ```ts title="server/index.ts" + // Step 1: Name form + app.post('/internal/form/step1-submit', async (c) => { + const { name } = await c.req.json(); + + return c.json({ + showForm: { + name: 'step2Form', + form: { + fields: [ + { + type: 'string', + name: 'food', + label: "What's your favorite food?", + required: true, + }, + ], + }, + data: { name } // Pass data to next step + } + }); + }); + + // Step 2: Food form + app.post('/internal/form/step2-submit', async (c) => { + const { name, food } = await c.req.json(); + + return c.json({ + showForm: { + name: 'step3Form', + form: { + fields: [ + { + type: 'string', + name: 'drink', + label: "What's your favorite drink?", + required: true, + }, + ], + }, + data: { name, food } // Pass accumulated data + } + }); + }); + + // Step 3: Final form + app.post('/internal/form/step3-submit', async (c) => { + const { name, food, drink } = await c.req.json(); + + return c.json({ + showToast: `Thanks ${name}! You like ${food} and ${drink}.` + }); + }); + ``` + + + + ```ts title="server/index.ts" // Step 1: Name form router.post("/internal/form/step1-submit", async (req, res: Response) => { @@ -807,6 +1000,9 @@ Below is a collection of common use cases and patterns. }); }); ``` + + + ```tsx @@ -979,6 +1175,87 @@ This example includes one of each of the [supported field types](#supported-fiel } ``` + + + + ```ts title="server/index.ts" + app.post('/internal/form/everything-submit', async (c) => { + const formValues = await c.req.json(); + console.log('Form values:', formValues); + + return c.json({ + showToast: 'Thanks!' + }); + }); + + // Example showing the form + app.post('/internal/menu/show-everything-form', async (c) => { + return c.json({ + showForm: { + name: 'everythingForm', + form: { + title: 'My favorites', + description: 'Tell us about your favorite food!', + fields: [ + { + type: 'string', + name: 'food', + label: 'What is your favorite food?', + helpText: 'Must be edible', + required: true, + }, + { + label: 'About that food', + type: 'group', + fields: [ + { + type: 'number', + name: 'times', + label: 'How many times a week do you eat it?', + defaultValue: 1, + }, + { + type: 'paragraph', + name: 'what', + label: 'What makes it your favorite?', + }, + { + type: 'select', + name: 'healthy', + label: 'Is it healthy?', + options: [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + { label: 'Maybe', value: 'maybe' }, + ], + defaultValue: ['maybe'], + }, + ], + }, + { + type: 'boolean', + name: 'again', + label: 'Can we ask again?', + }, + ], + acceptLabel: 'Submit', + cancelLabel: 'Cancel', + } + } + }); + }); + ``` + + + + ```ts title="server/index.ts" router.post("/internal/form/everything-submit", async (req, res: Response) => { console.log('Form values:', req.body); @@ -1045,6 +1322,9 @@ This example includes one of each of the [supported field types](#supported-fiel }); }); ``` + + + ```tsx @@ -1161,6 +1441,31 @@ This example includes one of each of the [supported field types](#supported-fiel } ``` + + + + ```ts title="server/index.ts" + app.post('/internal/form/image-submit', async (c) => { + const { myImage } = await c.req.json(); + // Use the mediaUrl to store in redis and display it in an block, or send to external service to modify + console.log('Image uploaded:', myImage); + + return c.json({ + showToast: 'Image uploaded successfully!' + }); + }); + ``` + + + + ```ts title="server/index.ts" router.post("/internal/form/image-submit", async (req, res: Response) => { const { myImage } = req.body; @@ -1172,6 +1477,9 @@ This example includes one of each of the [supported field types](#supported-fiel }); }); ``` + + + ```tsx diff --git a/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx b/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx index d178533..eaaf0bd 100644 --- a/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx +++ b/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # Menu Actions @@ -16,30 +16,55 @@ Add an item to the three dot menu for posts, comments, or subreddits. Menu actio **Menu items defined in devvit.json:** - ```json title="devvit.json" - { - "menu": { - "items": [ - { - "description": "Show user information", - "endpoint": "/internal/menu/show-info", - "location": "post" - } - ] - } +```json title="devvit.json" +{ + "menu": { + "items": [ + { + "description": "Show user information", + "endpoint": "/internal/menu/show-info", + "location": "post" + } + ] } - ``` +} +``` - **Simple endpoint with direct client effects:** +**Simple endpoint with direct client effects:** + + + + +```ts title="server/index.ts" +app.post("/internal/menu/show-info", async (c) => { + // Simple actions don't need server processing + return c.json({ + showToast: "Menu action clicked!", + }); +}); +``` - ```ts title="server/index.ts" - router.post("/internal/menu/show-info", async (_req, res) => { - // Simple actions don't need server processing - res.json({ - showToast: 'Menu action clicked!' - }); + + + +```ts title="server/index.ts" +app.post("/internal/menu/show-info", async (_req, res) => { + // Simple actions don't need server processing + res.json({ + showToast: "Menu action clicked!", }); - ``` +}); +``` + + + @@ -47,44 +72,45 @@ Add an item to the three dot menu for posts, comments, or subreddits. Menu actio ```tsx import { Devvit } from '@devvit/public-api'; - // Simple menu action with direct client effects - Devvit.addMenuItem({ - label: 'Show user info', - location: 'post', // 'post', 'comment', 'subreddit', or array - onPress: async (event, context) => { - // Direct client effect - no server processing needed - context.ui.showToast('Menu action clicked!'); - }, - }); +// Simple menu action with direct client effects +Devvit.addMenuItem({ +label: 'Show user info', +location: 'post', // 'post', 'comment', 'subreddit', or array +onPress: async (event, context) => { +// Direct client effect - no server processing needed + context.ui.showToast('Menu action clicked!'); +}, +}); - // Menu action with form - const surveyForm = Devvit.createForm( - { - fields: [ - { - type: 'string', - name: 'feedback', - label: 'Your feedback', - }, - ], - }, - (event, context) => { - // onSubmit handler - context.ui.showToast({ text: `Thanks for the feedback: ${event.values.feedback}` }); - } - ); - - Devvit.addMenuItem({ - label: 'Quick survey', - location: 'subreddit', - forUserType: 'moderator', // Optional: restrict to moderators - onPress: async (event, context) => { - context.ui.showForm(surveyForm); - }, - }); - ``` +// Menu action with form +const surveyForm = Devvit.createForm( +{ +fields: [ +{ +type: 'string', +name: 'feedback', +label: 'Your feedback', +}, +], +}, +(event, context) => { +// onSubmit handler +context.ui.showToast({ text: `Thanks for the feedback: ${event.values.feedback}` }); +} +); + +Devvit.addMenuItem({ +label: 'Quick survey', +location: 'subreddit', +forUserType: 'moderator', // Optional: restrict to moderators +onPress: async (event, context) => { +context.ui.showForm(surveyForm); +}, +}); - +```` + +
## Supported Contexts @@ -106,89 +132,142 @@ For moderator permission security, when opening a form from a menu action with ` In Devvit Web, your menu item should respond with a client side effect to give feedback to users. This is available as a UIResponse as you do not have access to the `@devvit/web/client` library from your server endpoints. - - - **Menu items with server processing:** - - ```json title="devvit.json" - { - "menu": { - "items": [ - { - "label": "Process and validate data", - "endpoint": "/internal/menu/complex-action", - "forUserType": "moderator", - "location": "subreddit" - } - ] - } + + +**Menu items with server processing:** + +```json title="devvit.json" +{ + "menu": { + "items": [ + { + "label": "Process and validate data", + "endpoint": "/internal/menu/complex-action", + "forUserType": "moderator", + "location": "subreddit" + } + ] } - ``` +} +```` + + + + +```ts title="server/index.ts" +app.post("/internal/menu/complex-action", async (c) => { + try { + // Perform server-side processing + const userData = await validateAndProcessData(); + + // Show form with server-fetched data + return c.json({ + showForm: { + name: "processForm", + form: { + fields: [ + { + type: "string", + name: "processedData", + label: "Processed Data", + }, + ], + }, + data: { processedData: userData.processed }, + }, + }); + } catch (error) { + return c.json({ + showToast: "Processing failed. Please try again.", + }); + } +}); +``` - ```ts title="server/index.ts" - import { UIResponse } from '@devvit/web/shared'; - - router.post("/internal/menu/complex-action", async (_req, res: Response) => { + + + +```ts title="server/index.ts" +import { UIResponse } from "@devvit/web/shared"; + +app.post( + "/internal/menu/complex-action", + async (_req, res: Response) => { try { // Perform server-side processing const userData = await validateAndProcessData(); - + // Show form with server-fetched data res.json({ showForm: { - name: 'processForm', + name: "processForm", form: { fields: [ { - type: 'string', - name: 'processedData', - label: 'Processed Data', + type: "string", + name: "processedData", + label: "Processed Data", }, ], }, - data: { processedData: userData.processed } - } + data: { processedData: userData.processed }, + }, }); } catch (error) { res.json({ - showToast: 'Processing failed. Please try again.' + showToast: "Processing failed. Please try again.", }); } - }); - ``` + }, +); +``` + + + For Devvit Blocks, use the direct context approach even for complex workflows: - ```tsx - Devvit.addMenuItem({ - label: 'Process and validate data', - location: 'post', // 'post', 'comment', 'subreddit', or array - forUserType: 'moderator', // Optional: restrict to moderators - onPress: async (event, context) => { - try { - // Perform server-side processing - const userData = await validateAndProcessData(); - - // Show form with server-fetched data - const result = await context.ui.showForm({ +```tsx +Devvit.addMenuItem({ + label: "Process and validate data", + location: "post", // 'post', 'comment', 'subreddit', or array + forUserType: "moderator", // Optional: restrict to moderators + onPress: async (event, context) => { + try { + // Perform server-side processing + const userData = await validateAndProcessData(); + + // Show form with server-fetched data + const result = await context.ui.showForm( + { fields: [ { - type: 'string', - name: 'processedData', - label: 'Processed Data', + type: "string", + name: "processedData", + label: "Processed Data", }, ], - }, (values) => { + }, + (values) => { context.ui.showToast(`Processed: ${values.processedData}`); - }); - } catch (error) { - context.ui.showToast('Processing failed. Please try again.'); - } - }, - }); - ``` + }, + ); + } catch (error) { + context.ui.showToast("Processing failed. Please try again."); + } + }, +}); +``` + @@ -197,27 +276,100 @@ In Devvit Web, your menu item should respond with a client side effect to give f Menu responses can trigger any client effect after server processing: **Show toast after processing:** + + + + +```ts +return c.json({ + showToast: { + text: "Processing completed!", + appearance: "success", + }, +}); +``` + + + + ```ts res.json({ showToast: { - text: 'Processing completed!', - appearance: 'success' - } + text: "Processing completed!", + appearance: "success", + }, }); ``` + + + **Navigate after data fetching:** + + + + +```ts +const post = await reddit.getPostById(postId); +return c.json({ + navigateTo: post, +}); +``` + + + + ```ts const post = await reddit.getPostById(postId); res.json({ - navigateTo: post + navigateTo: post, }); ``` + + + **Chain multiple forms:** + + + + ```ts +// First form response leads to second form +return c.json({ + showForm: { + name: 'secondForm', + form: { fields: [...] }, + data: { fromStep1: processedData } + } +}); +``` + + +```ts // First form response leads to second form res.json({ showForm: { @@ -228,6 +380,9 @@ res.json({ }); ``` + + + ## Limitations - A sort order of actions in the context menu can't be specified. diff --git a/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_configuration.md b/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_configuration.md index 4e81e89..d4f3526 100644 --- a/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_configuration.md +++ b/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_configuration.md @@ -1,6 +1,6 @@ # Configure Your App -The devvit.json file serves as your app's configuration file. Use it to specify entry points, configure features like [event triggers](../server/triggers) and [scheduled actions](../server/scheduler.md), and enable app functionality such as [image uploads](../server/media-uploads.mdx). This page covers all available devvit.json configuration options. A complete devvit.json example file is provided [here](#complete-example). +The devvit.json file serves as your app's configuration file. Use it to specify entry points, configure features like [event triggers](../server/triggers) and [scheduled actions](../server/scheduler.mdx), and enable app functionality such as [image uploads](../server/media-uploads.mdx). This page covers all available devvit.json configuration options. A complete devvit.json example file is provided [here](#complete-example). ## devvit.json @@ -67,9 +67,10 @@ Additionally, you must include at least one of: ### Development -| Property | Type | Description | Required | -| -------- | ------ | ------------------------- | -------- | -| `dev` | object | Development configuration | No | +| Property | Type | Description | Required | +| --------- | ------ | --------------------------------------------------- | -------- | +| `dev` | object | Development configuration | No | +| `scripts` | object | Build commands run by the Devvit CLI (optional) | No | ## Detailed configuration @@ -281,6 +282,24 @@ Configure app presentation: - `icon` (string): Path to 1024x1024 PNG icon (required) +### Scripts configuration + +Configure build commands run by the Devvit CLI. These commands run relative to the `devvit.json` directory. + +```json +{ + "scripts": { + "dev": "vite build --watch", + "build": "vite build" + } +} +``` + +**Properties:** + +- `dev` (string): Command run by `devvit playtest` to build or watch your client/server artifacts +- `build` (string): Command run by `devvit upload` to build your client/server artifacts + ### Development configuration Configure development settings: @@ -384,6 +403,10 @@ The `devvit.json` configuration is validated against the JSON Schema at build ti }, "dev": { "subreddit": "my-test-sub" + }, + "scripts": { + "dev": "vite build --watch", + "build": "vite build" } } ``` diff --git a/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_overview.mdx b/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_overview.mdx index 90edf5b..9da13ae 100644 --- a/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_overview.mdx +++ b/versioned_docs/version-0.12/capabilities/devvit-web/devvit_web_overview.mdx @@ -1,4 +1,4 @@ -import DevvitWebArch from '../../assets/devvit_web/devvit_web_arch.png'; +import DevvitWebArch from "../../assets/devvit_web/devvit_web_arch.png"; # Devvit Web @@ -9,7 +9,7 @@ Devvit Web includes an easy way to build Devvit apps using a standard web stack. Devvit Web allows developers to build Devvit apps just like you would for the web. At the core, Devvit Web provides: - **A standard web app** that allows you to build with industry-standard frameworks and technologies (like React, Three.js, or Phaser). -- **Server endpoints** that you define to communicate between the webview client and the Devvit server, using industry-standard frameworks and technologies (like Express.js, Koa, etc.). +- **Server endpoints** that you define to communicate between the webview client and the Devvit server, using industry-standard frameworks and technologies (like Express.js, Hono, Koa, etc.). - **Devvit configuration** with a traditional client/server split. Devvit capabilities are now in one of three places: - A configuration file in devvit.json for defining app metadata, permissions, and capabilities - Client capabilities in the @devvit/client SDK @@ -23,21 +23,21 @@ In addition, since you’re working with standard web technologies your apps sho Visit [https://developers.reddit.com/new](https://developers.reddit.com/new) and choose one of our templates or take a look at the github repositories: -* [React](https://github.com/reddit/devvit-template-react) -* [Phaser](https://github.com/reddit/devvit-template-phaser) -* [Three.js](https://github.com/reddit/devvit-template-threejs) -* [Hello World](https://github.com/reddit/devvit-template-hello-world) +- [React](https://github.com/reddit/devvit-template-react) +- [Phaser](https://github.com/reddit/devvit-template-phaser) +- [Three.js](https://github.com/reddit/devvit-template-threejs) +- [Hello World](https://github.com/reddit/devvit-template-hello-world) ## Limitations As with most experimental features, there are some caveats. -| Limitation | What it means | -| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Serverless endpoints | The node server will run just long enough to execute your endpoint function and return a response, which means you can’t use packages that require long-running connections like streaming. | +| Limitation | What it means | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Serverless endpoints | The node server will run just long enough to execute your endpoint function and return a response, which means you can’t use packages that require long-running connections like streaming. | | Package limitations | Developers cannot use `fs` or external native packages. For now, we recommend using external services over the native dependencies, such as [StreamPot](https://streampot.io/) (instead of ffmpeg) and [OpenAI](https://platform.openai.com/docs/guides/embeddings) (instead of @xenova/transformers) . | -| Single request and single response handling only | Streaming or chunked responses and websockets are not supported. Long-polling is supported if it’s under the max request time. | -| No external requests from your client | You can’t have any external requests other than the app's webview domain. All backend responses are locked down to the webview domain via CSP. (Your backend can make external fetch requests though.) | +| Single request and single response handling only | Streaming or chunked responses and websockets are not supported. Long-polling is supported if it’s under the max request time. | +| No external requests from your client | You can’t have any external requests other than the app's webview domain. All backend responses are locked down to the webview domain via CSP. (Your backend can make external fetch requests though.) | Devvit Web still has the same technical requirements: @@ -47,7 +47,6 @@ Devvit Web still has the same technical requirements: - Max response size: 10MB - HTML/CSS/JS only - ## Devvit Web components Devvit Web uses endpoints between the client and server to make communication similar to standard web apps. A Devvit Web app has three components: @@ -58,11 +57,12 @@ Devvit Web uses endpoints between the client and server to make communication si Devvit Web templates all have the same file structure: -```tsx -- src - - client / // contains the webview code - - server / // endpoints for the client -- devvit.json; // the devvit config file +```text +. +├── src/ +│ ├── client/ # contains the webview code +│ └── server/ # endpoints for the client +└── devvit.json # the devvit config file ``` Now, instead of passing messages with postMessage (old way), you’ll define `/api/endpoints` (new way). @@ -75,7 +75,7 @@ When you want to make server-side calls, or use server-side capabilities, you’ ### Server folder -This folder includes server-side code. We provide a node server, and you can use typical node server frameworks like Koa or Express. This is where you can access key capabilities like [Redis](../server/redis.mdx), Reddit API client, and [fetch](../server/http-fetch.mdx). +This folder includes server-side code. We provide a node server, and you can use typical node server frameworks like Hono, Koa, or Express. This is where you can access key capabilities like [Redis](../server/redis.mdx), Reddit API client, and [fetch](../server/http-fetch.mdx). We also provide an authentication middleware so you don’t have to worry about authentication. @@ -83,7 +83,11 @@ We also provide an authentication middleware so you don’t have to worry about All server endpoints must start with `/api/` (e.g. `/api/get-something` or `/api/widgets/42`). ::: -devvit web architecture +devvit web architecture ### Configuration in `devvit.json` diff --git a/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx b/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx index 508eba6..ecd4065 100644 --- a/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx +++ b/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx @@ -63,6 +63,74 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t + + + + ```tsx title="server/index.ts" + import { Hono } from 'hono'; + import { cache, context, createServer, getServerPort, reddit } from '@devvit/web/server'; + + const app = new Hono(); + + app.get('/api/subreddit', async (c) => { + const { postId } = context; + + if (!postId) { + console.error('API Subreddit Error: postId not found in devvit context'); + return c.json( + { + status: 'error', + message: 'postId is required but missing from context', + }, + 400 + ); + } + + try { + const subredditName = await cache( + async () => { + const subreddit = await reddit.getCurrentSubreddit(); + if (!subreddit) { + throw new Error('Subreddit is required but missing from context'); + } + return subreddit.name; + }, + { + key: 'current_subreddit', + ttl: 24 * 60 * 60, // expire after one day. + } + ); + console.log(`Current subreddit: ${subredditName}`); + + return c.json({ + type: 'subreddit', + subreddit: subredditName, + }); + } catch (error) { + console.error(`API Subreddit Error for post ${postId}:`, error); + let errorMessage = 'Unknown error during subreddit retrieval'; + if (error instanceof Error) { + errorMessage = `Subreddit retrieval failed: ${error.message}`; + } + return c.json({ status: 'error', message: errorMessage }, 400); + } + }); + + const server = createServer(app); + server.on('error', (err) => console.error(`server error; ${err.stack}`)); + server.listen(getServerPort()); + ``` + + + + ```tsx title="server/index.ts" import express from "express"; import { @@ -138,6 +206,9 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t server.on("error", (err) => console.error(`server error; ${err.stack}`)); server.listen(getServerPort()); ``` + + + diff --git a/versioned_docs/version-0.12/capabilities/server/http-fetch.mdx b/versioned_docs/version-0.12/capabilities/server/http-fetch.mdx index 3b9463e..3ea13c2 100644 --- a/versioned_docs/version-0.12/capabilities/server/http-fetch.mdx +++ b/versioned_docs/version-0.12/capabilities/server/http-fetch.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # HTTP Fetch @@ -27,13 +27,13 @@ Your Devvit app can make network requests to access allow-listed external domain ```ts import { Devvit } from '@devvit/public-api'; - Devvit.configure({ - http: { - domains: ['my-site.com', 'another-domain.net'], - }, - }); +Devvit.configure({ +http: { +domains: ['my-site.com', 'another-domain.net'], +}, +}); -``` +```` @@ -66,13 +66,13 @@ Apps must request each individual domain that it intends to fetch, even if the d - + Devvit Web applications have two different contexts for using fetch: - + ### Server-side fetch - + Server-side fetch allows your app to make HTTP requests to allowlisted external domains from your server-side code (e.g., API routes, server actions): - + ```tsx title="server/index.ts" const response = await fetch('https://example.com/api/data', { method: 'GET', @@ -80,42 +80,43 @@ Apps must request each individual domain that it intends to fetch, even if the d 'Content-Type': 'application/json', }, }); - + const data = await response.json(); console.log('External API response:', data); - ``` - - ### Client-side fetch - - Client-side fetch has different restrictions and can only make requests to your own webview domain: - - **Client-side restrictions:** - - **Domain limitation**: Can only make requests to your own webview domain - - **Endpoint requirement**: All requests must target endpoints that end with `/api` - - **Authentication**: Handled automatically - no need to manage auth tokens - - **No external domains**: Cannot make requests to external domains from client-side code - +```` + +### Client-side fetch + +Client-side fetch has different restrictions and can only make requests to your own webview domain: + +**Client-side restrictions:** + +- **Domain limitation**: Can only make requests to your own webview domain +- **Endpoint requirement**: All requests must target endpoints that end with `/api` +- **Authentication**: Handled automatically - no need to manage auth tokens +- **No external domains**: Cannot make requests to external domains from client-side code + ```tsx title="client/index.ts" - const handleFetchData = async () => { - // ✅ Correct: Fetching your own webview's API endpoint - const response = await fetch('/api/user-data', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const data = await response.json(); - console.log('API response:', data); - }; - - // ❌ Incorrect: Cannot fetch external domains from client-side - // const response = await fetch('https://external-api.com/data'); - - // ❌ Incorrect: Endpoint must end with /api - // const response = await fetch('/user-data'); - ``` - +const handleFetchData = async () => { + // ✅ Correct: Fetching your own webview's API endpoint + const response = await fetch("/api/user-data", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + console.log("API response:", data); +}; + +// ❌ Incorrect: Cannot fetch external domains from client-side +// const response = await fetch('https://external-api.com/data'); + +// ❌ Incorrect: Endpoint must end with /api +// const response = await fetch('/user-data'); +``` + @@ -158,7 +159,7 @@ Apps must request each individual domain that it intends to fetch, even if the d If you see the following error, it means HTTP Fetch requests are hitting the internal timeout limits. To resolve this: -- Use a queue or kick off an async request in your back end. You can use [Scheduler](./scheduler.md) to monitor the result. +- Use a queue or kick off an async request in your back end. You can use [Scheduler](./scheduler.mdx) to monitor the result. - Optimize the overall HTTP request latency if you have a self-hosted server. ```ts @@ -208,4 +209,3 @@ The following domains are globally allowed and can be fetched by any app: - pbs.org - i.giphy.com - chessboardjs.com - diff --git a/versioned_docs/version-0.12/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md b/versioned_docs/version-0.12/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md index 5b9eee5..82cbf88 100644 --- a/versioned_docs/version-0.12/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md +++ b/versioned_docs/version-0.12/capabilities/server/launch_screen_and_entry_points/view_modes_entry_points.md @@ -20,7 +20,7 @@ Devvit apps support two view modes: ## Multiple Entry Points -Multiple entry points let the user start the game from different contexts or states. For example, you can have a button that launches into a leaderboard view and another for a specific game mode, each of these would be configured as an entry point for your app. You can define multiple entry points in your `devvit.json` and `src/client/vite.config.ts` to create different experiences: +Multiple entry points let the user start the game from different contexts or states. For example, you can have a button that launches into a leaderboard view and another for a specific game mode, each of these would be configured as an entry point for your app. Define multiple entry points in your `devvit.json`. If you use the [Devvit Vite plugin](../../../guides/tools/vite), it automatically infers the client build inputs from these entrypoints, so you don't need to maintain a custom Rollup `input` list. ```js title="devvit.json" { @@ -30,7 +30,7 @@ Multiple entry points let the user start the game from different contexts or sta "default": { "entry": "preview.html", "height": "regular", - "inline": true + "inline": true }, "game": { "entry": "game.html" @@ -44,32 +44,13 @@ Multiple entry points let the user start the game from different contexts or sta ``` ```ts title="vite.config.ts" -import { defineConfig } from 'vite'; -import tailwind from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwind from "@tailwindcss/vite"; +import { devvit } from "@devvit/start/vite"; -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), tailwind()], - build: { - outDir: '../../dist/client', - sourcemap: true, - rollupOptions: { - input: { - default: resolve(dirname(fileURLToPath(import.meta.url)), 'preview.html'), - game: resolve(dirname(fileURLToPath(import.meta.url)), 'game.html'), - leaderboard: resolve(dirname(fileURLToPath(import.meta.url)), 'leaderboard.html'), - }, - output: { - entryFileNames: '[name].js', - chunkFileNames: '[name].js', - assetFileNames: '[name][extname]', - sourcemapFileNames: '[name].js.map', - }, - }, - }, + plugins: [react(), tailwind(), devvit()], }); ``` @@ -78,6 +59,7 @@ export default defineConfig({ ```tsx your-app/ ├── devvit.json +├── vite.config.ts ├── src/ │ ├── server/ │ │ └── index.ts @@ -94,23 +76,23 @@ your-app/ └── styles.css ``` -The `dir` property specifies where your built client files are located. During development, your build process (e.g., Vite, webpack) typically compiles files from `src/client/` to `dist/client/`. The entry paths are relative to this `dir` location. +The `dir` property specifies where your built client files are located. With the Devvit Vite plugin, the `entry` values point at your source HTML files (for example `src/client/preview.html`), and the plugin outputs the matching files into `dist/client` during `vite build`. ### Creating Posts with Specific Entry Points Use the `entry` parameter when creating posts to specify which entry point from your `devvit.json` configuration to use. The entry value must match one of the keys defined in `post.entrypoints`. ```tsx title="server/index.ts" -import { reddit } from '@devvit/web/server'; +import { reddit } from "@devvit/web/server"; // Create a post using the default entrypoint async function createDefaultPost(context: any) { return await reddit.submitCustomPost({ subredditName: context.subredditName!, - title: 'Adventure Game', - entry: 'default', + title: "Adventure Game", + entry: "default", postData: { - gameState: 'menu', + gameState: "menu", }, }); } @@ -119,10 +101,10 @@ async function createDefaultPost(context: any) { async function createGamePost(context: any) { return await reddit.submitCustomPost({ subredditName: context.subredditName!, - title: 'Adventure Game', - entry: 'game', // Must match a key in devvit.json entrypoints + title: "Adventure Game", + entry: "game", // Must match a key in devvit.json entrypoints postData: { - gameState: 'active', + gameState: "active", initialized: true, }, }); @@ -141,14 +123,14 @@ async function createGamePost(context: any) { You can transition from inline mode to expanded mode with a different entry point, like this: ```tsx -import { requestExpandedMode } from '@devvit/web/client'; +import { requestExpandedMode } from "@devvit/web/client"; // Switch to the 'game' entrypoint in expanded mode const handleStartGame = async (event: React.MouseEvent) => { try { - await requestExpandedMode(event.nativeEvent, 'game'); + await requestExpandedMode(event.nativeEvent, "game"); } catch (error) { - console.error('Failed to enter expanded mode:', error); + console.error("Failed to enter expanded mode:", error); } }; ``` diff --git a/versioned_docs/version-0.12/capabilities/server/overview.md b/versioned_docs/version-0.12/capabilities/server/overview.md index a36be05..1fdcc26 100644 --- a/versioned_docs/version-0.12/capabilities/server/overview.md +++ b/versioned_docs/version-0.12/capabilities/server/overview.md @@ -20,7 +20,7 @@ Allows you to query information from Reddit such as comments, posts and upvotes. Allows you to store app data in a scalable database, free of charge. Limited to the installation scope of the application. -## [Scheduler](./scheduler.md) +## [Scheduler](./scheduler.mdx) Allows you to run automated server-side tasks on a schedule, for example, checking for updates every hour. diff --git a/versioned_docs/version-0.12/capabilities/server/post-data.mdx b/versioned_docs/version-0.12/capabilities/server/post-data.mdx index 1bb20f5..d92ba4c 100644 --- a/versioned_docs/version-0.12/capabilities/server/post-data.mdx +++ b/versioned_docs/version-0.12/capabilities/server/post-data.mdx @@ -19,6 +19,60 @@ When creating a post, include the `postData` parameter with your custom data obj + + + + ```ts title="server/index.ts" + import { context, reddit } from '@devvit/web/server'; + + app.post('/api/create-post', async (c) => { + const { subredditName } = context; + + if (!subredditName) { + return c.json({ error: 'Subreddit name is required' }, 400); + } + + const post = await reddit.submitCustomPost({ + subredditName, + title: 'Post with custom data', + entry: 'default', + postData: { + challengeNumber: 42, + totalGuesses: 0, + gameState: 'active', + pixels: [ + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] + ], + }, + }); + + return c.json({ + postId: post.id, + message: 'Post created successfully', + }); + }); + ``` + + + + ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; @@ -61,6 +115,9 @@ When creating a post, include the `postData` parameter with your custom data obj }); }); ``` + + + ```ts title="main.tsx" @@ -99,6 +156,54 @@ To update post data after creation, fetch the post and use the `setPostData()` m + + + + ```ts title="server/index.ts" + import { context, reddit } from '@devvit/web/server'; + + app.post('/api/update-post-data', async (c) => { + const { postId } = context; + const { favoriteColor, username } = await c.req.json(); + + if (!postId) { + return c.json({ error: 'Post ID is required' }, 400); + } + + try { + const post = await reddit.getPostById(postId); + + // Get existing post data to merge with updates + const currentData = context.postData || {}; + + await post.setPostData({ + ...currentData, + favoriteColor: favoriteColor || 'unknown', + lastUpdatedBy: username || 'anonymous', + lastUpdatedAt: new Date().toISOString(), + }); + + return c.json({ + success: true, + message: 'Post data updated successfully', + }); + } catch (error) { + console.error('Error updating post data:', error); + return c.json({ error: 'Failed to update post data' }, 500); + } + }); + ``` + + + + ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; @@ -137,6 +242,9 @@ To update post data after creation, fetch the post and use the `setPostData()` m } }); ``` + + + ```ts title="main.tsx" diff --git a/versioned_docs/version-0.12/capabilities/server/redis.mdx b/versioned_docs/version-0.12/capabilities/server/redis.mdx index 2796c21..bb806b1 100644 --- a/versioned_docs/version-0.12/capabilities/server/redis.mdx +++ b/versioned_docs/version-0.12/capabilities/server/redis.mdx @@ -44,16 +44,45 @@ All limits are applied at a per-installation granularity. ] } ``` + + + ```ts title="server/index.ts" - // Assumes Express.js import { redis } from '@devvit/redis'; + + app.post('/internal/menu/redis-test', async (c) => { + const key = 'hello'; + await redis.set(key, 'world'); + const value = await redis.get(key); + console.log(`${key}: ${value}`); + return c.json({ status: 'ok' }); + }); + ``` + + + + + ```ts title="server/index.ts" + import { redis } from '@devvit/redis'; + router.post("/internal/menu/redis-test", async (_req, res: Response) => { const key = 'hello'; await redis.set(key, 'world'); const value = await redis.get(key); console.log(`${key}: ${value}`); + res.json({ status: 'ok' }); }); ``` + + + ```ts @@ -916,7 +945,174 @@ Register your form handler, menu trigger, and scheduler endpoint here. } ``` -Add these route handlers to your Express app. +Add these route handlers to your server. + + + + +```ts +import { redis, scheduler } from '@devvit/web/server'; +// Import the compressed client +import { redisCompressed } from '@devvit/redis'; + +const MY_DATA_HASH_KEY = 'my:app:large:dataset'; + +// 1. Menu Endpoint: Returns the form definition +app.post('/internal/menu/ops/migrate-example', async (c) => { + return c.json({ + showForm: { + name: 'migrateExampleForm', // Must match key in devvit.json "forms" + form: { + title: 'Migrate Hash to Compression', + acceptLabel: 'Start Migration', + fields: [ + { + name: 'startCursor', + label: 'Start Cursor (0 for beginning)', + type: 'string', + defaultValue: '0', + }, + { + name: 'chunkSize', + label: 'Items per batch', + type: 'number', + defaultValue: 20000, + }, + ], + }, + }, + }); +}); + +// 2. Form Handler: Receives input and schedules the first job +app.post('/internal/form/ops/migrate-example', async (c) => { + const { startCursor, chunkSize } = await c.req.json().catch(() => ({})); + const cursor = startCursor || '0'; + const size = Number(chunkSize) || 20000; + + console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); + + // Kick off the first job in the chain + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), // Run immediately + data: { + cursor, + chunkSize: size, + processed: 0, + }, + }); + + return c.json({ + showToast: { + text: 'Migration started in background', + appearance: 'success', + }, + }); +}); + +// 3. Scheduler Endpoint: The recursive worker +app.post('/internal/scheduler/migrate-example-data', async (c) => { + const startTime = Date.now(); + + try { + const body = await c.req.json().catch(() => ({})); + const data = body.data ?? {}; + + let cursor = Number(data.cursor) || 0; + const chunkSize = Number(data.chunkSize) || 20000; + const processedTotal = Number(data.processed) || 0; + + console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); + + let keepRunning = true; + let processedInJob = 0; + const SCAN_COUNT = 250; // Internal batch size to keep event loop moving + + while (keepRunning) { + // Stop if we've processed enough items for this single execution + if (processedInJob >= chunkSize) { + break; + } + + const { cursor: nextCursor, fieldValues } = await redis.hScan( + MY_DATA_HASH_KEY, + cursor, + undefined, // match pattern + SCAN_COUNT + ); + + // Parallel Processing: + // We treat the batch as a set of promises to execute simultaneously. + // Promise.allSettled ensures one failure doesn't crash the whole job. + await Promise.allSettled( + fieldValues.map(async ({ field, value }) => { + // LOGIC: + // 1. We read the raw value. + // 2. We write it back using 'redisCompressed'. + // The proxy detects the write and compresses the string if beneficial. + if (value && value.length > 0) { + await redisCompressed.hSet(MY_DATA_HASH_KEY, { [field]: value }); + } + }) + ); + + processedInJob += fieldValues.length; + + // Cursor logic: 0 means iteration is complete + if (nextCursor === 0) { + cursor = 0; + keepRunning = false; + } else { + cursor = nextCursor; + } + + // Safety: Check execution time. + // If we are close to 30s (Devvit limit), stop early and requeue. + if (Date.now() - startTime > 20000) { + console.log('[Migration] Time limit approaching, stopping early.'); + keepRunning = false; + } + } + + const newTotal = processedTotal + processedInJob; + + // Daisy Chaining: + // If the cursor is not 0, we still have more data to scan. + // We schedule *this same job* to run again immediately. + if (cursor !== 0) { + console.log(`[Migration] Requeueing. Next cursor: ${cursor}. Processed so far: ${newTotal}`); + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), + data: { + cursor, + chunkSize, + processed: newTotal, + }, + }); + + return c.json({ status: 'requeued', processed: newTotal, cursor }); + } + + console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); + return c.json({ status: 'success', processed: newTotal }); + } catch (error) { + console.error('[Migration] Critical Job Error', error); + return c.json({ status: 'error', message: error.message }, 500); + } +}); +``` + + + ```ts import { redis, scheduler } from '@devvit/web/server'; @@ -1072,6 +1268,9 @@ app.post('/internal/scheduler/migrate-example-data', async (req, res) => { }); ``` + + + Note that the job may timeout, in which case you will need to find the last logged cursor to start the menu item action job again. Try adjusting the chunk size if you experience timeouts. You can monitor the migration progress using the logs command: diff --git a/versioned_docs/version-0.12/capabilities/server/scheduler.md b/versioned_docs/version-0.12/capabilities/server/scheduler.md deleted file mode 100644 index 8d9e0bb..0000000 --- a/versioned_docs/version-0.12/capabilities/server/scheduler.md +++ /dev/null @@ -1,247 +0,0 @@ -# Scheduler - -The scheduler allows your app to perform actions at specific times, such as sending private messages, tracking upvotes, or scheduling timeouts for user actions. You can schedule both recurring and one-off jobs using the scheduler. - ---- - -## Scheduling recurring jobs - -To create a regularly occurring event in your app, declare a task in your `devvit.json` and handle the event in your server logic. - -### 1. Add a recurring task to `devvit.json` - -Ensure the endpoint follows the format `/internal/.+` and specify a `cron` schedule: - -```json title="devvit.json" -"scheduler": { - "tasks": { - "regular-interval-example-task": { - "endpoint": "/internal/scheduler/regular-interval-task-example", - "cron": "*/1 * * * *" - } - } -}, -``` - -- The `cron` parameter uses the standard [UNIX cron format](https://en.wikipedia.org/wiki/Cron): - ``` - # * * * * * - # | | | | | - # | | | | day of the week (0–6, Sunday to Saturday; 7 is also Sunday on some systems) - # | | | month (1–12) - # | | day of the month (1–31) - # | hour (0–23) - # minute (0–59) - ``` -- We recommend using [Cronitor](https://crontab.guru/) to build cron strings. - -### 2. Handle the event in your server - -```ts title=/server/index.ts -router.post('/internal/scheduler/regular-interval-task-example', async (req, res) => { - console.log(`Handle event for cron example at ${new Date().toISOString()}!`); - // Handle the event here - res.status(200).json({ status: 'ok' }); -}); -``` - ---- - -## Scheduling one-off jobs at runtime - -One-off tasks must also be declared in `devvit.json`. - -### 1. Add the tasks to `devvit.json` - -```json title='devvit.json' -"scheduler": { - "tasks": { - "regular-interval-task-example": { - "endpoint": "/internal/scheduler/regular-interval-task-example", - "cron": "*/1 * * * *" - }, - "one-off-task-example": { - "endpoint": "/internal/scheduler/one-off-task-example" - } - } -} -``` - -### 2. Schedule a job at runtime - -Example usage: - -```ts -import { scheduler } from '@devvit/web/server'; - -// Handle the occurrence of the event -router.post('/internal/scheduler/one-off-task-example', async (req, res) => { - const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); - - let scheduledJob: ScheduledJob = { - id: `job-one-off-for-post${postId}`, - name: 'one-off-task-example', - data: { postId }, - runAt: oneMinuteFromNow, - }; - - let jobId = await scheduler.runJob(scheduledJob); - console.log(`Scheduled job ${jobId} for post ${postId}`); - console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); - // Handle the event here - res.status(200).json({ status: 'ok' }); -}); -``` - -## Cancel a scheduled job - -Use the job ID to cancel a scheduled action and remove it from your app. This example shows how to set up a moderator menu action to cancel a job. - -### 1. Add menu item to `devvit.json` - -```json title="devvit.json" -{ - "menu": { - "items": [ - { - "label": "Cancel Job", - "description": "Cancel a scheduled job", - "forUserType": "moderator", - "location": "post", - "endpoint": "/internal/menu/cancel-job" - } - ] - }, - "permissions": { - "redis": true - } -} -``` - -### 2. Handle the menu action in your server - -```ts title="server/index.ts" -import { redis } from '@devvit/redis'; -import { scheduler } from '@devvit/web/server'; - -router.post('/internal/menu/cancel-job', async (req, res) => { - try { - // Get the post ID from the menu action request - const postId = req.body.targetId; - - // Retrieve the job ID from Redis (stored when the job was created) - const jobId = await redis.get(`job:${postId}`); - - if (!jobId) { - return res.json({ - showToast: { - text: 'No scheduled job found for this post', - appearance: 'neutral', - }, - }); - } - - // Cancel the scheduled job - await scheduler.cancelJob(jobId); - - // Clean up the stored job ID - await redis.del(`job:${postId}`); - - res.json({ - showToast: { - text: 'Successfully cancelled the scheduled job', - appearance: 'success', - }, - }); - } catch (error) { - console.error('Error cancelling job:', error); - res.json({ - showToast: { - text: 'Failed to cancel job', - appearance: 'neutral', - }, - }); - } -}); -``` - -### Example: Storing a job ID when creating a job - -When you create a scheduled job, store its ID in Redis so you can reference it later - -```ts title="server/index.ts" -router.post('/api/schedule-action', async (req, res) => { - const { postId, delayMinutes } = req.body; - const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); - - const scheduledJob: ScheduledJob = { - id: `job-${postId}-${Date.now()}`, - name: 'one-off-task-example', - data: { postId }, - runAt, - }; - - const jobId = await scheduler.runJob(scheduledJob); - - // Store the job ID in Redis for later cancellation - await redis.set(`job:${postId}`, jobId); - - res.json({ - jobId, - message: 'Job scheduled successfully', - }); -}); -``` - -## List jobs - -This example shows how to handle a request within your server/index.ts to list your scheduled jobs and return them to the client. - -```ts title="server/index.ts" -router.get("/api/list-jobs", async (_req, res): Promise => { - try { - const jobs: (ScheduledJob | ScheduledCronJob)[] = await scheduler.listJobs(); - - console.log(`[LIST] Found ${jobs.length} scheduled jobs`); - - res.json({ - status: "success", - jobs: jobs, - count: jobs.length - }); - } catch (error) { - console.error(`[LIST] Error listing jobs:`, error); - res.status(500).json({ - status: "error", - message: error instanceof Error ? error.message : "Failed to list jobs" - }); - } -``` - -## Faster scheduler - -:::note -This feature is experimental, which means the design is not final but it's still available for you to use. -::: - -Scheduled jobs currently perform one scheduled run per minute. To go faster, you can now run jobs every second by adding seconds granularity to your cron expression. - -```tsx -await scheduler.runJob({ - name: 'run_every_30_seconds', - cron: '*/30 * * * * *', -}); -``` - -How frequent a scheduled job runs will depend on how long the job takes to complete and how many jobs are running in parallel. This means a job may take a bit longer than scheduled, but the overall resolution should be better than a minute. - ---- - -## Limitations - -_Limits are per installation of an app:_ - -1. An installation can have up to **10 live recurring actions**. -2. The `runJob()` method enforces two rate limits when creating actions: - - **Creation rate:** Up to 60 calls to `runJob()` per minute - - **Delivery rate:** Up to 60 deliveries per minute diff --git a/versioned_docs/version-0.12/capabilities/server/scheduler.mdx b/versioned_docs/version-0.12/capabilities/server/scheduler.mdx new file mode 100644 index 0000000..a0d9e70 --- /dev/null +++ b/versioned_docs/version-0.12/capabilities/server/scheduler.mdx @@ -0,0 +1,432 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + +# Scheduler + +The scheduler allows your app to perform actions at specific times, such as sending private messages, tracking upvotes, or scheduling timeouts for user actions. You can schedule both recurring and one-off jobs using the scheduler. + +--- + +## Scheduling recurring jobs + +To create a regularly occurring event in your app, declare a task in your `devvit.json` and handle the event in your server logic. + +### 1. Add a recurring task to `devvit.json` + +Ensure the endpoint follows the format `/internal/.+` and specify a `cron` schedule: + +```json title="devvit.json" +"scheduler": { + "tasks": { + "regular-interval-example-task": { + "endpoint": "/internal/scheduler/regular-interval-task-example", + "cron": "*/1 * * * *" + } + } +}, +``` + +- The `cron` parameter uses the standard [UNIX cron format](https://en.wikipedia.org/wiki/Cron): + ``` + # * * * * * + # | | | | | + # | | | | day of the week (0–6, Sunday to Saturday; 7 is also Sunday on some systems) + # | | | month (1–12) + # | | day of the month (1–31) + # | hour (0–23) + # minute (0–59) + ``` +- We recommend using [Cronitor](https://crontab.guru/) to build cron strings. + +### 2. Handle the event in your server + + + + +```ts title="/server/index.ts" +app.post("/internal/scheduler/regular-interval-task-example", async (c) => { + console.log(`Handle event for cron example at ${new Date().toISOString()}!`); + // Handle the event here + return c.json({ status: "ok" }, 200); +}); +``` + + + + +```ts title="/server/index.ts" +app.post( + "/internal/scheduler/regular-interval-task-example", + async (_req, res) => { + console.log( + `Handle event for cron example at ${new Date().toISOString()}!`, + ); + // Handle the event here + res.status(200).json({ status: "ok" }); + }, +); +``` + + + + +--- + +## Scheduling one-off jobs at runtime + +One-off tasks must also be declared in `devvit.json`. + +### 1. Add the tasks to `devvit.json` + +```json title='devvit.json' +"scheduler": { + "tasks": { + "regular-interval-task-example": { + "endpoint": "/internal/scheduler/regular-interval-task-example", + "cron": "*/1 * * * *" + }, + "one-off-task-example": { + "endpoint": "/internal/scheduler/one-off-task-example" + } + } +} +``` + +### 2. Schedule a job at runtime + +Example usage: + + + + +```ts title="/server/index.ts" +app.post("/internal/scheduler/one-off-task-example", async (c) => { + const { postId } = await c.req.json(); + const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); + + const scheduledJob: ScheduledJob = { + id: `job-one-off-for-post${postId}`, + name: "one-off-task-example", + data: { postId }, + runAt: oneMinuteFromNow, + }; + + const jobId = await scheduler.runJob(scheduledJob); + console.log(`Scheduled job ${jobId} for post ${postId}`); + console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); + // Handle the event here + return c.json({ status: "ok" }, 200); +}); +``` + + + + +```ts title="/server/index.ts" +app.post("/internal/scheduler/one-off-task-example", async (req, res) => { + const { postId } = req.body; + const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); + + const scheduledJob: ScheduledJob = { + id: `job-one-off-for-post${postId}`, + name: "one-off-task-example", + data: { postId }, + runAt: oneMinuteFromNow, + }; + + const jobId = await scheduler.runJob(scheduledJob); + console.log(`Scheduled job ${jobId} for post ${postId}`); + console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); + // Handle the event here + res.status(200).json({ status: "ok" }); +}); +``` + + + + +## Cancel a scheduled job + +Use the job ID to cancel a scheduled action and remove it from your app. This example shows how to set up a moderator menu action to cancel a job. + +### 1. Add menu item to `devvit.json` + +```json title="devvit.json" +{ + "menu": { + "items": [ + { + "label": "Cancel Job", + "description": "Cancel a scheduled job", + "forUserType": "moderator", + "location": "post", + "endpoint": "/internal/menu/cancel-job" + } + ] + }, + "permissions": { + "redis": true + } +} +``` + +### 2. Handle the menu action in your server + + + + +```ts title="/server/index.ts" +app.post("/internal/menu/cancel-job", async (c) => { + try { + // Get the post ID from the menu action request + const { targetId: postId } = await c.req.json(); + + // Retrieve the job ID from Redis (stored when the job was created) + const jobId = await redis.get(`job:${postId}`); + + if (!jobId) { + return c.json({ + showToast: { + text: "No scheduled job found for this post", + appearance: "neutral", + }, + }); + } + + // Cancel the scheduled job + await scheduler.cancelJob(jobId); + + // Clean up the stored job ID + await redis.del(`job:${postId}`); + + return c.json({ + showToast: { + text: "Successfully cancelled the scheduled job", + appearance: "success", + }, + }); + } catch (error) { + console.error("Error cancelling job:", error); + return c.json({ + showToast: { + text: "Failed to cancel job", + appearance: "neutral", + }, + }); + } +}); +``` + + + + +```ts title="/server/index.ts" +app.post("/internal/menu/cancel-job", async (req, res) => { + try { + // Get the post ID from the menu action request + const postId = req.body.targetId; + + // Retrieve the job ID from Redis (stored when the job was created) + const jobId = await redis.get(`job:${postId}`); + + if (!jobId) { + return res.json({ + showToast: { + text: "No scheduled job found for this post", + appearance: "neutral", + }, + }); + } + + // Cancel the scheduled job + await scheduler.cancelJob(jobId); + + // Clean up the stored job ID + await redis.del(`job:${postId}`); + + return res.json({ + showToast: { + text: "Successfully cancelled the scheduled job", + appearance: "success", + }, + }); + } catch (error) { + console.error("Error cancelling job:", error); + return res.json({ + showToast: { + text: "Failed to cancel job", + appearance: "neutral", + }, + }); + } +}); +``` + + + + +### Example: Storing a job ID when creating a job + +When you create a scheduled job, store its ID in Redis so you can reference it later + + + + +```ts title="/server/index.ts" +app.post("/api/schedule-action", async (c) => { + const { postId, delayMinutes } = await c.req.json(); + const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); + + const scheduledJob: ScheduledJob = { + id: `job-${postId}-${Date.now()}`, + name: "one-off-task-example", + data: { postId }, + runAt, + }; + + const jobId = await scheduler.runJob(scheduledJob); + + // Store the job ID in Redis for later cancellation + await redis.set(`job:${postId}`, jobId); + + return c.json({ + jobId, + message: "Job scheduled successfully", + }); +}); +``` + + + + +```ts title="/server/index.ts" +app.post("/api/schedule-action", async (req, res) => { + const { postId, delayMinutes } = req.body; + const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); + + const scheduledJob: ScheduledJob = { + id: `job-${postId}-${Date.now()}`, + name: "one-off-task-example", + data: { postId }, + runAt, + }; + + const jobId = await scheduler.runJob(scheduledJob); + + // Store the job ID in Redis for later cancellation + await redis.set(`job:${postId}`, jobId); + + return res.json({ + jobId, + message: "Job scheduled successfully", + }); +}); +``` + + + + +## List jobs + +This example shows how to handle a request within your server/index.ts to list your scheduled jobs and return them to the client. + + + + +```ts title="/server/index.ts" +app.get("/api/list-jobs", async (c) => { + try { + const jobs: (ScheduledJob | ScheduledCronJob)[] = + await scheduler.listJobs(); + + console.log(`[LIST] Found ${jobs.length} scheduled jobs`); + + return c.json({ + status: "success", + jobs, + count: jobs.length, + }); + } catch (error) { + console.error(`[LIST] Error listing jobs:`, error); + return c.json( + { + status: "error", + message: error instanceof Error ? error.message : "Failed to list jobs", + }, + 500, + ); + } +}); +``` + + + + +```ts title="/server/index.ts" +app.get("/api/list-jobs", async (_req, res): Promise => { + try { + const jobs: (ScheduledJob | ScheduledCronJob)[] = + await scheduler.listJobs(); + + console.log(`[LIST] Found ${jobs.length} scheduled jobs`); + + res.json({ + status: "success", + jobs, + count: jobs.length, + }); + } catch (error) { + console.error(`[LIST] Error listing jobs:`, error); + res.status(500).json({ + status: "error", + message: error instanceof Error ? error.message : "Failed to list jobs", + }); + } +}); +``` + + + + +## Faster scheduler + +:::note +This feature is experimental, which means the design is not final but it's still available for you to use. +::: + +Scheduled jobs currently perform one scheduled run per minute. To go faster, you can now run jobs every second by adding seconds granularity to your cron expression. + +```tsx +await scheduler.runJob({ + name: "run_every_30_seconds", + cron: "*/30 * * * * *", +}); +``` + +How frequent a scheduled job runs will depend on how long the job takes to complete and how many jobs are running in parallel. This means a job may take a bit longer than scheduled, but the overall resolution should be better than a minute. + +--- + +## Limitations + +_Limits are per installation of an app:_ + +1. An installation can have up to **10 live recurring actions**. +2. The `runJob()` method enforces two rate limits when creating actions: + - **Creation rate:** Up to 60 calls to `runJob()` per minute + - **Delivery rate:** Up to 60 deliveries per minute diff --git a/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx b/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx index 98a7654..f7359a0 100644 --- a/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx +++ b/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx @@ -190,6 +190,47 @@ Settings can be retrieved from within your app. + + + + ```tsx title="server/index.ts" + import { settings } from '@devvit/web/server'; + + // Get a single setting + const apiKey = await settings.get('apiKey'); + + // Get multiple settings + const [welcomeMessage, features] = await Promise.all([ + settings.get('welcomeMessage'), + settings.get('enabledFeatures') + ]); + + // Use in an endpoint + app.post('/api/process', async (c) => { + const apiKey = await settings.get('apiKey'); + const environment = await settings.get('environment'); + + const response = await fetch('https://api.example.com/endpoint', { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'X-Environment': environment + } + }); + + return c.json({ success: true }); + }); + ``` + + + + ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; @@ -217,6 +258,9 @@ Settings can be retrieved from within your app. res.json({ success: true }); }); ``` + + + ```tsx title="main.tsx" @@ -289,6 +333,43 @@ Validate user input to ensure it meets your requirements before saving. } ``` + + + + ```tsx title="server/index.ts" + import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; + + app.post('/internal/settings/validate-age', async (c) => { + const { value } = await c.req.json(); + + if (!value || value < 0) { + return c.json({ + success: false, + error: 'Age must be a positive number', + }); + } + + if (value > 365) { + return c.json({ + success: false, + error: 'Maximum age is 365 days', + }); + } + + return c.json({ success: true }); + }); + ``` + + + + ```tsx title="server/index.ts" import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; @@ -320,6 +401,9 @@ Validate user input to ensure it meets your requirements before saving. } ); ``` + + + Add an `onValidate` handler to your setting definition: @@ -391,6 +475,48 @@ Here's a complete example showing both secrets and subreddit settings in action: } ``` + + + + ```tsx title="server/index.ts" + import { settings } from '@devvit/web/server'; + + app.post('/api/generate', async (c) => { + const [apiKey, model, maxTokens] = await Promise.all([ + settings.get('openaiApiKey'), + settings.get('aiModel'), + settings.get('maxTokens') + ]); + const { messages } = await c.req.json(); + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + messages, + }), + }); + + const data = await response.json(); + return c.json(data); + }); + ``` + + + + ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; @@ -418,6 +544,9 @@ Here's a complete example showing both secrets and subreddit settings in action: res.json(data); }); ``` + + + ```tsx title="main.tsx" diff --git a/versioned_docs/version-0.12/capabilities/server/triggers.mdx b/versioned_docs/version-0.12/capabilities/server/triggers.mdx index e923baf..7928af0 100644 --- a/versioned_docs/version-0.12/capabilities/server/triggers.mdx +++ b/versioned_docs/version-0.12/capabilities/server/triggers.mdx @@ -1,5 +1,5 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; # Triggers @@ -43,26 +43,28 @@ A full list of events and their payloads can be found in the [EventTypes documen Declare the triggers and their corresponding endpoints in your `devvit.json`: - ```json - "triggers": { - "onAppUpgrade": "/internal/on-app-upgrade", - "onCommentCreate": "/internal/on-comment-create", - "onPostSubmit": "/internal/on-post-submit" - } - ``` +```json +"triggers": { + "onAppUpgrade": "/internal/on-app-upgrade", + "onCommentCreate": "/internal/on-comment-create", + "onPostSubmit": "/internal/on-post-submit" +} +``` + Declare the triggers in your `devvit.json`: - ```json - { - "name": "your-app-name", - "blocks": { - "entry": "src/main.tsx", - "triggers": ["onPostCreate"] - } +```json +{ + "name": "your-app-name", + "blocks": { + "entry": "src/main.tsx", + "triggers": ["onPostCreate"] } - ``` +} +``` + @@ -72,65 +74,113 @@ A full list of events and their payloads can be found in the [EventTypes documen Listen for the events in your server and access the data passed into the request: + + + +```tsx title="server/index.ts" +app.post('/internal/on-app-upgrade', async (c) => { + console.log('Handle event for on-app-upgrade!'); + const body = await c.req.json(); + const installer = body.installer; + console.log('Installer:', JSON.stringify(installer, null, 2)); + return c.json({ status: 'ok' }); +}); + +app.post('/internal/on-comment-create', async (c) => { + console.log('Handle event for on-comment-create!'); + const body = await c.req.json(); + const comment = body.comment; + const author = body.author; + console.log('Comment:', JSON.stringify(comment, null, 2)); + console.log('Author:', JSON.stringify(author, null, 2)); + return c.json({ status: 'ok' }); +}); + +app.post('/internal/on-post-submit', async (c) => { + console.log('Handle event for on-post-submit!'); + const body = await c.req.json(); + const post = body.post; + const author = body.author; + console.log('Post:', JSON.stringify(post, null, 2)); + console.log('Author:', JSON.stringify(author, null, 2)); + return c.json({ status: 'ok' }); +}); +``` + + + + ```tsx title="server/index.ts" - const router = express.Router(); - - // .. - - router.post('/internal/on-app-upgrade', async (req, res) => { - console.log(`Handle event for on-app-upgrade!`); - const installer = req.body.installer; - console.log('Installer:', JSON.stringify(installer, null, 2)); - res.status(200).json({ status: 'ok' }); - }); - - router.post('/internal/on-comment-create', async (req, res) => { - console.log(`Handle event for on-comment-create!`); - const comment = req.body.comment; - const author = req.body.author; - console.log('Comment:', JSON.stringify(comment, null, 2)); - console.log('Author:', JSON.stringify(author, null, 2)); - res.status(200).json({ status: 'ok' }); - }); - - router.post('/internal/on-post-submit', async (req, res) => { - console.log(`Handle event for on-post-submit!`); - const post = req.body.post; - const author = req.body.author; - console.log('Post:', JSON.stringify(post, null, 2)); - console.log('Author:', JSON.stringify(author, null, 2)); - res.status(200).json({ status: 'ok' }); - }); - ``` +const router = express.Router(); + +// .. + +router.post("/internal/on-app-upgrade", async (req, res) => { + console.log(`Handle event for on-app-upgrade!`); + const installer = req.body.installer; + console.log("Installer:", JSON.stringify(installer, null, 2)); + res.status(200).json({ status: "ok" }); +}); + +router.post("/internal/on-comment-create", async (req, res) => { + console.log(`Handle event for on-comment-create!`); + const comment = req.body.comment; + const author = req.body.author; + console.log("Comment:", JSON.stringify(comment, null, 2)); + console.log("Author:", JSON.stringify(author, null, 2)); + res.status(200).json({ status: "ok" }); +}); + +router.post("/internal/on-post-submit", async (req, res) => { + console.log(`Handle event for on-post-submit!`); + const post = req.body.post; + const author = req.body.author; + console.log("Post:", JSON.stringify(post, null, 2)); + console.log("Author:", JSON.stringify(author, null, 2)); + res.status(200).json({ status: "ok" }); +}); +``` + + + + Handle trigger events in your main file. Example (`src/main.tsx`): - ```tsx - import { Devvit } from '@devvit/public-api'; - - // Handling a PostSubmit event - Devvit.addTrigger({ - event: 'PostSubmit', // Event name from above - onEvent: async (event) => { - console.log(`Received OnPostSubmit event:\n${JSON.stringify(event)}`); - }, - }); - - // Handling multiple events: PostUpdate and PostReport - Devvit.addTrigger({ - events: ['PostUpdate', 'PostReport'], // An array of events - onEvent: async (event) => { - if (event.type == 'PostUpdate') { - console.log(`Received OnPostUpdate event:\n${JSON.stringify(request)}`); - } else if (event.type === 'PostReport') { - console.log(`Received OnPostReport event:\n${JSON.stringify(request)}`); - } - }, - }); - - export default Devvit; - ``` +```tsx +import { Devvit } from "@devvit/public-api"; + +// Handling a PostSubmit event +Devvit.addTrigger({ + event: "PostSubmit", // Event name from above + onEvent: async (event) => { + console.log(`Received OnPostSubmit event:\n${JSON.stringify(event)}`); + }, +}); + +// Handling multiple events: PostUpdate and PostReport +Devvit.addTrigger({ + events: ["PostUpdate", "PostReport"], // An array of events + onEvent: async (event) => { + if (event.type == "PostUpdate") { + console.log(`Received OnPostUpdate event:\n${JSON.stringify(request)}`); + } else if (event.type === "PostReport") { + console.log(`Received OnPostReport event:\n${JSON.stringify(request)}`); + } + }, +}); + +export default Devvit; +``` + diff --git a/versioned_docs/version-0.12/capabilities/server/userActions.md b/versioned_docs/version-0.12/capabilities/server/userActions.mdx similarity index 76% rename from versioned_docs/version-0.12/capabilities/server/userActions.md rename to versioned_docs/version-0.12/capabilities/server/userActions.mdx index 0416b33..0c89a11 100644 --- a/versioned_docs/version-0.12/capabilities/server/userActions.md +++ b/versioned_docs/version-0.12/capabilities/server/userActions.mdx @@ -1,3 +1,6 @@ +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + # User Actions User actions allow your app to submit posts, submit comments, and subscribe to the current subreddit on behalf of the logged in user. These actions occur on the logged in user's account instead of the app account. This enables stronger user engagement while ensuring user control and transparency. @@ -85,6 +88,44 @@ Apps that use `submitPost()` with `runAs: 'USER'` require `userGeneratedContent` This example uses a form to prompt the user for input and then submits a post as the user. + + + +```tsx title="server/index.ts" +import { reddit } from '@devvit/web/server'; + +// ... + +app.post('/internal/post-create', async (c) => { + const { subredditName } = context; + if (!subredditName) { + return c.json({ status: 'error', message: 'subredditName is required' }, 400); + } + + reddit.submitPost({ + runAs: 'USER', + userGeneratedContent: { + text: "Hello there! This is a new post from the user's account", + }, + subredditName, + title: 'Post Title', + entry: 'default', + }); + + return c.json({ status: 'success', message: `Post created in subreddit ${subredditName}` }); +}); +``` + + + + ```tsx title="server/index.ts" import { reddit } from '@devvit/web/server'; @@ -111,12 +152,41 @@ router.post('/internal/post-create', async (_req, res) => { }); ``` + + + --- ## Example: Subscribe to current subreddit The [subscribeToCurrentSubreddit()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#subscribetocurrentsubreddit) API does not take a `runAs` parameter; it subscribes as the user by default (if specified in `devvit.json` and approved). + + + +```ts +import { reddit } from '@devvit/web/server'; + +app.post('/api/subscribe', async (c) => { + try { + await reddit.subscribeToCurrentSubreddit(); + return c.json({ status: 'success' }); + } catch (error) { + return c.json({ status: 'error', message: 'Failed to subscribe' }, 500); + } +}); +``` + + + + ```ts import { reddit } from '@devvit/web/server'; @@ -130,4 +200,7 @@ router.post('/api/subscribe', async (_req, res) => { }); ``` + + + For user privacy there is no API to check if the user is already subscribed to the current subreddit. You may want to store the subscription state in Redis to provide contextually aware UI. diff --git a/docs/earn-money/payments/payments_add.md b/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx similarity index 82% rename from docs/earn-money/payments/payments_add.md rename to versioned_docs/version-0.12/earn-money/payments/payments_add.mdx index b1e4e7f..bdca007 100644 --- a/docs/earn-money/payments/payments_add.md +++ b/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx @@ -1,9 +1,12 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + # Add Payments The Devvit payments API is available in Devvit Web. Keep reading to learn how to configure your products and accept payments. :::note -Devvit Web is recommended for payments. Check out how to [migrate blocks apps](./payments_migrate.md) if you're app is currently using a blocks version of payments. +Devvit Web is recommended for payments. Check out how to [migrate blocks apps](./payments_migrate.mdx) if you're app is currently using a blocks version of payments. ::: To start with a template, select the payments template when you create a new project or run: @@ -45,15 +48,42 @@ You can reference an external `products.json` file, or define products directly. Create endpoints to fulfill and optionally revoke purchases. + + + +```tsx title="server/index.ts" +import type { PaymentHandlerResponse } from "@devvit/web/server"; + +app.post("/internal/payments/fulfill", async (c) => { + // Fulfill the order (grant entitlements, record delivery, etc.) + return c.json({ success: true } satisfies PaymentHandlerResponse); +}); + +app.post("/internal/payments/refund", async (c) => { + // Optionally revoke entitlements for a refunded order + return c.json({ success: true } satisfies PaymentHandlerResponse); +}); +``` + + + + ```tsx title="server/index.ts" -import type { PaymentHandlerResponse } from '@devvit/web/server'; +import type { PaymentHandlerResponse } from "@devvit/web/server"; -router.post('/internal/payments/fulfill', async (req, res) => { +router.post("/internal/payments/fulfill", async (req, res) => { // Fulfill the order (grant entitlements, record delivery, etc.) res.json({ success: true } satisfies PaymentHandlerResponse); }); -router.post('/internal/payments/refund', async (req, res) => { +router.post("/internal/payments/refund", async (req, res) => { // Optionally revoke entitlements for a refunded order res.json({ success: true } satisfies PaymentHandlerResponse); }); @@ -61,24 +91,55 @@ router.post('/internal/payments/refund', async (req, res) => { export default router; ``` + + + ### Server: Fetch products On the server, use `payments.getProducts()` and `payments.getOrders()`. If the client needs product metadata, expose it via your own `/api/` endpoint. + + + ```tsx title="server/index.ts" // Example: expose products for client display -import { payments } from '@devvit/web/server'; +import { payments } from "@devvit/web/server"; -const products = await payments.getProducts(); -res.json(products); +app.get("/api/products", async (c) => { + const products = await payments.getProducts(); + return c.json(products); +}); ``` + + + +```tsx title="server/index.ts" +// Example: expose products for client display +import { payments } from "@devvit/web/server"; + +app.get("/api/products", async (_req, res) => { + const products = await payments.getProducts(); + res.json(products); +}); +``` + + + + ### Client: trigger checkout Use `purchase()` from `@devvit/web/client` with a product SKU (or array of SKUs). ```tsx title="client/index.ts" -import { purchase, OrderResultStatus } from '@devvit/web/client'; +import { purchase, OrderResultStatus } from "@devvit/web/client"; export async function buy(sku: string) { const result = await purchase(sku); @@ -224,33 +285,33 @@ Use a consistent and clear product component to display paid goods or services t Use `addPaymentHandler` to specify the function that is called during the order flow. This customizes how your app fulfills product orders and provides the ability for you to reject an order. -Errors thrown within the payment handler automatically reject the order. To provide a custom error message to the frontend of your application, you can return {success: false, reason: } with a reason for the order rejection. +Errors thrown within the payment handler automatically reject the order. To provide a custom error message to the frontend of your application, you can return `{success: false, reason: }` with a reason for the order rejection. This example shows how to issue an "extra life" to a user when they purchase the "extra_life" product. ```ts -import { type Context } from '@devvit/public-api'; -import { addPaymentHandler } from '@devvit/payments'; -import { Devvit, useState } from '@devvit/public-api'; +import { type Context } from "@devvit/public-api"; +import { addPaymentHandler } from "@devvit/payments"; +import { Devvit, useState } from "@devvit/public-api"; Devvit.configure({ redis: true, redditAPI: true, }); -const GOD_MODE_SKU = 'god_mode'; +const GOD_MODE_SKU = "god_mode"; addPaymentHandler({ fulfillOrder: async (order, ctx) => { if (!order.products.some(({ sku }) => sku === GOD_MODE_SKU)) { - throw new Error('Unable to fulfill order: sku not found'); + throw new Error("Unable to fulfill order: sku not found"); } - if (order.status !== 'PAID') { - throw new Error('Becoming a god has a cost (in Reddit Gold)'); + if (order.status !== "PAID") { + throw new Error("Becoming a god has a cost (in Reddit Gold)"); } const redisKey = godModeRedisKey(ctx.postId, ctx.userId); - await ctx.redis.set(redisKey, 'true'); + await ctx.redis.set(redisKey, "true"); }, }); ``` @@ -272,14 +333,14 @@ Your app can acknowledge or reject the order. For example, for goods with limite Use the `useProducts` hook or `getProducts` function to fetch details about products. ```tsx -import { useProducts } from '@devvit/payments'; +import { useProducts } from "@devvit/payments"; export function ProductsList(context: Devvit.Context): JSX.Element { // Only query for products with the metadata "category" of value "powerup". // The metadata field can be empty - if it is, useProducts will not filter on metadata. const { products } = useProducts(context, { metadata: { - category: 'powerup', + category: "powerup", }, }); diff --git a/docs/earn-money/payments/payments_migrate.md b/versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx similarity index 61% rename from docs/earn-money/payments/payments_migrate.md rename to versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx index 317e2b6..0953d86 100644 --- a/docs/earn-money/payments/payments_migrate.md +++ b/versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx @@ -1,3 +1,6 @@ +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; + # Migrate Blocks Payments If you already have payments set up on a Blocks app, use the following steps to migrate your payments functionality. @@ -25,15 +28,40 @@ Reference your `products.json` and declare endpoints. - Blocks: `addPaymentHandler({ fulfillOrder, refundOrder })` - Devvit Web: implement `/internal/payments/fulfill` and `/internal/payments/refund` + + + +```tsx +import type { PaymentHandlerResponse } from "@devvit/web/server"; + +app.post("/internal/payments/fulfill", async (c) => { + // migrate your old fulfillOrder logic here + return c.json({ success: true } satisfies PaymentHandlerResponse); +}); +``` + + + + ```tsx -import type { PaymentHandlerResponse } from '@devvit/web/server'; +import type { PaymentHandlerResponse } from "@devvit/web/server"; -router.post('/internal/payments/fulfill', async (req, res) => { +router.post("/internal/payments/fulfill", async (req, res) => { // migrate your old fulfillOrder logic here res.json({ success: true } satisfies PaymentHandlerResponse); }); ``` + + + 3. Update client purchase calls - Blocks: `usePayments().purchase(sku)` diff --git a/versioned_docs/version-0.12/earn-money/payments/support_this_app.md b/versioned_docs/version-0.12/earn-money/payments/support_this_app.md index 3f7ec4f..42732df 100644 --- a/versioned_docs/version-0.12/earn-money/payments/support_this_app.md +++ b/versioned_docs/version-0.12/earn-money/payments/support_this_app.md @@ -5,13 +5,13 @@ You can ask users to contribute to your app’s development by adding the “sup ## Requirements 1. You must give something in return to users who support your app. This could be unique custom user flair, an honorable mention in a thank you post, or another creative way to show your appreciation. -2. The “Support this App” purchase button must meet the Developer Platform’s [design guidelines](./payments_add.md#design-guidelines). +2. The “Support this App” purchase button must meet the Developer Platform’s [design guidelines](./payments_add.mdx#design-guidelines). ## How to integrate app support ### Create the product -Use the Devvit CLI to generate the [product configuration](./payments_add.md#register-products). +Use the Devvit CLI to generate the [product configuration](./payments_add.mdx#register-products). ```tsx devvit products add support-app @@ -19,24 +19,24 @@ devvit products add support-app ### Add a payment handler -The [payment handler](./payments_add.md#complete-the-payment-flow) is where you award the promised incentive to your supporters. For example, this is how you can award custom user flair: +The [payment handler](./payments_add.mdx#complete-the-payment-flow) is where you award the promised incentive to your supporters. For example, this is how you can award custom user flair: ```tsx addPaymentHandler({ fulfillOrder: async (order, context) => { const username = await context.reddit.getCurrentUsername(); if (!username) { - throw new Error('User not found'); + throw new Error("User not found"); } const subredditName = await context.reddit.getCurrentSubredditName(); await context.reddit.setUserFlair({ - text: 'Super Duper User', + text: "Super Duper User", subredditName, username, - backgroundColor: '#ffbea6', - textColor: 'dark', + backgroundColor: "#ffbea6", + textColor: "dark", }); }, }); @@ -47,7 +47,7 @@ addPaymentHandler({ Next you need to provide a way for users to support your app: - If you use Devvit blocks, you can use the ProductButton helper to render a purchase button. -- If you use webviews, make sure that your design follows the [design guidelines](./payments_add.md#design-guidelines) to [initiate purchases](./payments_add.md#initiate-orders). +- If you use webviews, make sure that your design follows the [design guidelines](./payments_add.mdx#design-guidelines) to [initiate purchases](./payments_add.mdx#initiate-orders). ![Support App Example](../../assets/support_this_app.png) diff --git a/versioned_docs/version-0.12/guides/launch/feature-guide.md b/versioned_docs/version-0.12/guides/launch/feature-guide.mdx similarity index 97% rename from versioned_docs/version-0.12/guides/launch/feature-guide.md rename to versioned_docs/version-0.12/guides/launch/feature-guide.mdx index 8349e8f..0cda987 100644 --- a/versioned_docs/version-0.12/guides/launch/feature-guide.md +++ b/versioned_docs/version-0.12/guides/launch/feature-guide.mdx @@ -34,9 +34,13 @@ Games that see organic growth are also likely to be scouted by our team for feat Reddit features games and developers across multiple discovery surfaces to help players find new favorites: -- **Games Feed.** The Games Feed showcases playable experiences directly within Reddit. When featured, games are rotated into a list of games that is algorythmically served to users visiting the feed. +- **Games Feed.** The Games Feed showcases playable experiences directly within Reddit. When featured, games are rotated into a list of games that is algorithmically served to users visiting the feed. -![Featured games](../../assets/featured_games.png) +Featured games - **Community Drawer.** Our lefthand drawer provides an easy access point for any redditor to see a mix of recently played games and curated popular games. diff --git a/versioned_docs/version-0.12/guides/tools/logs.md b/versioned_docs/version-0.12/guides/tools/logs.md index 7628e20..83bb9e1 100644 --- a/versioned_docs/version-0.12/guides/tools/logs.md +++ b/versioned_docs/version-0.12/guides/tools/logs.md @@ -9,13 +9,13 @@ Any logs sent to `console` will be available via `devvit logs` for installed app The following example creates a basic app that simply creates a single log. ```typescript title="main.tsx" -import { Context, Devvit } from '@devvit/public-api'; +import { Context, Devvit } from "@devvit/public-api"; Devvit.addMenuItem({ - location: 'post', - label: 'Create a log!', + location: "post", + label: "Create a log!", onPress: (event, context) => { - console.log('Action called!'); + console.log("Action called!"); context.ui.showToast(`Successfully logged!`); }, }); @@ -28,13 +28,13 @@ export default Devvit; To stream logs for an installed app, open a terminal and navigate to your project directory and run: ```bash -$ devvit logs +devvit logs ``` You can also specify the app name to stream logs for from another folder. ```bash -$ devvit logs +devvit logs ``` You should now see logs streaming onto your console: @@ -70,7 +70,7 @@ You can view historical logs by using the `--since=XX` flag. You can use the fol The following example will show logs from `my-app` on `my-subreddit` in the past day. ```bash -$ devvit logs --since=1d +devvit logs --since=1d ``` You will now see historical logs created by your app on this subreddit: diff --git a/versioned_docs/version-0.12/guides/tools/playtest.md b/versioned_docs/version-0.12/guides/tools/playtest.md index fb18fa4..d5e6853 100644 --- a/versioned_docs/version-0.12/guides/tools/playtest.md +++ b/versioned_docs/version-0.12/guides/tools/playtest.md @@ -63,13 +63,13 @@ Exiting the playtest does not uninstall the playtest version or revert your app If you want to revert back to the latest non-playtest version of the app, run the following command from within your project directory: ```bash -$ devvit install +devvit install ``` If you want to revert to a different version of your pre-playtest app, you can specify which version using the `install` command. Entering app name is optional if you are running this command from within your project directory. ```bash -$ devvit install [@version] +devvit install [@version] ``` ## Upload your app @@ -77,7 +77,7 @@ $ devvit install [@version] If you’re satisfied with your playtest app and want to upload an installable version, run: ```bash -$ devvit upload +devvit upload ``` This will automatically bump your app version to the next patch release. For example, if your playtest version is 0.0.1.6, the upload command will remove the playtest version increment and change your app version to 0.0.2. diff --git a/versioned_docs/version-0.12/guides/tools/vite.mdx b/versioned_docs/version-0.12/guides/tools/vite.mdx index 8acfcea..8f16014 100644 --- a/versioned_docs/version-0.12/guides/tools/vite.mdx +++ b/versioned_docs/version-0.12/guides/tools/vite.mdx @@ -6,11 +6,7 @@ sidebar_label: Vite Plugin # Build with the Devvit Vite plugin -::::warning Experimental -The Devvit Vite plugin is experimental and subject to breaking changes. -:::: - -The Devvit [Vite](https://vite.dev/) plugin is a 100% optional plugin for Devvit Web that unifies your client and server builds into a single command using [Vite's Environment API](https://vite.dev/guide/api-environment). +The Devvit [Vite](https://vite.dev/) plugin is an opinionated (and 100% optional) plugin for Devvit Web that unifies your client and server builds into a single command using [Vite's Environment API](https://vite.dev/guide/api-environment). Features: @@ -53,17 +49,31 @@ The plugin uses your `devvit.json` as the source of truth for client entry point "entrypoints": { "default": { "inline": true, - "entry": "src/splash.html" + "entry": "splash.html" } } } } ``` +The plugin reads `devvit.json` from the project root (the current working directory unless you set `root` in `vite.config.ts`). If it can’t find a valid config, the build fails. + +
+Why your client output can look nested + +By default, the Devvit Vite plugin sets the Vite root to `src/client` when that folder exists (unless you explicitly set `root` in `vite.config.ts`). That keeps client output flat, like `dist/client/index.html`. + +If you explicitly set `root` to the repo root or include `src/` in `post.entrypoints.*.entry`, Vite will preserve that path in the output, leading to nested paths like `dist/client/src/client/index.html`. Keep entry paths relative to the client root (no `src/` prefix) to preserve the flat layout. + +Server builds use a fixed entry point and are not affected by this behavior. + +
+ For the server build, the plugin looks for one of these files: - `src/server/index.ts` - `src/api/index.ts` +- `src/index.ts` If neither file exists, the build fails with a clear error message. @@ -76,6 +86,8 @@ Out of the box, the plugin configures two environments: > Note that Devvit requires a single CJS bundle to run the server code. Please do not mark server dependencies as `external` as it will break your server build. This may change in the future! +> The plugin currently always writes to `dist/client` and `dist/server`, regardless of `post.dir` or `server.dir` in `devvit.json`. Those values are used by Devvit, but Vite’s output paths are fixed in the plugin. + ## Customize the build The plugin accepts a small options object that lets you tweak both environments without redoing the whole config. Each option is merged into the generated Vite environment config. @@ -127,4 +139,4 @@ src/ ## Limitations and gotchas - **Build-only:** The plugin only supports `vite build`. There is no support for `vite dev` or Hot Module Replacement (HMR) at this time. This is because `devvit playtest` works by uploading your build to our servers and running it on Reddit.com. Instead, use `vite build --watch` as your dev command. -- **Entry points are required:** Building server apps only is not yet supported. If you'd like to request this feature, please [open an issue](https://github.com/reddit/devvit/issues/new). +- **Public dir resolution:** The plugin auto-detects a `public/` folder at the repo root or inside `src/client`. If both exist, the build fails—keep a single public directory. diff --git a/versioned_docs/version-0.12/quickstart/quickstart-gamemaker.mdx b/versioned_docs/version-0.12/quickstart/quickstart-gamemaker.mdx index d8a3055..7fa4411 100644 --- a/versioned_docs/version-0.12/quickstart/quickstart-gamemaker.mdx +++ b/versioned_docs/version-0.12/quickstart/quickstart-gamemaker.mdx @@ -41,7 +41,12 @@ Cutting the template to the target directory... To run your app, navigate to your project directory with `cd my-app` and run `npm run dev`. You should see logs that conclude with: ``` -https://www.reddit.com/r/my-app_dev?playtest=my-app +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ✓ Playtest ready │ +│ ➜ URL: https://www.reddit.com/r/my-app_dev/?playtest=my-app │ +│ ➜ Version: v0.0.0.1 │ +│ ➜ Open the URL above and refresh to see your latest changes. │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` Follow this link to see your app. diff --git a/versioned_docs/version-0.12/quickstart/quickstart-mod-tool.md b/versioned_docs/version-0.12/quickstart/quickstart-mod-tool.md index ef28a43..94864aa 100644 --- a/versioned_docs/version-0.12/quickstart/quickstart-mod-tool.md +++ b/versioned_docs/version-0.12/quickstart/quickstart-mod-tool.md @@ -12,7 +12,7 @@ This tutorial should take about 10 minutes to complete. Once complete, you'll be ## Environment setup 1. Install Node.JS and NPM ([instructions](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)) -2. Go to `https://developers.reddit.com/new` and choose Mod Tool under Other templates. +2. Go to [https://developers.reddit.com/new](https://developers.reddit.com/new) and choose Mod Tool under Other templates. 3. Go through the wizard. You will need to create a Reddit account and connect it to Reddit developers. 4. Follow the instructions on your terminal. diff --git a/versioned_docs/version-0.12/quickstart/quickstart-unity.mdx b/versioned_docs/version-0.12/quickstart/quickstart-unity.mdx index 369eef4..2f28b4f 100644 --- a/versioned_docs/version-0.12/quickstart/quickstart-unity.mdx +++ b/versioned_docs/version-0.12/quickstart/quickstart-unity.mdx @@ -45,7 +45,12 @@ The Devvit Unity Template includes a pre-built Unity project. The full Unity pro To run your app, navigate to your project directory with `cd my-app` and run `npm run dev`. You should see logs that conclude with: ``` -✨ https://www.reddit.com/r/my-app_dev?playtest=my-app +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ✓ Playtest ready │ +│ ➜ URL: https://www.reddit.com/r/my-app_dev/?playtest=my-app │ +│ ➜ Version: v0.0.0.1 │ +│ ➜ Open the URL above and refresh to see your latest changes. │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` Follow this link to see your app. diff --git a/versioned_docs/version-0.12/quickstart/quickstart.md b/versioned_docs/version-0.12/quickstart/quickstart.md index 4c6c78f..b0bd539 100644 --- a/versioned_docs/version-0.12/quickstart/quickstart.md +++ b/versioned_docs/version-0.12/quickstart/quickstart.md @@ -37,7 +37,12 @@ Cutting the template to the target directory... To run your app, `cd my-app` and then run `npm run dev`. You should see some logs start up that finish with: ``` -✨ https://www.reddit.com/r/my-app_dev?playtest=my-app +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ✓ Playtest ready │ +│ ➜ URL: https://www.reddit.com/r/my-app_dev/?playtest=my-app │ +│ ➜ Version: v0.0.0.1 │ +│ ➜ Open the URL above and refresh to see your latest changes. │ +└─────────────────────────────────────────────────────────────────────────────┘ ``` The dev command automatically creates a development subreddit for your app and a test post for you to develop against. When you visit the url, it should look something like this. @@ -64,12 +69,12 @@ This special file in the root of the project contains configurations for many of ## Testing your app on a specific subreddit -You need to test your app on a subreddit. Your backend calls will not work when testing the app locally. For that we will be leveraging Devvit's Playtest tool. If you have a preference for a specific subreddit to playtest, change the `package.json` file to include your subreddit name in `dev:devvit`: +You need to test your app on a subreddit. Your backend calls will not work when testing the app locally. For that we will be leveraging Devvit's Playtest tool. If you have a preference for a specific subreddit to playtest, change the `package.json` file to include your subreddit name in `dev`: ```javascript title="package.json" "scripts": { //... - "dev:devvit": "devvit playtest r/MY_PREFERRED_SUBREDDIT", + "dev": "devvit playtest r/MY_PREFERRED_SUBREDDIT", //... } ``` From 3932b0313cbf5b0b4dbcd416b67863e3e40de68c Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 3 Feb 2026 19:27:46 -0500 Subject: [PATCH 2/9] add strong types for all endpoints --- docs/capabilities/client/forms.mdx | 121 ++++-- docs/capabilities/client/menu-actions.mdx | 35 +- docs/capabilities/server/cache-helper.mdx | 107 +++--- docs/capabilities/server/post-data.mdx | 218 +++++++---- docs/capabilities/server/redis.mdx | 345 ++++++++++-------- docs/capabilities/server/scheduler.mdx | 184 ++++++---- .../server/settings-and-secrets.mdx | 45 ++- docs/capabilities/server/triggers.mdx | 48 ++- docs/earn-money/payments/payments_add.mdx | 44 ++- docs/earn-money/payments/payments_migrate.mdx | 17 +- .../capabilities/client/forms.mdx | 121 ++++-- .../capabilities/client/menu-actions.mdx | 35 +- .../capabilities/server/cache-helper.mdx | 107 +++--- .../capabilities/server/post-data.mdx | 218 +++++++---- .../capabilities/server/redis.mdx | 345 ++++++++++-------- .../capabilities/server/scheduler.mdx | 184 ++++++---- .../server/settings-and-secrets.mdx | 45 ++- .../capabilities/server/triggers.mdx | 48 ++- .../earn-money/payments/payments_add.mdx | 44 ++- .../earn-money/payments/payments_migrate.mdx | 17 +- 20 files changed, 1450 insertions(+), 878 deletions(-) diff --git a/docs/capabilities/client/forms.mdx b/docs/capabilities/client/forms.mdx index e70f868..2ba147a 100644 --- a/docs/capabilities/client/forms.mdx +++ b/docs/capabilities/client/forms.mdx @@ -182,12 +182,18 @@ For forms that open from a menu item, you can use menu responses. This is useful ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type NameFormRequest = { name: string }; + type ReviewFormRequest = { review: string }; + // Menu action that triggers menu response form app.post('/internal/menu/start-workflow', async (c) => { + const _input = await c.req.json(); // Server processing before showing form const userData = await fetchUserData(); - return c.json({ + return c.json({ showForm: { name: 'nameForm', form: { @@ -206,13 +212,13 @@ For forms that open from a menu item, you can use menu responses. This is useful // Form submission handler that can chain to another form app.post('/internal/form/name-submit', async (c) => { - const { name } = await c.req.json(); + const { name } = await c.req.json(); // Server processing await saveUserName(name); // Show next form in workflow - return c.json({ + return c.json({ showForm: { name: 'reviewForm', form: { @@ -229,11 +235,11 @@ For forms that open from a menu item, you can use menu responses. This is useful }); app.post('/internal/form/review-submit', async (c) => { - const { review } = await c.req.json(); + const { review } = await c.req.json(); await saveReview(review); - return c.json({ + return c.json({ showToast: 'Thank you for your feedback!' }); }); @@ -243,10 +249,13 @@ For forms that open from a menu item, you can use menu responses. This is useful ```ts title="server/index.ts" - import { UIResponse } from '@devvit/web/shared'; + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type NameFormRequest = { name: string }; + type ReviewFormRequest = { review: string }; // Menu action that triggers menu response form - router.post("/internal/menu/start-workflow", async (_req, res: Response) => { + router.post("/internal/menu/start-workflow", async (_req, res) => { // Server processing before showing form const userData = await fetchUserData(); @@ -268,7 +277,7 @@ For forms that open from a menu item, you can use menu responses. This is useful }); // Form submission handler that can chain to another form - router.post("/internal/form/name-submit", async (req, res: Response) => { + router.post("/internal/form/name-submit", async (req, res) => { const { name } = req.body; // Server processing @@ -291,7 +300,7 @@ For forms that open from a menu item, you can use menu responses. This is useful }); }); - router.post("/internal/form/review-submit", async (req, res: Response) => { + router.post("/internal/form/review-submit", async (req, res) => { const { review } = req.body; await saveReview(review); @@ -677,11 +686,16 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type DynamicFormRequest = { username: string }; + // Endpoint that shows form with dynamic data app.post('/internal/menu/show-dynamic-form', async (c) => { + const _input = await c.req.json(); const user = await reddit.getCurrentUser(); - return c.json({ + return c.json({ showForm: { name: 'dynamicForm', form: { @@ -702,9 +716,9 @@ Below is a collection of common use cases and patterns. // Form submission handler app.post('/internal/form/dynamic-submit', async (c) => { - const { username } = await c.req.json(); + const { username } = await c.req.json(); - return c.json({ + return c.json({ showToast: `Hello ${username}` }); }); @@ -714,8 +728,12 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type DynamicFormRequest = { username: string }; + // Endpoint that shows form with dynamic data - router.post("/internal/menu/show-dynamic-form", async (_req, res: Response) => { + router.post("/internal/menu/show-dynamic-form", async (_req, res) => { const user = await reddit.getCurrentUser(); res.json({ @@ -738,7 +756,7 @@ Below is a collection of common use cases and patterns. }); // Form submission handler - router.post("/internal/form/dynamic-submit", async (req, res: Response) => { + router.post("/internal/form/dynamic-submit", async (req, res) => { const { username } = req.body; res.json({ @@ -889,11 +907,17 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { UiResponse } from '@devvit/web/shared'; + + type Step1FormRequest = { name: string }; + type Step2FormRequest = { name: string; food: string }; + type Step3FormRequest = { name: string; food: string; drink: string }; + // Step 1: Name form app.post('/internal/form/step1-submit', async (c) => { - const { name } = await c.req.json(); + const { name } = await c.req.json(); - return c.json({ + return c.json({ showForm: { name: 'step2Form', form: { @@ -913,9 +937,9 @@ Below is a collection of common use cases and patterns. // Step 2: Food form app.post('/internal/form/step2-submit', async (c) => { - const { name, food } = await c.req.json(); + const { name, food } = await c.req.json(); - return c.json({ + return c.json({ showForm: { name: 'step3Form', form: { @@ -935,9 +959,9 @@ Below is a collection of common use cases and patterns. // Step 3: Final form app.post('/internal/form/step3-submit', async (c) => { - const { name, food, drink } = await c.req.json(); + const { name, food, drink } = await c.req.json(); - return c.json({ + return c.json({ showToast: `Thanks ${name}! You like ${food} and ${drink}.` }); }); @@ -947,8 +971,14 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { UiResponse } from '@devvit/web/shared'; + + type Step1FormRequest = { name: string }; + type Step2FormRequest = { name: string; food: string }; + type Step3FormRequest = { name: string; food: string; drink: string }; + // Step 1: Name form - router.post("/internal/form/step1-submit", async (req, res: Response) => { + router.post("/internal/form/step1-submit", async (req, res) => { const { name } = req.body; res.json({ @@ -970,7 +1000,7 @@ Below is a collection of common use cases and patterns. }); // Step 2: Food form - router.post("/internal/form/step2-submit", async (req, res: Response) => { + router.post("/internal/form/step2-submit", async (req, res) => { const { name, food } = req.body; res.json({ @@ -992,7 +1022,7 @@ Below is a collection of common use cases and patterns. }); // Step 3: Final form - router.post("/internal/form/step3-submit", async (req, res: Response) => { + router.post("/internal/form/step3-submit", async (req, res) => { const { name, food, drink } = req.body; res.json({ @@ -1186,18 +1216,29 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type EverythingFormRequest = { + food: string; + times?: number; + what?: string; + healthy?: string[]; + again?: boolean; + }; + app.post('/internal/form/everything-submit', async (c) => { - const formValues = await c.req.json(); + const formValues = await c.req.json(); console.log('Form values:', formValues); - return c.json({ + return c.json({ showToast: 'Thanks!' }); }); // Example showing the form app.post('/internal/menu/show-everything-form', async (c) => { - return c.json({ + const _input = await c.req.json(); + return c.json({ showForm: { name: 'everythingForm', form: { @@ -1257,7 +1298,17 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" - router.post("/internal/form/everything-submit", async (req, res: Response) => { + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type EverythingFormRequest = { + food: string; + times?: number; + what?: string; + healthy?: string[]; + again?: boolean; + }; + + router.post("/internal/form/everything-submit", async (req, res) => { console.log('Form values:', req.body); res.json({ @@ -1266,7 +1317,7 @@ This example includes one of each of the [supported field types](#supported-fiel }); // Example showing the form - router.post("/internal/menu/show-everything-form", async (_req, res: Response) => { + router.post("/internal/menu/show-everything-form", async (_req, res) => { res.json({ showForm: { name: 'everythingForm', @@ -1452,12 +1503,16 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" + import type { UiResponse } from '@devvit/web/shared'; + + type ImageFormRequest = { myImage: string }; + app.post('/internal/form/image-submit', async (c) => { - const { myImage } = await c.req.json(); + const { myImage } = await c.req.json(); // Use the mediaUrl to store in redis and display it in an block, or send to external service to modify console.log('Image uploaded:', myImage); - return c.json({ + return c.json({ showToast: 'Image uploaded successfully!' }); }); @@ -1467,7 +1522,11 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" - router.post("/internal/form/image-submit", async (req, res: Response) => { + import type { UiResponse } from '@devvit/web/shared'; + + type ImageFormRequest = { myImage: string }; + + router.post("/internal/form/image-submit", async (req, res) => { const { myImage } = req.body; // Use the mediaUrl to store in redis and display it in an block, or send to external service to modify console.log('Image uploaded:', myImage); diff --git a/docs/capabilities/client/menu-actions.mdx b/docs/capabilities/client/menu-actions.mdx index eaaf0bd..748ea50 100644 --- a/docs/capabilities/client/menu-actions.mdx +++ b/docs/capabilities/client/menu-actions.mdx @@ -43,9 +43,12 @@ Add an item to the three dot menu for posts, comments, or subreddits. Menu actio ```ts title="server/index.ts" +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + app.post("/internal/menu/show-info", async (c) => { + const _input = await c.req.json(); // Simple actions don't need server processing - return c.json({ + return c.json({ showToast: "Menu action clicked!", }); }); @@ -55,12 +58,17 @@ app.post("/internal/menu/show-info", async (c) => { ```ts title="server/index.ts" -app.post("/internal/menu/show-info", async (_req, res) => { - // Simple actions don't need server processing - res.json({ - showToast: "Menu action clicked!", - }); -}); +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + +app.post( + "/internal/menu/show-info", + async (_req, res) => { + // Simple actions don't need server processing + res.json({ + showToast: "Menu action clicked!", + }); + }, +); ``` @@ -162,13 +170,16 @@ In Devvit Web, your menu item should respond with a client side effect to give f ```ts title="server/index.ts" +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + app.post("/internal/menu/complex-action", async (c) => { + const _input = await c.req.json(); try { // Perform server-side processing const userData = await validateAndProcessData(); // Show form with server-fetched data - return c.json({ + return c.json({ showForm: { name: "processForm", form: { @@ -184,7 +195,7 @@ app.post("/internal/menu/complex-action", async (c) => { }, }); } catch (error) { - return c.json({ + return c.json({ showToast: "Processing failed. Please try again.", }); } @@ -195,11 +206,11 @@ app.post("/internal/menu/complex-action", async (c) => { ```ts title="server/index.ts" -import { UIResponse } from "@devvit/web/shared"; +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; -app.post( +app.post( "/internal/menu/complex-action", - async (_req, res: Response) => { + async (_req, res) => { try { // Perform server-side processing const userData = await validateAndProcessData(); diff --git a/docs/capabilities/server/cache-helper.mdx b/docs/capabilities/server/cache-helper.mdx index ecd4065..e4f50bf 100644 --- a/docs/capabilities/server/cache-helper.mdx +++ b/docs/capabilities/server/cache-helper.mdx @@ -77,6 +77,16 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t import { Hono } from 'hono'; import { cache, context, createServer, getServerPort, reddit } from '@devvit/web/server'; + type SubredditResponse = { + type: 'subreddit'; + subreddit: string; + }; + + type SubredditErrorResponse = { + status: 'error'; + message: string; + }; + const app = new Hono(); app.get('/api/subreddit', async (c) => { @@ -84,7 +94,7 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t if (!postId) { console.error('API Subreddit Error: postId not found in devvit context'); - return c.json( + return c.json( { status: 'error', message: 'postId is required but missing from context', @@ -109,7 +119,7 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t ); console.log(`Current subreddit: ${subredditName}`); - return c.json({ + return c.json({ type: 'subreddit', subreddit: subredditName, }); @@ -119,7 +129,7 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t if (error instanceof Error) { errorMessage = `Subreddit retrieval failed: ${error.message}`; } - return c.json({ status: 'error', message: errorMessage }, 400); + return c.json({ status: 'error', message: errorMessage }, 400); } }); @@ -133,9 +143,6 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t ```tsx title="server/index.ts" import express from "express"; - import { - SubredditResponse, - } from "../shared/types/api"; import { cache, createServer, @@ -144,6 +151,16 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t reddit, } from "@devvit/web/server"; + type SubredditResponse = { + type: "subreddit"; + subreddit: string; + }; + + type SubredditErrorResponse = { + status: "error"; + message: string; + }; + const app = express(); // Middleware for JSON body parsing @@ -155,50 +172,50 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t const router = express.Router(); - router.get< - { postId: string }, - SubredditResponse | { status: string; message: string } - >("/api/subreddit", async (_req, res): Promise => { - const { postId } = context; - - if (!postId) { - console.error("API Subreddit Error: postId not found in devvit context"); - res.status(400).json({ - status: "error", - message: "postId is required but missing from context", - }); - return; - } + router.get( + "/api/subreddit", + async (_req, res): Promise => { + const { postId } = context; + + if (!postId) { + console.error("API Subreddit Error: postId not found in devvit context"); + res.status(400).json({ + status: "error", + message: "postId is required but missing from context", + }); + return; + } - try { - const subredditName = await cache( - async () => { - const subreddit = await reddit.getCurrentSubreddit(); - if (!subreddit) { - throw new Error("Subreddit is required but missing from context"); + try { + const subredditName = await cache( + async () => { + const subreddit = await reddit.getCurrentSubreddit(); + if (!subreddit) { + throw new Error("Subreddit is required but missing from context"); + } + return subreddit.name; + }, + { + key: `current_subreddit`, + ttl: 24 * 60 * 60 // expire after one day. } - return subreddit.name; - }, - { - key: `current_subreddit`, - ttl: 24 * 60 * 60 // expire after one day. + ); + console.log(`Current subreddit: ${subredditName}`); + + res.json({ + type: "subreddit", + subreddit: subredditName, + }); + } catch (error) { + console.error(`API Subreddit Error for post ${postId}:`, error); + let errorMessage = "Unknown error during subreddit retrieval"; + if (error instanceof Error) { + errorMessage = `Subreddit retrieval failed: ${error.message}`; } - ); - console.log(`Current subreddit: ${subredditName}`); - - res.json({ - type: "subreddit" as "subreddit", - subreddit: subredditName, - }); - } catch (error) { - console.error(`API Subreddit Error for post ${postId}:`, error); - let errorMessage = "Unknown error during subreddit retrieval"; - if (error instanceof Error) { - errorMessage = `Subreddit retrieval failed: ${error.message}`; + res.status(400).json({ status: "error", message: errorMessage }); } - res.status(400).json({ status: "error", message: errorMessage }); } - }); + ); app.use(router); diff --git a/docs/capabilities/server/post-data.mdx b/docs/capabilities/server/post-data.mdx index d92ba4c..0337cc4 100644 --- a/docs/capabilities/server/post-data.mdx +++ b/docs/capabilities/server/post-data.mdx @@ -31,39 +31,51 @@ When creating a post, include the `postData` parameter with your custom data obj ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; + import type { JsonObject } from '@devvit/web/shared'; + + type CreatePostResponse = { + postId: string; + message: string; + }; + + type ErrorResponse = { + error: string; + }; app.post('/api/create-post', async (c) => { const { subredditName } = context; if (!subredditName) { - return c.json({ error: 'Subreddit name is required' }, 400); + return c.json({ error: 'Subreddit name is required' }, 400); } + const postData: JsonObject = { + challengeNumber: 42, + totalGuesses: 0, + gameState: 'active', + pixels: [ + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] + ], + }; + const post = await reddit.submitCustomPost({ subredditName, title: 'Post with custom data', entry: 'default', - postData: { - challengeNumber: 42, - totalGuesses: 0, - gameState: 'active', - pixels: [ - [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], - [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], - [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], - [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] - ], - }, + postData, }); - return c.json({ + return c.json({ postId: post.id, message: 'Post created successfully', }); @@ -75,21 +87,29 @@ When creating a post, include the `postData` parameter with your custom data obj ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; + import type { JsonObject } from '@devvit/web/shared'; - router.post('/api/create-post', async (req, res) => { - const { subredditName } = context; + type CreatePostResponse = { + postId: string; + message: string; + }; - if (!subredditName) { - return res.status(400).json({ - error: 'Subreddit name is required' - }); - } + type ErrorResponse = { + error: string; + }; - const post = await reddit.submitCustomPost({ - subredditName, - title: 'Post with custom data', - entry: 'default', - postData: { + router.post( + '/api/create-post', + async (_req, res) => { + const { subredditName } = context; + + if (!subredditName) { + return res.status(400).json({ + error: 'Subreddit name is required' + }); + } + + const postData: JsonObject = { challengeNumber: 42, totalGuesses: 0, gameState: 'active', @@ -106,14 +126,21 @@ When creating a post, include the `postData` parameter with your custom data obj [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] ], - }, - }); + }; - res.json({ - postId: post.id, - message: 'Post created successfully' - }); - }); + const post = await reddit.submitCustomPost({ + subredditName, + title: 'Post with custom data', + entry: 'default', + postData, + }); + + res.json({ + postId: post.id, + message: 'Post created successfully' + }); + }, + ); ``` @@ -168,20 +195,35 @@ To update post data after creation, fetch the post and use the `setPostData()` m ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; + import type { JsonObject } from '@devvit/web/shared'; + + type UpdatePostDataRequest = { + favoriteColor?: string; + username?: string; + }; + + type UpdatePostDataResponse = { + success: true; + message: string; + }; + + type ErrorResponse = { + error: string; + }; app.post('/api/update-post-data', async (c) => { const { postId } = context; - const { favoriteColor, username } = await c.req.json(); + const { favoriteColor, username } = await c.req.json(); if (!postId) { - return c.json({ error: 'Post ID is required' }, 400); + return c.json({ error: 'Post ID is required' }, 400); } try { const post = await reddit.getPostById(postId); // Get existing post data to merge with updates - const currentData = context.postData || {}; + const currentData = (context.postData || {}) as JsonObject; await post.setPostData({ ...currentData, @@ -190,13 +232,13 @@ To update post data after creation, fetch the post and use the `setPostData()` m lastUpdatedAt: new Date().toISOString(), }); - return c.json({ + return c.json({ success: true, message: 'Post data updated successfully', }); } catch (error) { console.error('Error updating post data:', error); - return c.json({ error: 'Failed to update post data' }, 500); + return c.json({ error: 'Failed to update post data' }, 500); } }); ``` @@ -206,41 +248,59 @@ To update post data after creation, fetch the post and use the `setPostData()` m ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; - - router.post('/api/update-post-data', async (req, res) => { - const { postId } = context; - const { favoriteColor, username } = req.body; - - if (!postId) { - return res.status(400).json({ - error: 'Post ID is required' - }); - } - - try { - const post = await reddit.getPostById(postId); - - // Get existing post data to merge with updates - const currentData = context.postData || {}; - - await post.setPostData({ - ...currentData, - favoriteColor: favoriteColor || 'unknown', - lastUpdatedBy: username || 'anonymous', - lastUpdatedAt: new Date().toISOString(), - }); - - res.json({ - success: true, - message: 'Post data updated successfully' - }); - } catch (error) { - console.error('Error updating post data:', error); - res.status(500).json({ - error: 'Failed to update post data' - }); - } - }); + import type { JsonObject } from '@devvit/web/shared'; + + type UpdatePostDataRequest = { + favoriteColor?: string; + username?: string; + }; + + type UpdatePostDataResponse = { + success: true; + message: string; + }; + + type ErrorResponse = { + error: string; + }; + + router.post( + '/api/update-post-data', + async (req, res) => { + const { postId } = context; + const { favoriteColor, username } = req.body; + + if (!postId) { + return res.status(400).json({ + error: 'Post ID is required' + }); + } + + try { + const post = await reddit.getPostById(postId); + + // Get existing post data to merge with updates + const currentData = (context.postData || {}) as JsonObject; + + await post.setPostData({ + ...currentData, + favoriteColor: favoriteColor || 'unknown', + lastUpdatedBy: username || 'anonymous', + lastUpdatedAt: new Date().toISOString(), + }); + + res.json({ + success: true, + message: 'Post data updated successfully' + }); + } catch (error) { + console.error('Error updating post data:', error); + res.status(500).json({ + error: 'Failed to update post data' + }); + } + }, + ); ``` diff --git a/docs/capabilities/server/redis.mdx b/docs/capabilities/server/redis.mdx index bb806b1..bcca403 100644 --- a/docs/capabilities/server/redis.mdx +++ b/docs/capabilities/server/redis.mdx @@ -56,13 +56,15 @@ All limits are applied at a per-installation granularity. ```ts title="server/index.ts" import { redis } from '@devvit/redis'; + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; app.post('/internal/menu/redis-test', async (c) => { + const _request = await c.req.json(); const key = 'hello'; await redis.set(key, 'world'); const value = await redis.get(key); console.log(`${key}: ${value}`); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); ``` @@ -71,15 +73,19 @@ All limits are applied at a per-installation granularity. ```ts title="server/index.ts" import { redis } from '@devvit/redis'; + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; - router.post("/internal/menu/redis-test", async (_req, res: Response) => { - const key = 'hello'; - await redis.set(key, 'world'); - const value = await redis.get(key); - console.log(`${key}: ${value}`); - res.json({ status: 'ok' }); - }); - ``` + router.post( + "/internal/menu/redis-test", + async (_req, res) => { + const key = 'hello'; + await redis.set(key, 'world'); + const value = await redis.get(key); + console.log(`${key}: ${value}`); + res.json({ status: 'ok' }); + }, + ); + ```
@@ -958,15 +964,28 @@ Add these route handlers to your server. ```ts -import { redis, scheduler } from '@devvit/web/server'; +import { redis, scheduler, type TaskRequest, type TaskResponse } from '@devvit/web/server'; // Import the compressed client import { redisCompressed } from '@devvit/redis'; +import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + +type MigrateExampleFormRequest = { + startCursor?: string; + chunkSize?: number; +}; + +type MigrateExampleJobData = { + cursor?: number | string; + chunkSize?: number; + processed?: number; +}; const MY_DATA_HASH_KEY = 'my:app:large:dataset'; // 1. Menu Endpoint: Returns the form definition app.post('/internal/menu/ops/migrate-example', async (c) => { - return c.json({ + const _request = await c.req.json(); + return c.json({ showForm: { name: 'migrateExampleForm', // Must match key in devvit.json "forms" form: { @@ -993,9 +1012,11 @@ app.post('/internal/menu/ops/migrate-example', async (c) => { // 2. Form Handler: Receives input and schedules the first job app.post('/internal/form/ops/migrate-example', async (c) => { - const { startCursor, chunkSize } = await c.req.json().catch(() => ({})); - const cursor = startCursor || '0'; - const size = Number(chunkSize) || 20000; + const body = await c.req.json().catch( + () => ({} as MigrateExampleFormRequest) + ); + const cursor = body.startCursor || '0'; + const size = Number(body.chunkSize) || 20000; console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); @@ -1010,7 +1031,7 @@ app.post('/internal/form/ops/migrate-example', async (c) => { }, }); - return c.json({ + return c.json({ showToast: { text: 'Migration started in background', appearance: 'success', @@ -1023,12 +1044,14 @@ app.post('/internal/scheduler/migrate-example-data', async (c) => { const startTime = Date.now(); try { - const body = await c.req.json().catch(() => ({})); - const data = body.data ?? {}; + const body = await c.req.json>().catch( + () => ({} as TaskRequest) + ); + const data = body.data; - let cursor = Number(data.cursor) || 0; - const chunkSize = Number(data.chunkSize) || 20000; - const processedTotal = Number(data.processed) || 0; + let cursor = Number(data?.cursor) || 0; + const chunkSize = Number(data?.chunkSize) || 20000; + const processedTotal = Number(data?.processed) || 0; console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); @@ -1099,14 +1122,14 @@ app.post('/internal/scheduler/migrate-example-data', async (c) => { }, }); - return c.json({ status: 'requeued', processed: newTotal, cursor }); + return c.json({ status: 'requeued', processed: newTotal, cursor }); } console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); - return c.json({ status: 'success', processed: newTotal }); + return c.json({ status: 'success', processed: newTotal }); } catch (error) { console.error('[Migration] Critical Job Error', error); - return c.json({ status: 'error', message: error.message }, 500); + return c.json({ status: 'error', message: error.message }, 500); } }); ``` @@ -1115,157 +1138,177 @@ app.post('/internal/scheduler/migrate-example-data', async (c) => { ```ts -import { redis, scheduler } from '@devvit/web/server'; +import { redis, scheduler, type TaskRequest, type TaskResponse } from '@devvit/web/server'; // Import the compressed client import { redisCompressed } from '@devvit/redis'; +import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + +type MigrateExampleFormRequest = { + startCursor?: string; + chunkSize?: number; +}; + +type MigrateExampleJobData = { + cursor?: number | string; + chunkSize?: number; + processed?: number; +}; const MY_DATA_HASH_KEY = 'my:app:large:dataset'; // 1. Menu Endpoint: Returns the form definition -app.post('/internal/menu/ops/migrate-example', async (_req, res) => { - res.json({ - showForm: { - name: 'migrateExampleForm', // Must match key in devvit.json "forms" - form: { - title: 'Migrate Hash to Compression', - acceptLabel: 'Start Migration', - fields: [ - { - name: 'startCursor', - label: 'Start Cursor (0 for beginning)', - type: 'string', - defaultValue: '0', - }, - { - name: 'chunkSize', - label: 'Items per batch', - type: 'number', - defaultValue: 20000, - }, - ], +app.post( + '/internal/menu/ops/migrate-example', + async (_req, res) => { + res.json({ + showForm: { + name: 'migrateExampleForm', // Must match key in devvit.json "forms" + form: { + title: 'Migrate Hash to Compression', + acceptLabel: 'Start Migration', + fields: [ + { + name: 'startCursor', + label: 'Start Cursor (0 for beginning)', + type: 'string', + defaultValue: '0', + }, + { + name: 'chunkSize', + label: 'Items per batch', + type: 'number', + defaultValue: 20000, + }, + ], + }, }, - }, - }); -}); + }); + }, +); // 2. Form Handler: Receives input and schedules the first job -app.post('/internal/form/ops/migrate-example', async (req, res) => { - const { startCursor, chunkSize } = req.body ?? {}; - const cursor = startCursor || '0'; - const size = Number(chunkSize) || 20000; - - console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); - - // Kick off the first job in the chain - await scheduler.runJob({ - name: 'migrate-example-data', - runAt: new Date(), // Run immediately - data: { - cursor, - chunkSize: size, - processed: 0, - }, - }); +app.post( + '/internal/form/ops/migrate-example', + async (req, res) => { + const { startCursor, chunkSize } = req.body ?? ({} as MigrateExampleFormRequest); + const cursor = startCursor || '0'; + const size = Number(chunkSize) || 20000; + + console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); + + // Kick off the first job in the chain + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), // Run immediately + data: { + cursor, + chunkSize: size, + processed: 0, + }, + }); - res.json({ - showToast: { - text: 'Migration started in background', - appearance: 'success', - }, - }); -}); + res.json({ + showToast: { + text: 'Migration started in background', + appearance: 'success', + }, + }); + }, +); // 3. Scheduler Endpoint: The recursive worker -app.post('/internal/scheduler/migrate-example-data', async (req, res) => { - const startTime = Date.now(); - - try { - const body = req.body ?? {}; - const data = body.data ?? {}; +app.post>( + '/internal/scheduler/migrate-example-data', + async (req, res) => { + const startTime = Date.now(); - let cursor = Number(data.cursor) || 0; - const chunkSize = Number(data.chunkSize) || 20000; - const processedTotal = Number(data.processed) || 0; + try { + const data = req.body.data; - console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); + let cursor = Number(data?.cursor) || 0; + const chunkSize = Number(data?.chunkSize) || 20000; + const processedTotal = Number(data?.processed) || 0; - let keepRunning = true; - let processedInJob = 0; - const SCAN_COUNT = 250; // Internal batch size to keep event loop moving + console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); - while (keepRunning) { - // Stop if we've processed enough items for this single execution - if (processedInJob >= chunkSize) { - break; - } + let keepRunning = true; + let processedInJob = 0; + const SCAN_COUNT = 250; // Internal batch size to keep event loop moving - const { cursor: nextCursor, fieldValues } = await redis.hScan( - MY_DATA_HASH_KEY, - cursor, - undefined, // match pattern - SCAN_COUNT - ); + while (keepRunning) { + // Stop if we've processed enough items for this single execution + if (processedInJob >= chunkSize) { + break; + } - // Parallel Processing: - // We treat the batch as a set of promises to execute simultaneously. - // Promise.allSettled ensures one failure doesn't crash the whole job. - await Promise.allSettled( - fieldValues.map(async ({ field, value }) => { - // LOGIC: - // 1. We read the raw value. - // 2. We write it back using 'redisCompressed'. - // The proxy detects the write and compresses the string if beneficial. - if (value && value.length > 0) { - await redisCompressed.hSet(MY_DATA_HASH_KEY, { [field]: value }); - } - }) - ); + const { cursor: nextCursor, fieldValues } = await redis.hScan( + MY_DATA_HASH_KEY, + cursor, + undefined, // match pattern + SCAN_COUNT + ); + + // Parallel Processing: + // We treat the batch as a set of promises to execute simultaneously. + // Promise.allSettled ensures one failure doesn't crash the whole job. + await Promise.allSettled( + fieldValues.map(async ({ field, value }) => { + // LOGIC: + // 1. We read the raw value. + // 2. We write it back using 'redisCompressed'. + // The proxy detects the write and compresses the string if beneficial. + if (value && value.length > 0) { + await redisCompressed.hSet(MY_DATA_HASH_KEY, { [field]: value }); + } + }) + ); + + processedInJob += fieldValues.length; + + // Cursor logic: 0 means iteration is complete + if (nextCursor === 0) { + cursor = 0; + keepRunning = false; + } else { + cursor = nextCursor; + } + + // Safety: Check execution time. + // If we are close to 30s (Devvit limit), stop early and requeue. + if (Date.now() - startTime > 20000) { + console.log('[Migration] Time limit approaching, stopping early.'); + keepRunning = false; + } + } - processedInJob += fieldValues.length; + const newTotal = processedTotal + processedInJob; + + // Daisy Chaining: + // If the cursor is not 0, we still have more data to scan. + // We schedule *this same job* to run again immediately. + if (cursor !== 0) { + console.log(`[Migration] Requeueing. Next cursor: ${cursor}. Processed so far: ${newTotal}`); + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), + data: { + cursor, + chunkSize, + processed: newTotal, + }, + }); - // Cursor logic: 0 means iteration is complete - if (nextCursor === 0) { - cursor = 0; - keepRunning = false; + res.json({ status: 'requeued', processed: newTotal, cursor }); } else { - cursor = nextCursor; + console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); + res.json({ status: 'success', processed: newTotal }); } - - // Safety: Check execution time. - // If we are close to 30s (Devvit limit), stop early and requeue. - if (Date.now() - startTime > 20000) { - console.log('[Migration] Time limit approaching, stopping early.'); - keepRunning = false; - } - } - - const newTotal = processedTotal + processedInJob; - - // Daisy Chaining: - // If the cursor is not 0, we still have more data to scan. - // We schedule *this same job* to run again immediately. - if (cursor !== 0) { - console.log(`[Migration] Requeueing. Next cursor: ${cursor}. Processed so far: ${newTotal}`); - await scheduler.runJob({ - name: 'migrate-example-data', - runAt: new Date(), - data: { - cursor, - chunkSize, - processed: newTotal, - }, - }); - - res.json({ status: 'requeued', processed: newTotal, cursor }); - } else { - console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); - res.json({ status: 'success', processed: newTotal }); + } catch (error) { + console.error('[Migration] Critical Job Error', error); + res.status(500).json({ status: 'error', message: error.message }); } - } catch (error) { - console.error('[Migration] Critical Job Error', error); - res.status(500).json({ status: 'error', message: error.message }); - } -}); + }, +); ``` diff --git a/docs/capabilities/server/scheduler.mdx b/docs/capabilities/server/scheduler.mdx index c755a75..b7b870f 100644 --- a/docs/capabilities/server/scheduler.mdx +++ b/docs/capabilities/server/scheduler.mdx @@ -47,10 +47,13 @@ Ensure the endpoint follows the format `/internal/.+` and specify a `cron` sched ```ts title="/server/index.ts" +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + app.post("/internal/scheduler/regular-interval-task-example", async (c) => { + const _input = await c.req.json(); console.log(`Handle event for cron example at ${new Date().toISOString()}!`); // Handle the event here - return c.json({ status: "ok" }, 200); + return c.json({ status: "ok" }, 200); }); ``` @@ -58,7 +61,9 @@ app.post("/internal/scheduler/regular-interval-task-example", async (c) => { ```ts title="/server/index.ts" -app.post( +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + +app.post( "/internal/scheduler/regular-interval-task-example", async (_req, res) => { console.log( @@ -106,8 +111,11 @@ Example usage: ```ts title="/server/index.ts" +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + app.post("/internal/scheduler/one-off-task-example", async (c) => { - const { postId } = await c.req.json(); + const { data } = await c.req.json>(); + const { postId } = data!; const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); const scheduledJob: ScheduledJob = { @@ -121,7 +129,7 @@ app.post("/internal/scheduler/one-off-task-example", async (c) => { console.log(`Scheduled job ${jobId} for post ${postId}`); console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); // Handle the event here - return c.json({ status: "ok" }, 200); + return c.json({ status: "ok" }, 200); }); ``` @@ -129,8 +137,13 @@ app.post("/internal/scheduler/one-off-task-example", async (c) => { ```ts title="/server/index.ts" -app.post("/internal/scheduler/one-off-task-example", async (req, res) => { - const { postId } = req.body; +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + +app.post>( + "/internal/scheduler/one-off-task-example", + async (req, res) => { + const { data } = req.body; + const { postId } = data!; const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); const scheduledJob: ScheduledJob = { @@ -145,7 +158,8 @@ app.post("/internal/scheduler/one-off-task-example", async (req, res) => { console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); // Handle the event here res.status(200).json({ status: "ok" }); -}); + }, +); ``` @@ -185,16 +199,18 @@ Use the job ID to cancel a scheduled action and remove it from your app. This ex ```ts title="/server/index.ts" +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + app.post("/internal/menu/cancel-job", async (c) => { try { // Get the post ID from the menu action request - const { targetId: postId } = await c.req.json(); + const { targetId: postId } = await c.req.json(); // Retrieve the job ID from Redis (stored when the job was created) const jobId = await redis.get(`job:${postId}`); if (!jobId) { - return c.json({ + return c.json({ showToast: { text: "No scheduled job found for this post", appearance: "neutral", @@ -208,7 +224,7 @@ app.post("/internal/menu/cancel-job", async (c) => { // Clean up the stored job ID await redis.del(`job:${postId}`); - return c.json({ + return c.json({ showToast: { text: "Successfully cancelled the scheduled job", appearance: "success", @@ -216,7 +232,7 @@ app.post("/internal/menu/cancel-job", async (c) => { }); } catch (error) { console.error("Error cancelling job:", error); - return c.json({ + return c.json({ showToast: { text: "Failed to cancel job", appearance: "neutral", @@ -230,22 +246,26 @@ app.post("/internal/menu/cancel-job", async (c) => { ```ts title="/server/index.ts" -app.post("/internal/menu/cancel-job", async (req, res) => { - try { - // Get the post ID from the menu action request - const postId = req.body.targetId; +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + +app.post( + "/internal/menu/cancel-job", + async (req, res) => { + try { + // Get the post ID from the menu action request + const postId = req.body.targetId; // Retrieve the job ID from Redis (stored when the job was created) const jobId = await redis.get(`job:${postId}`); - if (!jobId) { - return res.json({ - showToast: { - text: "No scheduled job found for this post", - appearance: "neutral", - }, - }); - } + if (!jobId) { + return res.json({ + showToast: { + text: "No scheduled job found for this post", + appearance: "neutral", + }, + }); + } // Cancel the scheduled job await scheduler.cancelJob(jobId); @@ -253,22 +273,23 @@ app.post("/internal/menu/cancel-job", async (req, res) => { // Clean up the stored job ID await redis.del(`job:${postId}`); - return res.json({ - showToast: { - text: "Successfully cancelled the scheduled job", - appearance: "success", - }, - }); - } catch (error) { - console.error("Error cancelling job:", error); - return res.json({ - showToast: { - text: "Failed to cancel job", - appearance: "neutral", - }, - }); - } -}); + return res.json({ + showToast: { + text: "Successfully cancelled the scheduled job", + appearance: "success", + }, + }); + } catch (error) { + console.error("Error cancelling job:", error); + return res.json({ + showToast: { + text: "Failed to cancel job", + appearance: "neutral", + }, + }); + } + }, +); ``` @@ -285,8 +306,12 @@ When you create a scheduled job, store its ID in Redis so you can reference it l ```ts title="/server/index.ts" +type ScheduleActionRequest = { postId: string; delayMinutes: number }; +type ScheduleActionResponse = { jobId: string; message: string }; + app.post("/api/schedule-action", async (c) => { - const { postId, delayMinutes } = await c.req.json(); + const { postId, delayMinutes } = + await c.req.json(); const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); const scheduledJob: ScheduledJob = { @@ -301,7 +326,7 @@ app.post("/api/schedule-action", async (c) => { // Store the job ID in Redis for later cancellation await redis.set(`job:${postId}`, jobId); - return c.json({ + return c.json({ jobId, message: "Job scheduled successfully", }); @@ -312,8 +337,13 @@ app.post("/api/schedule-action", async (c) => { ```ts title="/server/index.ts" -app.post("/api/schedule-action", async (req, res) => { - const { postId, delayMinutes } = req.body; +type ScheduleActionRequest = { postId: string; delayMinutes: number }; +type ScheduleActionResponse = { jobId: string; message: string }; + +app.post( + "/api/schedule-action", + async (req, res) => { + const { postId, delayMinutes } = req.body; const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); const scheduledJob: ScheduledJob = { @@ -328,11 +358,12 @@ app.post("/api/schedule-action", async (req, res) => { // Store the job ID in Redis for later cancellation await redis.set(`job:${postId}`, jobId); - return res.json({ - jobId, - message: "Job scheduled successfully", - }); -}); + return res.json({ + jobId, + message: "Job scheduled successfully", + }); + }, +); ``` @@ -349,6 +380,14 @@ This example shows how to handle a request within your server/index.ts to list y ```ts title="/server/index.ts" +type ListJobsSuccessResponse = { + status: "success"; + jobs: (ScheduledJob | ScheduledCronJob)[]; + count: number; +}; +type ListJobsErrorResponse = { status: "error"; message: string }; +type ListJobsResponse = ListJobsSuccessResponse | ListJobsErrorResponse; + app.get("/api/list-jobs", async (c) => { try { const jobs: (ScheduledJob | ScheduledCronJob)[] = @@ -356,14 +395,14 @@ app.get("/api/list-jobs", async (c) => { console.log(`[LIST] Found ${jobs.length} scheduled jobs`); - return c.json({ + return c.json({ status: "success", jobs, count: jobs.length, }); } catch (error) { console.error(`[LIST] Error listing jobs:`, error); - return c.json( + return c.json( { status: "error", message: error instanceof Error ? error.message : "Failed to list jobs", @@ -378,26 +417,37 @@ app.get("/api/list-jobs", async (c) => { ```ts title="/server/index.ts" -app.get("/api/list-jobs", async (_req, res): Promise => { - try { - const jobs: (ScheduledJob | ScheduledCronJob)[] = - await scheduler.listJobs(); +type ListJobsSuccessResponse = { + status: "success"; + jobs: (ScheduledJob | ScheduledCronJob)[]; + count: number; +}; +type ListJobsErrorResponse = { status: "error"; message: string }; +type ListJobsResponse = ListJobsSuccessResponse | ListJobsErrorResponse; + +app.get( + "/api/list-jobs", + async (_req, res): Promise => { + try { + const jobs: (ScheduledJob | ScheduledCronJob)[] = + await scheduler.listJobs(); console.log(`[LIST] Found ${jobs.length} scheduled jobs`); - res.json({ - status: "success", - jobs, - count: jobs.length, - }); - } catch (error) { - console.error(`[LIST] Error listing jobs:`, error); - res.status(500).json({ - status: "error", - message: error instanceof Error ? error.message : "Failed to list jobs", - }); - } -}); + res.json({ + status: "success", + jobs, + count: jobs.length, + }); + } catch (error) { + console.error(`[LIST] Error listing jobs:`, error); + res.status(500).json({ + status: "error", + message: error instanceof Error ? error.message : "Failed to list jobs", + }); + } + }, +); ``` diff --git a/docs/capabilities/server/settings-and-secrets.mdx b/docs/capabilities/server/settings-and-secrets.mdx index f7359a0..2c7804f 100644 --- a/docs/capabilities/server/settings-and-secrets.mdx +++ b/docs/capabilities/server/settings-and-secrets.mdx @@ -203,6 +203,8 @@ Settings can be retrieved from within your app. ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; + type ProcessResponse = { success: true }; + // Get a single setting const apiKey = await settings.get('apiKey'); @@ -224,7 +226,7 @@ Settings can be retrieved from within your app. } }); - return c.json({ success: true }); + return c.json({ success: true }); }); ``` @@ -234,6 +236,8 @@ Settings can be retrieved from within your app. ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; + type ProcessResponse = { success: true }; + // Get a single setting const apiKey = await settings.get('apiKey'); @@ -244,7 +248,7 @@ Settings can be retrieved from within your app. ]); // Use in an endpoint - router.post('/api/process', async (req, res) => { + router.post('/api/process', async (req, res) => { const apiKey = await settings.get('apiKey'); const environment = await settings.get('environment'); @@ -344,26 +348,26 @@ Validate user input to ensure it meets your requirements before saving. ```tsx title="server/index.ts" - import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; + import type { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/shared'; app.post('/internal/settings/validate-age', async (c) => { - const { value } = await c.req.json(); + const { value } = await c.req.json>(); if (!value || value < 0) { - return c.json({ + return c.json({ success: false, error: 'Age must be a positive number', }); } if (value > 365) { - return c.json({ + return c.json({ success: false, error: 'Maximum age is 365 days', }); } - return c.json({ success: true }); + return c.json({ success: true }); }); ``` @@ -371,14 +375,11 @@ Validate user input to ensure it meets your requirements before saving. ```tsx title="server/index.ts" - import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; + import type { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/shared'; - router.post( + router.post>( '/internal/settings/validate-age', - async ( - req: Request>, - res: Response - ): Promise => { + async (req, res): Promise => { const { value } = req.body; if (!value || value < 0) { @@ -486,15 +487,19 @@ Here's a complete example showing both secrets and subreddit settings in action: ```tsx title="server/index.ts" + import type { JsonObject, JsonValue } from '@devvit/web/shared'; import { settings } from '@devvit/web/server'; + type GenerateRequest = { messages: JsonValue }; + type GenerateResponse = JsonObject; + app.post('/api/generate', async (c) => { const [apiKey, model, maxTokens] = await Promise.all([ settings.get('openaiApiKey'), settings.get('aiModel'), settings.get('maxTokens') ]); - const { messages } = await c.req.json(); + const { messages } = await c.req.json(); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -509,8 +514,8 @@ Here's a complete example showing both secrets and subreddit settings in action: }), }); - const data = await response.json(); - return c.json(data); + const data = (await response.json()) as GenerateResponse; + return c.json(data); }); ``` @@ -518,9 +523,13 @@ Here's a complete example showing both secrets and subreddit settings in action: ```tsx title="server/index.ts" + import type { JsonObject, JsonValue } from '@devvit/web/shared'; import { settings } from '@devvit/web/server'; - router.post('/api/generate', async (req, res) => { + type GenerateRequest = { messages: JsonValue }; + type GenerateResponse = JsonObject; + + router.post('/api/generate', async (req, res) => { const [apiKey, model, maxTokens] = await Promise.all([ settings.get('openaiApiKey'), settings.get('aiModel'), @@ -540,7 +549,7 @@ Here's a complete example showing both secrets and subreddit settings in action: }), }); - const data = await response.json(); + const data = (await response.json()) as GenerateResponse; res.json(data); }); ``` diff --git a/docs/capabilities/server/triggers.mdx b/docs/capabilities/server/triggers.mdx index 7928af0..f9be593 100644 --- a/docs/capabilities/server/triggers.mdx +++ b/docs/capabilities/server/triggers.mdx @@ -85,32 +85,39 @@ A full list of events and their payloads can be found in the [EventTypes documen ```tsx title="server/index.ts" +import type { + OnAppUpgradeRequest, + OnCommentCreateRequest, + OnPostSubmitRequest, + TriggerResponse, +} from '@devvit/web/shared'; + app.post('/internal/on-app-upgrade', async (c) => { console.log('Handle event for on-app-upgrade!'); - const body = await c.req.json(); - const installer = body.installer; + const input = await c.req.json(); + const installer = input.installer; console.log('Installer:', JSON.stringify(installer, null, 2)); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); app.post('/internal/on-comment-create', async (c) => { console.log('Handle event for on-comment-create!'); - const body = await c.req.json(); - const comment = body.comment; - const author = body.author; + const input = await c.req.json(); + const comment = input.comment; + const author = input.author; console.log('Comment:', JSON.stringify(comment, null, 2)); console.log('Author:', JSON.stringify(author, null, 2)); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); app.post('/internal/on-post-submit', async (c) => { console.log('Handle event for on-post-submit!'); - const body = await c.req.json(); - const post = body.post; - const author = body.author; + const input = await c.req.json(); + const post = input.post; + const author = input.author; console.log('Post:', JSON.stringify(post, null, 2)); console.log('Author:', JSON.stringify(author, null, 2)); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); ``` @@ -118,18 +125,29 @@ app.post('/internal/on-post-submit', async (c) => { ```tsx title="server/index.ts" +import type { + OnAppUpgradeRequest, + OnCommentCreateRequest, + OnPostSubmitRequest, + TriggerResponse, +} from '@devvit/web/shared'; + const router = express.Router(); // .. -router.post("/internal/on-app-upgrade", async (req, res) => { +router.post( + "/internal/on-app-upgrade", + async (req, res) => { console.log(`Handle event for on-app-upgrade!`); const installer = req.body.installer; console.log("Installer:", JSON.stringify(installer, null, 2)); res.status(200).json({ status: "ok" }); }); -router.post("/internal/on-comment-create", async (req, res) => { +router.post( + "/internal/on-comment-create", + async (req, res) => { console.log(`Handle event for on-comment-create!`); const comment = req.body.comment; const author = req.body.author; @@ -138,7 +156,9 @@ router.post("/internal/on-comment-create", async (req, res) => { res.status(200).json({ status: "ok" }); }); -router.post("/internal/on-post-submit", async (req, res) => { +router.post( + "/internal/on-post-submit", + async (req, res) => { console.log(`Handle event for on-post-submit!`); const post = req.body.post; const author = req.body.author; diff --git a/docs/earn-money/payments/payments_add.mdx b/docs/earn-money/payments/payments_add.mdx index bdca007..94638af 100644 --- a/docs/earn-money/payments/payments_add.mdx +++ b/docs/earn-money/payments/payments_add.mdx @@ -59,16 +59,18 @@ Create endpoints to fulfill and optionally revoke purchases. ```tsx title="server/index.ts" -import type { PaymentHandlerResponse } from "@devvit/web/server"; +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; app.post("/internal/payments/fulfill", async (c) => { + const order = await c.req.json(); // Fulfill the order (grant entitlements, record delivery, etc.) - return c.json({ success: true } satisfies PaymentHandlerResponse); + return c.json({ success: true }); }); app.post("/internal/payments/refund", async (c) => { + const order = await c.req.json(); // Optionally revoke entitlements for a refunded order - return c.json({ success: true } satisfies PaymentHandlerResponse); + return c.json({ success: true }); }); ``` @@ -76,17 +78,25 @@ app.post("/internal/payments/refund", async (c) => { ```tsx title="server/index.ts" -import type { PaymentHandlerResponse } from "@devvit/web/server"; - -router.post("/internal/payments/fulfill", async (req, res) => { - // Fulfill the order (grant entitlements, record delivery, etc.) - res.json({ success: true } satisfies PaymentHandlerResponse); -}); - -router.post("/internal/payments/refund", async (req, res) => { - // Optionally revoke entitlements for a refunded order - res.json({ success: true } satisfies PaymentHandlerResponse); -}); +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; + +router.post( + "/internal/payments/fulfill", + async (req, res) => { + const order = req.body; + // Fulfill the order (grant entitlements, record delivery, etc.) + res.json({ success: true }); + }, +); + +router.post( + "/internal/payments/refund", + async (req, res) => { + const order = req.body; + // Optionally revoke entitlements for a refunded order + res.json({ success: true }); + }, +); export default router; ``` @@ -110,11 +120,12 @@ On the server, use `payments.getProducts()` and `payments.getOrders()`. If the c ```tsx title="server/index.ts" // Example: expose products for client display +import type { Product } from "@devvit/web/shared"; import { payments } from "@devvit/web/server"; app.get("/api/products", async (c) => { const products = await payments.getProducts(); - return c.json(products); + return c.json(products); }); ``` @@ -123,9 +134,10 @@ app.get("/api/products", async (c) => { ```tsx title="server/index.ts" // Example: expose products for client display +import type { Product } from "@devvit/web/shared"; import { payments } from "@devvit/web/server"; -app.get("/api/products", async (_req, res) => { +app.get("/api/products", async (_req, res) => { const products = await payments.getProducts(); res.json(products); }); diff --git a/docs/earn-money/payments/payments_migrate.mdx b/docs/earn-money/payments/payments_migrate.mdx index 0953d86..942190c 100644 --- a/docs/earn-money/payments/payments_migrate.mdx +++ b/docs/earn-money/payments/payments_migrate.mdx @@ -39,11 +39,12 @@ Reference your `products.json` and declare endpoints. ```tsx -import type { PaymentHandlerResponse } from "@devvit/web/server"; +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; app.post("/internal/payments/fulfill", async (c) => { + const order = await c.req.json(); // migrate your old fulfillOrder logic here - return c.json({ success: true } satisfies PaymentHandlerResponse); + return c.json({ success: true }); }); ``` @@ -51,12 +52,16 @@ app.post("/internal/payments/fulfill", async (c) => { ```tsx -import type { PaymentHandlerResponse } from "@devvit/web/server"; +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; -router.post("/internal/payments/fulfill", async (req, res) => { +router.post( + "/internal/payments/fulfill", + async (req, res) => { + const order = req.body; // migrate your old fulfillOrder logic here - res.json({ success: true } satisfies PaymentHandlerResponse); -}); + res.json({ success: true }); + }, +); ``` diff --git a/versioned_docs/version-0.12/capabilities/client/forms.mdx b/versioned_docs/version-0.12/capabilities/client/forms.mdx index e70f868..2ba147a 100644 --- a/versioned_docs/version-0.12/capabilities/client/forms.mdx +++ b/versioned_docs/version-0.12/capabilities/client/forms.mdx @@ -182,12 +182,18 @@ For forms that open from a menu item, you can use menu responses. This is useful ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type NameFormRequest = { name: string }; + type ReviewFormRequest = { review: string }; + // Menu action that triggers menu response form app.post('/internal/menu/start-workflow', async (c) => { + const _input = await c.req.json(); // Server processing before showing form const userData = await fetchUserData(); - return c.json({ + return c.json({ showForm: { name: 'nameForm', form: { @@ -206,13 +212,13 @@ For forms that open from a menu item, you can use menu responses. This is useful // Form submission handler that can chain to another form app.post('/internal/form/name-submit', async (c) => { - const { name } = await c.req.json(); + const { name } = await c.req.json(); // Server processing await saveUserName(name); // Show next form in workflow - return c.json({ + return c.json({ showForm: { name: 'reviewForm', form: { @@ -229,11 +235,11 @@ For forms that open from a menu item, you can use menu responses. This is useful }); app.post('/internal/form/review-submit', async (c) => { - const { review } = await c.req.json(); + const { review } = await c.req.json(); await saveReview(review); - return c.json({ + return c.json({ showToast: 'Thank you for your feedback!' }); }); @@ -243,10 +249,13 @@ For forms that open from a menu item, you can use menu responses. This is useful ```ts title="server/index.ts" - import { UIResponse } from '@devvit/web/shared'; + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type NameFormRequest = { name: string }; + type ReviewFormRequest = { review: string }; // Menu action that triggers menu response form - router.post("/internal/menu/start-workflow", async (_req, res: Response) => { + router.post("/internal/menu/start-workflow", async (_req, res) => { // Server processing before showing form const userData = await fetchUserData(); @@ -268,7 +277,7 @@ For forms that open from a menu item, you can use menu responses. This is useful }); // Form submission handler that can chain to another form - router.post("/internal/form/name-submit", async (req, res: Response) => { + router.post("/internal/form/name-submit", async (req, res) => { const { name } = req.body; // Server processing @@ -291,7 +300,7 @@ For forms that open from a menu item, you can use menu responses. This is useful }); }); - router.post("/internal/form/review-submit", async (req, res: Response) => { + router.post("/internal/form/review-submit", async (req, res) => { const { review } = req.body; await saveReview(review); @@ -677,11 +686,16 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type DynamicFormRequest = { username: string }; + // Endpoint that shows form with dynamic data app.post('/internal/menu/show-dynamic-form', async (c) => { + const _input = await c.req.json(); const user = await reddit.getCurrentUser(); - return c.json({ + return c.json({ showForm: { name: 'dynamicForm', form: { @@ -702,9 +716,9 @@ Below is a collection of common use cases and patterns. // Form submission handler app.post('/internal/form/dynamic-submit', async (c) => { - const { username } = await c.req.json(); + const { username } = await c.req.json(); - return c.json({ + return c.json({ showToast: `Hello ${username}` }); }); @@ -714,8 +728,12 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type DynamicFormRequest = { username: string }; + // Endpoint that shows form with dynamic data - router.post("/internal/menu/show-dynamic-form", async (_req, res: Response) => { + router.post("/internal/menu/show-dynamic-form", async (_req, res) => { const user = await reddit.getCurrentUser(); res.json({ @@ -738,7 +756,7 @@ Below is a collection of common use cases and patterns. }); // Form submission handler - router.post("/internal/form/dynamic-submit", async (req, res: Response) => { + router.post("/internal/form/dynamic-submit", async (req, res) => { const { username } = req.body; res.json({ @@ -889,11 +907,17 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { UiResponse } from '@devvit/web/shared'; + + type Step1FormRequest = { name: string }; + type Step2FormRequest = { name: string; food: string }; + type Step3FormRequest = { name: string; food: string; drink: string }; + // Step 1: Name form app.post('/internal/form/step1-submit', async (c) => { - const { name } = await c.req.json(); + const { name } = await c.req.json(); - return c.json({ + return c.json({ showForm: { name: 'step2Form', form: { @@ -913,9 +937,9 @@ Below is a collection of common use cases and patterns. // Step 2: Food form app.post('/internal/form/step2-submit', async (c) => { - const { name, food } = await c.req.json(); + const { name, food } = await c.req.json(); - return c.json({ + return c.json({ showForm: { name: 'step3Form', form: { @@ -935,9 +959,9 @@ Below is a collection of common use cases and patterns. // Step 3: Final form app.post('/internal/form/step3-submit', async (c) => { - const { name, food, drink } = await c.req.json(); + const { name, food, drink } = await c.req.json(); - return c.json({ + return c.json({ showToast: `Thanks ${name}! You like ${food} and ${drink}.` }); }); @@ -947,8 +971,14 @@ Below is a collection of common use cases and patterns. ```ts title="server/index.ts" + import type { UiResponse } from '@devvit/web/shared'; + + type Step1FormRequest = { name: string }; + type Step2FormRequest = { name: string; food: string }; + type Step3FormRequest = { name: string; food: string; drink: string }; + // Step 1: Name form - router.post("/internal/form/step1-submit", async (req, res: Response) => { + router.post("/internal/form/step1-submit", async (req, res) => { const { name } = req.body; res.json({ @@ -970,7 +1000,7 @@ Below is a collection of common use cases and patterns. }); // Step 2: Food form - router.post("/internal/form/step2-submit", async (req, res: Response) => { + router.post("/internal/form/step2-submit", async (req, res) => { const { name, food } = req.body; res.json({ @@ -992,7 +1022,7 @@ Below is a collection of common use cases and patterns. }); // Step 3: Final form - router.post("/internal/form/step3-submit", async (req, res: Response) => { + router.post("/internal/form/step3-submit", async (req, res) => { const { name, food, drink } = req.body; res.json({ @@ -1186,18 +1216,29 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type EverythingFormRequest = { + food: string; + times?: number; + what?: string; + healthy?: string[]; + again?: boolean; + }; + app.post('/internal/form/everything-submit', async (c) => { - const formValues = await c.req.json(); + const formValues = await c.req.json(); console.log('Form values:', formValues); - return c.json({ + return c.json({ showToast: 'Thanks!' }); }); // Example showing the form app.post('/internal/menu/show-everything-form', async (c) => { - return c.json({ + const _input = await c.req.json(); + return c.json({ showForm: { name: 'everythingForm', form: { @@ -1257,7 +1298,17 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" - router.post("/internal/form/everything-submit", async (req, res: Response) => { + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + + type EverythingFormRequest = { + food: string; + times?: number; + what?: string; + healthy?: string[]; + again?: boolean; + }; + + router.post("/internal/form/everything-submit", async (req, res) => { console.log('Form values:', req.body); res.json({ @@ -1266,7 +1317,7 @@ This example includes one of each of the [supported field types](#supported-fiel }); // Example showing the form - router.post("/internal/menu/show-everything-form", async (_req, res: Response) => { + router.post("/internal/menu/show-everything-form", async (_req, res) => { res.json({ showForm: { name: 'everythingForm', @@ -1452,12 +1503,16 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" + import type { UiResponse } from '@devvit/web/shared'; + + type ImageFormRequest = { myImage: string }; + app.post('/internal/form/image-submit', async (c) => { - const { myImage } = await c.req.json(); + const { myImage } = await c.req.json(); // Use the mediaUrl to store in redis and display it in an block, or send to external service to modify console.log('Image uploaded:', myImage); - return c.json({ + return c.json({ showToast: 'Image uploaded successfully!' }); }); @@ -1467,7 +1522,11 @@ This example includes one of each of the [supported field types](#supported-fiel ```ts title="server/index.ts" - router.post("/internal/form/image-submit", async (req, res: Response) => { + import type { UiResponse } from '@devvit/web/shared'; + + type ImageFormRequest = { myImage: string }; + + router.post("/internal/form/image-submit", async (req, res) => { const { myImage } = req.body; // Use the mediaUrl to store in redis and display it in an block, or send to external service to modify console.log('Image uploaded:', myImage); diff --git a/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx b/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx index eaaf0bd..748ea50 100644 --- a/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx +++ b/versioned_docs/version-0.12/capabilities/client/menu-actions.mdx @@ -43,9 +43,12 @@ Add an item to the three dot menu for posts, comments, or subreddits. Menu actio ```ts title="server/index.ts" +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + app.post("/internal/menu/show-info", async (c) => { + const _input = await c.req.json(); // Simple actions don't need server processing - return c.json({ + return c.json({ showToast: "Menu action clicked!", }); }); @@ -55,12 +58,17 @@ app.post("/internal/menu/show-info", async (c) => { ```ts title="server/index.ts" -app.post("/internal/menu/show-info", async (_req, res) => { - // Simple actions don't need server processing - res.json({ - showToast: "Menu action clicked!", - }); -}); +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + +app.post( + "/internal/menu/show-info", + async (_req, res) => { + // Simple actions don't need server processing + res.json({ + showToast: "Menu action clicked!", + }); + }, +); ``` @@ -162,13 +170,16 @@ In Devvit Web, your menu item should respond with a client side effect to give f ```ts title="server/index.ts" +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + app.post("/internal/menu/complex-action", async (c) => { + const _input = await c.req.json(); try { // Perform server-side processing const userData = await validateAndProcessData(); // Show form with server-fetched data - return c.json({ + return c.json({ showForm: { name: "processForm", form: { @@ -184,7 +195,7 @@ app.post("/internal/menu/complex-action", async (c) => { }, }); } catch (error) { - return c.json({ + return c.json({ showToast: "Processing failed. Please try again.", }); } @@ -195,11 +206,11 @@ app.post("/internal/menu/complex-action", async (c) => { ```ts title="server/index.ts" -import { UIResponse } from "@devvit/web/shared"; +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; -app.post( +app.post( "/internal/menu/complex-action", - async (_req, res: Response) => { + async (_req, res) => { try { // Perform server-side processing const userData = await validateAndProcessData(); diff --git a/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx b/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx index ecd4065..e4f50bf 100644 --- a/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx +++ b/versioned_docs/version-0.12/capabilities/server/cache-helper.mdx @@ -77,6 +77,16 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t import { Hono } from 'hono'; import { cache, context, createServer, getServerPort, reddit } from '@devvit/web/server'; + type SubredditResponse = { + type: 'subreddit'; + subreddit: string; + }; + + type SubredditErrorResponse = { + status: 'error'; + message: string; + }; + const app = new Hono(); app.get('/api/subreddit', async (c) => { @@ -84,7 +94,7 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t if (!postId) { console.error('API Subreddit Error: postId not found in devvit context'); - return c.json( + return c.json( { status: 'error', message: 'postId is required but missing from context', @@ -109,7 +119,7 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t ); console.log(`Current subreddit: ${subredditName}`); - return c.json({ + return c.json({ type: 'subreddit', subreddit: subredditName, }); @@ -119,7 +129,7 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t if (error instanceof Error) { errorMessage = `Subreddit retrieval failed: ${error.message}`; } - return c.json({ status: 'error', message: errorMessage }, 400); + return c.json({ status: 'error', message: errorMessage }, 400); } }); @@ -133,9 +143,6 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t ```tsx title="server/index.ts" import express from "express"; - import { - SubredditResponse, - } from "../shared/types/api"; import { cache, createServer, @@ -144,6 +151,16 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t reddit, } from "@devvit/web/server"; + type SubredditResponse = { + type: "subreddit"; + subreddit: string; + }; + + type SubredditErrorResponse = { + status: "error"; + message: string; + }; + const app = express(); // Middleware for JSON body parsing @@ -155,50 +172,50 @@ Here’s a way to set up in-app caching instead of using scheduler or interval t const router = express.Router(); - router.get< - { postId: string }, - SubredditResponse | { status: string; message: string } - >("/api/subreddit", async (_req, res): Promise => { - const { postId } = context; - - if (!postId) { - console.error("API Subreddit Error: postId not found in devvit context"); - res.status(400).json({ - status: "error", - message: "postId is required but missing from context", - }); - return; - } + router.get( + "/api/subreddit", + async (_req, res): Promise => { + const { postId } = context; + + if (!postId) { + console.error("API Subreddit Error: postId not found in devvit context"); + res.status(400).json({ + status: "error", + message: "postId is required but missing from context", + }); + return; + } - try { - const subredditName = await cache( - async () => { - const subreddit = await reddit.getCurrentSubreddit(); - if (!subreddit) { - throw new Error("Subreddit is required but missing from context"); + try { + const subredditName = await cache( + async () => { + const subreddit = await reddit.getCurrentSubreddit(); + if (!subreddit) { + throw new Error("Subreddit is required but missing from context"); + } + return subreddit.name; + }, + { + key: `current_subreddit`, + ttl: 24 * 60 * 60 // expire after one day. } - return subreddit.name; - }, - { - key: `current_subreddit`, - ttl: 24 * 60 * 60 // expire after one day. + ); + console.log(`Current subreddit: ${subredditName}`); + + res.json({ + type: "subreddit", + subreddit: subredditName, + }); + } catch (error) { + console.error(`API Subreddit Error for post ${postId}:`, error); + let errorMessage = "Unknown error during subreddit retrieval"; + if (error instanceof Error) { + errorMessage = `Subreddit retrieval failed: ${error.message}`; } - ); - console.log(`Current subreddit: ${subredditName}`); - - res.json({ - type: "subreddit" as "subreddit", - subreddit: subredditName, - }); - } catch (error) { - console.error(`API Subreddit Error for post ${postId}:`, error); - let errorMessage = "Unknown error during subreddit retrieval"; - if (error instanceof Error) { - errorMessage = `Subreddit retrieval failed: ${error.message}`; + res.status(400).json({ status: "error", message: errorMessage }); } - res.status(400).json({ status: "error", message: errorMessage }); } - }); + ); app.use(router); diff --git a/versioned_docs/version-0.12/capabilities/server/post-data.mdx b/versioned_docs/version-0.12/capabilities/server/post-data.mdx index d92ba4c..0337cc4 100644 --- a/versioned_docs/version-0.12/capabilities/server/post-data.mdx +++ b/versioned_docs/version-0.12/capabilities/server/post-data.mdx @@ -31,39 +31,51 @@ When creating a post, include the `postData` parameter with your custom data obj ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; + import type { JsonObject } from '@devvit/web/shared'; + + type CreatePostResponse = { + postId: string; + message: string; + }; + + type ErrorResponse = { + error: string; + }; app.post('/api/create-post', async (c) => { const { subredditName } = context; if (!subredditName) { - return c.json({ error: 'Subreddit name is required' }, 400); + return c.json({ error: 'Subreddit name is required' }, 400); } + const postData: JsonObject = { + challengeNumber: 42, + totalGuesses: 0, + gameState: 'active', + pixels: [ + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1], + [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], + [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], + [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] + ], + }; + const post = await reddit.submitCustomPost({ subredditName, title: 'Post with custom data', entry: 'default', - postData: { - challengeNumber: 42, - totalGuesses: 0, - gameState: 'active', - pixels: [ - [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], - [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], - [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], - [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], - [1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1], - [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0], - [0, 0, 2, 2, 1, 1, 1, 1, 1, 0, 0], - [0, 0, 0, 2, 2, 1, 1, 1, 0, 0, 0], - [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] - ], - }, + postData, }); - return c.json({ + return c.json({ postId: post.id, message: 'Post created successfully', }); @@ -75,21 +87,29 @@ When creating a post, include the `postData` parameter with your custom data obj ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; + import type { JsonObject } from '@devvit/web/shared'; - router.post('/api/create-post', async (req, res) => { - const { subredditName } = context; + type CreatePostResponse = { + postId: string; + message: string; + }; - if (!subredditName) { - return res.status(400).json({ - error: 'Subreddit name is required' - }); - } + type ErrorResponse = { + error: string; + }; - const post = await reddit.submitCustomPost({ - subredditName, - title: 'Post with custom data', - entry: 'default', - postData: { + router.post( + '/api/create-post', + async (_req, res) => { + const { subredditName } = context; + + if (!subredditName) { + return res.status(400).json({ + error: 'Subreddit name is required' + }); + } + + const postData: JsonObject = { challengeNumber: 42, totalGuesses: 0, gameState: 'active', @@ -106,14 +126,21 @@ When creating a post, include the `postData` parameter with your custom data obj [0, 0, 0, 0, 2, 2, 1, 0, 0, 0, 0], [0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] ], - }, - }); + }; - res.json({ - postId: post.id, - message: 'Post created successfully' - }); - }); + const post = await reddit.submitCustomPost({ + subredditName, + title: 'Post with custom data', + entry: 'default', + postData, + }); + + res.json({ + postId: post.id, + message: 'Post created successfully' + }); + }, + ); ``` @@ -168,20 +195,35 @@ To update post data after creation, fetch the post and use the `setPostData()` m ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; + import type { JsonObject } from '@devvit/web/shared'; + + type UpdatePostDataRequest = { + favoriteColor?: string; + username?: string; + }; + + type UpdatePostDataResponse = { + success: true; + message: string; + }; + + type ErrorResponse = { + error: string; + }; app.post('/api/update-post-data', async (c) => { const { postId } = context; - const { favoriteColor, username } = await c.req.json(); + const { favoriteColor, username } = await c.req.json(); if (!postId) { - return c.json({ error: 'Post ID is required' }, 400); + return c.json({ error: 'Post ID is required' }, 400); } try { const post = await reddit.getPostById(postId); // Get existing post data to merge with updates - const currentData = context.postData || {}; + const currentData = (context.postData || {}) as JsonObject; await post.setPostData({ ...currentData, @@ -190,13 +232,13 @@ To update post data after creation, fetch the post and use the `setPostData()` m lastUpdatedAt: new Date().toISOString(), }); - return c.json({ + return c.json({ success: true, message: 'Post data updated successfully', }); } catch (error) { console.error('Error updating post data:', error); - return c.json({ error: 'Failed to update post data' }, 500); + return c.json({ error: 'Failed to update post data' }, 500); } }); ``` @@ -206,41 +248,59 @@ To update post data after creation, fetch the post and use the `setPostData()` m ```ts title="server/index.ts" import { context, reddit } from '@devvit/web/server'; - - router.post('/api/update-post-data', async (req, res) => { - const { postId } = context; - const { favoriteColor, username } = req.body; - - if (!postId) { - return res.status(400).json({ - error: 'Post ID is required' - }); - } - - try { - const post = await reddit.getPostById(postId); - - // Get existing post data to merge with updates - const currentData = context.postData || {}; - - await post.setPostData({ - ...currentData, - favoriteColor: favoriteColor || 'unknown', - lastUpdatedBy: username || 'anonymous', - lastUpdatedAt: new Date().toISOString(), - }); - - res.json({ - success: true, - message: 'Post data updated successfully' - }); - } catch (error) { - console.error('Error updating post data:', error); - res.status(500).json({ - error: 'Failed to update post data' - }); - } - }); + import type { JsonObject } from '@devvit/web/shared'; + + type UpdatePostDataRequest = { + favoriteColor?: string; + username?: string; + }; + + type UpdatePostDataResponse = { + success: true; + message: string; + }; + + type ErrorResponse = { + error: string; + }; + + router.post( + '/api/update-post-data', + async (req, res) => { + const { postId } = context; + const { favoriteColor, username } = req.body; + + if (!postId) { + return res.status(400).json({ + error: 'Post ID is required' + }); + } + + try { + const post = await reddit.getPostById(postId); + + // Get existing post data to merge with updates + const currentData = (context.postData || {}) as JsonObject; + + await post.setPostData({ + ...currentData, + favoriteColor: favoriteColor || 'unknown', + lastUpdatedBy: username || 'anonymous', + lastUpdatedAt: new Date().toISOString(), + }); + + res.json({ + success: true, + message: 'Post data updated successfully' + }); + } catch (error) { + console.error('Error updating post data:', error); + res.status(500).json({ + error: 'Failed to update post data' + }); + } + }, + ); ``` diff --git a/versioned_docs/version-0.12/capabilities/server/redis.mdx b/versioned_docs/version-0.12/capabilities/server/redis.mdx index bb806b1..bcca403 100644 --- a/versioned_docs/version-0.12/capabilities/server/redis.mdx +++ b/versioned_docs/version-0.12/capabilities/server/redis.mdx @@ -56,13 +56,15 @@ All limits are applied at a per-installation granularity. ```ts title="server/index.ts" import { redis } from '@devvit/redis'; + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; app.post('/internal/menu/redis-test', async (c) => { + const _request = await c.req.json(); const key = 'hello'; await redis.set(key, 'world'); const value = await redis.get(key); console.log(`${key}: ${value}`); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); ``` @@ -71,15 +73,19 @@ All limits are applied at a per-installation granularity. ```ts title="server/index.ts" import { redis } from '@devvit/redis'; + import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; - router.post("/internal/menu/redis-test", async (_req, res: Response) => { - const key = 'hello'; - await redis.set(key, 'world'); - const value = await redis.get(key); - console.log(`${key}: ${value}`); - res.json({ status: 'ok' }); - }); - ``` + router.post( + "/internal/menu/redis-test", + async (_req, res) => { + const key = 'hello'; + await redis.set(key, 'world'); + const value = await redis.get(key); + console.log(`${key}: ${value}`); + res.json({ status: 'ok' }); + }, + ); + ```
@@ -958,15 +964,28 @@ Add these route handlers to your server. ```ts -import { redis, scheduler } from '@devvit/web/server'; +import { redis, scheduler, type TaskRequest, type TaskResponse } from '@devvit/web/server'; // Import the compressed client import { redisCompressed } from '@devvit/redis'; +import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + +type MigrateExampleFormRequest = { + startCursor?: string; + chunkSize?: number; +}; + +type MigrateExampleJobData = { + cursor?: number | string; + chunkSize?: number; + processed?: number; +}; const MY_DATA_HASH_KEY = 'my:app:large:dataset'; // 1. Menu Endpoint: Returns the form definition app.post('/internal/menu/ops/migrate-example', async (c) => { - return c.json({ + const _request = await c.req.json(); + return c.json({ showForm: { name: 'migrateExampleForm', // Must match key in devvit.json "forms" form: { @@ -993,9 +1012,11 @@ app.post('/internal/menu/ops/migrate-example', async (c) => { // 2. Form Handler: Receives input and schedules the first job app.post('/internal/form/ops/migrate-example', async (c) => { - const { startCursor, chunkSize } = await c.req.json().catch(() => ({})); - const cursor = startCursor || '0'; - const size = Number(chunkSize) || 20000; + const body = await c.req.json().catch( + () => ({} as MigrateExampleFormRequest) + ); + const cursor = body.startCursor || '0'; + const size = Number(body.chunkSize) || 20000; console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); @@ -1010,7 +1031,7 @@ app.post('/internal/form/ops/migrate-example', async (c) => { }, }); - return c.json({ + return c.json({ showToast: { text: 'Migration started in background', appearance: 'success', @@ -1023,12 +1044,14 @@ app.post('/internal/scheduler/migrate-example-data', async (c) => { const startTime = Date.now(); try { - const body = await c.req.json().catch(() => ({})); - const data = body.data ?? {}; + const body = await c.req.json>().catch( + () => ({} as TaskRequest) + ); + const data = body.data; - let cursor = Number(data.cursor) || 0; - const chunkSize = Number(data.chunkSize) || 20000; - const processedTotal = Number(data.processed) || 0; + let cursor = Number(data?.cursor) || 0; + const chunkSize = Number(data?.chunkSize) || 20000; + const processedTotal = Number(data?.processed) || 0; console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); @@ -1099,14 +1122,14 @@ app.post('/internal/scheduler/migrate-example-data', async (c) => { }, }); - return c.json({ status: 'requeued', processed: newTotal, cursor }); + return c.json({ status: 'requeued', processed: newTotal, cursor }); } console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); - return c.json({ status: 'success', processed: newTotal }); + return c.json({ status: 'success', processed: newTotal }); } catch (error) { console.error('[Migration] Critical Job Error', error); - return c.json({ status: 'error', message: error.message }, 500); + return c.json({ status: 'error', message: error.message }, 500); } }); ``` @@ -1115,157 +1138,177 @@ app.post('/internal/scheduler/migrate-example-data', async (c) => { ```ts -import { redis, scheduler } from '@devvit/web/server'; +import { redis, scheduler, type TaskRequest, type TaskResponse } from '@devvit/web/server'; // Import the compressed client import { redisCompressed } from '@devvit/redis'; +import type { MenuItemRequest, UiResponse } from '@devvit/web/shared'; + +type MigrateExampleFormRequest = { + startCursor?: string; + chunkSize?: number; +}; + +type MigrateExampleJobData = { + cursor?: number | string; + chunkSize?: number; + processed?: number; +}; const MY_DATA_HASH_KEY = 'my:app:large:dataset'; // 1. Menu Endpoint: Returns the form definition -app.post('/internal/menu/ops/migrate-example', async (_req, res) => { - res.json({ - showForm: { - name: 'migrateExampleForm', // Must match key in devvit.json "forms" - form: { - title: 'Migrate Hash to Compression', - acceptLabel: 'Start Migration', - fields: [ - { - name: 'startCursor', - label: 'Start Cursor (0 for beginning)', - type: 'string', - defaultValue: '0', - }, - { - name: 'chunkSize', - label: 'Items per batch', - type: 'number', - defaultValue: 20000, - }, - ], +app.post( + '/internal/menu/ops/migrate-example', + async (_req, res) => { + res.json({ + showForm: { + name: 'migrateExampleForm', // Must match key in devvit.json "forms" + form: { + title: 'Migrate Hash to Compression', + acceptLabel: 'Start Migration', + fields: [ + { + name: 'startCursor', + label: 'Start Cursor (0 for beginning)', + type: 'string', + defaultValue: '0', + }, + { + name: 'chunkSize', + label: 'Items per batch', + type: 'number', + defaultValue: 20000, + }, + ], + }, }, - }, - }); -}); + }); + }, +); // 2. Form Handler: Receives input and schedules the first job -app.post('/internal/form/ops/migrate-example', async (req, res) => { - const { startCursor, chunkSize } = req.body ?? {}; - const cursor = startCursor || '0'; - const size = Number(chunkSize) || 20000; - - console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); - - // Kick off the first job in the chain - await scheduler.runJob({ - name: 'migrate-example-data', - runAt: new Date(), // Run immediately - data: { - cursor, - chunkSize: size, - processed: 0, - }, - }); +app.post( + '/internal/form/ops/migrate-example', + async (req, res) => { + const { startCursor, chunkSize } = req.body ?? ({} as MigrateExampleFormRequest); + const cursor = startCursor || '0'; + const size = Number(chunkSize) || 20000; + + console.log(`[Migration] Manual start requested. Cursor: ${cursor}, Chunk: ${size}`); + + // Kick off the first job in the chain + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), // Run immediately + data: { + cursor, + chunkSize: size, + processed: 0, + }, + }); - res.json({ - showToast: { - text: 'Migration started in background', - appearance: 'success', - }, - }); -}); + res.json({ + showToast: { + text: 'Migration started in background', + appearance: 'success', + }, + }); + }, +); // 3. Scheduler Endpoint: The recursive worker -app.post('/internal/scheduler/migrate-example-data', async (req, res) => { - const startTime = Date.now(); - - try { - const body = req.body ?? {}; - const data = body.data ?? {}; +app.post>( + '/internal/scheduler/migrate-example-data', + async (req, res) => { + const startTime = Date.now(); - let cursor = Number(data.cursor) || 0; - const chunkSize = Number(data.chunkSize) || 20000; - const processedTotal = Number(data.processed) || 0; + try { + const data = req.body.data; - console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); + let cursor = Number(data?.cursor) || 0; + const chunkSize = Number(data?.chunkSize) || 20000; + const processedTotal = Number(data?.processed) || 0; - let keepRunning = true; - let processedInJob = 0; - const SCAN_COUNT = 250; // Internal batch size to keep event loop moving + console.log(`[Migration] Job started. Cursor: ${cursor}, Target Chunk: ${chunkSize}`); - while (keepRunning) { - // Stop if we've processed enough items for this single execution - if (processedInJob >= chunkSize) { - break; - } + let keepRunning = true; + let processedInJob = 0; + const SCAN_COUNT = 250; // Internal batch size to keep event loop moving - const { cursor: nextCursor, fieldValues } = await redis.hScan( - MY_DATA_HASH_KEY, - cursor, - undefined, // match pattern - SCAN_COUNT - ); + while (keepRunning) { + // Stop if we've processed enough items for this single execution + if (processedInJob >= chunkSize) { + break; + } - // Parallel Processing: - // We treat the batch as a set of promises to execute simultaneously. - // Promise.allSettled ensures one failure doesn't crash the whole job. - await Promise.allSettled( - fieldValues.map(async ({ field, value }) => { - // LOGIC: - // 1. We read the raw value. - // 2. We write it back using 'redisCompressed'. - // The proxy detects the write and compresses the string if beneficial. - if (value && value.length > 0) { - await redisCompressed.hSet(MY_DATA_HASH_KEY, { [field]: value }); - } - }) - ); + const { cursor: nextCursor, fieldValues } = await redis.hScan( + MY_DATA_HASH_KEY, + cursor, + undefined, // match pattern + SCAN_COUNT + ); + + // Parallel Processing: + // We treat the batch as a set of promises to execute simultaneously. + // Promise.allSettled ensures one failure doesn't crash the whole job. + await Promise.allSettled( + fieldValues.map(async ({ field, value }) => { + // LOGIC: + // 1. We read the raw value. + // 2. We write it back using 'redisCompressed'. + // The proxy detects the write and compresses the string if beneficial. + if (value && value.length > 0) { + await redisCompressed.hSet(MY_DATA_HASH_KEY, { [field]: value }); + } + }) + ); + + processedInJob += fieldValues.length; + + // Cursor logic: 0 means iteration is complete + if (nextCursor === 0) { + cursor = 0; + keepRunning = false; + } else { + cursor = nextCursor; + } + + // Safety: Check execution time. + // If we are close to 30s (Devvit limit), stop early and requeue. + if (Date.now() - startTime > 20000) { + console.log('[Migration] Time limit approaching, stopping early.'); + keepRunning = false; + } + } - processedInJob += fieldValues.length; + const newTotal = processedTotal + processedInJob; + + // Daisy Chaining: + // If the cursor is not 0, we still have more data to scan. + // We schedule *this same job* to run again immediately. + if (cursor !== 0) { + console.log(`[Migration] Requeueing. Next cursor: ${cursor}. Processed so far: ${newTotal}`); + await scheduler.runJob({ + name: 'migrate-example-data', + runAt: new Date(), + data: { + cursor, + chunkSize, + processed: newTotal, + }, + }); - // Cursor logic: 0 means iteration is complete - if (nextCursor === 0) { - cursor = 0; - keepRunning = false; + res.json({ status: 'requeued', processed: newTotal, cursor }); } else { - cursor = nextCursor; + console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); + res.json({ status: 'success', processed: newTotal }); } - - // Safety: Check execution time. - // If we are close to 30s (Devvit limit), stop early and requeue. - if (Date.now() - startTime > 20000) { - console.log('[Migration] Time limit approaching, stopping early.'); - keepRunning = false; - } - } - - const newTotal = processedTotal + processedInJob; - - // Daisy Chaining: - // If the cursor is not 0, we still have more data to scan. - // We schedule *this same job* to run again immediately. - if (cursor !== 0) { - console.log(`[Migration] Requeueing. Next cursor: ${cursor}. Processed so far: ${newTotal}`); - await scheduler.runJob({ - name: 'migrate-example-data', - runAt: new Date(), - data: { - cursor, - chunkSize, - processed: newTotal, - }, - }); - - res.json({ status: 'requeued', processed: newTotal, cursor }); - } else { - console.log(`[Migration] COMPLETE. Total items processed: ${newTotal}`); - res.json({ status: 'success', processed: newTotal }); + } catch (error) { + console.error('[Migration] Critical Job Error', error); + res.status(500).json({ status: 'error', message: error.message }); } - } catch (error) { - console.error('[Migration] Critical Job Error', error); - res.status(500).json({ status: 'error', message: error.message }); - } -}); + }, +); ``` diff --git a/versioned_docs/version-0.12/capabilities/server/scheduler.mdx b/versioned_docs/version-0.12/capabilities/server/scheduler.mdx index a0d9e70..521eb97 100644 --- a/versioned_docs/version-0.12/capabilities/server/scheduler.mdx +++ b/versioned_docs/version-0.12/capabilities/server/scheduler.mdx @@ -47,10 +47,13 @@ Ensure the endpoint follows the format `/internal/.+` and specify a `cron` sched ```ts title="/server/index.ts" +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + app.post("/internal/scheduler/regular-interval-task-example", async (c) => { + const _input = await c.req.json(); console.log(`Handle event for cron example at ${new Date().toISOString()}!`); // Handle the event here - return c.json({ status: "ok" }, 200); + return c.json({ status: "ok" }, 200); }); ``` @@ -58,7 +61,9 @@ app.post("/internal/scheduler/regular-interval-task-example", async (c) => { ```ts title="/server/index.ts" -app.post( +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + +app.post( "/internal/scheduler/regular-interval-task-example", async (_req, res) => { console.log( @@ -106,8 +111,11 @@ Example usage: ```ts title="/server/index.ts" +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + app.post("/internal/scheduler/one-off-task-example", async (c) => { - const { postId } = await c.req.json(); + const { data } = await c.req.json>(); + const { postId } = data!; const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); const scheduledJob: ScheduledJob = { @@ -121,7 +129,7 @@ app.post("/internal/scheduler/one-off-task-example", async (c) => { console.log(`Scheduled job ${jobId} for post ${postId}`); console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); // Handle the event here - return c.json({ status: "ok" }, 200); + return c.json({ status: "ok" }, 200); }); ``` @@ -129,8 +137,13 @@ app.post("/internal/scheduler/one-off-task-example", async (c) => { ```ts title="/server/index.ts" -app.post("/internal/scheduler/one-off-task-example", async (req, res) => { - const { postId } = req.body; +import type { TaskRequest, TaskResponse } from "@devvit/web/server"; + +app.post>( + "/internal/scheduler/one-off-task-example", + async (req, res) => { + const { data } = req.body; + const { postId } = data!; const oneMinuteFromNow = new Date(Date.now() + 1000 * 60); const scheduledJob: ScheduledJob = { @@ -145,7 +158,8 @@ app.post("/internal/scheduler/one-off-task-example", async (req, res) => { console.log(`Handle event for one-off event at ${new Date().toISOString()}!`); // Handle the event here res.status(200).json({ status: "ok" }); -}); + }, +); ``` @@ -185,16 +199,18 @@ Use the job ID to cancel a scheduled action and remove it from your app. This ex ```ts title="/server/index.ts" +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + app.post("/internal/menu/cancel-job", async (c) => { try { // Get the post ID from the menu action request - const { targetId: postId } = await c.req.json(); + const { targetId: postId } = await c.req.json(); // Retrieve the job ID from Redis (stored when the job was created) const jobId = await redis.get(`job:${postId}`); if (!jobId) { - return c.json({ + return c.json({ showToast: { text: "No scheduled job found for this post", appearance: "neutral", @@ -208,7 +224,7 @@ app.post("/internal/menu/cancel-job", async (c) => { // Clean up the stored job ID await redis.del(`job:${postId}`); - return c.json({ + return c.json({ showToast: { text: "Successfully cancelled the scheduled job", appearance: "success", @@ -216,7 +232,7 @@ app.post("/internal/menu/cancel-job", async (c) => { }); } catch (error) { console.error("Error cancelling job:", error); - return c.json({ + return c.json({ showToast: { text: "Failed to cancel job", appearance: "neutral", @@ -230,22 +246,26 @@ app.post("/internal/menu/cancel-job", async (c) => { ```ts title="/server/index.ts" -app.post("/internal/menu/cancel-job", async (req, res) => { - try { - // Get the post ID from the menu action request - const postId = req.body.targetId; +import type { MenuItemRequest, UiResponse } from "@devvit/web/shared"; + +app.post( + "/internal/menu/cancel-job", + async (req, res) => { + try { + // Get the post ID from the menu action request + const postId = req.body.targetId; // Retrieve the job ID from Redis (stored when the job was created) const jobId = await redis.get(`job:${postId}`); - if (!jobId) { - return res.json({ - showToast: { - text: "No scheduled job found for this post", - appearance: "neutral", - }, - }); - } + if (!jobId) { + return res.json({ + showToast: { + text: "No scheduled job found for this post", + appearance: "neutral", + }, + }); + } // Cancel the scheduled job await scheduler.cancelJob(jobId); @@ -253,22 +273,23 @@ app.post("/internal/menu/cancel-job", async (req, res) => { // Clean up the stored job ID await redis.del(`job:${postId}`); - return res.json({ - showToast: { - text: "Successfully cancelled the scheduled job", - appearance: "success", - }, - }); - } catch (error) { - console.error("Error cancelling job:", error); - return res.json({ - showToast: { - text: "Failed to cancel job", - appearance: "neutral", - }, - }); - } -}); + return res.json({ + showToast: { + text: "Successfully cancelled the scheduled job", + appearance: "success", + }, + }); + } catch (error) { + console.error("Error cancelling job:", error); + return res.json({ + showToast: { + text: "Failed to cancel job", + appearance: "neutral", + }, + }); + } + }, +); ``` @@ -285,8 +306,12 @@ When you create a scheduled job, store its ID in Redis so you can reference it l ```ts title="/server/index.ts" +type ScheduleActionRequest = { postId: string; delayMinutes: number }; +type ScheduleActionResponse = { jobId: string; message: string }; + app.post("/api/schedule-action", async (c) => { - const { postId, delayMinutes } = await c.req.json(); + const { postId, delayMinutes } = + await c.req.json(); const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); const scheduledJob: ScheduledJob = { @@ -301,7 +326,7 @@ app.post("/api/schedule-action", async (c) => { // Store the job ID in Redis for later cancellation await redis.set(`job:${postId}`, jobId); - return c.json({ + return c.json({ jobId, message: "Job scheduled successfully", }); @@ -312,8 +337,13 @@ app.post("/api/schedule-action", async (c) => { ```ts title="/server/index.ts" -app.post("/api/schedule-action", async (req, res) => { - const { postId, delayMinutes } = req.body; +type ScheduleActionRequest = { postId: string; delayMinutes: number }; +type ScheduleActionResponse = { jobId: string; message: string }; + +app.post( + "/api/schedule-action", + async (req, res) => { + const { postId, delayMinutes } = req.body; const runAt = new Date(Date.now() + delayMinutes * 60 * 1000); const scheduledJob: ScheduledJob = { @@ -328,11 +358,12 @@ app.post("/api/schedule-action", async (req, res) => { // Store the job ID in Redis for later cancellation await redis.set(`job:${postId}`, jobId); - return res.json({ - jobId, - message: "Job scheduled successfully", - }); -}); + return res.json({ + jobId, + message: "Job scheduled successfully", + }); + }, +); ``` @@ -349,6 +380,14 @@ This example shows how to handle a request within your server/index.ts to list y ```ts title="/server/index.ts" +type ListJobsSuccessResponse = { + status: "success"; + jobs: (ScheduledJob | ScheduledCronJob)[]; + count: number; +}; +type ListJobsErrorResponse = { status: "error"; message: string }; +type ListJobsResponse = ListJobsSuccessResponse | ListJobsErrorResponse; + app.get("/api/list-jobs", async (c) => { try { const jobs: (ScheduledJob | ScheduledCronJob)[] = @@ -356,14 +395,14 @@ app.get("/api/list-jobs", async (c) => { console.log(`[LIST] Found ${jobs.length} scheduled jobs`); - return c.json({ + return c.json({ status: "success", jobs, count: jobs.length, }); } catch (error) { console.error(`[LIST] Error listing jobs:`, error); - return c.json( + return c.json( { status: "error", message: error instanceof Error ? error.message : "Failed to list jobs", @@ -378,26 +417,37 @@ app.get("/api/list-jobs", async (c) => { ```ts title="/server/index.ts" -app.get("/api/list-jobs", async (_req, res): Promise => { - try { - const jobs: (ScheduledJob | ScheduledCronJob)[] = - await scheduler.listJobs(); +type ListJobsSuccessResponse = { + status: "success"; + jobs: (ScheduledJob | ScheduledCronJob)[]; + count: number; +}; +type ListJobsErrorResponse = { status: "error"; message: string }; +type ListJobsResponse = ListJobsSuccessResponse | ListJobsErrorResponse; + +app.get( + "/api/list-jobs", + async (_req, res): Promise => { + try { + const jobs: (ScheduledJob | ScheduledCronJob)[] = + await scheduler.listJobs(); console.log(`[LIST] Found ${jobs.length} scheduled jobs`); - res.json({ - status: "success", - jobs, - count: jobs.length, - }); - } catch (error) { - console.error(`[LIST] Error listing jobs:`, error); - res.status(500).json({ - status: "error", - message: error instanceof Error ? error.message : "Failed to list jobs", - }); - } -}); + res.json({ + status: "success", + jobs, + count: jobs.length, + }); + } catch (error) { + console.error(`[LIST] Error listing jobs:`, error); + res.status(500).json({ + status: "error", + message: error instanceof Error ? error.message : "Failed to list jobs", + }); + } + }, +); ``` diff --git a/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx b/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx index f7359a0..2c7804f 100644 --- a/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx +++ b/versioned_docs/version-0.12/capabilities/server/settings-and-secrets.mdx @@ -203,6 +203,8 @@ Settings can be retrieved from within your app. ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; + type ProcessResponse = { success: true }; + // Get a single setting const apiKey = await settings.get('apiKey'); @@ -224,7 +226,7 @@ Settings can be retrieved from within your app. } }); - return c.json({ success: true }); + return c.json({ success: true }); }); ``` @@ -234,6 +236,8 @@ Settings can be retrieved from within your app. ```tsx title="server/index.ts" import { settings } from '@devvit/web/server'; + type ProcessResponse = { success: true }; + // Get a single setting const apiKey = await settings.get('apiKey'); @@ -244,7 +248,7 @@ Settings can be retrieved from within your app. ]); // Use in an endpoint - router.post('/api/process', async (req, res) => { + router.post('/api/process', async (req, res) => { const apiKey = await settings.get('apiKey'); const environment = await settings.get('environment'); @@ -344,26 +348,26 @@ Validate user input to ensure it meets your requirements before saving. ```tsx title="server/index.ts" - import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; + import type { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/shared'; app.post('/internal/settings/validate-age', async (c) => { - const { value } = await c.req.json(); + const { value } = await c.req.json>(); if (!value || value < 0) { - return c.json({ + return c.json({ success: false, error: 'Age must be a positive number', }); } if (value > 365) { - return c.json({ + return c.json({ success: false, error: 'Maximum age is 365 days', }); } - return c.json({ success: true }); + return c.json({ success: true }); }); ``` @@ -371,14 +375,11 @@ Validate user input to ensure it meets your requirements before saving. ```tsx title="server/index.ts" - import { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/server'; + import type { SettingsValidationRequest, SettingsValidationResponse } from '@devvit/web/shared'; - router.post( + router.post>( '/internal/settings/validate-age', - async ( - req: Request>, - res: Response - ): Promise => { + async (req, res): Promise => { const { value } = req.body; if (!value || value < 0) { @@ -486,15 +487,19 @@ Here's a complete example showing both secrets and subreddit settings in action: ```tsx title="server/index.ts" + import type { JsonObject, JsonValue } from '@devvit/web/shared'; import { settings } from '@devvit/web/server'; + type GenerateRequest = { messages: JsonValue }; + type GenerateResponse = JsonObject; + app.post('/api/generate', async (c) => { const [apiKey, model, maxTokens] = await Promise.all([ settings.get('openaiApiKey'), settings.get('aiModel'), settings.get('maxTokens') ]); - const { messages } = await c.req.json(); + const { messages } = await c.req.json(); const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -509,8 +514,8 @@ Here's a complete example showing both secrets and subreddit settings in action: }), }); - const data = await response.json(); - return c.json(data); + const data = (await response.json()) as GenerateResponse; + return c.json(data); }); ``` @@ -518,9 +523,13 @@ Here's a complete example showing both secrets and subreddit settings in action: ```tsx title="server/index.ts" + import type { JsonObject, JsonValue } from '@devvit/web/shared'; import { settings } from '@devvit/web/server'; - router.post('/api/generate', async (req, res) => { + type GenerateRequest = { messages: JsonValue }; + type GenerateResponse = JsonObject; + + router.post('/api/generate', async (req, res) => { const [apiKey, model, maxTokens] = await Promise.all([ settings.get('openaiApiKey'), settings.get('aiModel'), @@ -540,7 +549,7 @@ Here's a complete example showing both secrets and subreddit settings in action: }), }); - const data = await response.json(); + const data = (await response.json()) as GenerateResponse; res.json(data); }); ``` diff --git a/versioned_docs/version-0.12/capabilities/server/triggers.mdx b/versioned_docs/version-0.12/capabilities/server/triggers.mdx index 7928af0..f9be593 100644 --- a/versioned_docs/version-0.12/capabilities/server/triggers.mdx +++ b/versioned_docs/version-0.12/capabilities/server/triggers.mdx @@ -85,32 +85,39 @@ A full list of events and their payloads can be found in the [EventTypes documen ```tsx title="server/index.ts" +import type { + OnAppUpgradeRequest, + OnCommentCreateRequest, + OnPostSubmitRequest, + TriggerResponse, +} from '@devvit/web/shared'; + app.post('/internal/on-app-upgrade', async (c) => { console.log('Handle event for on-app-upgrade!'); - const body = await c.req.json(); - const installer = body.installer; + const input = await c.req.json(); + const installer = input.installer; console.log('Installer:', JSON.stringify(installer, null, 2)); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); app.post('/internal/on-comment-create', async (c) => { console.log('Handle event for on-comment-create!'); - const body = await c.req.json(); - const comment = body.comment; - const author = body.author; + const input = await c.req.json(); + const comment = input.comment; + const author = input.author; console.log('Comment:', JSON.stringify(comment, null, 2)); console.log('Author:', JSON.stringify(author, null, 2)); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); app.post('/internal/on-post-submit', async (c) => { console.log('Handle event for on-post-submit!'); - const body = await c.req.json(); - const post = body.post; - const author = body.author; + const input = await c.req.json(); + const post = input.post; + const author = input.author; console.log('Post:', JSON.stringify(post, null, 2)); console.log('Author:', JSON.stringify(author, null, 2)); - return c.json({ status: 'ok' }); + return c.json({ status: 'ok' }); }); ``` @@ -118,18 +125,29 @@ app.post('/internal/on-post-submit', async (c) => { ```tsx title="server/index.ts" +import type { + OnAppUpgradeRequest, + OnCommentCreateRequest, + OnPostSubmitRequest, + TriggerResponse, +} from '@devvit/web/shared'; + const router = express.Router(); // .. -router.post("/internal/on-app-upgrade", async (req, res) => { +router.post( + "/internal/on-app-upgrade", + async (req, res) => { console.log(`Handle event for on-app-upgrade!`); const installer = req.body.installer; console.log("Installer:", JSON.stringify(installer, null, 2)); res.status(200).json({ status: "ok" }); }); -router.post("/internal/on-comment-create", async (req, res) => { +router.post( + "/internal/on-comment-create", + async (req, res) => { console.log(`Handle event for on-comment-create!`); const comment = req.body.comment; const author = req.body.author; @@ -138,7 +156,9 @@ router.post("/internal/on-comment-create", async (req, res) => { res.status(200).json({ status: "ok" }); }); -router.post("/internal/on-post-submit", async (req, res) => { +router.post( + "/internal/on-post-submit", + async (req, res) => { console.log(`Handle event for on-post-submit!`); const post = req.body.post; const author = req.body.author; diff --git a/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx b/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx index bdca007..94638af 100644 --- a/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx +++ b/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx @@ -59,16 +59,18 @@ Create endpoints to fulfill and optionally revoke purchases. ```tsx title="server/index.ts" -import type { PaymentHandlerResponse } from "@devvit/web/server"; +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; app.post("/internal/payments/fulfill", async (c) => { + const order = await c.req.json(); // Fulfill the order (grant entitlements, record delivery, etc.) - return c.json({ success: true } satisfies PaymentHandlerResponse); + return c.json({ success: true }); }); app.post("/internal/payments/refund", async (c) => { + const order = await c.req.json(); // Optionally revoke entitlements for a refunded order - return c.json({ success: true } satisfies PaymentHandlerResponse); + return c.json({ success: true }); }); ``` @@ -76,17 +78,25 @@ app.post("/internal/payments/refund", async (c) => { ```tsx title="server/index.ts" -import type { PaymentHandlerResponse } from "@devvit/web/server"; - -router.post("/internal/payments/fulfill", async (req, res) => { - // Fulfill the order (grant entitlements, record delivery, etc.) - res.json({ success: true } satisfies PaymentHandlerResponse); -}); - -router.post("/internal/payments/refund", async (req, res) => { - // Optionally revoke entitlements for a refunded order - res.json({ success: true } satisfies PaymentHandlerResponse); -}); +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; + +router.post( + "/internal/payments/fulfill", + async (req, res) => { + const order = req.body; + // Fulfill the order (grant entitlements, record delivery, etc.) + res.json({ success: true }); + }, +); + +router.post( + "/internal/payments/refund", + async (req, res) => { + const order = req.body; + // Optionally revoke entitlements for a refunded order + res.json({ success: true }); + }, +); export default router; ``` @@ -110,11 +120,12 @@ On the server, use `payments.getProducts()` and `payments.getOrders()`. If the c ```tsx title="server/index.ts" // Example: expose products for client display +import type { Product } from "@devvit/web/shared"; import { payments } from "@devvit/web/server"; app.get("/api/products", async (c) => { const products = await payments.getProducts(); - return c.json(products); + return c.json(products); }); ``` @@ -123,9 +134,10 @@ app.get("/api/products", async (c) => { ```tsx title="server/index.ts" // Example: expose products for client display +import type { Product } from "@devvit/web/shared"; import { payments } from "@devvit/web/server"; -app.get("/api/products", async (_req, res) => { +app.get("/api/products", async (_req, res) => { const products = await payments.getProducts(); res.json(products); }); diff --git a/versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx b/versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx index 0953d86..942190c 100644 --- a/versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx +++ b/versioned_docs/version-0.12/earn-money/payments/payments_migrate.mdx @@ -39,11 +39,12 @@ Reference your `products.json` and declare endpoints. ```tsx -import type { PaymentHandlerResponse } from "@devvit/web/server"; +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; app.post("/internal/payments/fulfill", async (c) => { + const order = await c.req.json(); // migrate your old fulfillOrder logic here - return c.json({ success: true } satisfies PaymentHandlerResponse); + return c.json({ success: true }); }); ``` @@ -51,12 +52,16 @@ app.post("/internal/payments/fulfill", async (c) => { ```tsx -import type { PaymentHandlerResponse } from "@devvit/web/server"; +import type { PaymentHandlerResponse, Order } from "@devvit/web/server"; -router.post("/internal/payments/fulfill", async (req, res) => { +router.post( + "/internal/payments/fulfill", + async (req, res) => { + const order = req.body; // migrate your old fulfillOrder logic here - res.json({ success: true } satisfies PaymentHandlerResponse); -}); + res.json({ success: true }); + }, +); ``` From aade9c4c52f5002ff0c0c9651e93a6d1e32e30d5 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 3 Feb 2026 19:30:51 -0500 Subject: [PATCH 3/9] fix broken links --- versioned_docs/version-0.11/changelog.md | 4 ++-- versioned_docs/version-0.12/capabilities/server/overview.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/versioned_docs/version-0.11/changelog.md b/versioned_docs/version-0.11/changelog.md index 8ab9eac..43027f2 100644 --- a/versioned_docs/version-0.11/changelog.md +++ b/versioned_docs/version-0.11/changelog.md @@ -140,7 +140,7 @@ In this release, Devvit introduces a new way for apps to let users create conten - **Saves the user time and effort**. It’s easy for users to jump into the conversation. - **Improves retention**. When people interact with your app, they’re more likely to stick around, and continued user engagement helps your app reach new people. Total positive feedback loop! -Check out our new [user action API](./capabilities/userActions.mdx) to see how you can add this to your own app. +Check out our new [user action API](./capabilities/userActions.md) to see how you can add this to your own app. Also in this release, we’ve streamlined developer communication. New devs will get automatic email notifications when they [upload](./dev_guide.mdx) their first app and every time an app is approved for [publishing](./publishing.md). @@ -312,7 +312,7 @@ Release 0.11.4 introduces [payments](./payments/payments_overview.md)! This pilo Since this is a pilot program, you'll need to submit an [enrollment form](https://forms.gle/TuTV5jbUwFKTcerUA) before developing and playtesting payments in your app. Before you publish your app, you’ll need to complete additional steps outlined in the payments documentation. -We’ve also added a new [template](./payments/payments_add.mdx) to help you set up payments functionality without needing to code a full app from scratch. +We’ve also added a new [template](./payments/payments_add.md) to help you set up payments functionality without needing to code a full app from scratch. **New features** This release also includes: diff --git a/versioned_docs/version-0.12/capabilities/server/overview.md b/versioned_docs/version-0.12/capabilities/server/overview.md index 1fdcc26..d8d1997 100644 --- a/versioned_docs/version-0.12/capabilities/server/overview.md +++ b/versioned_docs/version-0.12/capabilities/server/overview.md @@ -32,7 +32,7 @@ Allows you to build an app where the moderator can store secret keys in a safe a Allows you to run automated server-side tasks when certain events happen on Reddit, for example: when a new post is created, or when a new comment is created. -## [User actions](./userActions.md) +## [User actions](./userActions.mdx) Allows you to execute some actions, like posting or commenting, on behalf of the user. This means that these new posts or comments will not show up as created by the app, but by the user that is currently using the app. Access to this feature is subject to review by Admins. From 097a918da1a442a956f9b8be17ae0b4d5c9abc20 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 3 Feb 2026 19:40:17 -0500 Subject: [PATCH 4/9] fix warnings in the build warnings --- docs/earn-money/payments/payments_add.mdx | 4 +++- docusaurus.config.ts | 9 ++++++--- package.json | 4 ++++ .../version-0.12/earn-money/payments/payments_add.mdx | 4 +++- yarn.lock | 8 ++++---- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/earn-money/payments/payments_add.mdx b/docs/earn-money/payments/payments_add.mdx index 94638af..ff0e91a 100644 --- a/docs/earn-money/payments/payments_add.mdx +++ b/docs/earn-money/payments/payments_add.mdx @@ -175,9 +175,11 @@ Registered products are updated every time an app is uploaded, including when yo
Click here for instructions on how to add products manually to your products.json file. -The JSON schema for the file format is available at https://developers.reddit.com/schema/products.json. + +The JSON schema for the file format is available at products.json schema. Each product in the products field has the following attributes: + | **Attribute** | **Description** | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `sku` | A product identifier that can be used to group orders or organize your products. Each sku must be unique for each product in your app. | diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 32fe58d..410762a 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -4,7 +4,7 @@ import { themes as prismThemes } from "prism-react-renderer"; const baseUrl = process.env.DOCUSAURUS_BASE_URL ?? "/docs/"; -const LATEST_DEVVIT_VERSION = '0.12'; // update-versioned-docs.mjs sets this automatically +const LATEST_DEVVIT_VERSION = "0.12"; // update-versioned-docs.mjs sets this automatically const config: Config = { future: { @@ -16,10 +16,12 @@ const config: Config = { url: "https://developers.reddit.com", baseUrl, onBrokenLinks: "warn", - onBrokenMarkdownLinks: "warn", favicon: "/img/devvit_icon.png", markdown: { format: "detect", + hooks: { + onBrokenMarkdownLinks: "warn", + }, }, scripts: [ { @@ -86,7 +88,8 @@ const config: Config = { if (!/next|\/\d+\.\d+\//.test(item.url)) { return true; } else { - console.log("excluding", item.url); + // Too noisy! + // console.log("excluding", item.url); return false; } }); diff --git a/package.json b/package.json index a30b787..ec925b5 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,16 @@ "react-dom": "^19.0.0", "redocusaurus": "2.5.0" }, + "resolutions": { + "baseline-browser-mapping": "2.9.19" + }, "devDependencies": { "@algolia/client-search": "^5.17.0", "@docusaurus/module-type-aliases": "^3.9.2", "@docusaurus/theme-common": "^3.9.2", "@docusaurus/utils": "^3.9.2", "@types/react": "^19.0.0", + "baseline-browser-mapping": "2.9.19", "core-js": "^3.39.0", "mobx": "^6.13.5", "prettier": "3.3.3", diff --git a/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx b/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx index 94638af..ff0e91a 100644 --- a/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx +++ b/versioned_docs/version-0.12/earn-money/payments/payments_add.mdx @@ -175,9 +175,11 @@ Registered products are updated every time an app is uploaded, including when yo
Click here for instructions on how to add products manually to your products.json file. -The JSON schema for the file format is available at https://developers.reddit.com/schema/products.json. + +The JSON schema for the file format is available at products.json schema. Each product in the products field has the following attributes: + | **Attribute** | **Description** | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `sku` | A product identifier that can be used to group orders or organize your products. Each sku must be unique for each product in your app. | diff --git a/yarn.lock b/yarn.lock index 6078720..458ad6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3689,10 +3689,10 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -baseline-browser-mapping@^2.8.19: - version "2.8.24" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.24.tgz#f70388d8a136b701c819567f6798b797378be7b0" - integrity sha512-uUhTRDPXamakPyghwrUcjaGvvBqGrWvBHReoiULMIpOJVM9IYzQh83Xk2Onx5HlGI2o10NNCzcs9TG/S3TkwrQ== +baseline-browser-mapping@2.9.19, baseline-browser-mapping@^2.8.19: + version "2.9.19" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" + integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== batch@0.6.1: version "0.6.1" From 550024331445d1f400af672bc8d4f8b5b8fcf3ab Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Tue, 3 Feb 2026 19:46:38 -0500 Subject: [PATCH 5/9] fix broken links --- docs/capabilities/server/userActions.mdx | 2 +- docs/earn-money/payments/payments_publish.md | 2 +- versioned_docs/version-0.12/capabilities/server/userActions.mdx | 2 +- .../version-0.12/earn-money/payments/payments_publish.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/capabilities/server/userActions.mdx b/docs/capabilities/server/userActions.mdx index 0c89a11..00c5ee5 100644 --- a/docs/capabilities/server/userActions.mdx +++ b/docs/capabilities/server/userActions.mdx @@ -64,7 +64,7 @@ After enabling, you can call certain Reddit APIs on behalf of the user by passin Currently, the following APIs support this option: - [submitPost()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#submitpost) -- [submitCustomPost()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#submitcustompost) +- [submitCustomPost()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md) - [submitComment()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#submitcomment) If `runAs` is not specified, the API will use `runAs: 'APP'` by default. diff --git a/docs/earn-money/payments/payments_publish.md b/docs/earn-money/payments/payments_publish.md index 47347b8..9b7ab0c 100644 --- a/docs/earn-money/payments/payments_publish.md +++ b/docs/earn-money/payments/payments_publish.md @@ -16,4 +16,4 @@ You can change your app visibility at any time. See publishing an app for detail ### Ineligible products -Any apps or products for which you wish to enable payments must comply with our [Earn Policy](../reddit_developer_funds#terms-and-conditions) and [Devvit Guidelines](../../devvit_rules). +Any apps or products for which you wish to enable payments must comply with our [Earn Policy](https://developers.reddit.com/docs/earn-money/reddit_developer_funds) and [Devvit Guidelines](../../devvit_rules). diff --git a/versioned_docs/version-0.12/capabilities/server/userActions.mdx b/versioned_docs/version-0.12/capabilities/server/userActions.mdx index 0c89a11..00c5ee5 100644 --- a/versioned_docs/version-0.12/capabilities/server/userActions.mdx +++ b/versioned_docs/version-0.12/capabilities/server/userActions.mdx @@ -64,7 +64,7 @@ After enabling, you can call certain Reddit APIs on behalf of the user by passin Currently, the following APIs support this option: - [submitPost()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#submitpost) -- [submitCustomPost()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#submitcustompost) +- [submitCustomPost()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md) - [submitComment()](../../api/redditapi/RedditAPIClient/classes/RedditAPIClient.md#submitcomment) If `runAs` is not specified, the API will use `runAs: 'APP'` by default. diff --git a/versioned_docs/version-0.12/earn-money/payments/payments_publish.md b/versioned_docs/version-0.12/earn-money/payments/payments_publish.md index 47347b8..9b7ab0c 100644 --- a/versioned_docs/version-0.12/earn-money/payments/payments_publish.md +++ b/versioned_docs/version-0.12/earn-money/payments/payments_publish.md @@ -16,4 +16,4 @@ You can change your app visibility at any time. See publishing an app for detail ### Ineligible products -Any apps or products for which you wish to enable payments must comply with our [Earn Policy](../reddit_developer_funds#terms-and-conditions) and [Devvit Guidelines](../../devvit_rules). +Any apps or products for which you wish to enable payments must comply with our [Earn Policy](https://developers.reddit.com/docs/earn-money/reddit_developer_funds) and [Devvit Guidelines](../../devvit_rules). From 06491e400d4f733995d0bd1d9e6db2c657a6cc8c Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Wed, 4 Feb 2026 11:30:05 -0500 Subject: [PATCH 6/9] throw on missing links --- docusaurus.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 410762a..34e2135 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -20,7 +20,7 @@ const config: Config = { markdown: { format: "detect", hooks: { - onBrokenMarkdownLinks: "warn", + onBrokenMarkdownLinks: "throw", }, }, scripts: [ From bc5726ca9c5ce9360b5639f13e86c77c53aee406 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Wed, 4 Feb 2026 11:58:18 -0500 Subject: [PATCH 7/9] rm doc --- docs/guides/ai/ai.md | 4 ---- versioned_docs/version-0.12/guides/ai/ai.md | 4 ---- 2 files changed, 8 deletions(-) diff --git a/docs/guides/ai/ai.md b/docs/guides/ai/ai.md index f29ce25..9609ac2 100644 --- a/docs/guides/ai/ai.md +++ b/docs/guides/ai/ai.md @@ -7,10 +7,6 @@ Devvit ships with first class support for common AI tools and patterns. - https://developers.reddit.com/docs/llms.txt: Most useful for pasting into the chat UI of common LLMs BEFORE your prompt. Place your prompt last as models are auto-regressive. - https://developers.reddit.com/docs/llms-full.txt: Useful for pasting into the chat UI of LLMs with large context windows (Gemini, Claude Sonnet 4). This lets you chat with the docs instead of reading them. It's easy to pollute your context if your using this for coding so we recommend only using this to learn about Devvit or plan. To execute, use `llms.txt` as most modern LLMs can tool call websites. -## Cursor Support - -The React, ThreeJS, and Phaser templates ship with support for cursor rules out of the box. We've found these helps Cursor output high quality code for Devvit. Feel free to add and remove them as you see fit. - ## MCP Devvit ships with a MCP server to assist with agent driven development. There are two commands at the moment: diff --git a/versioned_docs/version-0.12/guides/ai/ai.md b/versioned_docs/version-0.12/guides/ai/ai.md index f29ce25..9609ac2 100644 --- a/versioned_docs/version-0.12/guides/ai/ai.md +++ b/versioned_docs/version-0.12/guides/ai/ai.md @@ -7,10 +7,6 @@ Devvit ships with first class support for common AI tools and patterns. - https://developers.reddit.com/docs/llms.txt: Most useful for pasting into the chat UI of common LLMs BEFORE your prompt. Place your prompt last as models are auto-regressive. - https://developers.reddit.com/docs/llms-full.txt: Useful for pasting into the chat UI of LLMs with large context windows (Gemini, Claude Sonnet 4). This lets you chat with the docs instead of reading them. It's easy to pollute your context if your using this for coding so we recommend only using this to learn about Devvit or plan. To execute, use `llms.txt` as most modern LLMs can tool call websites. -## Cursor Support - -The React, ThreeJS, and Phaser templates ship with support for cursor rules out of the box. We've found these helps Cursor output high quality code for Devvit. Feel free to add and remove them as you see fit. - ## MCP Devvit ships with a MCP server to assist with agent driven development. There are two commands at the moment: From 9165f7c3dcc37479da5214ce523ab06648af9e3f Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Wed, 4 Feb 2026 13:18:18 -0500 Subject: [PATCH 8/9] remove experimental --- docs/guides/tools/devvit_test.mdx | 363 +++++++++--------- .../version-0.12/guides/tools/devvit_test.mdx | 363 +++++++++--------- 2 files changed, 374 insertions(+), 352 deletions(-) diff --git a/docs/guides/tools/devvit_test.mdx b/docs/guides/tools/devvit_test.mdx index be2c8fe..5e7ff7f 100644 --- a/docs/guides/tools/devvit_test.mdx +++ b/docs/guides/tools/devvit_test.mdx @@ -6,27 +6,23 @@ sidebar_label: Testing # Testing with @devvit/test -:::warning Experimental -The `@devvit/test` package is currently experimental and subject to breaking changes. It is only available for Devvit Web apps. -::: - The `@devvit/test` package provides utilities to write unit and integration tests for your backend logic with [Vitest](https://vitest.dev/). ## Capability Support Out of the box, the test harness mocks many of Devvit's capabilities for you. Here's what's supported: -| Capability | Status | Notes | -| :--- | :--- | :--- | -| [Redis](#redis) | ✅ Supported | Per-test isolation; transactions supported | -| [Scheduler](#scheduler) | ✅ Supported | Jobs are listed immediately; time does not advance | -| [Settings](#settings) | ✅ Supported | Per-test isolation; configurable defaults | -| [Realtime](#realtime) | ✅ Supported | In-memory recording of sent/received messages | -| [Media](#media) | ✅ Supported | In-memory uploads with synthetic IDs/URLs | -| [Notifications](#notifications) | ✅ Supported | | -| [HTTP](#http) | ✅ Blocked by default | Network calls throw; mock `fetch` to allow | -| [Reddit API](#reddit-api) | ⚠️ Partially Supported | Helpful errors for unimplemented methods | -| Payments | ❌ Not Supported (yet) | | +| Capability | Status | Notes | +| :------------------------------ | :--------------------- | :------------------------------------------------- | +| [Redis](#redis) | ✅ Supported | Per-test isolation; transactions supported | +| [Scheduler](#scheduler) | ✅ Supported | Jobs are listed immediately; time does not advance | +| [Settings](#settings) | ✅ Supported | Per-test isolation; configurable defaults | +| [Realtime](#realtime) | ✅ Supported | In-memory recording of sent/received messages | +| [Media](#media) | ✅ Supported | In-memory uploads with synthetic IDs/URLs | +| [Notifications](#notifications) | ✅ Supported | | +| [HTTP](#http) | ✅ Blocked by default | Network calls throw; mock `fetch` to allow | +| [Reddit API](#reddit-api) | ⚠️ Partially Supported | Helpful errors for unimplemented methods | +| Payments | ❌ Not Supported (yet) | | You can use these capabilities inside your tests exactly as you do in production code. @@ -45,16 +41,16 @@ First, make sure you have `vitest` and `@devvit/test` installed in your project. To get started, create a test instance using `createDevvitTest()`. This returns a Vitest `TestAPI` instance that contains app code fencing and fixtures for Devvit capabilities. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { reddit } from '@devvit/reddit'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { reddit } from "@devvit/reddit"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('my first devvit test', async ({ redis }) => { - await redis.set('my-key', 'hello world'); - const value = await redis.get('my-key'); - expect(value).toBe('hello world'); +test("my first devvit test", async ({ redis }) => { + await redis.set("my-key", "hello world"); + const value = await redis.get("my-key"); + expect(value).toBe("hello world"); }); ``` @@ -63,15 +59,15 @@ test('my first devvit test', async ({ redis }) => { Each test receives Devvit-specific fixtures as arguments to your test body: ```typescript -import { realtime } from '@devvit/web/server'; +import { realtime } from "@devvit/web/server"; -test('an send realtime messages', async ({ mocks, userId, subredditName }) => { - await realtime.send('my-channel', { foo: 'bar' }); +test("an send realtime messages", async ({ mocks, userId, subredditName }) => { + await realtime.send("my-channel", { foo: "bar" }); - const messages = mocks.realtime.getSentMessagesForChannel('my-channel'); + const messages = mocks.realtime.getSentMessagesForChannel("my-channel"); expect(messages.length).toBe(1); - expect(messages[0].channel).toBe('my-channel'); - expect(messages[0].data?.msg).toEqual({ foo: 'bar' }); + expect(messages[0].channel).toBe("my-channel"); + expect(messages[0].data?.msg).toEqual({ foo: "bar" }); }); ``` @@ -105,21 +101,21 @@ The test harness is built for integration-style testing. Each test defined with For example, if two tests write to the same Redis key, they won't interfere with each other: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('should increment counter to 1', async ({ redis }) => { - await redis.incrBy('counter', 1); - const value = await redis.get('counter'); - expect(value).toBe('1'); +test("should increment counter to 1", async ({ redis }) => { + await redis.incrBy("counter", 1); + const value = await redis.get("counter"); + expect(value).toBe("1"); }); -test('should also increment counter to 1', async ({ redis }) => { - await redis.incrBy('counter', 1); - const value = await redis.get('counter'); - expect(value).toBe('1'); +test("should also increment counter to 1", async ({ redis }) => { + await redis.incrBy("counter", 1); + const value = await redis.get("counter"); + expect(value).toBe("1"); }); ``` @@ -130,31 +126,31 @@ When testing your app logic, it's best to use your own service layer methods to For instance, if you're testing a `deleteUser` function, create the user first using your `createUser` function: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('can delete a user', async ({ redis }) => { +test("can delete a user", async ({ redis }) => { const userManager = new UserManager(redis); // Your app class - + // Stage data using your API - await userManager.createUser('bob', { age: 30 }); + await userManager.createUser("bob", { age: 30 }); // Verify data was staged - const newUser = await userManager.getUser('bob'); + const newUser = await userManager.getUser("bob"); expect(newUser).toEqual({ age: 30 }); - + // Perform action - await userManager.deleteUser('bob'); + await userManager.deleteUser("bob"); // Verify data was deleted - const deletedUser = await userManager.getUser('bob'); + const deletedUser = await userManager.getUser("bob"); expect(deletedUser).toBeNull(); }); ``` -If you just need a quick smoke test, you *can* stage data directly via the same capabilities you use in production (e.g., `await redis.set('user:bob', JSON.stringify({ age: 30 }))`), but using your service APIs helps you cover more of your stack. +If you just need a quick smoke test, you _can_ stage data directly via the same capabilities you use in production (e.g., `await redis.set('user:bob', JSON.stringify({ age: 30 }))`), but using your service APIs helps you cover more of your stack. ## Capability Guides @@ -163,16 +159,16 @@ Each mocked capability exposes the same API surface you use in production. Unles ### Redis ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { redis } from '@devvit/redis'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { redis } from "@devvit/redis"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('tracks counters per test', async () => { - await redis.incrBy('score', 1); - const score = await redis.get('score'); - expect(score).toBe('1'); +test("tracks counters per test", async () => { + await redis.incrBy("score", 1); + const score = await redis.get("score"); + expect(score).toBe("1"); }); ``` @@ -182,16 +178,16 @@ Installation-scoped redis transactions work end-to-end in the harness. Use the same `watch`/`multi`/`exec` flow that production code does: ```typescript -test('commits redis transactions', async () => { - await redis.set('txn', '0'); - const txn = await redis.watch('txn'); +test("commits redis transactions", async () => { + await redis.set("txn", "0"); + const txn = await redis.watch("txn"); await txn.multi(); - await txn.incrBy('txn', 4); - await txn.incrBy('txn', 1); + await txn.incrBy("txn", 4); + await txn.incrBy("txn", 1); const results = await txn.exec(); expect(results).toStrictEqual([4, 5]); - expect(await redis.get('txn')).toBe('5'); + expect(await redis.get("txn")).toBe("5"); }); ``` @@ -205,15 +201,15 @@ Global and scoped Redis data are cleared for you after every test run. Global Re - Use the same API calls (`runJob`, `cancelJob`) you would in production. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { scheduler } from '@devvit/scheduler'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { scheduler } from "@devvit/scheduler"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('schedules and cancels jobs', async () => { +test("schedules and cancels jobs", async () => { const jobId = await scheduler.runJob({ - name: 'nightly-report', + name: "nightly-report", runAt: new Date(Date.now() + 1_000), data: { retry: false }, }); @@ -233,22 +229,22 @@ test('schedules and cancels jobs', async () => { - Settings are per-test. To use the same settings for all tests, configure defaults via `createDevvitTest({ settings: { ... } })`. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { settings } from '@devvit/settings'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { settings } from "@devvit/settings"; +import { expect } from "vitest"; const test = createDevvitTest({ settings: { - 'feature-flag': true, - 'api-key': 'secret-123', + "feature-flag": true, + "api-key": "secret-123", }, }); -test('reads configured settings', async () => { - const isEnabled = await settings.get('feature-flag'); +test("reads configured settings", async () => { + const isEnabled = await settings.get("feature-flag"); expect(isEnabled).toBe(true); - const apiKey = await settings.get('api-key'); - expect(apiKey).toBe('secret-123'); + const apiKey = await settings.get("api-key"); + expect(apiKey).toBe("secret-123"); }); ``` @@ -262,18 +258,18 @@ You can also access `context.settings` from the fixtures if you prefer to work w - Use `mocks.realtime.getSentMessagesForChannel()` to inspect what was sent during a test. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { realtime } from '@devvit/realtime/server'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { realtime } from "@devvit/realtime/server"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('emits realtime events', async ({ mocks }) => { - await realtime.send('scores', { latest: 42 }); +test("emits realtime events", async ({ mocks }) => { + await realtime.send("scores", { latest: 42 }); - const messages = mocks.realtime.getSentMessagesForChannel('scores'); + const messages = mocks.realtime.getSentMessagesForChannel("scores"); expect(messages).toHaveLength(1); - expect(messages[0].channel).toBe('scores'); + expect(messages[0].channel).toBe("scores"); expect(messages[0].data?.msg).toStrictEqual({ latest: 42 }); }); ``` @@ -285,27 +281,29 @@ test('emits realtime events', async ({ mocks }) => { - Uploads don't hit the network. The mock simply records the payload and returns synthetic IDs/URLs. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { media } from '@devvit/media'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { media } from "@devvit/media"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('uploads media assets', async () => { +test("uploads media assets", async () => { const response = await media.upload({ - url: 'https://example.com/image.png', - type: 'image', + url: "https://example.com/image.png", + type: "image", }); - expect(response.mediaId).toBe('media-1'); - expect(response.mediaUrl).toBe('https://i.redd.it/bogus-for-testing/media-1.png'); + expect(response.mediaId).toBe("media-1"); + expect(response.mediaUrl).toBe( + "https://i.redd.it/bogus-for-testing/media-1.png", + ); }); -test('inspects uploads via mocks', async ({ mocks }) => { - await media.upload({ url: 'https://example.com/image.png', type: 'image' }); +test("inspects uploads via mocks", async ({ mocks }) => { + await media.upload({ url: "https://example.com/image.png", type: "image" }); expect(mocks.media.uploads).toHaveLength(1); - expect(mocks.media.uploads[0].url).toBe('https://example.com/image.png'); + expect(mocks.media.uploads[0].url).toBe("https://example.com/image.png"); mocks.media.clear(); expect(mocks.media.uploads).toHaveLength(0); @@ -320,13 +318,13 @@ test('inspects uploads via mocks', async ({ mocks }) => { - Use `mocks.notifications` to inspect sent notifications and opted-in users. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { notifications } from '@devvit/notifications'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { notifications } from "@devvit/notifications"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('sends push notifications', async ({ mocks, userId }) => { +test("sends push notifications", async ({ mocks, userId }) => { // Opt in the current user await notifications.optInCurrentUser(); @@ -335,15 +333,15 @@ test('sends push notifications', async ({ mocks, userId }) => { // Send a notification await notifications.enqueue({ - title: 'Hello', - body: 'World', + title: "Hello", + body: "World", recipients: [{ userId }], }); // Verify notification was sent via mocks const sent = mocks.notifications.getSentNotifications(); expect(sent).toHaveLength(1); - expect(sent[0].title).toBe('Hello'); + expect(sent[0].title).toBe("Hello"); expect(sent[0].recipients[0].userId).toBe(userId); }); ``` @@ -360,14 +358,14 @@ By default, any `fetch()` calls in your tests will throw an error. To test code #### Default behavior ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('blocks HTTP by default', async () => { - await expect(fetch('https://noop.reddit.com')).rejects.toThrow( - 'HTTP requests are not allowed in tests' +test("blocks HTTP by default", async () => { + await expect(fetch("https://noop.reddit.com")).rejects.toThrow( + "HTTP requests are not allowed in tests", ); }); ``` @@ -377,30 +375,30 @@ test('blocks HTTP by default', async () => { Use `vi.spyOn` to mock `fetch()` calls: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('fetches Pokemon data', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ +test("fetches Pokemon data", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 25, - name: 'pikachu', + name: "pikachu", height: 4, weight: 60, - types: [{ type: { name: 'electric' } }], + types: [{ type: { name: "electric" } }], }), } as Response); - const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu'); + const response = await fetch("https://pokeapi.co/api/v2/pokemon/pikachu"); const data = await response.json(); expect(response.status).toBe(200); - expect(data.name).toBe('pikachu'); - expect(data.types[0].type.name).toBe('electric'); + expect(data.name).toBe("pikachu"); + expect(data.types[0].type.name).toBe("electric"); }); ``` @@ -409,25 +407,25 @@ test('fetches Pokemon data', async () => { You can also test error handling by making your mock return error responses: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('handles API errors', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ +test("handles API errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 500, - statusText: 'Internal Server Error', - text: async () => 'Internal Server Error', + statusText: "Internal Server Error", + text: async () => "Internal Server Error", } as Response); - const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu'); + const response = await fetch("https://pokeapi.co/api/v2/pokemon/pikachu"); expect(response.status).toBe(500); - expect(response.statusText).toBe('Internal Server Error'); + expect(response.statusText).toBe("Internal Server Error"); const text = await response.text(); - expect(text).toBe('Internal Server Error'); + expect(text).toBe("Internal Server Error"); }); ``` @@ -436,13 +434,13 @@ test('handles API errors', async () => { You can also mock POST requests and inspect the request body: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('sends POST request with body', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation((url, options) => { +test("sends POST request with body", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation((url, options) => { const body = options?.body as string; const parsedBody = JSON.parse(body); @@ -453,16 +451,16 @@ test('sends POST request with body', async () => { } as Response); }); - const response = await fetch('https://pokeapi.co/api/v2/pokemon', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'pikachu', type: 'electric' }), + const response = await fetch("https://pokeapi.co/api/v2/pokemon", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "pikachu", type: "electric" }), }); const data = await response.json(); expect(response.status).toBe(201); - expect(data.name).toBe('pikachu'); - expect(data.type).toBe('electric'); + expect(data.name).toBe("pikachu"); + expect(data.type).toBe("electric"); }); ``` @@ -475,45 +473,55 @@ test('sends POST request with body', async () => { The harness seeds a default user and subreddit based on your configuration. Use `mocks.reddit` to seed additional data or to inspect what the plugin saw. -| Service | Support | -| :--- | :--- | -| Users | ⚠️ Partially Supported | +| Service | Support | +| :--------------- | :--------------------- | +| Users | ⚠️ Partially Supported | | LinksAndComments | ⚠️ Partially Supported | -| Subreddits | ⚠️ Partially Supported | -| Flair | ❌ Not yet supported | -| Listings | ❌ Not yet supported | -| Moderation | ❌ Not yet supported | -| ModNote | ❌ Not yet supported | -| NewModmail | ❌ Not yet supported | -| PrivateMessages | ❌ Not yet supported | -| Widgets | ❌ Not yet supported | -| Wiki | ❌ Not yet supported | - -*Note: `LinksAndComments` is more commonly referred to as `posts`.* +| Subreddits | ⚠️ Partially Supported | +| Flair | ❌ Not yet supported | +| Listings | ❌ Not yet supported | +| Moderation | ❌ Not yet supported | +| ModNote | ❌ Not yet supported | +| NewModmail | ❌ Not yet supported | +| PrivateMessages | ❌ Not yet supported | +| Widgets | ❌ Not yet supported | +| Wiki | ❌ Not yet supported | + +_Note: `LinksAndComments` is more commonly referred to as `posts`._ #### Mocking Methods on Returned Objects Some methods exist on the objects returned by the API (like `user.getSocialLinks()`). Since these objects are real instances returned by the harness, you can spy on the specific instance to mock them. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { reddit, type SocialLinkType } from '@devvit/reddit'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { reddit, type SocialLinkType } from "@devvit/reddit"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('mocks social links on a returned user', async ({ mocks }) => { - mocks.reddit.users.addUser({ id: 't2_user', name: 'test_user' }); +test("mocks social links on a returned user", async ({ mocks }) => { + mocks.reddit.users.addUser({ id: "t2_user", name: "test_user" }); - const user = await reddit.getUserByUsername('test_user'); - if (!user) throw new Error('User not found'); + const user = await reddit.getUserByUsername("test_user"); + if (!user) throw new Error("User not found"); - vi.spyOn(user, 'getSocialLinks').mockResolvedValue([ - { id: '1', outboundUrl: 'https://example.com', type: 'REDDIT' as SocialLinkType, title: 'Example' }, + vi.spyOn(user, "getSocialLinks").mockResolvedValue([ + { + id: "1", + outboundUrl: "https://example.com", + type: "REDDIT" as SocialLinkType, + title: "Example", + }, ]); await expect(user.getSocialLinks()).resolves.toStrictEqual([ - { id: '1', outboundUrl: 'https://example.com', type: 'REDDIT', title: 'Example' }, + { + id: "1", + outboundUrl: "https://example.com", + type: "REDDIT", + title: "Example", + }, ]); }); ``` @@ -523,41 +531,40 @@ test('mocks social links on a returned user', async ({ mocks }) => { For supported calls like `getUserByUsername`, use the provided `mocks.reddit` fixture to seed the backing store. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('can fetch a user', async ({ mocks }) => { +test("can fetch a user", async ({ mocks }) => { mocks.reddit.users.addUser({ - id: 't2_12345', - name: 'testuser', + id: "t2_12345", + name: "testuser", createdUtc: Date.now() / 1000, }); - const user = await reddit.getUserByUsername('testuser'); - expect(user.id).toBe('t2_12345'); + const user = await reddit.getUserByUsername("testuser"); + expect(user.id).toBe("t2_12345"); }); ``` You can also seed Posts for `getPostById`: ```typescript -import { reddit } from '@devvit/reddit'; +import { reddit } from "@devvit/reddit"; -test('can fetch a post', async ({ mocks }) => { +test("can fetch a post", async ({ mocks }) => { mocks.reddit.linksAndComments.addPost({ - id: 't3_123', - title: 'My Test Post', - subreddit: 'testsub', + id: "t3_123", + title: "My Test Post", + subreddit: "testsub", }); - const post = await reddit.getPostById('t3_123'); - expect(post.title).toBe('My Test Post'); + const post = await reddit.getPostById("t3_123"); + expect(post.title).toBe("My Test Post"); }); ``` - ## Multiple Test Instances Most of the time, you'll define a single `const test = createDevvitTest()` at the top of your spec file and use fixtures/settings to customize behavior. But if you need distinct contexts, such as different subreddits or users, you can spin up additional instances, even within the same file. @@ -565,16 +572,20 @@ Most of the time, you'll define a single `const test = createDevvitTest()` at th ```typescript // Development subreddit context const devTest = createDevvitTest({ - subredditName: 'my_dev_sub', + subredditName: "my_dev_sub", }); // Production subreddit context const prodTest = createDevvitTest({ - subredditName: 'my_prod_sub', + subredditName: "my_prod_sub", }); -devTest('development logic', () => { /* ... */ }); -prodTest('production logic', () => { /* ... */ }); +devTest("development logic", () => { + /* ... */ +}); +prodTest("production logic", () => { + /* ... */ +}); ``` The reverse works too: you can create one instance and share it across every test in your application. diff --git a/versioned_docs/version-0.12/guides/tools/devvit_test.mdx b/versioned_docs/version-0.12/guides/tools/devvit_test.mdx index be2c8fe..5e7ff7f 100644 --- a/versioned_docs/version-0.12/guides/tools/devvit_test.mdx +++ b/versioned_docs/version-0.12/guides/tools/devvit_test.mdx @@ -6,27 +6,23 @@ sidebar_label: Testing # Testing with @devvit/test -:::warning Experimental -The `@devvit/test` package is currently experimental and subject to breaking changes. It is only available for Devvit Web apps. -::: - The `@devvit/test` package provides utilities to write unit and integration tests for your backend logic with [Vitest](https://vitest.dev/). ## Capability Support Out of the box, the test harness mocks many of Devvit's capabilities for you. Here's what's supported: -| Capability | Status | Notes | -| :--- | :--- | :--- | -| [Redis](#redis) | ✅ Supported | Per-test isolation; transactions supported | -| [Scheduler](#scheduler) | ✅ Supported | Jobs are listed immediately; time does not advance | -| [Settings](#settings) | ✅ Supported | Per-test isolation; configurable defaults | -| [Realtime](#realtime) | ✅ Supported | In-memory recording of sent/received messages | -| [Media](#media) | ✅ Supported | In-memory uploads with synthetic IDs/URLs | -| [Notifications](#notifications) | ✅ Supported | | -| [HTTP](#http) | ✅ Blocked by default | Network calls throw; mock `fetch` to allow | -| [Reddit API](#reddit-api) | ⚠️ Partially Supported | Helpful errors for unimplemented methods | -| Payments | ❌ Not Supported (yet) | | +| Capability | Status | Notes | +| :------------------------------ | :--------------------- | :------------------------------------------------- | +| [Redis](#redis) | ✅ Supported | Per-test isolation; transactions supported | +| [Scheduler](#scheduler) | ✅ Supported | Jobs are listed immediately; time does not advance | +| [Settings](#settings) | ✅ Supported | Per-test isolation; configurable defaults | +| [Realtime](#realtime) | ✅ Supported | In-memory recording of sent/received messages | +| [Media](#media) | ✅ Supported | In-memory uploads with synthetic IDs/URLs | +| [Notifications](#notifications) | ✅ Supported | | +| [HTTP](#http) | ✅ Blocked by default | Network calls throw; mock `fetch` to allow | +| [Reddit API](#reddit-api) | ⚠️ Partially Supported | Helpful errors for unimplemented methods | +| Payments | ❌ Not Supported (yet) | | You can use these capabilities inside your tests exactly as you do in production code. @@ -45,16 +41,16 @@ First, make sure you have `vitest` and `@devvit/test` installed in your project. To get started, create a test instance using `createDevvitTest()`. This returns a Vitest `TestAPI` instance that contains app code fencing and fixtures for Devvit capabilities. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { reddit } from '@devvit/reddit'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { reddit } from "@devvit/reddit"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('my first devvit test', async ({ redis }) => { - await redis.set('my-key', 'hello world'); - const value = await redis.get('my-key'); - expect(value).toBe('hello world'); +test("my first devvit test", async ({ redis }) => { + await redis.set("my-key", "hello world"); + const value = await redis.get("my-key"); + expect(value).toBe("hello world"); }); ``` @@ -63,15 +59,15 @@ test('my first devvit test', async ({ redis }) => { Each test receives Devvit-specific fixtures as arguments to your test body: ```typescript -import { realtime } from '@devvit/web/server'; +import { realtime } from "@devvit/web/server"; -test('an send realtime messages', async ({ mocks, userId, subredditName }) => { - await realtime.send('my-channel', { foo: 'bar' }); +test("an send realtime messages", async ({ mocks, userId, subredditName }) => { + await realtime.send("my-channel", { foo: "bar" }); - const messages = mocks.realtime.getSentMessagesForChannel('my-channel'); + const messages = mocks.realtime.getSentMessagesForChannel("my-channel"); expect(messages.length).toBe(1); - expect(messages[0].channel).toBe('my-channel'); - expect(messages[0].data?.msg).toEqual({ foo: 'bar' }); + expect(messages[0].channel).toBe("my-channel"); + expect(messages[0].data?.msg).toEqual({ foo: "bar" }); }); ``` @@ -105,21 +101,21 @@ The test harness is built for integration-style testing. Each test defined with For example, if two tests write to the same Redis key, they won't interfere with each other: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('should increment counter to 1', async ({ redis }) => { - await redis.incrBy('counter', 1); - const value = await redis.get('counter'); - expect(value).toBe('1'); +test("should increment counter to 1", async ({ redis }) => { + await redis.incrBy("counter", 1); + const value = await redis.get("counter"); + expect(value).toBe("1"); }); -test('should also increment counter to 1', async ({ redis }) => { - await redis.incrBy('counter', 1); - const value = await redis.get('counter'); - expect(value).toBe('1'); +test("should also increment counter to 1", async ({ redis }) => { + await redis.incrBy("counter", 1); + const value = await redis.get("counter"); + expect(value).toBe("1"); }); ``` @@ -130,31 +126,31 @@ When testing your app logic, it's best to use your own service layer methods to For instance, if you're testing a `deleteUser` function, create the user first using your `createUser` function: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('can delete a user', async ({ redis }) => { +test("can delete a user", async ({ redis }) => { const userManager = new UserManager(redis); // Your app class - + // Stage data using your API - await userManager.createUser('bob', { age: 30 }); + await userManager.createUser("bob", { age: 30 }); // Verify data was staged - const newUser = await userManager.getUser('bob'); + const newUser = await userManager.getUser("bob"); expect(newUser).toEqual({ age: 30 }); - + // Perform action - await userManager.deleteUser('bob'); + await userManager.deleteUser("bob"); // Verify data was deleted - const deletedUser = await userManager.getUser('bob'); + const deletedUser = await userManager.getUser("bob"); expect(deletedUser).toBeNull(); }); ``` -If you just need a quick smoke test, you *can* stage data directly via the same capabilities you use in production (e.g., `await redis.set('user:bob', JSON.stringify({ age: 30 }))`), but using your service APIs helps you cover more of your stack. +If you just need a quick smoke test, you _can_ stage data directly via the same capabilities you use in production (e.g., `await redis.set('user:bob', JSON.stringify({ age: 30 }))`), but using your service APIs helps you cover more of your stack. ## Capability Guides @@ -163,16 +159,16 @@ Each mocked capability exposes the same API surface you use in production. Unles ### Redis ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { redis } from '@devvit/redis'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { redis } from "@devvit/redis"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('tracks counters per test', async () => { - await redis.incrBy('score', 1); - const score = await redis.get('score'); - expect(score).toBe('1'); +test("tracks counters per test", async () => { + await redis.incrBy("score", 1); + const score = await redis.get("score"); + expect(score).toBe("1"); }); ``` @@ -182,16 +178,16 @@ Installation-scoped redis transactions work end-to-end in the harness. Use the same `watch`/`multi`/`exec` flow that production code does: ```typescript -test('commits redis transactions', async () => { - await redis.set('txn', '0'); - const txn = await redis.watch('txn'); +test("commits redis transactions", async () => { + await redis.set("txn", "0"); + const txn = await redis.watch("txn"); await txn.multi(); - await txn.incrBy('txn', 4); - await txn.incrBy('txn', 1); + await txn.incrBy("txn", 4); + await txn.incrBy("txn", 1); const results = await txn.exec(); expect(results).toStrictEqual([4, 5]); - expect(await redis.get('txn')).toBe('5'); + expect(await redis.get("txn")).toBe("5"); }); ``` @@ -205,15 +201,15 @@ Global and scoped Redis data are cleared for you after every test run. Global Re - Use the same API calls (`runJob`, `cancelJob`) you would in production. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { scheduler } from '@devvit/scheduler'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { scheduler } from "@devvit/scheduler"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('schedules and cancels jobs', async () => { +test("schedules and cancels jobs", async () => { const jobId = await scheduler.runJob({ - name: 'nightly-report', + name: "nightly-report", runAt: new Date(Date.now() + 1_000), data: { retry: false }, }); @@ -233,22 +229,22 @@ test('schedules and cancels jobs', async () => { - Settings are per-test. To use the same settings for all tests, configure defaults via `createDevvitTest({ settings: { ... } })`. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { settings } from '@devvit/settings'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { settings } from "@devvit/settings"; +import { expect } from "vitest"; const test = createDevvitTest({ settings: { - 'feature-flag': true, - 'api-key': 'secret-123', + "feature-flag": true, + "api-key": "secret-123", }, }); -test('reads configured settings', async () => { - const isEnabled = await settings.get('feature-flag'); +test("reads configured settings", async () => { + const isEnabled = await settings.get("feature-flag"); expect(isEnabled).toBe(true); - const apiKey = await settings.get('api-key'); - expect(apiKey).toBe('secret-123'); + const apiKey = await settings.get("api-key"); + expect(apiKey).toBe("secret-123"); }); ``` @@ -262,18 +258,18 @@ You can also access `context.settings` from the fixtures if you prefer to work w - Use `mocks.realtime.getSentMessagesForChannel()` to inspect what was sent during a test. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { realtime } from '@devvit/realtime/server'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { realtime } from "@devvit/realtime/server"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('emits realtime events', async ({ mocks }) => { - await realtime.send('scores', { latest: 42 }); +test("emits realtime events", async ({ mocks }) => { + await realtime.send("scores", { latest: 42 }); - const messages = mocks.realtime.getSentMessagesForChannel('scores'); + const messages = mocks.realtime.getSentMessagesForChannel("scores"); expect(messages).toHaveLength(1); - expect(messages[0].channel).toBe('scores'); + expect(messages[0].channel).toBe("scores"); expect(messages[0].data?.msg).toStrictEqual({ latest: 42 }); }); ``` @@ -285,27 +281,29 @@ test('emits realtime events', async ({ mocks }) => { - Uploads don't hit the network. The mock simply records the payload and returns synthetic IDs/URLs. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { media } from '@devvit/media'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { media } from "@devvit/media"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('uploads media assets', async () => { +test("uploads media assets", async () => { const response = await media.upload({ - url: 'https://example.com/image.png', - type: 'image', + url: "https://example.com/image.png", + type: "image", }); - expect(response.mediaId).toBe('media-1'); - expect(response.mediaUrl).toBe('https://i.redd.it/bogus-for-testing/media-1.png'); + expect(response.mediaId).toBe("media-1"); + expect(response.mediaUrl).toBe( + "https://i.redd.it/bogus-for-testing/media-1.png", + ); }); -test('inspects uploads via mocks', async ({ mocks }) => { - await media.upload({ url: 'https://example.com/image.png', type: 'image' }); +test("inspects uploads via mocks", async ({ mocks }) => { + await media.upload({ url: "https://example.com/image.png", type: "image" }); expect(mocks.media.uploads).toHaveLength(1); - expect(mocks.media.uploads[0].url).toBe('https://example.com/image.png'); + expect(mocks.media.uploads[0].url).toBe("https://example.com/image.png"); mocks.media.clear(); expect(mocks.media.uploads).toHaveLength(0); @@ -320,13 +318,13 @@ test('inspects uploads via mocks', async ({ mocks }) => { - Use `mocks.notifications` to inspect sent notifications and opted-in users. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { notifications } from '@devvit/notifications'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { notifications } from "@devvit/notifications"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('sends push notifications', async ({ mocks, userId }) => { +test("sends push notifications", async ({ mocks, userId }) => { // Opt in the current user await notifications.optInCurrentUser(); @@ -335,15 +333,15 @@ test('sends push notifications', async ({ mocks, userId }) => { // Send a notification await notifications.enqueue({ - title: 'Hello', - body: 'World', + title: "Hello", + body: "World", recipients: [{ userId }], }); // Verify notification was sent via mocks const sent = mocks.notifications.getSentNotifications(); expect(sent).toHaveLength(1); - expect(sent[0].title).toBe('Hello'); + expect(sent[0].title).toBe("Hello"); expect(sent[0].recipients[0].userId).toBe(userId); }); ``` @@ -360,14 +358,14 @@ By default, any `fetch()` calls in your tests will throw an error. To test code #### Default behavior ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('blocks HTTP by default', async () => { - await expect(fetch('https://noop.reddit.com')).rejects.toThrow( - 'HTTP requests are not allowed in tests' +test("blocks HTTP by default", async () => { + await expect(fetch("https://noop.reddit.com")).rejects.toThrow( + "HTTP requests are not allowed in tests", ); }); ``` @@ -377,30 +375,30 @@ test('blocks HTTP by default', async () => { Use `vi.spyOn` to mock `fetch()` calls: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('fetches Pokemon data', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ +test("fetches Pokemon data", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, status: 200, json: async () => ({ id: 25, - name: 'pikachu', + name: "pikachu", height: 4, weight: 60, - types: [{ type: { name: 'electric' } }], + types: [{ type: { name: "electric" } }], }), } as Response); - const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu'); + const response = await fetch("https://pokeapi.co/api/v2/pokemon/pikachu"); const data = await response.json(); expect(response.status).toBe(200); - expect(data.name).toBe('pikachu'); - expect(data.types[0].type.name).toBe('electric'); + expect(data.name).toBe("pikachu"); + expect(data.types[0].type.name).toBe("electric"); }); ``` @@ -409,25 +407,25 @@ test('fetches Pokemon data', async () => { You can also test error handling by making your mock return error responses: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('handles API errors', async () => { - vi.spyOn(globalThis, 'fetch').mockResolvedValue({ +test("handles API errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 500, - statusText: 'Internal Server Error', - text: async () => 'Internal Server Error', + statusText: "Internal Server Error", + text: async () => "Internal Server Error", } as Response); - const response = await fetch('https://pokeapi.co/api/v2/pokemon/pikachu'); + const response = await fetch("https://pokeapi.co/api/v2/pokemon/pikachu"); expect(response.status).toBe(500); - expect(response.statusText).toBe('Internal Server Error'); + expect(response.statusText).toBe("Internal Server Error"); const text = await response.text(); - expect(text).toBe('Internal Server Error'); + expect(text).toBe("Internal Server Error"); }); ``` @@ -436,13 +434,13 @@ test('handles API errors', async () => { You can also mock POST requests and inspect the request body: ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('sends POST request with body', async () => { - vi.spyOn(globalThis, 'fetch').mockImplementation((url, options) => { +test("sends POST request with body", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation((url, options) => { const body = options?.body as string; const parsedBody = JSON.parse(body); @@ -453,16 +451,16 @@ test('sends POST request with body', async () => { } as Response); }); - const response = await fetch('https://pokeapi.co/api/v2/pokemon', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'pikachu', type: 'electric' }), + const response = await fetch("https://pokeapi.co/api/v2/pokemon", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "pikachu", type: "electric" }), }); const data = await response.json(); expect(response.status).toBe(201); - expect(data.name).toBe('pikachu'); - expect(data.type).toBe('electric'); + expect(data.name).toBe("pikachu"); + expect(data.type).toBe("electric"); }); ``` @@ -475,45 +473,55 @@ test('sends POST request with body', async () => { The harness seeds a default user and subreddit based on your configuration. Use `mocks.reddit` to seed additional data or to inspect what the plugin saw. -| Service | Support | -| :--- | :--- | -| Users | ⚠️ Partially Supported | +| Service | Support | +| :--------------- | :--------------------- | +| Users | ⚠️ Partially Supported | | LinksAndComments | ⚠️ Partially Supported | -| Subreddits | ⚠️ Partially Supported | -| Flair | ❌ Not yet supported | -| Listings | ❌ Not yet supported | -| Moderation | ❌ Not yet supported | -| ModNote | ❌ Not yet supported | -| NewModmail | ❌ Not yet supported | -| PrivateMessages | ❌ Not yet supported | -| Widgets | ❌ Not yet supported | -| Wiki | ❌ Not yet supported | - -*Note: `LinksAndComments` is more commonly referred to as `posts`.* +| Subreddits | ⚠️ Partially Supported | +| Flair | ❌ Not yet supported | +| Listings | ❌ Not yet supported | +| Moderation | ❌ Not yet supported | +| ModNote | ❌ Not yet supported | +| NewModmail | ❌ Not yet supported | +| PrivateMessages | ❌ Not yet supported | +| Widgets | ❌ Not yet supported | +| Wiki | ❌ Not yet supported | + +_Note: `LinksAndComments` is more commonly referred to as `posts`._ #### Mocking Methods on Returned Objects Some methods exist on the objects returned by the API (like `user.getSocialLinks()`). Since these objects are real instances returned by the harness, you can spy on the specific instance to mock them. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { reddit, type SocialLinkType } from '@devvit/reddit'; -import { expect, vi } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { reddit, type SocialLinkType } from "@devvit/reddit"; +import { expect, vi } from "vitest"; const test = createDevvitTest(); -test('mocks social links on a returned user', async ({ mocks }) => { - mocks.reddit.users.addUser({ id: 't2_user', name: 'test_user' }); +test("mocks social links on a returned user", async ({ mocks }) => { + mocks.reddit.users.addUser({ id: "t2_user", name: "test_user" }); - const user = await reddit.getUserByUsername('test_user'); - if (!user) throw new Error('User not found'); + const user = await reddit.getUserByUsername("test_user"); + if (!user) throw new Error("User not found"); - vi.spyOn(user, 'getSocialLinks').mockResolvedValue([ - { id: '1', outboundUrl: 'https://example.com', type: 'REDDIT' as SocialLinkType, title: 'Example' }, + vi.spyOn(user, "getSocialLinks").mockResolvedValue([ + { + id: "1", + outboundUrl: "https://example.com", + type: "REDDIT" as SocialLinkType, + title: "Example", + }, ]); await expect(user.getSocialLinks()).resolves.toStrictEqual([ - { id: '1', outboundUrl: 'https://example.com', type: 'REDDIT', title: 'Example' }, + { + id: "1", + outboundUrl: "https://example.com", + type: "REDDIT", + title: "Example", + }, ]); }); ``` @@ -523,41 +531,40 @@ test('mocks social links on a returned user', async ({ mocks }) => { For supported calls like `getUserByUsername`, use the provided `mocks.reddit` fixture to seed the backing store. ```typescript -import { createDevvitTest } from '@devvit/test/server/vitest'; -import { expect } from 'vitest'; +import { createDevvitTest } from "@devvit/test/server/vitest"; +import { expect } from "vitest"; const test = createDevvitTest(); -test('can fetch a user', async ({ mocks }) => { +test("can fetch a user", async ({ mocks }) => { mocks.reddit.users.addUser({ - id: 't2_12345', - name: 'testuser', + id: "t2_12345", + name: "testuser", createdUtc: Date.now() / 1000, }); - const user = await reddit.getUserByUsername('testuser'); - expect(user.id).toBe('t2_12345'); + const user = await reddit.getUserByUsername("testuser"); + expect(user.id).toBe("t2_12345"); }); ``` You can also seed Posts for `getPostById`: ```typescript -import { reddit } from '@devvit/reddit'; +import { reddit } from "@devvit/reddit"; -test('can fetch a post', async ({ mocks }) => { +test("can fetch a post", async ({ mocks }) => { mocks.reddit.linksAndComments.addPost({ - id: 't3_123', - title: 'My Test Post', - subreddit: 'testsub', + id: "t3_123", + title: "My Test Post", + subreddit: "testsub", }); - const post = await reddit.getPostById('t3_123'); - expect(post.title).toBe('My Test Post'); + const post = await reddit.getPostById("t3_123"); + expect(post.title).toBe("My Test Post"); }); ``` - ## Multiple Test Instances Most of the time, you'll define a single `const test = createDevvitTest()` at the top of your spec file and use fixtures/settings to customize behavior. But if you need distinct contexts, such as different subreddits or users, you can spin up additional instances, even within the same file. @@ -565,16 +572,20 @@ Most of the time, you'll define a single `const test = createDevvitTest()` at th ```typescript // Development subreddit context const devTest = createDevvitTest({ - subredditName: 'my_dev_sub', + subredditName: "my_dev_sub", }); // Production subreddit context const prodTest = createDevvitTest({ - subredditName: 'my_prod_sub', + subredditName: "my_prod_sub", }); -devTest('development logic', () => { /* ... */ }); -prodTest('production logic', () => { /* ... */ }); +devTest("development logic", () => { + /* ... */ +}); +prodTest("production logic", () => { + /* ... */ +}); ``` The reverse works too: you can create one instance and share it across every test in your application. From 57b71727cb687d104417895272d511a38f2daff6 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Wed, 4 Feb 2026 17:21:18 -0500 Subject: [PATCH 9/9] update migration --- docs/guides/tools/vite.mdx | 5 +- .../version-0.12/guides/tools/vite.mdx | 71 ++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/guides/tools/vite.mdx b/docs/guides/tools/vite.mdx index 2dd3fda..1c52a5c 100644 --- a/docs/guides/tools/vite.mdx +++ b/docs/guides/tools/vite.mdx @@ -163,6 +163,8 @@ export default defineConfig({ }); ``` +> Note: You might see TypeScript errors when using this config because it's not included in any of the tsconfigs you have. You will need to include the file in a tsconfig file for this to be fixed. You can also rename it to `vite.config.js` if you don't want to use TypeScript. + 3. Remove `src/client/vite.config.ts` and `src/server/vite.config.ts` Technically, that's all you need to do! However, you can also make your development experience a lot nicer by utilizing the new `scripts` field in your `devvit.json` file. @@ -195,7 +197,7 @@ diff --git a/package.json b/package.json - "dev:server": "cd src/server && vite build --watch", - "dev:vite": "cd src/client && vite --port 7474", + "build": "vite build", -+ "dev": "devvit playtest --verbose", ++ "dev": "devvit playtest", ``` You can also remove the `concurrently` dependency after this switch. @@ -206,3 +208,4 @@ You can also remove the `concurrently` dependency after this switch. - **Build-only:** The plugin only supports `vite build`. There is no support for `vite dev` or Hot Module Replacement (HMR) at this time. This is because `devvit playtest` works by uploading your build to our servers and running it on Reddit.com. Instead, use `vite build --watch` as your dev command. - **Public dir resolution:** The plugin auto-detects a `public/` folder at the repo root or inside `src/client`. If both exist, the build fails—keep a single public directory. +- If you run into this error while using `tailwindcss`: `[@tailwindcss/vite:generate:build] Cannot create proxy with a non-object as target or handler`. All you need to do is bump `@tailwindcss/vite` and `tailwindcss` to `4.1.18` in your `package.json` and `npm install` diff --git a/versioned_docs/version-0.12/guides/tools/vite.mdx b/versioned_docs/version-0.12/guides/tools/vite.mdx index 8f16014..1c52a5c 100644 --- a/versioned_docs/version-0.12/guides/tools/vite.mdx +++ b/versioned_docs/version-0.12/guides/tools/vite.mdx @@ -52,6 +52,10 @@ The plugin uses your `devvit.json` as the source of truth for client entry point "entry": "splash.html" } } + }, + "server": { + "dir": "dist/server", + "entry": "index.ts" } } ``` @@ -79,7 +83,7 @@ If neither file exists, the build fails with a clear error message. ## What it builds -Out of the box, the plugin configures two environments: +Out of the box, the plugin configures two environments depending on what's defined in your `devvit.json`: - **Client build** outputs to `dist/client` and uses the entry points from `devvit.json`. - **Server build** outputs to `dist/server` as `index.cjs` @@ -136,7 +140,72 @@ src/ formatScore.ts # safe to import from client + server ``` +## Migrating Old Templates + +If you started with a template before this plugin, migrating it is simple! + +1. Run the installation command + +```bash +npm install @devvit/start +``` + +2. Add a `vite.config` to the root of your project. For example, this is how you would migrate a React app: + +```ts title="vite.config.ts" +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwind from "@tailwindcss/vite"; +import { devvit } from "@devvit/start/vite"; + +export default defineConfig({ + plugins: [react(), tailwind(), devvit()], +}); +``` + +> Note: You might see TypeScript errors when using this config because it's not included in any of the tsconfigs you have. You will need to include the file in a tsconfig file for this to be fixed. You can also rename it to `vite.config.js` if you don't want to use TypeScript. + +3. Remove `src/client/vite.config.ts` and `src/server/vite.config.ts` + +Technically, that's all you need to do! However, you can also make your development experience a lot nicer by utilizing the new `scripts` field in your `devvit.json` file. + +4. Add the following to your `devvit.json` file: + +```json title="devvit.json" +{ + "scripts": { + "dev": "vite build --watch", + "build": "vite build" + } +} +``` + +5. Update your `package.json` commands to look like the following: + +```patch title="package.json" +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -7,14 +7,6 @@ + "scripts": { +- "build:client": "cd src/client && vite build", +- "build:server": "cd src/server && vite build", +- "build": "npm run build:client && npm run build:server", +- "dev": "concurrently -k -p \"[{name}]\" -n \"CLIENT,SERVER,DEVVIT\" -c \"blue,green,magenta\" \"npm run dev:client\" \"npm run dev:server\" \"npm run dev:devvit\"", +- "dev:client": "cd src/client && vite build --watch", +- "dev:devvit": "devvit playtest", +- "dev:server": "cd src/server && vite build --watch", +- "dev:vite": "cd src/client && vite --port 7474", ++ "build": "vite build", ++ "dev": "devvit playtest", +``` + +You can also remove the `concurrently` dependency after this switch. + +6. Run `npm run dev` to make sure everything is working. + ## Limitations and gotchas - **Build-only:** The plugin only supports `vite build`. There is no support for `vite dev` or Hot Module Replacement (HMR) at this time. This is because `devvit playtest` works by uploading your build to our servers and running it on Reddit.com. Instead, use `vite build --watch` as your dev command. - **Public dir resolution:** The plugin auto-detects a `public/` folder at the repo root or inside `src/client`. If both exist, the build fails—keep a single public directory. +- If you run into this error while using `tailwindcss`: `[@tailwindcss/vite:generate:build] Cannot create proxy with a non-object as target or handler`. All you need to do is bump `@tailwindcss/vite` and `tailwindcss` to `4.1.18` in your `package.json` and `npm install`