mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
docs: migrate Edge Functions guides to @supabase/server (COM-269) (#46656)
## What Migrates the Edge Functions **documentation** from the legacy `Deno.serve` + manual `createClient` pattern to the [`@supabase/server`](https://github.com/supabase/server) `withSupabase` wrapper. This is the part of [COM-269](https://linear.app/supabase/issue/COM-269) that AI coding assistants index, so it's split out to ship first; the standalone `examples/` functions follow in a second PR. ## Canonical pattern ```ts import { withSupabase } from 'npm:@supabase/server@1' export default { fetch: withSupabase({ auth: 'user' }, async (req, ctx) => { const { data } = await ctx.supabase.from('countries').select('*') return Response.json({ data }) }), } ``` - `export default { fetch }` object shape (not `Deno.serve`, not a bare default export), versioned `npm:@supabase/server@1`. - `auth` mode picks the caller: `user` → `ctx.supabase` (RLS); `secret`/`publishable`/`none` → set `verify_jwt = false`, `secret` uses `ctx.supabaseAdmin`. - `Response.json(...)` over `new Response(JSON.stringify(...))`. ## Changes - **AI prompt** (`examples/prompts/edge-functions.md`) — rewritten to lead with `withSupabase` as the default; `auth`-mode table; `@supabase/server@1`. Highest AI-indexing impact. - **connect-to-postgres** — "Using supabase-js" now uses `ctx.supabase` (+ its CodeSample deps `postgres-on-the-edge`, `drizzle`). - **Example pages** — semantic-search, push-notifications, amazon-bedrock, cloudflare-turnstile, og-image, send-emails, slack-bot-mention, auth-send-email-hook. - **Guides** — ai-models, background-tasks, routing (+ `restful-tasks` dep), kysely-postgres, sentry-monitoring, upstash-redis, elevenlabs ×2, websockets, cors (reframed: CORS is automatic with `withSupabase`). ## Notable fixes - **websockets**: the JWT-auth examples had a latent bug — handler wasn't `async` and called `getClaims()` without the extracted token. Now `await supabase.auth.getUser(jwt)`. (`withSupabase` can't authenticate WebSocket clients since they can't send headers — noted in the page.) - **restful-tasks**: fixed a broken `npm:supabase-js` import → `npm:@supabase/supabase-js`. ## Follow-ups (not in this PR) - The ~42 standalone `examples/` edge functions → second PR. - A dedicated `withSupabase` intro page (today it's only documented inside the auth-framed "Securing Edge Functions" page). - `.claude/skills/supabase-server/SKILL.md` is stale (`allow:` vs `auth:`). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Updated Edge Function examples to the modern withSupabase + exported fetch handler pattern across guides and examples. * Standardized JSON response/error handling (uses built-in JSON helpers) and preserved streaming/SSE behaviors where applicable. * Clarified auth modes, context clients (user vs admin), and automatic CORS handling; removed manual preflight boilerplate. * Updated local serve/deploy instructions to include --no-verify-jwt for relevant examples. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
4af431da00
commit
adbd3c3d22
23 files changed
+1013
-934
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
|
||||
import { countries } from '../_shared/schema.ts'
|
||||
|
||||
const connectionString = Deno.env.get('SUPABASE_DB_URL')!
|
||||
@@ -7,8 +8,10 @@ const connectionString = Deno.env.get('SUPABASE_DB_URL')!
|
||||
const client = postgres(connectionString, { prepare: false })
|
||||
const db = drizzle(client)
|
||||
|
||||
Deno.serve(async (_req) => {
|
||||
const allCountries = await db.select().from(countries)
|
||||
export default {
|
||||
fetch: async (_req) => {
|
||||
const allCountries = await db.select().from(countries)
|
||||
|
||||
return Response.json(allCountries)
|
||||
})
|
||||
return Response.json(allCountries)
|
||||
},
|
||||
}
|
||||
@@ -13,36 +13,38 @@ const pool = new Pool(
|
||||
1
|
||||
)
|
||||
|
||||
Deno.serve(async (_req) => {
|
||||
try {
|
||||
// Grab a connection from the pool
|
||||
const connection = await pool.connect()
|
||||
|
||||
export default {
|
||||
fetch: async (_req) => {
|
||||
try {
|
||||
// Run a query
|
||||
const result = await connection.queryObject`SELECT * FROM animals`
|
||||
const animals = result.rows // [{ id: 1, name: "Lion" }, ...]
|
||||
// Grab a connection from the pool
|
||||
const connection = await pool.connect()
|
||||
|
||||
// Encode the result as pretty printed JSON
|
||||
const body = JSON.stringify(
|
||||
animals,
|
||||
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
|
||||
2
|
||||
)
|
||||
try {
|
||||
// Run a query
|
||||
const result = await connection.queryObject`SELECT * FROM animals`
|
||||
const animals = result.rows // [{ id: 1, name: "Lion" }, ...]
|
||||
|
||||
// Return the response with the correct content type header
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
// Release the connection back into the pool
|
||||
connection.release()
|
||||
// Encode the result as pretty printed JSON
|
||||
const body = JSON.stringify(
|
||||
animals,
|
||||
(_key, value) => (typeof value === 'bigint' ? value.toString() : value),
|
||||
2
|
||||
)
|
||||
|
||||
// Return the response with the correct content type header
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
// Release the connection back into the pool
|
||||
connection.release()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return new Response(String(err?.message ?? err), { status: 500 })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return new Response(String(err?.message ?? err), { status: 500 })
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -2,15 +2,8 @@
|
||||
// https://deno.land/manual/getting_started/setup_your_environment
|
||||
// This enables autocomplete, go to definition, etc.
|
||||
|
||||
import { createClient, SupabaseClient } from 'npm:supabase-js@2'
|
||||
// New approach (v2.95.0+)
|
||||
import { corsHeaders } from 'jsr:@supabase/supabase-js@2/cors'
|
||||
// For older versions, use hardcoded headers:
|
||||
// const corsHeaders = {
|
||||
// 'Access-Control-Allow-Origin': '*',
|
||||
// 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
// 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE',
|
||||
// }
|
||||
import { withSupabase } from 'npm:@supabase/server@^1'
|
||||
import type { SupabaseClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
interface Task {
|
||||
name: string
|
||||
@@ -21,109 +14,76 @@ async function getTask(supabaseClient: SupabaseClient, id: string) {
|
||||
const { data: task, error } = await supabaseClient.from('tasks').select('*').eq('id', id)
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({ task }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
return Response.json({ task })
|
||||
}
|
||||
|
||||
async function getAllTasks(supabaseClient: SupabaseClient) {
|
||||
const { data: tasks, error } = await supabaseClient.from('tasks').select('*')
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({ tasks }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
return Response.json({ tasks })
|
||||
}
|
||||
|
||||
async function deleteTask(supabaseClient: SupabaseClient, id: string) {
|
||||
const { error } = await supabaseClient.from('tasks').delete().eq('id', id)
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
return Response.json({})
|
||||
}
|
||||
|
||||
async function updateTask(supabaseClient: SupabaseClient, id: string, task: Task) {
|
||||
const { error } = await supabaseClient.from('tasks').update(task).eq('id', id)
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({ task }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
return Response.json({ task })
|
||||
}
|
||||
|
||||
async function createTask(supabaseClient: SupabaseClient, task: Task) {
|
||||
const { error } = await supabaseClient.from('tasks').insert(task)
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({ task }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
return Response.json({ task })
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const { url, method } = req
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
|
||||
const { url, method } = req
|
||||
|
||||
// This is needed if you're planning to invoke your function from a browser.
|
||||
if (method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
try {
|
||||
// ctx.supabase is scoped to the calling user, so your row-level-security
|
||||
// (RLS) policies are applied.
|
||||
const supabaseClient = ctx.supabase
|
||||
|
||||
try {
|
||||
const SUPABASE_PUBLISHABLE_KEYS = JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)
|
||||
// Create a Supabase client with the Auth context of the logged in user.
|
||||
const supabaseClient = createClient(
|
||||
// Supabase API URL - env var exported by default.
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
// Supabase publishable key - env var exported by default.
|
||||
SUPABASE_PUBLISHABLE_KEYS['default'] ?? '',
|
||||
// Create client with Auth context of the user that called the function.
|
||||
// This way your row-level-security (RLS) policies are applied.
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get('Authorization')! },
|
||||
},
|
||||
// For more details on URLPattern, check https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API
|
||||
const taskPattern = new URLPattern({ pathname: '/restful-tasks/:id' })
|
||||
const matchingPath = taskPattern.exec(url)
|
||||
const id = matchingPath ? matchingPath.pathname.groups.id : null
|
||||
|
||||
let task = null
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
const body = await req.json()
|
||||
task = body.task
|
||||
}
|
||||
)
|
||||
|
||||
// For more details on URLPattern, check https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API
|
||||
const taskPattern = new URLPattern({ pathname: '/restful-tasks/:id' })
|
||||
const matchingPath = taskPattern.exec(url)
|
||||
const id = matchingPath ? matchingPath.pathname.groups.id : null
|
||||
// call relevant method based on method and id
|
||||
switch (true) {
|
||||
case id && method === 'GET':
|
||||
return getTask(supabaseClient, id as string)
|
||||
case id && method === 'PUT':
|
||||
return updateTask(supabaseClient, id as string, task)
|
||||
case id && method === 'DELETE':
|
||||
return deleteTask(supabaseClient, id as string)
|
||||
case method === 'POST':
|
||||
return createTask(supabaseClient, task)
|
||||
case method === 'GET':
|
||||
return getAllTasks(supabaseClient)
|
||||
default:
|
||||
return getAllTasks(supabaseClient)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
let task = null
|
||||
if (method === 'POST' || method === 'PUT') {
|
||||
const body = await req.json()
|
||||
task = body.task
|
||||
return Response.json({ error: error.message }, { status: 400 })
|
||||
}
|
||||
|
||||
// call relevant method based on method and id
|
||||
switch (true) {
|
||||
case id && method === 'GET':
|
||||
return getTask(supabaseClient, id as string)
|
||||
case id && method === 'PUT':
|
||||
return updateTask(supabaseClient, id as string, task)
|
||||
case id && method === 'DELETE':
|
||||
return deleteTask(supabaseClient, id as string)
|
||||
case method === 'POST':
|
||||
return createTask(supabaseClient, task)
|
||||
case method === 'GET':
|
||||
return getAllTasks(supabaseClient)
|
||||
default:
|
||||
return getAllTasks(supabaseClient)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
}
|
||||
@@ -10,47 +10,115 @@ You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **h
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
|
||||
1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
|
||||
2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.
|
||||
3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.
|
||||
4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.
|
||||
5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.
|
||||
6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from "node:process". Use Node APIs when you find gaps in Deno APIs.
|
||||
7. Do NOT use `import { serve } from "https://deno.land/std@0.168.0/http/server.ts"`. Instead use the built-in `Deno.serve`.
|
||||
8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
|
||||
4. For external imports, always define a version. For example, `npm:express` should be written as `npm:express@4.18.2`.
|
||||
5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from `deno.land/x`, `esm.sh` and `unpkg.com`. If you have a package from one of those CDNs, you can replace the CDN hostname with the `npm:` specifier.
|
||||
6. You can also use Node built-in APIs. You will need to import them using the `node:` specifier. For example, to import Node process: `import process from "node:process"`. Use Node APIs when you find gaps in Deno APIs.
|
||||
7. Do NOT use `import { serve } from "https://deno.land/std@0.168.0/http/server.ts"`, and do NOT use `Deno.serve`. Instead, export a default object with a `fetch` handler:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
fetch: async (req: Request) => {
|
||||
return Response.json({ message: 'Hello world' })
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This is the request handler contract for Supabase Edge Functions, and it also runs unchanged on Cloudflare Workers and Bun. Always wrap this handler with `withSupabase` to secure and configure it (see guideline 8).
|
||||
|
||||
8. Write your handler with `withSupabase` from `npm:@supabase/server@^1`. One wrapper gives you:
|
||||
- Authentication: verifies the caller's credentials.
|
||||
- Authorization: only lets through callers that match the `auth` mode you declare.
|
||||
- Pre-configured clients on `ctx`: `ctx.supabase` (scoped to the caller's RLS) and `ctx.supabaseAdmin` (bypasses RLS).
|
||||
- CORS handling, including preflight requests.
|
||||
|
||||
Your one decision is the `auth` mode:
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server@^1'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
|
||||
const { data, error } = await ctx.supabase.from('countries').select('*')
|
||||
if (error) throw error
|
||||
return Response.json({ data })
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
Choose the `auth` mode by who calls the function:
|
||||
|
||||
| Caller | `auth` | `verify_jwt` | Client |
|
||||
| ---------------------------------------------------- | --------------- | ---------------------- | ---------------------------------- |
|
||||
| Signed-in user (JWT on `Authorization`) | `'user'` | `true` (default, omit) | `ctx.supabase` (RLS-scoped) |
|
||||
| Cron, worker, `pg_net`, or another function | `'secret'` | `false` | `ctx.supabaseAdmin` (bypasses RLS) |
|
||||
| Public client | `'publishable'` | `false` | `ctx.supabase` |
|
||||
| Public endpoint or external webhook (verify in code) | `'none'` | `false` | `ctx.supabaseAdmin` if needed |
|
||||
|
||||
For any mode other than `'user'`, set `verify_jwt = false` for that function in `supabase/config.toml`:
|
||||
|
||||
```toml
|
||||
[functions.my-function]
|
||||
verify_jwt = false
|
||||
```
|
||||
|
||||
`ctx.userClaims` holds the verified user identity. To accept only one named key, use `auth: 'secret:<name>'` or `auth: 'publishable:<name>'`. For a public endpoint, use `auth: 'none'`; you still get CORS handling and `ctx.supabaseAdmin`.
|
||||
|
||||
9. The following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
|
||||
- SUPABASE_URL
|
||||
- SUPABASE_PUBLISHABLE_KEYS
|
||||
- SUPABASE_SECRET_KEYS
|
||||
- SUPABASE_DB_URL
|
||||
|
||||
You then need to parse them with `JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)` or `JSON.parse(Deno.env.get('SUPABASE_PUBLISHABLE_KEYS')!)` to access the actual keys in the code. For example, assign the parsed map first with `const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)` and then index it with `SUPABASE_SECRET_KEYS['default']` to access the default secret key. 9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file` 10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly. 11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs. 12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.
|
||||
`withSupabase` reads these for you, so prefer it over reading keys by hand. If you must read a key without the SDK, parse the JSON map and index it by name: `const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)`, then `SUPABASE_SECRET_KEYS['default']` for the default secret key. The publishable keys work the same way through `SUPABASE_PUBLISHABLE_KEYS`.
|
||||
|
||||
10. To set other environment variables (ie. secrets) users can put them in an env file and run `supabase secrets set --env-file path/to/env-file`.
|
||||
11. A single Edge Function can handle multiple routes. It is recommended to use a library like Hono or Express to handle the routes as it's easier for developers to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly. For per-route Supabase auth with Hono, use the adapter from `npm:@supabase/server@^1/adapters/hono`.
|
||||
12. File write operations are ONLY permitted on the `/tmp` directory. You can use either Deno or Node File APIs.
|
||||
13. Use the `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking the response to a request. Do NOT assume it is available in the request / execution context.
|
||||
|
||||
## Example Templates
|
||||
|
||||
### Recommended: Edge Function with `withSupabase`
|
||||
|
||||
```ts
|
||||
import { withSupabase } from 'npm:@supabase/server@^1'
|
||||
|
||||
export default {
|
||||
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
|
||||
const { data, error } = await ctx.supabase.from('countries').select('*')
|
||||
if (error) throw error
|
||||
return Response.json({ data })
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
### Simple Hello World Function
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
interface reqPayload {
|
||||
name: string
|
||||
}
|
||||
|
||||
console.info('server started')
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const { name }: reqPayload = await req.json()
|
||||
const data = {
|
||||
message: `Hello ${name} from foo!`,
|
||||
}
|
||||
export default {
|
||||
fetch: async (req: Request) => {
|
||||
const { name }: reqPayload = await req.json()
|
||||
const data = {
|
||||
message: `Hello ${name} from foo!`,
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },
|
||||
})
|
||||
})
|
||||
return Response.json(data)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Example Function using Node built-in API
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import process from 'node:process'
|
||||
@@ -73,7 +141,7 @@ server.listen(9999)
|
||||
|
||||
### Using npm packages in Functions
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
import express from 'npm:express@4.18.2'
|
||||
|
||||
const app = express()
|
||||
@@ -87,18 +155,15 @@ app.listen(8000)
|
||||
|
||||
### Generate embeddings using built-in @Supabase.ai API
|
||||
|
||||
```tsx
|
||||
```ts
|
||||
const model = new Supabase.ai.Session('gte-small')
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const params = new URL(req.url).searchParams
|
||||
const input = params.get('text')
|
||||
const output = await model.run(input, { mean_pool: true, normalize: true })
|
||||
return new Response(JSON.stringify(output), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
})
|
||||
export default {
|
||||
fetch: async (req: Request) => {
|
||||
const params = new URL(req.url).searchParams
|
||||
const input = params.get('text')
|
||||
const output = await model.run(input, { mean_pool: true, normalize: true })
|
||||
return Response.json(output)
|
||||
},
|
||||
}
|
||||
```
|
||||
Reference in new issue
Block a user