├── .changeset ├── README.md └── config.json ├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ ├── publish-snapshot.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── README.md ├── apps └── site │ ├── .env.example │ ├── .gitignore │ ├── LICENSE.md │ ├── README.md │ ├── app │ ├── components │ │ ├── Button.tsx │ │ ├── Callout.tsx │ │ ├── Fence.tsx │ │ ├── Hero.tsx │ │ ├── HeroBackground.tsx │ │ ├── Icon.tsx │ │ ├── Layout.tsx │ │ ├── Logo.jsx │ │ ├── MobileNavigation.tsx │ │ ├── Navigation.tsx │ │ ├── Prose.tsx │ │ ├── QuickLinks.jsx │ │ ├── Search.tsx │ │ ├── ThemeSelector.jsx │ │ └── icons │ │ │ ├── InstallationIcon.jsx │ │ │ ├── LightbulbIcon.jsx │ │ │ ├── PluginsIcon.jsx │ │ │ ├── PresetsIcon.jsx │ │ │ ├── ThemingIcon.jsx │ │ │ └── WarningIcon.jsx │ ├── docs.server.ts │ ├── images │ │ ├── blur-cyan.png │ │ ├── blur-indigo.png │ │ └── hero-bg.jpg │ ├── markdoc.ts │ ├── root.tsx │ ├── routes │ │ ├── $.tsx │ │ └── _index.tsx │ └── styles │ │ ├── fonts.css │ │ ├── prism.css │ │ └── tailwind.css │ ├── load-context.ts │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── favicon.ico │ ├── fonts │ │ ├── Inter-italic.var.woff2 │ │ └── Inter-roman.var.woff2 │ └── superflare-og.jpg │ ├── scripts │ └── serve-local-docs.mjs │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── types │ └── build.d.ts │ ├── typography.js │ ├── vite.config.ts │ ├── worker-configuration.d.ts │ ├── worker.ts │ └── wrangler.json ├── examples └── remix-cms │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── app │ ├── components │ │ ├── Form.tsx │ │ ├── Toast.tsx │ │ └── admin │ │ │ ├── Button.tsx │ │ │ ├── MarkdownComposer.tsx │ │ │ └── Page.tsx │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── events │ │ └── ArticleUpdated.ts │ ├── jobs │ │ └── SayHelloJob.ts │ ├── listeners │ │ └── LogArticleUpdated.ts │ ├── models │ │ ├── Article.ts │ │ └── User.ts │ ├── root.tsx │ ├── routes │ │ ├── admin.tsx │ │ ├── admin │ │ │ ├── articles.$slug.preview.tsx │ │ │ ├── articles.$slug.tsx │ │ │ ├── articles.new.tsx │ │ │ ├── articles.tsx │ │ │ ├── components │ │ │ │ └── article-form.tsx │ │ │ ├── index.tsx │ │ │ ├── profile.tsx │ │ │ └── upload.$.ts │ │ ├── auth.tsx │ │ ├── auth │ │ │ ├── hooks.ts │ │ │ ├── login.tsx │ │ │ ├── logout.ts │ │ │ └── register.tsx │ │ ├── channel.$channelName.ts │ │ ├── index.tsx │ │ └── storage.$.tsx │ ├── styles │ │ └── syntax.css │ ├── tailwind.css │ ├── utils.ts │ └── utils │ │ ├── markdown.server.ts │ │ └── use-channel.ts │ ├── db │ ├── factories │ │ └── UserFactory.ts │ ├── migrations │ │ ├── 0000_create_users.ts │ │ └── 0001_create_articles.ts │ └── seed.ts │ ├── functions │ └── [[remix]].ts │ ├── load-context.ts │ ├── migrations │ ├── 0000_create_users.sql │ └── 0001_create_articles.sql │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── _headers │ ├── _routes.json │ └── favicon.ico │ ├── superflare.config.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ ├── types │ └── build.d.ts │ ├── vite.config.ts │ ├── worker-configuration.d.ts │ ├── worker.ts │ └── wrangler.json ├── package.json ├── packages ├── eslint-config-custom │ ├── index.js │ └── package.json ├── superflare-remix │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── cloudflare.env.d.ts │ ├── dev.ts │ ├── index.ts │ ├── load-context.ts │ ├── package.json │ ├── tsconfig.json │ └── tsup.config.ts ├── superflare │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── bin │ │ └── superflare.js │ ├── cli.ts │ ├── cli │ │ ├── config.ts │ │ ├── console.ts │ │ ├── d1-database.ts │ │ ├── d1-types.ts │ │ ├── db │ │ │ ├── index.ts │ │ │ └── seed.ts │ │ ├── dev.ts │ │ ├── generate.ts │ │ ├── generate │ │ │ ├── job.ts │ │ │ ├── migration.ts │ │ │ └── model.ts │ │ ├── logger.ts │ │ ├── migrate.ts │ │ ├── new.ts │ │ ├── stubs │ │ │ ├── job.stub.ts │ │ │ ├── migration.stub.ts │ │ │ └── model.stub.ts │ │ ├── wrangler.ts │ │ └── yargs-types.ts │ ├── cloudflare.env.d.ts │ ├── docs │ │ ├── broadcasting.md │ │ ├── database │ │ │ ├── getting-started.md │ │ │ ├── migrations.md │ │ │ ├── relationships.md │ │ │ └── seeding.md │ │ ├── deploying.md │ │ ├── design.md │ │ ├── events.md │ │ ├── file-storage.md │ │ ├── getting-started.md │ │ ├── index.md │ │ ├── manifest.json │ │ ├── models.md │ │ ├── queues.md │ │ ├── reference │ │ │ ├── cli.md │ │ │ └── superflare-config.md │ │ ├── scheduled-tasks.md │ │ ├── security │ │ │ ├── authentication.md │ │ │ └── hashing.md │ │ └── sessions.md │ ├── index.ts │ ├── index.types.ts │ ├── package.json │ ├── src │ │ ├── auth.ts │ │ ├── channels.ts │ │ ├── config.ts │ │ ├── durable-objects │ │ │ └── Channel.ts │ │ ├── event.ts │ │ ├── factory.ts │ │ ├── fetch.ts │ │ ├── form-data.ts │ │ ├── hash.ts │ │ ├── job.ts │ │ ├── listener.ts │ │ ├── model.ts │ │ ├── query-builder.ts │ │ ├── queue.ts │ │ ├── relations │ │ │ ├── belongs-to.ts │ │ │ ├── has-many.ts │ │ │ ├── has-one.ts │ │ │ └── relation.ts │ │ ├── scheduled.ts │ │ ├── schema.ts │ │ ├── seeder.ts │ │ ├── serialize.ts │ │ ├── session.ts │ │ ├── storage.ts │ │ ├── string.ts │ │ └── websockets.ts │ ├── tests │ │ ├── channels.test.ts │ │ ├── d1-types.test.ts │ │ ├── db.ts │ │ ├── generate │ │ │ └── migration.test.ts │ │ ├── migrate.test.ts │ │ ├── model.test.ts │ │ ├── model │ │ │ ├── belongs-to.test.ts │ │ │ ├── has-many.test.ts │ │ │ └── has-one.test.ts │ │ ├── scheduled.test.ts │ │ ├── schema.test.ts │ │ ├── string.test.ts │ │ ├── utils.ts │ │ └── websockets.test.ts │ ├── tsconfig.json │ └── tsup.config.ts └── tsconfig │ ├── README.md │ ├── base.json │ ├── package.json │ └── react-library.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── templates └── remix │ ├── .gitignore │ ├── README.md │ ├── app │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── models │ │ └── User.ts │ ├── root.tsx │ └── routes │ │ ├── _auth.login.tsx │ │ ├── _auth.logout.ts │ │ ├── _auth.register.tsx │ │ ├── _index.tsx │ │ └── dashboard.tsx │ ├── db │ ├── migrations │ │ └── 0000_create_users.ts │ └── seed.ts │ ├── load-context.ts │ ├── migrations │ └── 0000_create_users.sql │ ├── package.json │ ├── superflare.config.ts │ ├── tsconfig.json │ ├── vite.config.ts │ ├── worker-configuration.d.ts │ ├── worker.ts │ └── wrangler.json └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/changelog-git", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": ["site"] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ["custom"], 5 | settings: {}, 6 | }; 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Repo 15 | uses: actions/checkout@v3 16 | 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: 7.26.2 20 | 21 | - name: Setup Node.js 18 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 18 25 | cache: pnpm 26 | 27 | - name: Install Dependencies 28 | run: pnpm i 29 | 30 | - name: Build code 31 | run: pnpm build 32 | 33 | - name: Run tests 34 | run: pnpm test 35 | -------------------------------------------------------------------------------- /.github/workflows/publish-snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Publish Snapshot 2 | on: [pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | 12 | - run: corepack enable 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: "pnpm" 17 | 18 | - name: Install dependencies 19 | run: pnpm install 20 | 21 | - name: Build 22 | run: pnpm build 23 | 24 | - run: pnpx pkg-pr-new publish './packages/superflare' './packages/superflare-remix' -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v3 21 | 22 | - uses: pnpm/action-setup@v2 23 | with: 24 | version: 7.26.2 25 | 26 | - name: Setup Node.js 18 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: 18 30 | cache: pnpm 31 | 32 | - name: Install Dependencies 33 | run: pnpm i 34 | 35 | - name: Create Release Pull Request 36 | uses: changesets/action@v1 37 | with: 38 | publish: pnpm changeset publish 39 | title: "[ci] release ${{ github.ref_name }}" 40 | env: 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | .dev.vars 32 | 33 | # turbo 34 | .turbo 35 | 36 | # scratch 37 | scratch 38 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Superflare 2 | 3 | A full-stack toolkit for Cloudflare Workers. 4 | 5 | [Read the docs](https://superflare.dev), or get started now: 6 | 7 | ```bash 8 | npx superflare@latest new 9 | ``` 10 | -------------------------------------------------------------------------------- /apps/site/.env.example: -------------------------------------------------------------------------------- 1 | DOCSEARCH_APP_ID=RETR9S9VHS 2 | DOCSEARCH_API_KEY=326c1723a310dfe29004b47608709907 3 | DOCSEARCH_INDEX_NAME=tailwindui-protocol 4 | -------------------------------------------------------------------------------- /apps/site/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | node_modules 35 | 36 | /.cache 37 | /build/* 38 | !/build/index.d.ts 39 | /public/build 40 | .env 41 | /.wrangler 42 | 43 | .dev.vars 44 | -------------------------------------------------------------------------------- /apps/site/README.md: -------------------------------------------------------------------------------- 1 | # Protocol 2 | 3 | Protocol is a [Tailwind UI](https://tailwindui.com) site template built using [Tailwind CSS](https://tailwindcss.com) and [Next.js](https://nextjs.org). 4 | 5 | ## Getting started 6 | 7 | To get started with this template, first install the npm dependencies: 8 | 9 | ```bash 10 | npm install 11 | cp .env.example .dev.vars 12 | ``` 13 | 14 | Next, run the development server: 15 | 16 | ```bash 17 | npm run dev 18 | ``` 19 | 20 | Finally, open [http://localhost:3000](http://localhost:3000) in your browser to view the website. 21 | 22 | ## Customizing 23 | 24 | You can start editing this template by modifying the files in the `/src` folder. The site will auto-update as you edit these files. 25 | 26 | ## Global search 27 | 28 | By default this template uses [Algolia DocSearch](https://docsearch.algolia.com) for the global search. DocSearch is free for open-source projects, and you can sign up for an account on their website. Once your DocSearch account is ready, update the following [environment variables](https://nextjs.org/docs/basic-features/environment-variables) in your project with the values provided by Algolia: 29 | 30 | ``` 31 | DOCSEARCH_APP_ID= 32 | DOCSEARCH_API_KEY= 33 | DOCSEARCH_INDEX_NAME= 34 | ``` 35 | 36 | ## License 37 | 38 | This site template is a commercial product and is licensed under the [Tailwind UI license](https://tailwindui.com/license). 39 | 40 | ## Learn more 41 | 42 | To learn more about the technologies used in this site template, see the following resources: 43 | 44 | - [Tailwind CSS](https://tailwindcss.com/docs) - the official Tailwind CSS documentation 45 | - [Next.js](https://nextjs.org/docs) - the official Next.js documentation 46 | - [Headless UI](https://headlessui.dev) - the official Headless UI documentation 47 | - [Framer Motion](https://www.framer.com/docs/) - the official Framer Motion documentation 48 | - [MDX](https://mdxjs.com/) - the official MDX documentation 49 | - [Algolia Autocomplete](https://www.algolia.com/doc/ui-libraries/autocomplete/introduction/what-is-autocomplete/) - the official Algolia Autocomplete documentation 50 | - [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) - the official Zustand documentation 51 | -------------------------------------------------------------------------------- /apps/site/app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | import clsx from "clsx"; 3 | 4 | const styles = { 5 | primary: 6 | "rounded-full bg-rose-300 py-2 px-4 text-sm font-semibold text-slate-900 hover:bg-rose-200 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-300/50 active:bg-rose-500", 7 | secondary: 8 | "rounded-full bg-slate-800 py-2 px-4 text-sm font-medium text-white hover:bg-slate-700 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/50 active:text-slate-400", 9 | }; 10 | 11 | export function Button({ 12 | variant = "primary", 13 | className, 14 | href, 15 | ...props 16 | }: Omit< 17 | React.ComponentProps, 18 | "to" 19 | > & { 20 | variant?: keyof typeof styles; 21 | className?: string; 22 | href?: Href; 23 | }) { 24 | className = clsx(styles[variant], className); 25 | 26 | return href ? ( 27 | 28 | ) : ( 29 | 58 | 64 | 65 |
66 | 73 | 74 | 75 | 76 |
77 | 78 |
79 |
80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /apps/site/app/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation } from "@remix-run/react"; 2 | import clsx from "clsx"; 3 | import { Manifest } from "~/docs.server"; 4 | 5 | export function Navigation({ 6 | navigation, 7 | className, 8 | }: { 9 | navigation: Manifest; 10 | className?: string; 11 | }) { 12 | let router = useLocation(); 13 | 14 | return ( 15 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /apps/site/app/components/Prose.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export function Prose({ 4 | as: Component = "div", 5 | className, 6 | ...props 7 | }: { 8 | as?: React.ElementType; 9 | className?: string; 10 | } & React.ComponentPropsWithoutRef<"div">) { 11 | return ( 12 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/site/app/components/QuickLinks.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | 3 | import { Icon } from "./Icon"; 4 | 5 | export function QuickLinks({ children }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | 13 | export function QuickLink({ title, description, href, icon }) { 14 | return ( 15 |
16 |
17 |
18 | 19 |

20 | 21 | 22 | {title} 23 | 24 |

25 |

26 | {description} 27 |

28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/site/app/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { Link, useNavigate } from "@remix-run/react"; 4 | import { DocSearchModal, useDocSearchKeyboardEvents } from "@docsearch/react"; 5 | 6 | function Hit({ hit, children }: { hit: any; children: any }) { 7 | return {children}; 8 | } 9 | 10 | function SearchIcon(props: React.SVGProps) { 11 | return ( 12 | 15 | ); 16 | } 17 | 18 | export function Search() { 19 | let [isOpen, setIsOpen] = useState(false); 20 | let [modifierKey, setModifierKey] = useState(); 21 | const navigate = useNavigate(); 22 | 23 | const onOpen = useCallback(() => { 24 | setIsOpen(true); 25 | }, [setIsOpen]); 26 | 27 | const onClose = useCallback(() => { 28 | setIsOpen(false); 29 | }, [setIsOpen]); 30 | 31 | useDocSearchKeyboardEvents({ isOpen, onOpen, onClose }); 32 | 33 | useEffect(() => { 34 | setModifierKey( 35 | /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? "⌘" : "Ctrl " 36 | ); 37 | }, []); 38 | 39 | return ( 40 | <> 41 | 57 | {isOpen && 58 | createPortal( 59 | , 76 | document.body 77 | )} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /apps/site/app/components/icons/InstallationIcon.jsx: -------------------------------------------------------------------------------- 1 | import { DarkMode, Gradient, LightMode } from "../Icon"; 2 | 3 | export function InstallationIcon({ id, color }) { 4 | return ( 5 | <> 6 | 7 | 12 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /apps/site/app/components/icons/LightbulbIcon.jsx: -------------------------------------------------------------------------------- 1 | import { DarkMode, Gradient, LightMode } from "../Icon"; 2 | 3 | export function LightbulbIcon({ id, color }) { 4 | return ( 5 | <> 6 | 7 | 12 | 17 | 18 | 19 | 20 | 27 | 31 | 35 | 36 | 37 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/site/app/components/icons/PluginsIcon.jsx: -------------------------------------------------------------------------------- 1 | import { DarkMode, Gradient, LightMode } from "../Icon"; 2 | 3 | export function PluginsIcon({ id, color }) { 4 | return ( 5 | <> 6 | 7 | 12 | 17 | 22 | 23 | 24 | 25 | 32 | 33 | 34 | 35 | 40 | 46 | 47 | 48 | 49 | 50 | 51 | 55 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /apps/site/app/components/icons/PresetsIcon.jsx: -------------------------------------------------------------------------------- 1 | import { DarkMode, Gradient, LightMode } from "../Icon"; 2 | 3 | export function PresetsIcon({ id, color }) { 4 | return ( 5 | <> 6 | 7 | 12 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/site/app/components/icons/ThemingIcon.jsx: -------------------------------------------------------------------------------- 1 | import { DarkMode, Gradient, LightMode } from "../Icon"; 2 | 3 | export function ThemingIcon({ id, color }) { 4 | return ( 5 | <> 6 | 7 | 12 | 17 | 18 | 19 | 20 | 28 | 33 | 40 | 48 | 49 | 50 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /apps/site/app/components/icons/WarningIcon.jsx: -------------------------------------------------------------------------------- 1 | import { DarkMode, Gradient, LightMode } from "../Icon"; 2 | 3 | export function WarningIcon({ id, color }) { 4 | return ( 5 | <> 6 | 7 | 12 | 17 | 18 | 19 | 20 | 28 | 35 | 44 | 45 | 46 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /apps/site/app/images/blur-cyan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/app/images/blur-cyan.png -------------------------------------------------------------------------------- /apps/site/app/images/blur-indigo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/app/images/blur-indigo.png -------------------------------------------------------------------------------- /apps/site/app/images/hero-bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/app/images/hero-bg.jpg -------------------------------------------------------------------------------- /apps/site/app/markdoc.ts: -------------------------------------------------------------------------------- 1 | import Markdoc, { type RenderableTreeNode } from "@markdoc/markdoc"; 2 | import React from "react"; 3 | import { Fence } from "./components/Fence"; 4 | import { Callout } from "./components/Callout"; 5 | import { QuickLink, QuickLinks } from "./components/QuickLinks"; 6 | 7 | export function renderMarkdoc(content: RenderableTreeNode) { 8 | return Markdoc.renderers.react(content, React, { 9 | components: { 10 | Fence, 11 | QuickLink, 12 | QuickLinks, 13 | Callout, 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /apps/site/app/routes/$.tsx: -------------------------------------------------------------------------------- 1 | import { MetaFunction } from "@remix-run/react/dist/routeModules"; 2 | 3 | import { loader as indexLoader } from "./_index"; 4 | 5 | export { default } from "./_index"; 6 | 7 | export const loader = indexLoader; 8 | 9 | export const meta: MetaFunction = ({ data }) => [ 10 | { 11 | title: data?.title ? `${data.title} - Superflare` : "Superflare", 12 | }, 13 | { 14 | "twitter:title": data?.title ? `${data.title} - Superflare` : "Superflare", 15 | }, 16 | { description: data?.description, "twitter:description": data?.description }, 17 | ]; 18 | -------------------------------------------------------------------------------- /apps/site/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | json, 3 | type LoaderFunctionArgs, 4 | type MetaFunction, 5 | } from "@remix-run/cloudflare"; 6 | import { useLoaderData } from "@remix-run/react"; 7 | import { Layout } from "~/components/Layout"; 8 | import { getManifest, getMarkdownForPath, parseMarkdoc } from "~/docs.server"; 9 | import { renderMarkdoc } from "~/markdoc"; 10 | 11 | export async function loader({ 12 | params, 13 | context: { cloudflare }, 14 | }: LoaderFunctionArgs) { 15 | const path = params["*"] ?? ("index" as string); 16 | 17 | const useGitHub = process.env.NODE_ENV === "production"; 18 | const markdown = await getMarkdownForPath( 19 | path, 20 | cloudflare.env.GITHUB_TOKEN, 21 | useGitHub 22 | ); 23 | 24 | if (!markdown) { 25 | throw new Response("Not found", { status: 404 }); 26 | } 27 | 28 | const manifest = await getManifest(cloudflare.env.GITHUB_TOKEN, useGitHub); 29 | 30 | if (!manifest) { 31 | throw new Response("Manifest could not be loaded", { status: 404 }); 32 | } 33 | 34 | const { content, title, tableOfContents, description } = 35 | parseMarkdoc(markdown); 36 | 37 | return json({ content, title, tableOfContents, manifest, description }); 38 | } 39 | 40 | export const meta: MetaFunction = ({ data }) => [ 41 | { title: data?.title }, 42 | { "twitter:title": data?.title }, 43 | { description: data?.description }, 44 | { "twitter:description": data?.description }, 45 | ]; 46 | 47 | export default function DocsPage() { 48 | const { content, title, tableOfContents, manifest } = 49 | useLoaderData(); 50 | 51 | return ( 52 | 53 | {renderMarkdoc(content)} 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /apps/site/app/styles/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter'; 3 | font-weight: 100 900; 4 | font-display: optional; 5 | font-style: normal; 6 | font-named-instance: 'Regular'; 7 | src: url('/fonts/Inter-roman.var.woff2') format('woff2'); 8 | } 9 | 10 | @font-face { 11 | font-family: 'Inter'; 12 | font-weight: 100 900; 13 | font-display: optional; 14 | font-style: italic; 15 | font-named-instance: 'Italic'; 16 | src: url('/fonts/Inter-italic.var.woff2') format('woff2'); 17 | } 18 | -------------------------------------------------------------------------------- /apps/site/app/styles/prism.css: -------------------------------------------------------------------------------- 1 | pre[class*='language-'] { 2 | color: theme('colors.slate.50'); 3 | } 4 | 5 | .token.tag, 6 | .token.class-name, 7 | .token.selector, 8 | .token.selector .class, 9 | .token.selector.class, 10 | .token.function { 11 | color: theme('colors.pink.400'); 12 | } 13 | 14 | .token.attr-name, 15 | .token.keyword, 16 | .token.rule, 17 | .token.pseudo-class, 18 | .token.important { 19 | color: theme('colors.slate.300'); 20 | } 21 | 22 | .token.module { 23 | color: theme('colors.pink.400'); 24 | } 25 | 26 | .token.attr-value, 27 | .token.class, 28 | .token.string, 29 | .token.property { 30 | color: theme('colors.sky.300'); 31 | } 32 | 33 | .token.punctuation, 34 | .token.attr-equals { 35 | color: theme('colors.slate.500'); 36 | } 37 | 38 | .token.unit, 39 | .language-css .token.function { 40 | color: theme('colors.teal.200'); 41 | } 42 | 43 | .token.comment, 44 | .token.operator, 45 | .token.combinator { 46 | color: theme('colors.slate.400'); 47 | } 48 | -------------------------------------------------------------------------------- /apps/site/app/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "./fonts.css"; 3 | @import "./prism.css"; 4 | @import "tailwindcss/components"; 5 | @import "tailwindcss/utilities"; 6 | -------------------------------------------------------------------------------- /apps/site/load-context.ts: -------------------------------------------------------------------------------- 1 | import { type PlatformProxy } from "wrangler"; 2 | 3 | // NOTE: PlatformProxy’s caches property is incompatible with the caches global 4 | // https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/api/integrations/platform/caches.ts 5 | // TS error: Property 'default' is missing in type 'CacheStorage' but required in type 'CacheStorage_2'. 6 | type Cloudflare = Omit, "dispose" | "caches"> & { 7 | caches: CacheStorage; 8 | }; 9 | 10 | declare module "@remix-run/cloudflare" { 11 | interface AppLoadContext { 12 | cloudflare: Cloudflare; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "site", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "deploy": "wrangler deploy", 9 | "dev:remix": "remix vite:dev", 10 | "dev:docs": "node ./scripts/serve-local-docs.mjs", 11 | "dev": "concurrently \"npm:dev:*\"", 12 | "start": "wrangler dev", 13 | "typegen": "wrangler types", 14 | "typecheck": "tsc" 15 | }, 16 | "browserslist": "defaults, not ie <= 11", 17 | "dependencies": { 18 | "@docsearch/css": "^3.8.0", 19 | "@docsearch/react": "^3.8.0", 20 | "@headlessui/react": "^2.1.2", 21 | "@markdoc/markdoc": "0.4.0", 22 | "@remix-run/cloudflare": "^2.12.1", 23 | "@remix-run/react": "^2.12.1", 24 | "@sindresorhus/slugify": "^2.1.1", 25 | "@tailwindcss/typography": "^0.5.8", 26 | "autoprefixer": "^10.4.19", 27 | "clsx": "^1.2.0", 28 | "focus-visible": "^5.2.0", 29 | "isbot": "latest", 30 | "js-yaml": "^4.1.0", 31 | "postcss-focus-visible": "^6.0.4", 32 | "postcss-import": "^14.1.0", 33 | "prism-react-renderer": "^2.3.1", 34 | "react": "18.2.0", 35 | "react-dom": "18.2.0", 36 | "tailwindcss": "^3.4.4", 37 | "tiny-invariant": "^1.3.1" 38 | }, 39 | "devDependencies": { 40 | "@cloudflare/workers-types": "^4.20241011.0", 41 | "@remix-run/dev": "^2.12.1", 42 | "@remix-run/eslint-config": "^2.12.1", 43 | "@types/js-yaml": "^4.0.5", 44 | "@types/react": "^18.0.28", 45 | "@types/react-dom": "^18.0.11", 46 | "concurrently": "^8.2.2", 47 | "eslint": "8.26.0", 48 | "postcss": "^8.4.39", 49 | "prettier": "^2.7.1", 50 | "prettier-plugin-tailwindcss": "^0.1.13", 51 | "tsconfig": "workspace:*", 52 | "typescript": "^5", 53 | "vite": "^5.3.4", 54 | "vite-tsconfig-paths": "^4.3.2", 55 | "wrangler": "^3.91.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /apps/site/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "postcss-import": {}, 4 | "postcss-focus-visible": { 5 | replaceWith: "[data-focus-visible-added]", 6 | }, 7 | tailwindcss: {}, 8 | autoprefixer: {}, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /apps/site/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/public/favicon.ico -------------------------------------------------------------------------------- /apps/site/public/fonts/Inter-italic.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/public/fonts/Inter-italic.var.woff2 -------------------------------------------------------------------------------- /apps/site/public/fonts/Inter-roman.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/public/fonts/Inter-roman.var.woff2 -------------------------------------------------------------------------------- /apps/site/public/superflare-og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/apps/site/public/superflare-og.jpg -------------------------------------------------------------------------------- /apps/site/scripts/serve-local-docs.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Write a simple webserver that serves the local MD files from ../../../packages/superflare/docs. 3 | */ 4 | 5 | import { createServer } from "node:http"; 6 | import { readFile } from "node:fs/promises"; 7 | import { join, resolve } from "node:path"; 8 | import { fileURLToPath } from "node:url"; 9 | 10 | const __dirname = fileURLToPath(import.meta.url); 11 | 12 | // Load the markdown file from the local docs folder. 13 | const docsPath = resolve(__dirname, "../../../../packages/superflare/docs"); 14 | 15 | const server = createServer(async (req, res) => { 16 | const url = new URL(req.url, `http://localhost`); 17 | 18 | let pathname = url.pathname === "/" ? "/index.md" : url.pathname; 19 | 20 | const filePath = join(docsPath, pathname); 21 | 22 | try { 23 | const file = await readFile(filePath, "utf8"); 24 | res.statusCode = 200; 25 | res.setHeader("Content-Type", "text/markdown"); 26 | res.end(file); 27 | } catch (err) { 28 | console.log(err); 29 | res.statusCode = 404; 30 | res.end("Not found"); 31 | } 32 | }); 33 | 34 | const port = process.env.DOCS_PORT || 3123; 35 | 36 | server.listen(3123).on("listening", () => { 37 | console.log( 38 | `Local Docs Server running: 39 | - ${docsPath} 40 | - http://localhost:${port}` 41 | ); 42 | }); 43 | 44 | process.on("SIGINT", () => { 45 | server.close(); 46 | process.exit(); 47 | }); 48 | 49 | process.on("SIGTERM", () => { 50 | server.close(); 51 | process.exit(); 52 | }); 53 | -------------------------------------------------------------------------------- /apps/site/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import typography from "@tailwindcss/typography"; 2 | import type { Config } from "tailwindcss"; 3 | import defaultTheme from "tailwindcss/defaultTheme"; 4 | 5 | export default { 6 | content: ["./app/**/*.{js,jsx,ts,tsx}"], 7 | darkMode: "class", 8 | theme: { 9 | fontSize: { 10 | xs: ["0.75rem", { lineHeight: "1rem" }], 11 | sm: ["0.875rem", { lineHeight: "1.5rem" }], 12 | base: ["1rem", { lineHeight: "2rem" }], 13 | lg: ["1.125rem", { lineHeight: "1.75rem" }], 14 | xl: ["1.25rem", { lineHeight: "2rem" }], 15 | "2xl": ["1.5rem", { lineHeight: "2.5rem" }], 16 | "3xl": ["2rem", { lineHeight: "2.5rem" }], 17 | "4xl": ["2.5rem", { lineHeight: "3rem" }], 18 | "5xl": ["3rem", { lineHeight: "3.5rem" }], 19 | "6xl": ["3.75rem", { lineHeight: "1" }], 20 | "7xl": ["4.5rem", { lineHeight: "1" }], 21 | "8xl": ["6rem", { lineHeight: "1" }], 22 | "9xl": ["8rem", { lineHeight: "1" }], 23 | }, 24 | extend: { 25 | fontFamily: { 26 | sans: ["Inter", ...defaultTheme.fontFamily.sans], 27 | display: ["Kanit", ...defaultTheme.fontFamily.sans], 28 | }, 29 | maxWidth: { 30 | "8xl": "88rem", 31 | }, 32 | }, 33 | }, 34 | plugins: [typography], 35 | } satisfies Config; 36 | -------------------------------------------------------------------------------- /apps/site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["worker-configuration.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "types": ["@remix-run/cloudflare", "vite/client"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2022", 12 | "strict": true, 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/site/types/build.d.ts: -------------------------------------------------------------------------------- 1 | import { type ServerBuild } from "@remix-run/cloudflare"; 2 | 3 | export const assets: ServerBuild["assets"]; 4 | export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"]; 5 | export const entry: ServerBuild["entry"]; 6 | export const future: ServerBuild["future"]; 7 | export const publicPath: ServerBuild["publicPath"]; 8 | export const routes: ServerBuild["routes"]; 9 | -------------------------------------------------------------------------------- /apps/site/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { 3 | vitePlugin as remix, 4 | cloudflareDevProxyVitePlugin, 5 | } from "@remix-run/dev"; 6 | import tsconfigPaths from "vite-tsconfig-paths"; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | cloudflareDevProxyVitePlugin(), 11 | remix({ 12 | future: { 13 | v3_fetcherPersist: true, 14 | v3_lazyRouteDiscovery: true, 15 | v3_relativeSplatPath: true, 16 | v3_throwAbortReason: true, 17 | }, 18 | }), 19 | tsconfigPaths(), 20 | ], 21 | ssr: { 22 | noExternal: ["@docsearch/react", "@markdoc/markdoc"], 23 | resolve: { 24 | conditions: ["workerd", "worker", "browser"], 25 | }, 26 | }, 27 | resolve: { 28 | mainFields: ["browser", "module", "main"], 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /apps/site/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | interface Env { 4 | DOCSEARCH_APP_ID: string; 5 | DOCSEARCH_API_KEY: string; 6 | DOCSEARCH_INDEX_NAME: string; 7 | } 8 | -------------------------------------------------------------------------------- /apps/site/worker.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler, type ServerBuild } from "@remix-run/cloudflare"; 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-ignore This file won’t exist if it hasn’t yet been built 4 | import * as build from "./build/server"; // eslint-disable-line import/no-unresolved 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | const handleRemixRequest = createRequestHandler(build as any as ServerBuild); 8 | 9 | export default { 10 | async fetch(request, env, ctx) { 11 | try { 12 | const loadContext = { 13 | cloudflare: { 14 | // This object matches the return value from Wrangler's 15 | // `getPlatformProxy` used during development via Remix's 16 | // `cloudflareDevProxyVitePlugin`: 17 | // https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy 18 | cf: request.cf, 19 | ctx: { 20 | passThroughOnException: ctx.passThroughOnException.bind(ctx), 21 | waitUntil: ctx.waitUntil.bind(ctx), 22 | }, 23 | caches, 24 | env, 25 | }, 26 | }; 27 | return await handleRemixRequest(request, loadContext); 28 | } catch (error) { 29 | console.log(error); 30 | return new Response("An unexpected error occurred", { status: 500 }); 31 | } 32 | }, 33 | } satisfies ExportedHandler; 34 | -------------------------------------------------------------------------------- /apps/site/wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superflare-site", 3 | "main": "./worker.ts", 4 | "compatibility_date": "2024-09-25", 5 | "assets": { 6 | "directory": "./build/client" 7 | }, 8 | "build": { 9 | "command": "npm run build" 10 | }, 11 | "observability": { 12 | "enabled": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/remix-cms/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/remix-cms/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build/* 5 | !/build/index.d.ts 6 | /public/build 7 | .env 8 | /.wrangler 9 | superflare.env.d.ts 10 | -------------------------------------------------------------------------------- /examples/remix-cms/README.md: -------------------------------------------------------------------------------- 1 | # Remix CMS 2 | 3 | This is a demo project of a CMS powered by Superflare: 4 | 5 | - Content is stored in D1 6 | - Images and other assets are stored in R2 and are backed by Superflare models 7 | - Auth is powered by Superflare 8 | - A GitHub-like markdown editor experience including drag-and-drop file upload support 9 | -------------------------------------------------------------------------------- /examples/remix-cms/app/components/Form.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { ElementType } from "react"; 3 | 4 | export function FormField({ 5 | label, 6 | name, 7 | type = "text", 8 | as = "input", 9 | wrapperClassName = "", 10 | inputClassName = "", 11 | options, 12 | ...props 13 | }: { 14 | label: string; 15 | name: string; 16 | as?: ElementType; 17 | type?: string; 18 | wrapperClassName?: string; 19 | inputClassName?: string; 20 | options?: { label: string; value: string }[]; 21 | } & React.ComponentPropsWithoutRef) { 22 | const Component = as; 23 | 24 | return ( 25 |
26 | 32 |
33 | {/* @ts-ignore */} 34 | 44 | {options?.map((option) => ( 45 | 48 | ))} 49 | 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /examples/remix-cms/app/components/admin/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | import clsx from "clsx"; 3 | 4 | export function Button({ 5 | children, 6 | className, 7 | to, 8 | as = "button", 9 | ...props 10 | }: { 11 | children: React.ReactNode; 12 | className?: string; 13 | to?: string; 14 | as?: React.ElementType; 15 | } & React.ComponentPropsWithoutRef<"button">) { 16 | let Component = to ? Link : as; 17 | 18 | return ( 19 | 27 | {children} 28 | 29 | ); 30 | } 31 | 32 | export function SecondaryButton({ 33 | children, 34 | className, 35 | to, 36 | as = "button", 37 | ...props 38 | }: { 39 | children: React.ReactNode; 40 | className?: string; 41 | to?: string; 42 | as?: React.ElementType; 43 | } & React.ComponentPropsWithoutRef<"button"> & 44 | React.ComponentPropsWithoutRef) { 45 | let Component = to ? Link : as; 46 | 47 | return ( 48 | 56 | {children} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /examples/remix-cms/app/components/admin/MarkdownComposer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type ClipboardEventHandler, 3 | type DragEventHandler, 4 | useCallback, 5 | useRef, 6 | } from "react"; 7 | import TME, { type TextareaMarkdownRef } from "textarea-markdown-editor"; 8 | 9 | // https://github.com/vitejs/vite/issues/2139#issuecomment-802981228 10 | const TextareaMarkdown: typeof TME = (TME as any).default ?? TME; 11 | 12 | /** 13 | * MarkdownComposer is a wrapper around the [`TextareaMarkdown`](https://github.com/Resetand/textarea-markdown-editor) component. 14 | * It installs a listener for pasting or dragging-and-dropping images and allows you to hook into the upload process. 15 | */ 16 | export default function MarkdownComposer({ 17 | onInsertImage, 18 | ...props 19 | }: { 20 | /** 21 | * @param file The incoming file to upload. 22 | * @returns A URL string to the uploaded file. 23 | */ 24 | onInsertImage?: (file: File) => Promise; 25 | } & React.ComponentProps) { 26 | const ref = useRef(null); 27 | 28 | const print = useCallback((text: string) => { 29 | const textarea = ref.current; 30 | const cursor = textarea?.cursor; 31 | if (!cursor) { 32 | return; 33 | } 34 | cursor.insert(`${cursor.MARKER}${text}${cursor.MARKER}`); 35 | }, []); 36 | 37 | const upload = useCallback( 38 | async (file: File) => { 39 | if (!onInsertImage) return; 40 | print(`![Uploading ${file!.name}...]()`); 41 | const url = await onInsertImage(file); 42 | print(`![${file.name}](${url})`); 43 | }, 44 | [onInsertImage, print] 45 | ); 46 | 47 | const onPaste = useCallback>( 48 | async (event) => { 49 | const files = event.clipboardData?.files; 50 | if (files) { 51 | for (let i = 0; i < files.length; i++) { 52 | const file = files[i]; 53 | if (file.type.indexOf("image") !== -1) { 54 | upload(file); 55 | event.preventDefault(); 56 | } 57 | } 58 | } 59 | }, 60 | [upload] 61 | ); 62 | 63 | const onDrop = useCallback>( 64 | async (event) => { 65 | const files = event.dataTransfer?.files; 66 | if (files) { 67 | for (let i = 0; i < files.length; i++) { 68 | const file = files[i]; 69 | if (file.type.indexOf("image") !== -1) { 70 | upload(file); 71 | event.preventDefault(); 72 | } 73 | } 74 | } 75 | }, 76 | [upload] 77 | ); 78 | 79 | return ( 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /examples/remix-cms/app/components/admin/Page.tsx: -------------------------------------------------------------------------------- 1 | export function Page({ 2 | title, 3 | action, 4 | children, 5 | }: { 6 | title: string; 7 | action?: React.ReactNode; 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 |
12 |
13 |
14 |

15 | {title} 16 |

17 |
{action}
18 |
19 |
20 |
21 |
{children}
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /examples/remix-cms/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /examples/remix-cms/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToReadableStream } from "react-dom/server"; 2 | import { type EntryContext } from "@remix-run/cloudflare"; 3 | import { RemixServer } from "@remix-run/react"; 4 | import { isbot } from "isbot"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: EntryContext 11 | ) { 12 | const body = await renderToReadableStream( 13 | , 14 | { 15 | onError(error: unknown) { 16 | responseStatusCode = 500; 17 | // Log streaming rendering errors from inside the shell 18 | console.error(error); 19 | }, 20 | signal: request.signal, 21 | } 22 | ); 23 | 24 | if (isbot(request.headers.get("user-agent") || "")) { 25 | await body.allReady; 26 | } 27 | 28 | responseHeaders.set("Content-Type", "text/html"); 29 | 30 | return new Response(body, { 31 | status: responseStatusCode, 32 | headers: responseHeaders, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /examples/remix-cms/app/events/ArticleUpdated.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "superflare"; 2 | import type { Article } from "~/models/Article"; 3 | 4 | export class ArticleUpdated extends Event { 5 | static shouldQueue = true; 6 | 7 | constructor(public article: Article) { 8 | super(); 9 | } 10 | 11 | broadcastTo() { 12 | return `article.${this.article.slug}`; 13 | } 14 | } 15 | 16 | Event.register(ArticleUpdated); 17 | -------------------------------------------------------------------------------- /examples/remix-cms/app/jobs/SayHelloJob.ts: -------------------------------------------------------------------------------- 1 | import { Job } from "superflare"; 2 | import type { Article } from "~/models/Article"; 3 | 4 | export class SayHelloJob extends Job { 5 | constructor(public article: Article) { 6 | super(); 7 | } 8 | 9 | async handle(): Promise { 10 | console.log(`Hello, ${this.article.title}!`); 11 | } 12 | } 13 | 14 | Job.register(SayHelloJob); 15 | -------------------------------------------------------------------------------- /examples/remix-cms/app/listeners/LogArticleUpdated.ts: -------------------------------------------------------------------------------- 1 | import { Listener } from "superflare"; 2 | import type { ArticleUpdated } from "~/events/ArticleUpdated"; 3 | 4 | export class LogArticleUpdated extends Listener { 5 | handle(event: ArticleUpdated) { 6 | console.log(`Article ${event.article.title} updated!`); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/remix-cms/app/models/Article.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "superflare"; 2 | import { User } from "./User"; 3 | 4 | export class Article extends Model { 5 | user!: User | Promise; 6 | $user() { 7 | return this.belongsTo(User); 8 | } 9 | 10 | toJSON(): ArticleRow { 11 | return super.toJSON(); 12 | } 13 | } 14 | 15 | Model.register(Article); 16 | 17 | export interface Article extends ArticleRow {} 18 | -------------------------------------------------------------------------------- /examples/remix-cms/app/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "superflare"; 2 | 3 | export class User extends Model { 4 | toJSON(): Omit { 5 | const { password, ...rest } = super.toJSON(); 6 | return rest; 7 | } 8 | } 9 | Model.register(User); 10 | 11 | export interface User extends UserRow {} 12 | -------------------------------------------------------------------------------- /examples/remix-cms/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from "@remix-run/cloudflare"; 2 | import { 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "@remix-run/react"; 9 | import "./tailwind.css"; 10 | import "./styles/syntax.css"; 11 | 12 | export const meta: MetaFunction = () => [ 13 | { charset: "utf-8" }, 14 | { title: "Remix CMS" }, 15 | { viewport: "width=device-width,initial-scale=1" }, 16 | ]; 17 | 18 | export default function App() { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/admin/articles.$slug.preview.tsx: -------------------------------------------------------------------------------- 1 | import { PencilSquareIcon } from "@heroicons/react/24/outline"; 2 | import { json, type LoaderFunctionArgs } from "@remix-run/cloudflare"; 3 | import { useLoaderData } from "@remix-run/react"; 4 | import invariant from "tiny-invariant"; 5 | 6 | import { SecondaryButton } from "~/components/admin/Button"; 7 | import { Page } from "~/components/admin/Page"; 8 | import { Article } from "~/models/Article"; 9 | import { convertToHtml } from "~/utils/markdown.server"; 10 | 11 | export async function loader({ params }: LoaderFunctionArgs) { 12 | const { slug } = params; 13 | 14 | invariant(typeof slug === "string", "Missing slug"); 15 | 16 | const article = await Article.where("slug", slug).first(); 17 | 18 | if (!article) { 19 | throw new Response("Not found", { status: 404 }); 20 | } 21 | 22 | return json({ 23 | article, 24 | html: await convertToHtml(article.content ?? ""), 25 | }); 26 | } 27 | 28 | export default function NewArticle() { 29 | const { html, article } = useLoaderData(); 30 | 31 | return ( 32 | 36 | 37 | Edit 38 | 39 | } 40 | > 41 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/admin/articles.$slug.tsx: -------------------------------------------------------------------------------- 1 | import { EyeIcon } from "@heroicons/react/24/outline"; 2 | import { json, type LoaderFunctionArgs } from "@remix-run/cloudflare"; 3 | import { useLoaderData, useRevalidator } from "@remix-run/react"; 4 | import invariant from "tiny-invariant"; 5 | import { Button, SecondaryButton } from "~/components/admin/Button"; 6 | import { Page } from "~/components/admin/Page"; 7 | import { SayHelloJob } from "~/jobs/SayHelloJob"; 8 | import { Article } from "~/models/Article"; 9 | import { useChannel } from "~/utils/use-channel"; 10 | import { ArticleForm } from "./components/article-form"; 11 | 12 | export { action } from "./components/article-form"; 13 | 14 | export async function loader({ params }: LoaderFunctionArgs) { 15 | const { slug } = params; 16 | 17 | invariant(typeof slug === "string", "Missing slug"); 18 | 19 | const article = await Article.where("slug", slug).first(); 20 | 21 | if (!article) { 22 | throw new Response("Not found", { status: 404 }); 23 | } 24 | 25 | SayHelloJob.dispatch(article); 26 | 27 | return json({ article }); 28 | } 29 | 30 | export default function NewArticle() { 31 | const { article } = useLoaderData(); 32 | const { revalidate } = useRevalidator(); 33 | useChannel(`article.${article.slug}`, (message) => { 34 | console.log(message); 35 | revalidate(); 36 | }); 37 | 38 | return ( 39 | 43 | 44 | 45 | Preview 46 | 47 | 50 |
51 | } 52 | > 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/admin/articles.new.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "~/components/admin/Button"; 2 | import { Page } from "~/components/admin/Page"; 3 | import { ArticleForm } from "./components/article-form"; 4 | 5 | export { action } from "./components/article-form"; 6 | 7 | export default function NewArticle() { 8 | return ( 9 | 13 | Create 14 | 15 | } 16 | > 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { Page } from "~/components/admin/Page"; 2 | 3 | export default function () { 4 | return Hello, world; 5 | } 6 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/admin/profile.tsx: -------------------------------------------------------------------------------- 1 | import { Form } from "@remix-run/react"; 2 | import { Page } from "~/components/admin/Page"; 3 | import { useAdmin } from "../auth/hooks"; 4 | 5 | export default function Profile() { 6 | const adminData = useAdmin(); 7 | 8 | return ( 9 | 10 |

Your name is {adminData?.user?.name}

11 |
12 | 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/admin/upload.$.ts: -------------------------------------------------------------------------------- 1 | import { json, type ActionFunctionArgs } from "@remix-run/cloudflare"; 2 | import { parseMultipartFormData, storage } from "superflare"; 3 | 4 | export async function action({ request }: ActionFunctionArgs) { 5 | const formData = await parseMultipartFormData( 6 | request, 7 | async ({ stream, filename }) => { 8 | const object = await storage().putRandom(stream, { 9 | extension: filename?.split(".").pop(), 10 | }); 11 | 12 | return object.key; 13 | } 14 | ); 15 | 16 | return json({ 17 | url: storage().url(formData.get("file") as string), 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/auth.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "@remix-run/react"; 2 | 3 | export default function Auth() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/auth/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRouteLoaderData } from "@remix-run/react"; 2 | 3 | import { loader as adminLoader } from "../admin"; 4 | 5 | export function useAdmin() { 6 | return useRouteLoaderData("routes/admin"); 7 | } 8 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/auth/login.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useActionData } from "@remix-run/react"; 2 | import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; 3 | import { Button } from "~/components/admin/Button"; 4 | import { FormField } from "~/components/Form"; 5 | import { User } from "~/models/User"; 6 | 7 | export async function action({ 8 | request, 9 | context: { auth }, 10 | }: ActionFunctionArgs) { 11 | if (await auth.check(User)) { 12 | return redirect("/admin"); 13 | } 14 | 15 | const formData = new URLSearchParams(await request.text()); 16 | 17 | if (formData.get("bypass") === "user") { 18 | auth.login((await User.first()) as User); 19 | return redirect("/admin"); 20 | } 21 | 22 | const email = formData.get("email") as string; 23 | const password = formData.get("password") as string; 24 | 25 | if (await auth.attempt(User, { email, password })) { 26 | return redirect("/admin"); 27 | } 28 | 29 | return json({ error: "Invalid credentials" }, { status: 400 }); 30 | } 31 | 32 | export default function Login() { 33 | const actionData = useActionData(); 34 | 35 | return ( 36 | <> 37 |
38 |
39 |

Log in

40 |
41 | 48 | 55 | {actionData?.error ? ( 56 |
{actionData.error}
57 | ) : null} 58 |
59 | 60 |
61 |
62 | Register 63 |
64 |
65 | 66 |
67 | 70 |
71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { type ActionFunctionArgs, redirect } from "@remix-run/cloudflare"; 2 | 3 | export async function action({ context: { auth } }: ActionFunctionArgs) { 4 | auth.logout(); 5 | 6 | return redirect("/"); 7 | } 8 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/auth/register.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useActionData } from "@remix-run/react"; 2 | import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; 3 | import { Button } from "~/components/admin/Button"; 4 | import { FormField } from "~/components/Form"; 5 | import { User } from "~/models/User"; 6 | import { hash } from "superflare"; 7 | 8 | export async function action({ 9 | request, 10 | context: { auth }, 11 | }: ActionFunctionArgs) { 12 | if (await auth.check(User)) { 13 | return redirect("/admin"); 14 | } 15 | 16 | const formData = new URLSearchParams(await request.text()); 17 | const email = formData.get("email") as string; 18 | const password = formData.get("password") as string; 19 | const name = formData.get("name") as string; 20 | 21 | if (await User.where("email", email).count()) { 22 | return json({ error: "Email already exists" }, { status: 400 }); 23 | } 24 | 25 | const user = await User.create({ 26 | email, 27 | name, 28 | password: await hash().make(password), 29 | }); 30 | 31 | auth.login(user); 32 | 33 | return redirect("/admin"); 34 | } 35 | 36 | export default function Register() { 37 | const actionData = useActionData(); 38 | 39 | return ( 40 |
41 |
42 |

Register

43 |
44 | 45 | 52 | 59 | {actionData?.error ? ( 60 |
{actionData.error}
61 | ) : null} 62 |
63 | 64 |
65 |
66 | Register 67 |
68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/channel.$channelName.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunctionArgs } from "@remix-run/cloudflare"; 2 | import { handleWebSockets } from "superflare"; 3 | import { User } from "~/models/User"; 4 | 5 | export async function loader({ 6 | request, 7 | context: { auth, session }, 8 | }: LoaderFunctionArgs) { 9 | return handleWebSockets(request, { auth, session, userModel: User }); 10 | } 11 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Index() { 2 | return ( 3 |
4 |

TODO: Display content

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /examples/remix-cms/app/routes/storage.$.tsx: -------------------------------------------------------------------------------- 1 | import { type LoaderFunctionArgs } from "@remix-run/cloudflare"; 2 | import { servePublicPathFromStorage } from "superflare"; 3 | 4 | export async function loader({ request }: LoaderFunctionArgs) { 5 | const { pathname } = new URL(request.url); 6 | return servePublicPathFromStorage(pathname); 7 | } 8 | -------------------------------------------------------------------------------- /examples/remix-cms/app/styles/syntax.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Theme: a11y-dark 3 | Author: @ericwbailey 4 | Maintainer: @ericwbailey 5 | 6 | Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css 7 | */ 8 | 9 | .hljs { 10 | background: #2b2b2b; 11 | color: #f8f8f2; 12 | } 13 | 14 | /* Comment */ 15 | .hljs-comment, 16 | .hljs-quote { 17 | color: #d4d0ab; 18 | } 19 | 20 | /* Red */ 21 | .hljs-variable, 22 | .hljs-template-variable, 23 | .hljs-tag, 24 | .hljs-name, 25 | .hljs-selector-id, 26 | .hljs-selector-class, 27 | .hljs-regexp, 28 | .hljs-deletion { 29 | color: #ffa07a; 30 | } 31 | 32 | /* Orange */ 33 | .hljs-number, 34 | .hljs-built_in, 35 | .hljs-literal, 36 | .hljs-type, 37 | .hljs-params, 38 | .hljs-meta, 39 | .hljs-link { 40 | color: #f5ab35; 41 | } 42 | 43 | /* Yellow */ 44 | .hljs-attribute { 45 | color: #ffd700; 46 | } 47 | 48 | /* Green */ 49 | .hljs-string, 50 | .hljs-symbol, 51 | .hljs-bullet, 52 | .hljs-addition { 53 | color: #abe338; 54 | } 55 | 56 | /* Blue */ 57 | .hljs-title, 58 | .hljs-section { 59 | color: #00e0e0; 60 | } 61 | 62 | /* Purple */ 63 | .hljs-keyword, 64 | .hljs-selector-tag { 65 | color: #dcc6e0; 66 | } 67 | 68 | .hljs-emphasis { 69 | font-style: italic; 70 | } 71 | 72 | .hljs-strong { 73 | font-weight: bold; 74 | } 75 | 76 | @media screen and (-ms-high-contrast: active) { 77 | 78 | .hljs-addition, 79 | .hljs-attribute, 80 | .hljs-built_in, 81 | .hljs-bullet, 82 | .hljs-comment, 83 | .hljs-link, 84 | .hljs-literal, 85 | .hljs-meta, 86 | .hljs-number, 87 | .hljs-params, 88 | .hljs-string, 89 | .hljs-symbol, 90 | .hljs-type, 91 | .hljs-quote { 92 | color: highlight; 93 | } 94 | 95 | .hljs-keyword, 96 | .hljs-selector-tag { 97 | font-weight: bold; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /examples/remix-cms/app/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /examples/remix-cms/app/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate a relative time string using the Intl.RelativeTimeFormat API. 3 | */ 4 | export function getRelativeTime(date: Date) { 5 | // If the date was in the last minute, use unsigned seconds as the format 6 | const diffInSeconds = Math.abs( 7 | Math.floor((new Date().getTime() - date.getTime()) / 1000) 8 | ); 9 | if (diffInSeconds < 60) { 10 | return new Intl.RelativeTimeFormat("en", { 11 | numeric: "auto", 12 | style: "short", 13 | }).format(-diffInSeconds, "second"); 14 | } 15 | 16 | // If the date was in the last hour, use minutes as the format 17 | const diffInMinutes = Math.floor(diffInSeconds / 60); 18 | if (diffInMinutes < 60) { 19 | return new Intl.RelativeTimeFormat("en", { 20 | numeric: "auto", 21 | style: "short", 22 | }).format(-diffInMinutes, "minute"); 23 | } 24 | 25 | // If the date was in the last day, use hours as the format 26 | const diffInHours = Math.floor(diffInMinutes / 60); 27 | if (diffInHours < 24) { 28 | return new Intl.RelativeTimeFormat("en", { 29 | numeric: "auto", 30 | style: "short", 31 | }).format(-diffInHours, "hour"); 32 | } 33 | 34 | // If the date was in the last week, use days as the format 35 | const diffInDays = Math.floor(diffInHours / 24); 36 | if (diffInDays < 7) { 37 | return new Intl.RelativeTimeFormat("en", { 38 | numeric: "auto", 39 | style: "short", 40 | }).format(-diffInDays, "day"); 41 | } 42 | 43 | // If the date was in the last month, use weeks as the format 44 | const diffInWeeks = Math.floor(diffInDays / 7); 45 | if (diffInWeeks < 4) { 46 | return new Intl.RelativeTimeFormat("en", { 47 | numeric: "auto", 48 | style: "short", 49 | }).format(-diffInWeeks, "week"); 50 | } 51 | 52 | // If the date was in the last year, use months as the format 53 | const diffInMonths = Math.floor(diffInDays / 30); 54 | if (diffInMonths < 12) { 55 | return new Intl.RelativeTimeFormat("en", { 56 | numeric: "auto", 57 | style: "short", 58 | }).format(-diffInMonths, "month"); 59 | } 60 | 61 | // If the date was more than a year ago, use years as the format 62 | const diffInYears = Math.floor(diffInDays / 365); 63 | return new Intl.RelativeTimeFormat("en", { 64 | numeric: "auto", 65 | style: "short", 66 | }).format(-diffInYears, "year"); 67 | } 68 | -------------------------------------------------------------------------------- /examples/remix-cms/app/utils/markdown.server.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import hljs from "highlight.js"; 3 | 4 | export async function convertToHtml(input: string) { 5 | return marked(input, { 6 | breaks: true, 7 | gfm: true, 8 | headerIds: false, 9 | smartLists: true, 10 | smartypants: true, 11 | highlight: (code, lang) => 12 | hljs.highlight(code, { 13 | language: lang, 14 | }).value, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /examples/remix-cms/app/utils/use-channel.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | 3 | export function useChannel( 4 | channelName: String, 5 | onMessage: (event: any) => void 6 | ) { 7 | const socket = useRef(null); 8 | 9 | useEffect(() => { 10 | const wss = window.location.protocol === "http:" ? "ws://" : "wss://"; 11 | const url = `${wss}${window.location.hostname}:${window.location.port}/channel/${channelName}`; 12 | async function getWebSocket() { 13 | const ws = new WebSocket(url); 14 | if (!ws) { 15 | throw new Error("server didn't accept WebSocket"); 16 | } 17 | 18 | ws.addEventListener("message", (message) => onMessage(message)); 19 | 20 | ws.addEventListener("open", (event) => { 21 | socket.current = ws; 22 | 23 | // Send user info message. 24 | ws.send(JSON.stringify({ name: "josh " + Date.now() })); 25 | }); 26 | 27 | ws.addEventListener("close", (event) => { 28 | console.log( 29 | "WebSocket closed, reconnecting:", 30 | event.code, 31 | event.reason 32 | ); 33 | }); 34 | ws.addEventListener("error", (event) => { 35 | console.log("WebSocket error, reconnecting:", event); 36 | }); 37 | 38 | socket.current = ws; 39 | } 40 | 41 | if (!socket.current) { 42 | getWebSocket(); 43 | } 44 | 45 | return () => { 46 | socket.current?.removeEventListener("message", onMessage); 47 | 48 | // TODO: Close websocket when we navigate away from the current page. 49 | // socket.current?.close(); 50 | }; 51 | }, [channelName, onMessage]); 52 | } 53 | -------------------------------------------------------------------------------- /examples/remix-cms/db/factories/UserFactory.ts: -------------------------------------------------------------------------------- 1 | import { Factory } from "superflare"; 2 | import { User } from "~/models/User"; 3 | import { faker } from "@faker-js/faker"; 4 | 5 | export const UserFactory = Factory.for(User).definition(() => ({ 6 | name: faker.name.fullName(), 7 | email: faker.internet.email(), 8 | password: faker.internet.password(), 9 | })); 10 | -------------------------------------------------------------------------------- /examples/remix-cms/db/migrations/0000_create_users.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "superflare"; 2 | 3 | export default function () { 4 | return Schema.create("users", (table) => { 5 | table.increments("id"); 6 | table.string("name").nullable(); 7 | table.string("email").unique(); 8 | table.string("password"); 9 | table.timestamps(); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /examples/remix-cms/db/migrations/0001_create_articles.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "superflare"; 2 | 3 | export default function () { 4 | return Schema.create("articles", (table) => { 5 | table.increments("id"); 6 | table.string("title"); 7 | table.string("slug").unique(); 8 | table.text("content").nullable(); 9 | table.integer("userId"); 10 | table.string("status"); 11 | table.timestamps(); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /examples/remix-cms/db/seed.ts: -------------------------------------------------------------------------------- 1 | import { seed } from "superflare"; 2 | import { UserFactory } from "./factories/UserFactory"; 3 | 4 | export default seed(async () => { 5 | await UserFactory.create(); 6 | }); 7 | -------------------------------------------------------------------------------- /examples/remix-cms/functions/[[remix]].ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRequestHandler, 3 | createCookieSessionStorage, 4 | } from "@remix-run/cloudflare"; 5 | import { handleFetch, SuperflareAuth } from "superflare"; 6 | import getConfig from "../superflare.config"; 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore This file won’t exist if it hasn’t yet been built 9 | import * as build from "../build/server"; // eslint-disable-line import/no-unresolved 10 | 11 | let remixHandler: ReturnType; 12 | 13 | export const onRequest: PagesFunction = async (ctx) => { 14 | if (!remixHandler) { 15 | remixHandler = createRequestHandler( 16 | build as any, 17 | ctx.env.CF_PAGES ? "production" : "development" 18 | ); 19 | } 20 | 21 | const sessionStorage = createCookieSessionStorage({ 22 | cookie: { 23 | httpOnly: true, 24 | path: "/", 25 | secure: /^(http|ws)s:\/\//.test(ctx.request.url), 26 | secrets: [ctx.env.APP_KEY], 27 | }, 28 | }); 29 | 30 | const session = await sessionStorage.getSession( 31 | ctx.request.headers.get("Cookie") 32 | ); 33 | 34 | return handleFetch( 35 | { 36 | config: getConfig({ 37 | request: ctx.request, 38 | env: ctx.env, 39 | ctx, 40 | }), 41 | getSessionCookie: () => sessionStorage.commitSession(session), 42 | }, 43 | () => 44 | remixHandler(ctx.request, { 45 | auth: new SuperflareAuth(session), 46 | session, 47 | env: ctx.env, 48 | }) 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /examples/remix-cms/load-context.ts: -------------------------------------------------------------------------------- 1 | import { type Cloudflare } from "@superflare/remix"; 2 | 3 | declare module "@remix-run/cloudflare" { 4 | interface AppLoadContext { 5 | cloudflare: Cloudflare; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/remix-cms/migrations/0000_create_users.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0000 2024-11-15T19:40:10.787Z 2 | -- Autogenerated by Superflare. Do not edit this file directly. 3 | CREATE TABLE users ( 4 | id INTEGER PRIMARY KEY, 5 | name TEXT, 6 | email TEXT NOT NULL UNIQUE, 7 | password TEXT NOT NULL, 8 | createdAt DATETIME NOT NULL, 9 | updatedAt DATETIME NOT NULL 10 | ); -------------------------------------------------------------------------------- /examples/remix-cms/migrations/0001_create_articles.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0001 2024-11-15T19:40:10.789Z 2 | -- Autogenerated by Superflare. Do not edit this file directly. 3 | CREATE TABLE articles ( 4 | id INTEGER PRIMARY KEY, 5 | title TEXT NOT NULL, 6 | slug TEXT NOT NULL UNIQUE, 7 | content TEXT, 8 | userId INTEGER NOT NULL, 9 | status TEXT NOT NULL, 10 | createdAt DATETIME NOT NULL, 11 | updatedAt DATETIME NOT NULL 12 | ); -------------------------------------------------------------------------------- /examples/remix-cms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-cms", 3 | "version": "1.0.0", 4 | "description": "An example Superflare app powered by Remix", 5 | "type": "module", 6 | "private": true, 7 | "sideEffects": false, 8 | "scripts": { 9 | "build": "remix vite:build", 10 | "deploy": "wrangler deploy", 11 | "dev": "superflare dev", 12 | "start": "wrangler dev", 13 | "migrate": "superflare migrate", 14 | "typegen": "wrangler types", 15 | "typecheck": "tsc" 16 | }, 17 | "keywords": [], 18 | "author": "Josh Larson ", 19 | "license": "MIT", 20 | "dependencies": { 21 | "@headlessui/react": "^1.7.13", 22 | "@heroicons/react": "^2.0.16", 23 | "@remix-run/cloudflare": "^2.12.1", 24 | "@remix-run/react": "^2.12.1", 25 | "@remix-run/v1-route-convention": "^0.1.4", 26 | "@superflare/remix": "workspace:*", 27 | "@tailwindcss/forms": "^0.5.3", 28 | "clsx": "^1.2.1", 29 | "highlight.js": "^11.7.0", 30 | "isbot": "^5.1.13", 31 | "marked": "^4.2.12", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "superflare": "workspace:*", 35 | "textarea-markdown-editor": "^1.0.4", 36 | "tiny-invariant": "^1.3.1" 37 | }, 38 | "devDependencies": { 39 | "@cloudflare/workers-types": "^4.20241011.0", 40 | "@faker-js/faker": "^7.6.0", 41 | "@remix-run/dev": "^2.12.1", 42 | "@remix-run/eslint-config": "^2.12.1", 43 | "@tailwindcss/typography": "^0.5.9", 44 | "@types/marked": "^4.0.8", 45 | "@types/react": "^18.0.28", 46 | "@types/react-dom": "^18.0.11", 47 | "autoprefixer": "^10.4.19", 48 | "eslint": "^8.35.0", 49 | "postcss": "^8.4.39", 50 | "tailwindcss": "^3.4.4", 51 | "tsconfig": "workspace:*", 52 | "typescript": "^5", 53 | "vite": "^5.3.4", 54 | "vite-tsconfig-paths": "^4.3.2", 55 | "wrangler": "^3.91.0" 56 | }, 57 | "engines": { 58 | "node": ">=16.13" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /examples/remix-cms/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/remix-cms/public/_headers: -------------------------------------------------------------------------------- 1 | /build/* 2 | Cache-Control: public, max-age=31536000, s-maxage=31536000 3 | -------------------------------------------------------------------------------- /examples/remix-cms/public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": ["/build/*", "/favicon.ico"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/remix-cms/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/superflare/905ba4c6cf43de5be83eca5960ff4e182a486152/examples/remix-cms/public/favicon.ico -------------------------------------------------------------------------------- /examples/remix-cms/superflare.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "superflare"; 2 | 3 | import { LogArticleUpdated } from "~/listeners/LogArticleUpdated"; 4 | 5 | export default defineConfig((ctx) => { 6 | return { 7 | appKey: ctx.env.APP_KEY, 8 | database: { 9 | default: ctx.env.DB, 10 | }, 11 | storage: { 12 | default: { 13 | binding: ctx.env.REMIX_CMS_MEDIA, 14 | publicPath: "/storage/media", 15 | }, 16 | }, 17 | queues: { 18 | default: ctx.env.QUEUE, 19 | }, 20 | listeners: { 21 | ArticleUpdated: [LogArticleUpdated], 22 | }, 23 | channels: { 24 | default: { 25 | binding: ctx.env.CHANNELS, 26 | }, 27 | }, 28 | }; 29 | }); 30 | -------------------------------------------------------------------------------- /examples/remix-cms/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import forms from "@tailwindcss/forms"; 2 | import typography from "@tailwindcss/typography"; 3 | import type { Config } from "tailwindcss"; 4 | 5 | export default { 6 | content: ["./app/**/*.{ts,tsx,jsx,js}"], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [forms, typography], 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /examples/remix-cms/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["worker-configuration.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "types": ["@remix-run/cloudflare", "vite/client"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2022", 12 | "strict": true, 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/remix-cms/types/build.d.ts: -------------------------------------------------------------------------------- 1 | import { type ServerBuild } from "@remix-run/cloudflare"; 2 | 3 | export const assets: ServerBuild["assets"]; 4 | export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"]; 5 | export const entry: ServerBuild["entry"]; 6 | export const future: ServerBuild["future"]; 7 | export const publicPath: ServerBuild["publicPath"]; 8 | export const routes: ServerBuild["routes"]; 9 | -------------------------------------------------------------------------------- /examples/remix-cms/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { vitePlugin as remix } from "@remix-run/dev"; 3 | import { createRoutesFromFolders } from "@remix-run/v1-route-convention"; 4 | import { superflareDevProxyVitePlugin } from "@superflare/remix/dev"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | superflareDevProxyVitePlugin(), 10 | remix({ 11 | future: { 12 | v3_fetcherPersist: true, 13 | v3_lazyRouteDiscovery: true, 14 | v3_relativeSplatPath: true, 15 | v3_throwAbortReason: true, 16 | }, 17 | // Tell Remix to ignore everything in the routes directory. 18 | // We’ll let createRoutesFromFolders take care of that. 19 | ignoredRouteFiles: ["**/*"], 20 | routes: async (defineRoutes) => { 21 | // createRoutesFromFolders will create routes for all files in the 22 | // routes directory using the same default conventions as Remix v1. 23 | return createRoutesFromFolders(defineRoutes, { 24 | ignoredFilePatterns: ["**/.*"], 25 | }); 26 | }, 27 | }), 28 | tsconfigPaths(), 29 | ], 30 | ssr: { 31 | resolve: { 32 | conditions: ["workerd", "worker", "browser"], 33 | }, 34 | }, 35 | resolve: { 36 | mainFields: ["browser", "module", "main"], 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /examples/remix-cms/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | interface Env { 4 | APP_KEY: string; 5 | CHANNELS: DurableObjectNamespace /* Channel from remix-cms */; 6 | REMIX_CMS_MEDIA: R2Bucket; 7 | DB: D1Database; 8 | QUEUE: Queue; 9 | } 10 | -------------------------------------------------------------------------------- /examples/remix-cms/worker.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler, type ServerBuild } from "@remix-run/cloudflare"; 2 | import { handleFetch } from "@superflare/remix"; 3 | import { handleQueue, handleScheduled } from "superflare"; 4 | import config from "./superflare.config"; 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore This file won’t exist if it hasn’t yet been built 7 | import * as build from "./build/server"; // eslint-disable-line import/no-unresolved 8 | 9 | export { Channel } from "superflare"; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | const handleRequest = createRequestHandler(build as any as ServerBuild); 13 | 14 | export default { 15 | async fetch(request, env, ctx) { 16 | try { 17 | return await handleFetch(request, env, ctx, config, handleRequest); 18 | } catch (error) { 19 | console.log(error); 20 | return new Response("An unexpected error occurred", { status: 500 }); 21 | } 22 | }, 23 | 24 | async queue(batch, env, ctx) { 25 | return handleQueue(batch, env, ctx, config); 26 | }, 27 | 28 | async scheduled(event, env, ctx) { 29 | return await handleScheduled(event, env, ctx, config, (schedule) => { 30 | schedule 31 | .run(async () => { 32 | console.log("Running every minute"); 33 | }) 34 | .everyMinute(); 35 | }); 36 | }, 37 | } satisfies ExportedHandler; 38 | -------------------------------------------------------------------------------- /examples/remix-cms/wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-cms", 3 | "compatibility_flags": ["nodejs_compat"], 4 | "main": "./worker.ts", 5 | "compatibility_date": "2024-09-25", 6 | "assets": { 7 | "directory": "./build/client" 8 | }, 9 | "build": { 10 | "command": "npm run build" 11 | }, 12 | "triggers": { 13 | "crons": ["* * * * *"] 14 | }, 15 | "d1_databases": [ 16 | { 17 | "binding": "DB", 18 | "database_id": "f6ea5020-02e4-4926-a499-657afebdf67d" 19 | } 20 | ], 21 | "r2_buckets": [ 22 | { 23 | "binding": "REMIX_CMS_MEDIA", 24 | "bucket_name": "remix-cms-media", 25 | "preview_bucket_name": "REMIX_CMS_MEDIA" 26 | } 27 | ], 28 | "queues": { 29 | "producers": [ 30 | { 31 | "queue": "remix-cms-queue", 32 | "binding": "QUEUE" 33 | } 34 | ], 35 | "consumers": [ 36 | { 37 | "queue": "remix-cms-queue" 38 | } 39 | ] 40 | }, 41 | "durable_objects": { 42 | "bindings": [ 43 | { 44 | "name": "CHANNELS", 45 | "class_name": "Channel", 46 | "script_name": "remix-cms" 47 | } 48 | ] 49 | }, 50 | "migrations": [ 51 | { 52 | "tag": "v1", 53 | "new_classes": ["Channel"] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superflare", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*", 8 | "templates/*" 9 | ], 10 | "scripts": { 11 | "build": "turbo run build --filter='./packages/*'", 12 | "dev": "turbo run dev --parallel --filter='!./templates/*'", 13 | "test": "NODE_OPTIONS=--no-warnings npx vitest", 14 | "lint": "turbo run lint", 15 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 16 | "superflare": "./packages/superflare/bin/superflare.js" 17 | }, 18 | "devDependencies": { 19 | "eslint-config-custom": "workspace:*", 20 | "prettier": "^2.8.4", 21 | "turbo": "^2.2.3", 22 | "vitest": "^0.28.3" 23 | }, 24 | "engines": { 25 | "node": ">=14.0.0" 26 | }, 27 | "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee", 28 | "dependencies": { 29 | "@changesets/changelog-git": "^0.1.14", 30 | "@changesets/cli": "^2.26.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["turbo", "prettier"], 3 | env: { 4 | es6: true, 5 | }, 6 | parserOptions: { 7 | ecmaVersion: "latest", 8 | }, 9 | rules: { 10 | "react/jsx-key": "off", 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "eslint": "^7.23.0", 9 | "eslint-config-prettier": "^8.3.0", 10 | "eslint-plugin-react": "7.31.8", 11 | "eslint-config-turbo": "latest" 12 | }, 13 | "devDependencies": { 14 | "typescript": "^5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/superflare-remix/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/superflare-remix/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @superflare/remix 2 | 3 | ## 0.0.18 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [aaebb5b] 8 | - superflare@0.1.1 9 | 10 | ## 0.0.17 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [1a16f73] 15 | - superflare@0.1.0 16 | 17 | ## 0.0.16 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [e7ea5e5] 22 | - superflare@0.0.24 23 | 24 | ## 0.0.15 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies [1bb00a5] 29 | - superflare@0.0.23 30 | 31 | ## 0.0.14 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies [5ae4bac] 36 | - superflare@0.0.22 37 | 38 | ## 0.0.13 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies [1c37d48] 43 | - superflare@0.0.21 44 | 45 | ## 0.0.12 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies [d700790] 50 | - superflare@0.0.20 51 | 52 | ## 0.0.11 53 | 54 | ### Patch Changes 55 | 56 | - Updated dependencies [713e1eb] 57 | - superflare@0.0.19 58 | 59 | ## 0.0.10 60 | 61 | ### Patch Changes 62 | 63 | - Updated dependencies [592a05e] 64 | - superflare@0.0.18 65 | 66 | ## 0.0.9 67 | 68 | ### Patch Changes 69 | 70 | - Updated dependencies [320da3c] 71 | - superflare@0.0.17 72 | 73 | ## 0.0.8 74 | 75 | ### Patch Changes 76 | 77 | - Updated dependencies [b662086] 78 | - superflare@0.0.16 79 | 80 | ## 0.0.7 81 | 82 | ### Patch Changes 83 | 84 | - Updated dependencies [c78f8b1] 85 | - superflare@0.0.15 86 | 87 | ## 0.0.6 88 | 89 | ### Patch Changes 90 | 91 | - Updated dependencies [ce7cea9] 92 | - superflare@0.0.14 93 | 94 | ## 0.0.5 95 | 96 | ### Patch Changes 97 | 98 | - Updated dependencies [fde0626] 99 | - Updated dependencies [c8e63f1] 100 | - superflare@0.0.13 101 | 102 | ## 0.0.4 103 | 104 | ### Patch Changes 105 | 106 | - 806dceb: Prevent recursive type defs 107 | - Updated dependencies [09901b7] 108 | - Updated dependencies [0bbd1f8] 109 | - Updated dependencies [091f84c] 110 | - superflare@0.0.12 111 | 112 | ## 0.0.3 113 | 114 | ### Patch Changes 115 | 116 | - d3d4776: Add lots of refinement 117 | - Updated dependencies [d3d4776] 118 | - superflare@0.0.11 119 | 120 | ## 0.0.2 121 | 122 | ### Patch Changes 123 | 124 | - 0aa44c9: Lots of changes. Please see the docs! 125 | -------------------------------------------------------------------------------- /packages/superflare-remix/README.md: -------------------------------------------------------------------------------- 1 | # @superflare/remix 2 | 3 | This package provides a wrapper around Superflare worker entrypoints to make handling requests more elegant. 4 | 5 | [Read the docs](https://superflare.dev), or get started now: 6 | 7 | ```bash 8 | npx superflare@latest new 9 | ``` 10 | -------------------------------------------------------------------------------- /packages/superflare-remix/cloudflare.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/superflare-remix/index.ts: -------------------------------------------------------------------------------- 1 | import { type AppLoadContext } from "@remix-run/cloudflare"; 2 | import { 3 | type DefineConfigReturn, 4 | handleFetch as superflareHandleFetch, 5 | SuperflareAuth, 6 | SuperflareSession, 7 | } from "superflare"; 8 | 9 | import { type Cloudflare, getLoadContext } from "./load-context"; 10 | 11 | export { type Cloudflare, getLoadContext } from "./load-context"; 12 | 13 | declare module "@remix-run/cloudflare" { 14 | interface AppLoadContext { 15 | auth: InstanceType; 16 | session: InstanceType; 17 | getSessionCookie: () => Promise; 18 | } 19 | } 20 | 21 | /** 22 | * `handleFetch` is a Remix-specific wrapper around Superflare's function of the same name. 23 | * It calls getLoadContext to inject `auth` and `session` into the Remix load context. 24 | */ 25 | export async function handleFetch( 26 | request: Request, 27 | env: Env, 28 | ctx: ExecutionContext, 29 | config: DefineConfigReturn, 30 | remixHandler: ( 31 | request: Request, 32 | loadContext: AppLoadContext 33 | ) => Promise 34 | ) { 35 | const loadContext = await getLoadContext({ 36 | request, 37 | context: { 38 | // This object matches the return value from Wrangler's 39 | // `getPlatformProxy` used during development via Remix's 40 | // `cloudflareDevProxyVitePlugin`: 41 | // https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy 42 | cloudflare: { caches, ctx, env, cf: request.cf }, 43 | }, 44 | SuperflareAuth, 45 | SuperflareSession, 46 | }); 47 | 48 | return await superflareHandleFetch( 49 | { 50 | request, 51 | env, 52 | ctx, 53 | config, 54 | session: loadContext.session, 55 | getSessionCookie: loadContext.getSessionCookie, 56 | }, 57 | () => { 58 | return remixHandler( 59 | request as any as Request, 60 | loadContext as any as AppLoadContext 61 | ); 62 | } 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /packages/superflare-remix/load-context.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AppLoadContext, 3 | createCookieSessionStorage, 4 | } from "@remix-run/cloudflare"; 5 | import type { SuperflareAuth, SuperflareSession } from "superflare"; 6 | import { type PlatformProxy } from "wrangler"; 7 | 8 | // NOTE: PlatformProxy’s caches property is incompatible with the caches global 9 | // https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/api/integrations/platform/caches.ts 10 | export type Cloudflare = Omit< 11 | PlatformProxy, 12 | "dispose" | "caches" | "cf" 13 | > & { 14 | caches: CacheStorage; 15 | cf: Request["cf"]; 16 | }; 17 | 18 | // Shared implementation compatible with Vite, Wrangler, and Workers 19 | export async function getLoadContext(payload: { 20 | request: Request; 21 | context: { cloudflare: Cloudflare }; 22 | SuperflareAuth: typeof SuperflareAuth; 23 | SuperflareSession: typeof SuperflareSession; 24 | }): Promise }> { 25 | const { request, context } = payload; 26 | const { env } = context.cloudflare; 27 | if (!env.APP_KEY) { 28 | throw new Error( 29 | "APP_KEY is required. Please ensure you have defined it as an environment variable." 30 | ); 31 | } 32 | 33 | const { getSession, commitSession } = createCookieSessionStorage({ 34 | cookie: { 35 | httpOnly: true, 36 | path: "/", 37 | secure: /^(http|ws)s:\/\//.test(request.url), 38 | secrets: [env.APP_KEY], 39 | }, 40 | }); 41 | 42 | const session = new payload.SuperflareSession( 43 | await getSession(request.headers.get("Cookie")) 44 | ); 45 | 46 | // Ensure we have a sessionId here (instead of in superflare#handleFetch) 47 | if (!session.has("sessionId")) { 48 | session.set("sessionId", crypto.randomUUID()); 49 | } 50 | 51 | /** 52 | * We inject auth and session into the Remix load context. 53 | * Someday, we could replace this with AsyncLocalStorage. 54 | */ 55 | return { 56 | ...context, 57 | session, 58 | auth: new payload.SuperflareAuth(session), 59 | getSessionCookie: () => commitSession(session.getSession()), 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /packages/superflare-remix/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@superflare/remix", 3 | "version": "0.0.18", 4 | "description": "Remix plugin for Superflare", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "bugs": { 8 | "url": "https://github.com/jplhomer/superflare/issues" 9 | }, 10 | "exports": { 11 | ".": { 12 | "import": "./dist/index.mjs", 13 | "require": "./dist/index.js", 14 | "types": "./dist/index.d.ts" 15 | }, 16 | "./dev": { 17 | "import": "./dist/dev.mjs", 18 | "require": "./dist/dev.js", 19 | "types": "./dist/dev.d.ts" 20 | }, 21 | "./package.json": "./package.json" 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "scripts": { 27 | "build": "tsup", 28 | "dev": "tsup --watch", 29 | "prepack": "pnpm build", 30 | "typecheck": "tsc" 31 | }, 32 | "keywords": [ 33 | "remix", 34 | "cloudflare", 35 | "superflare" 36 | ], 37 | "author": "Josh Larson ", 38 | "license": "MIT", 39 | "devDependencies": { 40 | "@cloudflare/workers-types": "^4.20241011.0", 41 | "@remix-run/cloudflare": "^2.12.1", 42 | "@remix-run/dev": "^2.12.1", 43 | "@remix-run/server-runtime": "^2.12.1", 44 | "tsconfig": "workspace:*", 45 | "tsup": "^6.6.3", 46 | "typescript": "^5", 47 | "vite": "^5", 48 | "wrangler": "^3.91.0" 49 | }, 50 | "peerDependencies": { 51 | "@remix-run/cloudflare": "^2.12.1", 52 | "@remix-run/dev": "^2.12.1", 53 | "@remix-run/server-runtime": "^2.12.1", 54 | "wrangler": "^3.91.0" 55 | }, 56 | "dependencies": { 57 | "superflare": "workspace:*" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/superflare-remix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["cloudflare.env.d.ts", "**/*"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 7 | "module": "commonjs", 8 | "experimentalDecorators": true, 9 | "isolatedModules": false, 10 | "rootDir": ".", 11 | "outDir": "./dist" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/superflare-remix/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | const nodejsCompatPlugin: any = { 4 | name: "nodejs_compat Plugin", 5 | setup(pluginBuild: any) { 6 | pluginBuild.onResolve({ filter: /node:.*/ }, (opts: any) => { 7 | return { external: true }; 8 | }); 9 | }, 10 | }; 11 | 12 | export default defineConfig([ 13 | { 14 | format: ["esm", "cjs"], 15 | esbuildPlugins: [nodejsCompatPlugin], 16 | entry: ["index.ts", "dev.ts"], 17 | dts: true, 18 | }, 19 | ]); 20 | -------------------------------------------------------------------------------- /packages/superflare/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /packages/superflare/README.md: -------------------------------------------------------------------------------- 1 | # Superflare 2 | 3 | A full-stack toolkit for Cloudflare Workers. 4 | 5 | [Read the docs](https://superflare.dev), or get started now: 6 | 7 | ```bash 8 | npx superflare@latest new 9 | ``` 10 | -------------------------------------------------------------------------------- /packages/superflare/cli/config.ts: -------------------------------------------------------------------------------- 1 | import { register } from "esbuild-register/dist/node"; 2 | import path from "node:path"; 3 | 4 | import type { Logger } from "./logger"; 5 | 6 | interface SuperflarePackageJsonConfig { 7 | d1?: string[]; 8 | r2?: string[]; 9 | } 10 | 11 | /** 12 | * Get the user's superflare.config.ts file, if it exists. 13 | * This contains a list of d1 and r2 bindings. We need to 14 | * load this file in order to know what bindings to pass 15 | * to `wrangler pages dev`, but we don't want to make 16 | * the dev define this information in two places. 17 | */ 18 | export async function getSuperflareConfig( 19 | workingDir: string, 20 | logger?: Logger 21 | ): Promise { 22 | register(); 23 | 24 | /** 25 | * Use a Fun Trick™ to get the user's superflare.config.ts bindings by 26 | * passing a proxy to the `env` key. We can then inspect the proxy 27 | * to see what keys were accessed, and use those as the bindings. 28 | */ 29 | const envProxies: Record = {}; 30 | const ctxStub = { 31 | env: new Proxy( 32 | {}, 33 | { 34 | get: (_target, prop: string) => { 35 | // Define a new unique key so we can track it 36 | envProxies[prop] = Symbol(prop); 37 | 38 | return envProxies[prop]; 39 | }, 40 | } 41 | ), 42 | }; 43 | 44 | try { 45 | const config = require(path.join(workingDir, "superflare.config.ts")); 46 | const results = config.default(ctxStub); 47 | const flippedEnvProxies = Object.entries(envProxies).reduce( 48 | (acc, [key, value]) => { 49 | acc[value as symbol] = key; 50 | return acc; 51 | }, 52 | {} as Record 53 | ); 54 | 55 | // D1 configs are stored in the `database` key 56 | const d1Bindings = Object.keys(results?.database ?? {}) 57 | .map((key) => { 58 | const binding = results.database[key]; 59 | 60 | if (typeof binding === "symbol") { 61 | return flippedEnvProxies[binding] as string; 62 | } 63 | }) 64 | .filter(Boolean) as string[]; 65 | 66 | // R2 configs are stored in the `storage` key 67 | const r2Bindings = Object.keys(results?.storage ?? {}) 68 | .map((key) => { 69 | const binding = results.storage[key].binding; 70 | 71 | if (typeof binding === "symbol") { 72 | return flippedEnvProxies[binding] as string; 73 | } 74 | }) 75 | .filter(Boolean) as string[]; 76 | 77 | return { 78 | d1: d1Bindings, 79 | r2: r2Bindings, 80 | }; 81 | } catch (e: any) { 82 | logger?.debug(`Error loading superflare.config.ts: ${e.message}`); 83 | return null; 84 | } 85 | } 86 | 87 | export async function getWranglerJsonConfig( 88 | workingDir: string, 89 | logger?: Logger 90 | ): Promise { 91 | try { 92 | const config = require(path.join(workingDir, "wrangler.json")); 93 | return config; 94 | } catch (e: any) { 95 | logger?.debug(`Error loading wrangler.json: ${e.message}`); 96 | return null; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/superflare/cli/console.ts: -------------------------------------------------------------------------------- 1 | import { start } from "node:repl"; 2 | import { readdir } from "node:fs/promises"; 3 | import { register } from "esbuild-register/dist/node"; 4 | import { homedir } from "node:os"; 5 | import { inspect } from "node:util"; 6 | import path from "node:path"; 7 | import { CommonYargsArgv, StrictYargsOptionsToInterface } from "./yargs-types"; 8 | import { getD1Database } from "./d1-database"; 9 | 10 | export function consoleOptions(yargs: CommonYargsArgv) { 11 | return yargs 12 | .option("db", { 13 | alias: "d", 14 | describe: "The name of the D1 database binding", 15 | default: "DB", 16 | }) 17 | .option("models", { 18 | alias: "m", 19 | describe: "Path to the models directory", 20 | 21 | // Default to the path in the app directory 22 | default: path.join(process.cwd(), "app", "models"), 23 | }); 24 | } 25 | 26 | export async function consoleHandler( 27 | argv: StrictYargsOptionsToInterface 28 | ) { 29 | const modelsDirectory = argv.models; 30 | const dbName = argv.db; 31 | 32 | return createRepl({ modelsDirectory, dbName }); 33 | } 34 | 35 | export async function createRepl({ 36 | modelsDirectory, 37 | dbName, 38 | }: { 39 | modelsDirectory: string; 40 | dbName: string; 41 | }) { 42 | /** 43 | * We're going to be importing some TS files, so we need to register esbuild. 44 | */ 45 | register(); 46 | 47 | /** 48 | * Create a REPL server. 49 | */ 50 | const server = start({ 51 | prompt: ">> ", 52 | input: process.stdin, 53 | output: process.stdout, 54 | terminal: 55 | process.stdout.isTTY && !parseInt(process.env.NODE_NO_READLINE!, 10), 56 | useGlobal: true, 57 | writer: (output) => { 58 | return ( 59 | inspect(output, { 60 | colors: true, 61 | showProxy: false, 62 | }) 63 | .split("\n") 64 | .map((line, idx) => (idx === 0 ? "=> " + line : " " + line)) 65 | .join("\n") + "\n" 66 | ); 67 | }, 68 | }); 69 | 70 | /** 71 | * Set up history for the REPL. 72 | */ 73 | const historyPath = `${homedir()}/.superflare_history`; 74 | server.setupHistory(historyPath, () => {}); 75 | 76 | /** 77 | * Assign a `db` context with the current database. 78 | */ 79 | const db = await getD1Database(dbName); 80 | server.context["db"] = db; 81 | 82 | /** 83 | * Run the Superflare `config` to ensure Models have access to the database. 84 | */ 85 | server.eval( 86 | `const {setConfig} = require('superflare'); setConfig({database: { default: db }});`, 87 | server.context, 88 | "repl", 89 | () => {} 90 | ); 91 | 92 | /** 93 | * Get a list of the models in the user's dir. 94 | */ 95 | const models = (await readdir(modelsDirectory)).filter( 96 | // Must be a TS file and start with a capital letter 97 | (file) => file.endsWith(".ts") && /^[A-Z]/.test(file) 98 | ); 99 | 100 | /** 101 | * Iterate through the models and import them. 102 | */ 103 | for (const modelFileName of models) { 104 | const module = require(`${modelsDirectory}/${modelFileName}`); 105 | const model = modelFileName.replace(".ts", ""); 106 | 107 | /** 108 | * Assign it to the global context of the server. 109 | */ 110 | server.context[model] = module[model]; 111 | } 112 | 113 | server.displayPrompt(); 114 | 115 | return server; 116 | } 117 | -------------------------------------------------------------------------------- /packages/superflare/cli/d1-database.ts: -------------------------------------------------------------------------------- 1 | import type { D1Database as D1DatabaseType } from "@cloudflare/workers-types"; 2 | 3 | export async function createD1Database( 4 | sqliteDbPath: string, 5 | logger = console.log 6 | ): Promise { 7 | const { npxImport } = await import("npx-import"); 8 | const [{ D1Database, D1DatabaseAPI }, { createSQLiteDB }] = await npxImport< 9 | [typeof import("@miniflare/d1"), typeof import("@miniflare/shared")] 10 | >(["@miniflare/d1", "@miniflare/shared"], logger); 11 | const sqliteDb = await createSQLiteDB(sqliteDbPath); 12 | const db = new D1Database(new D1DatabaseAPI(sqliteDb)); 13 | return db as any as D1DatabaseType; 14 | } 15 | 16 | export async function getD1Database(dbName: string, logger = console.log) { 17 | const { npxImport } = await import("npx-import"); 18 | const { getPlatformProxy } = await npxImport( 19 | "wrangler", 20 | logger 21 | ); 22 | const { env } = await getPlatformProxy(); 23 | return env[dbName] as D1DatabaseType | undefined; 24 | } 25 | -------------------------------------------------------------------------------- /packages/superflare/cli/db/index.ts: -------------------------------------------------------------------------------- 1 | import { CommonYargsArgv } from "../yargs-types"; 2 | import { seedOptions, seedHandler } from "./seed"; 3 | 4 | export function db(yargs: CommonYargsArgv) { 5 | return yargs.command( 6 | "seed", 7 | "🌱 Seed your database with data", 8 | seedOptions, 9 | seedHandler 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/superflare/cli/db/seed.ts: -------------------------------------------------------------------------------- 1 | import { register } from "esbuild-register/dist/node"; 2 | import path from "node:path"; 3 | import { getD1Database } from "../d1-database"; 4 | import { logger } from "../logger"; 5 | import { CommonYargsArgv, StrictYargsOptionsToInterface } from "../yargs-types"; 6 | 7 | export function seedOptions(yargs: CommonYargsArgv) { 8 | return yargs 9 | .option("db", { 10 | alias: "d", 11 | describe: "The name of the D1 database binding", 12 | default: "DB", 13 | }) 14 | .option("seed-path", { 15 | describe: "Path to the seed file", 16 | default: path.join(process.cwd(), "db", "seed.ts"), 17 | }); 18 | } 19 | 20 | export async function seedHandler( 21 | argv: StrictYargsOptionsToInterface 22 | ) { 23 | const dbName = argv.db; 24 | const seedPath = argv.seedPath; 25 | await seedDb(dbName, seedPath); 26 | } 27 | 28 | export async function seedDb(dbName: string, seedPath: string) { 29 | if (seedPath) { 30 | logger.info(`Seeding database...`); 31 | 32 | register(); 33 | try { 34 | const seedModule = require(seedPath); 35 | const d1Database = await getD1Database(dbName, logger.log); 36 | if (!d1Database) { 37 | throw new Error(`Database ${dbName} not found`); 38 | } 39 | // TODO: Find out why errors in the seeder are not bubbled to this try/catch 40 | if (seedModule.default) { 41 | await seedModule.default(d1Database); 42 | logger.info(`Seeding complete!`); 43 | } else { 44 | logger.warn(`Warning: Did not find a default export in ${seedPath}.`); 45 | } 46 | } catch (e: any) { 47 | logger.error(`Error seeding database: ${e.message}`); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/superflare/cli/dev.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from "node:child_process"; 2 | import { getSuperflareConfig, getWranglerJsonConfig } from "./config"; 3 | import { logger } from "./logger"; 4 | import { CommonYargsArgv, StrictYargsOptionsToInterface } from "./yargs-types"; 5 | 6 | export function devOptions(yargs: CommonYargsArgv) { 7 | return yargs 8 | .option("mode", { 9 | type: "string", 10 | choices: ["workers", "pages"], 11 | description: 12 | "Whether to run in workers or pages mode. Defaults to workers.", 13 | default: "workers", 14 | }) 15 | .positional("entrypoint", { 16 | type: "string", 17 | description: "The entrypoint to use for the workers dev server.", 18 | }) 19 | .option("compatibility-date", { 20 | type: "string", 21 | description: 22 | "The date to use for compatibility mode. Defaults to the current date.", 23 | default: new Date().toISOString().split("T")[0], 24 | }) 25 | .option("port", { 26 | type: "number", 27 | description: "The port to run the dev server on. Defaults to 8788.", 28 | default: 8788, 29 | }) 30 | .option("binding", { 31 | alias: "b", 32 | type: "array", 33 | description: 34 | "A binding to pass to the dev command. Can be specified multiple times. Works for both workers and pages.", 35 | default: [], 36 | }) 37 | .option("live-reload", { 38 | type: "boolean", 39 | description: "Whether to enable live reload. Defaults to true.", 40 | default: true, 41 | }); 42 | } 43 | 44 | export async function devHandler( 45 | argv: StrictYargsOptionsToInterface 46 | ) { 47 | const isPagesMode = argv.mode === "pages"; 48 | const isWorkersMode = !isPagesMode; 49 | 50 | logger.info( 51 | `Starting "wrangler" and ViteDevServer in ${ 52 | isPagesMode ? "pages" : "workers" 53 | } mode...` 54 | ); 55 | 56 | const config = await getSuperflareConfig(process.cwd(), logger); 57 | if (!config) { 58 | logger.warn( 59 | "Warning: Did not find a `superflare.config.ts` in your project. " + 60 | "You will want to add one in order to provide your appKey and D1 and R2 bindings for Superflare to use.\n" + 61 | "More info: https://superflare.dev/reference/superflare-config" 62 | ); 63 | } 64 | 65 | const d1Bindings = config?.d1; 66 | 67 | if (d1Bindings && Array.isArray(d1Bindings) && d1Bindings.length) { 68 | logger.info(`Using D1 binding: ${d1Bindings.join(", ")}`); 69 | } 70 | 71 | const r2Bindings = config?.r2; 72 | 73 | if (r2Bindings && Array.isArray(r2Bindings) && r2Bindings.length) { 74 | logger.info(`Using R2 bindings: ${r2Bindings.join(", ")}`); 75 | } 76 | 77 | const wranglerJsonConfig = await getWranglerJsonConfig(process.cwd(), logger); 78 | const workersEntrypoint = argv.entrypoint ?? wranglerJsonConfig?.main; 79 | 80 | if (isWorkersMode && !workersEntrypoint) { 81 | logger.error( 82 | "Error: You must set a `main` value pointing to your entrypoint in your `wrangler.json` in order to run in workers mode." 83 | ); 84 | process.exit(1); 85 | } 86 | 87 | spawn("wrangler", ["dev", "--no-bundle"], { 88 | stdio: "ignore", 89 | shell: true, 90 | env: process.env, 91 | }); 92 | 93 | spawn("remix", ["vite:dev"], { 94 | stdio: "inherit", 95 | shell: true, 96 | env: process.env, 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /packages/superflare/cli/generate.ts: -------------------------------------------------------------------------------- 1 | import { Argv } from "yargs"; 2 | import { jobHandler, jobOptions } from "./generate/job"; 3 | import { migrationHandler, migrationOptions } from "./generate/migration"; 4 | import { modelHandler, modelOptions } from "./generate/model"; 5 | 6 | export function generate(yargs: Argv) { 7 | return yargs 8 | .command( 9 | "job ", 10 | "Generate a Job", 11 | // @ts-expect-error: IDK 12 | jobOptions, 13 | jobHandler 14 | ) 15 | .command( 16 | "migration ", 17 | "Generate a Migration", 18 | // @ts-expect-error: IDK 19 | migrationOptions, 20 | migrationHandler 21 | ) 22 | .command( 23 | "model ", 24 | "Generate a Model", 25 | // @ts-expect-error: IDK 26 | modelOptions, 27 | modelHandler 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/superflare/cli/generate/job.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import path from "node:path"; 3 | import { logger } from "../logger"; 4 | import { jobTemplate } from "../stubs/job.stub"; 5 | import { CommonYargsArgv, StrictYargsOptionsToInterface } from "../yargs-types"; 6 | 7 | export function jobOptions(arg: CommonYargsArgv) { 8 | return arg 9 | .positional("name", { 10 | type: "string", 11 | demandOption: true, 12 | description: "The name of the Job to generate", 13 | }) 14 | .option("path", { 15 | type: "string", 16 | description: "The path to generate the Job in", 17 | default: path.join(process.cwd(), "app", "jobs"), 18 | }); 19 | } 20 | 21 | export async function jobHandler( 22 | yargs: StrictYargsOptionsToInterface 23 | ) { 24 | logger.log(`Generating Job ${yargs.name}`); 25 | 26 | const output = jobTemplate(yargs.name); 27 | 28 | const jobPath = path.join(yargs.path, `${yargs.name}.ts`); 29 | await writeFile(jobPath, output); 30 | 31 | logger.log(`Generated Job ${yargs.name} at ${jobPath}`); 32 | } 33 | -------------------------------------------------------------------------------- /packages/superflare/cli/generate/migration.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readdir, writeFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { toSnakeCase } from "../../src/string"; 4 | import { getSuperflareConfig } from "../config"; 5 | import { logger } from "../logger"; 6 | import { defaultSuperflareMigrationsPath } from "../migrate"; 7 | import { blankMigration } from "../stubs/migration.stub"; 8 | import { CommonYargsArgv, StrictYargsOptionsToInterface } from "../yargs-types"; 9 | 10 | export function migrationOptions(yargs: CommonYargsArgv) { 11 | return yargs.positional("name", { 12 | describe: "The name of the migration", 13 | type: "string", 14 | }); 15 | } 16 | 17 | export async function migrationHandler( 18 | argv: StrictYargsOptionsToInterface 19 | ) { 20 | const name = argv.name as string; 21 | 22 | generateMigration(name); 23 | } 24 | 25 | export async function generateMigration( 26 | name: string, 27 | rootPath = process.cwd() 28 | ) { 29 | const wranglerMigrationsPath = join(rootPath, "migrations"); 30 | 31 | let nextMigrationNumberInteger = 0; 32 | 33 | try { 34 | const existingMigrations = await readdir(wranglerMigrationsPath); 35 | nextMigrationNumberInteger = getNextMigrationNumber(existingMigrations); 36 | } catch (_e) { 37 | // No migrations folder exists yet; start with 0. 38 | } 39 | 40 | // Make the next migration number a string with leading zeros. 41 | const nextMigrationNumber = nextMigrationNumberInteger 42 | .toString() 43 | .padStart(4, "0"); 44 | const migrationName = `${nextMigrationNumber}_${toSnakeCase(name)}`; 45 | const migrationsPath = defaultSuperflareMigrationsPath(rootPath); 46 | await mkdir(migrationsPath, { recursive: true }); 47 | 48 | const migrationPath = join(migrationsPath, `${migrationName}.ts`); 49 | await writeFile(migrationPath, blankMigration()); 50 | 51 | logger.info(`Migration generated at ${migrationPath}`); 52 | } 53 | 54 | function getNextMigrationNumber(existingMigrations: string[]) { 55 | if (!existingMigrations.length) { 56 | return 0; 57 | } 58 | 59 | const mostRecentMigration = existingMigrations 60 | .filter((migration) => migration.match(/^[0-9]{4}_/)) 61 | .sort() 62 | .pop(); 63 | const mostRecentMigrationNumber = mostRecentMigration?.split("_")[0]; 64 | 65 | if (!mostRecentMigrationNumber) { 66 | throw new Error("Could not determine most recent migration number"); 67 | } 68 | 69 | return parseInt(mostRecentMigrationNumber, 10) + 1; 70 | } 71 | -------------------------------------------------------------------------------- /packages/superflare/cli/generate/model.ts: -------------------------------------------------------------------------------- 1 | import { mkdir, readFile, writeFile } from "node:fs/promises"; 2 | import path from "node:path"; 3 | import { modelToTableName } from "../../src/string"; 4 | import { SUPERFLARE_TYPES_FILE } from "../d1-types"; 5 | import { logger } from "../logger"; 6 | import { modelTemplate } from "../stubs/model.stub"; 7 | import { CommonYargsArgv, StrictYargsOptionsToInterface } from "../yargs-types"; 8 | import { generateMigration } from "./migration"; 9 | 10 | export function modelOptions(yargs: CommonYargsArgv) { 11 | return yargs 12 | .option("name", { 13 | type: "string", 14 | description: "Name of the model", 15 | required: true, 16 | }) 17 | .option("path", { 18 | type: "string", 19 | description: "The path to generate the Model in", 20 | default: path.join(process.cwd(), "app", "models"), 21 | }) 22 | .option("migration", { 23 | alias: "m", 24 | type: "boolean", 25 | description: "Generate a migration for the model", 26 | default: false, 27 | }); 28 | } 29 | 30 | export async function modelHandler( 31 | argv: StrictYargsOptionsToInterface 32 | ) { 33 | const { name } = argv; 34 | 35 | logger.log(`Generating model ${name}`); 36 | 37 | const output = modelTemplate(name); 38 | 39 | const modelPath = path.join(argv.path, `${name}.ts`); 40 | await mkdir(path.dirname(modelPath), { recursive: true }); 41 | await writeFile(modelPath, output); 42 | 43 | // Update the superflare types to ensure the {Model}Row exists, even if it's empty. 44 | const typeFilePath = path.join(process.cwd(), SUPERFLARE_TYPES_FILE); 45 | 46 | try { 47 | let contents = await readFile(typeFilePath, "utf-8"); 48 | contents += `\n\interface ${name}Row {};`; 49 | 50 | await writeFile(typeFilePath, contents); 51 | } catch (_e) { 52 | const contents = `interface ${name}Row {};`; 53 | 54 | await writeFile(typeFilePath, contents); 55 | } 56 | 57 | logger.log(`Generated model ${name} at ${modelPath}`); 58 | 59 | if (argv.migration) { 60 | const tableName = modelToTableName(name); 61 | const migrationName = `create_${tableName}`; 62 | 63 | generateMigration(migrationName); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/superflare/cli/logger.ts: -------------------------------------------------------------------------------- 1 | import { format } from "node:util"; 2 | import CLITable from "cli-table3"; 3 | import chalk from "chalk"; 4 | 5 | export const LOGGER_LEVELS = { 6 | none: -1, 7 | error: 0, 8 | warn: 1, 9 | info: 2, 10 | log: 3, 11 | debug: 4, 12 | } as const; 13 | 14 | export type LoggerLevel = keyof typeof LOGGER_LEVELS; 15 | export type TableRow = Record; 16 | 17 | /** 18 | * Inspired by wrangler's `Logger` class 19 | * @see https://github.com/cloudflare/wrangler2/blob/6b6ce5060a12e94a59c76249bb8022ea2737c5f7/packages/wrangler/src/logger.ts#L45 20 | */ 21 | export class Logger { 22 | debug = (...args: unknown[]) => this.doLog("debug", args); 23 | info = (...args: unknown[]) => this.doLog("info", args); 24 | log = (...args: unknown[]) => this.doLog("log", args); 25 | warn = (...args: unknown[]) => this.doLog("warn", args); 26 | error = (...args: unknown[]) => this.doLog("error", args); 27 | 28 | table(data: TableRow[]) { 29 | const keys: Keys[] = 30 | data.length === 0 ? [] : (Object.keys(data[0]) as Keys[]); 31 | const t = new CLITable({ 32 | head: keys.map((k) => chalk.bold.blue(k)), 33 | }); 34 | t.push(...data.map((row) => keys.map((k) => row[k]))); 35 | return this.doLog("log", [t.toString()]); 36 | } 37 | 38 | private doLog(messageLevel: Exclude, args: unknown[]) { 39 | console[messageLevel](format(...args)); 40 | } 41 | } 42 | 43 | export const logger = new Logger(); 44 | -------------------------------------------------------------------------------- /packages/superflare/cli/stubs/job.stub.ts: -------------------------------------------------------------------------------- 1 | export function jobTemplate(name: string) { 2 | return `import { Job } from "superflare"; 3 | 4 | export class ${name} extends Job { 5 | constructor() { 6 | super(); 7 | } 8 | 9 | async handle(): Promise { 10 | // Handle the job 11 | } 12 | } 13 | 14 | Job.register(${name}); 15 | `; 16 | } 17 | -------------------------------------------------------------------------------- /packages/superflare/cli/stubs/migration.stub.ts: -------------------------------------------------------------------------------- 1 | export function blankMigration(contents = "// return ...") { 2 | return `import { Schema } from 'superflare'; 3 | 4 | export default function () { 5 | ${contents} 6 | }`; 7 | } 8 | -------------------------------------------------------------------------------- /packages/superflare/cli/stubs/model.stub.ts: -------------------------------------------------------------------------------- 1 | export function modelTemplate(name: string) { 2 | return `import { Model } from "superflare"; 3 | 4 | export class ${name} extends Model { 5 | toJSON(): ${name}Row { 6 | return super.toJSON(); 7 | } 8 | } 9 | 10 | Model.register(${name}); 11 | 12 | export interface ${name} extends ${name}Row {} 13 | `; 14 | } 15 | -------------------------------------------------------------------------------- /packages/superflare/cli/yargs-types.ts: -------------------------------------------------------------------------------- 1 | // Borrowed from: https://github.com/OnlineOrNot/onlineornot/blob/02894d76b04f524cbc2677510ee676145d420d53/packages/onlineornot/src/yargs-types.ts#L6 2 | 3 | import type { ArgumentsCamelCase, Argv, CamelCaseKey } from "yargs"; 4 | 5 | /** 6 | * Yargs options included in every superflare command. 7 | */ 8 | export interface CommonYargsOptions { 9 | v: boolean | undefined; 10 | } 11 | 12 | export type CommonYargsArgv = Argv; 13 | 14 | export type YargvToInterface = T extends Argv 15 | ? ArgumentsCamelCase

16 | : never; 17 | 18 | // See http://stackoverflow.com/questions/51465182/how-to-remove-index-signature-using-mapped-types 19 | type RemoveIndex = { 20 | [K in keyof T as string extends K 21 | ? never 22 | : number extends K 23 | ? never 24 | : K]: T[K]; 25 | }; 26 | 27 | /** 28 | * Given some Yargs Options function factory, extract the interface 29 | * that corresponds to the yargs arguments, remove index types, and only allow camelCase 30 | */ 31 | export type StrictYargsOptionsToInterface< 32 | T extends (yargs: CommonYargsArgv) => Argv 33 | > = T extends (yargs: CommonYargsArgv) => Argv 34 | ? OnlyCamelCase>> 35 | : never; 36 | 37 | type OnlyCamelCase> = { 38 | [key in keyof T as CamelCaseKey]: T[key]; 39 | }; 40 | -------------------------------------------------------------------------------- /packages/superflare/cloudflare.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/superflare/docs/database/seeding.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Seeding the Database 3 | description: Learn how to seed your local database with Superflare. 4 | --- 5 | 6 | ## Introduction 7 | 8 | When developing an application locally, it's helpful to be able to populate or "seed" you local database with test data to allow you to rapidly iterate on the user interface or other aspects of your application. 9 | 10 | Superflare allows you to define a `seed.ts` file in your project's `db` folder which will be used to seed your local database when you run `npx superflare migrate --seed`. 11 | 12 | ## Creating a seed file 13 | 14 | To create a new seed file, just add it to `db/seed.ts`: 15 | 16 | ```ts 17 | import { seed } from "superflare"; 18 | 19 | export default seed(async () => { 20 | await Post.create({ 21 | title: "Hello World", 22 | }); 23 | }); 24 | ``` 25 | 26 | You can perform any actions you'd like within the seeder. It's up to you! 27 | 28 | ## Running the seeder 29 | 30 | To run the seeder, run the following command: 31 | 32 | ```bash 33 | npx superflare migrate --seed 34 | ``` 35 | 36 | It can be useful to run the seeder in conjunction with a fresh database. To do this, pass the `--fresh` or `-f` flag: 37 | 38 | ```bash 39 | npx superflare migrate --fresh --seed 40 | ``` 41 | 42 | This ensures you don't run into any unique constraints with existing data. 43 | -------------------------------------------------------------------------------- /packages/superflare/docs/deploying.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploying 3 | description: Deploying Superflare apps to production 4 | --- 5 | 6 | ## Introduction 7 | 8 | To deploy your Superflare app to production, you'll need to use `npx wrangler`. Superflare doesn't provide any helpers at this time. 9 | 10 | ## Checklist 11 | 12 | - Make sure you've run D1 migrations against your production database before deploying with `npx wrangler d1 migrations apply --remote`. 13 | - Make sure you've set an `APP_KEY` secret with `npx wrangler secret put APP_KEY`. 14 | -------------------------------------------------------------------------------- /packages/superflare/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hello, Superflare 3 | description: A new way to build full-stack applications on Cloudflare Workers 4 | --- 5 | 6 | [Superflare](https://superflare.dev) is an experimental full-stack toolkit for Cloudflare Workers. It features a relational ORM for [D1 databases](/models), utilities for [R2 storage](/file-storage), and lots more. 7 | 8 | Superflare is _not_ a full-stack framework. In fact, Superflare works best when combined with [Remix](https://remix.run), [Next.js](https://nextjs.org) (soon!) or [Nuxt.js](https://nuxtjs.com) (soon!). 9 | 10 | Check out various Superflare [example apps](https://github.com/jplhomer/superflare/tree/main/examples/) to get a feel for what you can build next, or [get started](/getting-started) with a new Superflare application. 11 | 12 | Check it out on [GitHub](https://github.com/jplhomer/superflare), or create a new app right now: 13 | 14 | ```bash 15 | npx superflare@latest new 16 | ``` 17 | 18 | Superflare is a project created by me, [Josh Larson](https://jplhomer.org). 19 | 20 | I'm doing this to have fun and not to support all of your production apps for $0. If you're curious, [read more about my design principles](/design). 21 | -------------------------------------------------------------------------------- /packages/superflare/docs/manifest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Introduction", 4 | "links": [ 5 | { 6 | "title": "Hello, Superflare", 7 | "href": "/" 8 | }, 9 | { 10 | "title": "Getting Started", 11 | "href": "/getting-started" 12 | }, 13 | { 14 | "title": "Deploying", 15 | "href": "/deploying" 16 | }, 17 | { 18 | "title": "Design Principles", 19 | "href": "/design" 20 | } 21 | ] 22 | }, 23 | { 24 | "title": "Using Superflare", 25 | "links": [ 26 | { 27 | "title": "Models (D1)", 28 | "href": "/models" 29 | }, 30 | { 31 | "title": "File Storage (R2)", 32 | "href": "/file-storage" 33 | }, 34 | { 35 | "title": "Scheduled Tasks (Cron)", 36 | "href": "/scheduled-tasks" 37 | }, 38 | { 39 | "title": "Sessions", 40 | "href": "/sessions" 41 | }, 42 | { 43 | "title": "Queues", 44 | "href": "/queues" 45 | }, 46 | { 47 | "title": "Events", 48 | "href": "/events" 49 | }, 50 | { 51 | "title": "Broadcasting", 52 | "href": "/broadcasting" 53 | } 54 | ] 55 | }, 56 | { 57 | "title": "Database", 58 | "links": [ 59 | { 60 | "title": "Getting Started", 61 | "href": "/database/getting-started" 62 | }, 63 | { 64 | "title": "Relationships", 65 | "href": "/database/relationships" 66 | }, 67 | { 68 | "title": "Migrations", 69 | "href": "/database/migrations" 70 | }, 71 | { 72 | "title": "Seeding", 73 | "href": "/database/seeding" 74 | } 75 | ] 76 | }, 77 | { 78 | "title": "Security", 79 | "links": [ 80 | { 81 | "title": "Authentication", 82 | "href": "/security/authentication" 83 | }, 84 | { 85 | "title": "Hashing", 86 | "href": "/security/hashing" 87 | } 88 | ] 89 | }, 90 | { 91 | "title": "Reference", 92 | "links": [ 93 | { 94 | "title": "superflare.config", 95 | "href": "/reference/superflare-config" 96 | }, 97 | { 98 | "title": "CLI", 99 | "href": "/reference/cli" 100 | } 101 | ] 102 | } 103 | ] 104 | -------------------------------------------------------------------------------- /packages/superflare/docs/reference/superflare-config.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: superflare.config 3 | description: Configuration options for Superflare apps 4 | --- 5 | 6 | ## Defining Superflare configuration 7 | 8 | Superflare apps are configured using a `superflare.config.ts` file in the root of your project. This file should export a function wrapped with `defineConfig` that returns an object. 9 | 10 | ```ts 11 | import { defineConfig } from "superflare"; 12 | 13 | export default defineConfig((ctx) => { 14 | return { 15 | // ... 16 | }; 17 | }); 18 | ``` 19 | 20 | It receives a single `ctx` argument that contains the following properties: 21 | 22 | - `env`: The current `Env` values passed from Cloudflare. These contain your resource bindings and secrets. 23 | - `ctx`: This is `waitUntil` and various other runtime things. You probably won't use this. 24 | - `request`: The current request. **You should not assume this is available.** Superflare parses this config file for other non-request related things, like handling incoming queue messages. 25 | 26 | ## Available configuration options 27 | 28 | ### `appKey` 29 | 30 | **Required**. The `APP_KEY` secret that you set in Cloudflare. This is used to sign session cookies and more. 31 | 32 | ### `database` 33 | 34 | This is an object which contains strings for [database connections](/database/getting-started) which refer to bindings. At a minimum, you should provide a `default` connection: 35 | 36 | ```ts 37 | database: { 38 | default: env.DB, 39 | otherConnection: env.OTHER_DB, 40 | }, 41 | ``` 42 | 43 | ### `storage` 44 | 45 | This is an object which contains strings for [storage disks](/storage) which refer to bindings. At a minimum, you should provide a `default` disk: 46 | 47 | ```ts 48 | storage: { 49 | default: env.STORAGE, 50 | otherDisk: env.OTHER_STORAGE, 51 | }, 52 | ``` 53 | 54 | ### `queues` 55 | 56 | This is an object which contains strings for [queue connections](/queues) which refer to bindings. At a minimum, you should provide a `default` connection: 57 | 58 | ```ts 59 | queues: { 60 | default: env.QUEUE, 61 | otherConnection: env.OTHER_QUEUE, 62 | }, 63 | ``` 64 | 65 | ### `listeners` 66 | 67 | This is an object which contains a mapping between [`Events`](/events) and `Listener`s. The keys of the object are the Event class names, and the values are an array of`Listener` classes. 68 | 69 | ```ts 70 | listeners: { 71 | 'UserCreated': [UserCreatedListener], 72 | }, 73 | ``` 74 | 75 | ### `channels` 76 | 77 | This is an object which contains configuration for [`Broadcasting`](/broadcasting) Channels. If you plan to broadcast real-time events to your users, you'll need to configure at minimum a default binding. 78 | 79 | You may also configure private channels using `authorize` functions which correspond to the channel names you use when calling `broadcastTo()` on [`Event`](/events) classes. 80 | 81 | ```ts 82 | channels: { 83 | default: { 84 | binding: env.CHANNEL, 85 | }, 86 | "posts.*": { 87 | async authorize(user, channelId) => { 88 | // ... 89 | }, 90 | }, 91 | } 92 | ``` 93 | -------------------------------------------------------------------------------- /packages/superflare/docs/security/hashing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hashing 3 | description: Hashing passwords and other sensitive data 4 | --- 5 | 6 | ## Introduction 7 | 8 | Superflare provides a `hash()` helper powered by `bcrypt` to help you hash passwords and other sensitive data. 9 | 10 | ## Basic Usage 11 | 12 | ### Hashing passwords 13 | 14 | To hash a value like a password, use the `hash().make()` helper: 15 | 16 | ```ts 17 | import { hash } from "superflare"; 18 | 19 | const hashed = await hash().make("my-password"); 20 | ``` 21 | 22 | ### Verifying a password matches a hash 23 | 24 | To verify a value, use the `hash().check()` helper: 25 | 26 | ```ts 27 | import { hash } from "superflare"; 28 | 29 | const isCorrect = await hash().check("my-password", hashed); 30 | ``` 31 | -------------------------------------------------------------------------------- /packages/superflare/docs/sessions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Sessions 3 | description: Storing information about users across requests with HTTP sessions. 4 | --- 5 | 6 | Sessions provide a way to store information about users across requests. Superflare provides a `SuperflareSession` class which acts as a wrapper around a simple `Session` object. 7 | 8 | The `SuperflareSession` instance keeps track of any changes to the session, and Superflare automatically commits the changes to the session store on the outgoing response. 9 | 10 | ## Creating sessions 11 | 12 | _The following instructions assume you are using Remix. They will be updated when Superflare supports other frameworks._ 13 | 14 | The `@superflare/remix` package exports `handleFetch`, which takes care of session creation and makes the session available on your Remix `AppContext` in deployed workers. There is an additional entry point, `@superflare/remix/dev`, that exports `superflareDevProxyVitePlugin` to provide the same automatic session handling in local dev when using the Vite dev server. See the [Getting Started](/getting-started) guide for details. 15 | 16 | ## Using sessions 17 | 18 | To use your sessions, you can pull the `session` out of your loader and action context you injected above: 19 | 20 | ```ts 21 | export async function action({ context: { session } }) { 22 | session.set("theme", "dark"); 23 | 24 | // ... 25 | } 26 | 27 | export async function loader({ context: { session } }) { 28 | const theme = session.get("theme"); 29 | 30 | return json({ theme }); 31 | } 32 | ``` 33 | 34 | ## Flash messages 35 | 36 | You may want to issue a message to your session which disappears after it is read once. This is useful for things like displaying a success message after a form submission. 37 | 38 | To do this, you can use the `flash` method: 39 | 40 | ```ts 41 | export async function loader({ context: { session } }) { 42 | session.flash("success", "Your form was submitted successfully!"); 43 | 44 | return json({ success: true }); 45 | } 46 | ``` 47 | 48 | Then, you can read the flash message in your action using the `getFlash` method: 49 | 50 | ```ts 51 | export async function action({ context: { session } }) { 52 | const success = session.getFlash("success"); 53 | 54 | return json({ success }); 55 | } 56 | ``` 57 | 58 | {% callout title="Using getFlash" %} 59 | Superflare tracks when you modify a session and automatically commits the changes to the session store on the outgoing response. Since reading flash messages involves modifying the session, you should use `getFlash` to read flash messages and not `get` to ensure your session is updated. 60 | {% /callout %} 61 | -------------------------------------------------------------------------------- /packages/superflare/index.ts: -------------------------------------------------------------------------------- 1 | export { setConfig, defineConfig } from "./src/config"; 2 | export { Model } from "./src/model"; 3 | export { SuperflareSession } from "./src/session"; 4 | export { DatabaseException } from "./src/query-builder"; 5 | export { seed } from "./src/seeder"; 6 | export { storage, servePublicPathFromStorage } from "./src/storage"; 7 | export { Factory } from "./src/factory"; 8 | export { handleFetch } from "./src/fetch"; 9 | export { handleQueue } from "./src/queue"; 10 | export { Job } from "./src/job"; 11 | export { SuperflareAuth } from "./src/auth"; 12 | export { hash } from "./src/hash"; 13 | export { Event } from "./src/event"; 14 | export { Listener } from "./src/listener"; 15 | export { handleWebSockets } from "./src/websockets"; 16 | export { Channel } from "./src/durable-objects/Channel"; 17 | export { Schema } from "./src/schema"; 18 | export { parseMultipartFormData } from "./src/form-data"; 19 | export { handleScheduled } from "./src/scheduled"; 20 | -------------------------------------------------------------------------------- /packages/superflare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superflare", 3 | "version": "0.1.1", 4 | "description": "A full-stack framework for Cloudflare Workers.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.types.d.ts", 7 | "bugs": { 8 | "url": "https://github.com/jplhomer/superflare/issues" 9 | }, 10 | "scripts": { 11 | "test": "vitest", 12 | "build": "tsup --format esm,cjs", 13 | "dev": "tsup --format esm,cjs --watch", 14 | "prepack": "pnpm build", 15 | "typecheck": "tsc" 16 | }, 17 | "exports": { 18 | ".": { 19 | "import": "./dist/index.mjs", 20 | "require": "./dist/index.js", 21 | "types": "./dist/index.types.d.ts" 22 | }, 23 | "./package.json": "./package.json" 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "bin": { 29 | "superflare": "bin/superflare.js" 30 | }, 31 | "keywords": [ 32 | "cloudflare", 33 | "workers", 34 | "framework", 35 | "full-stack", 36 | "orm", 37 | "d1" 38 | ], 39 | "author": "Josh Larson ", 40 | "license": "MIT", 41 | "dependencies": { 42 | "@clack/prompts": "^0.7.0", 43 | "@web3-storage/multipart-parser": "^1.0.0", 44 | "bcryptjs": "^2.4.3", 45 | "chalk": "^4.1.2", 46 | "cli-table3": "^0.6.3", 47 | "esbuild": "^0.17.10", 48 | "esbuild-register": "^3.4.2", 49 | "gunzip-maybe": "^1.4.2", 50 | "npx-import": "^1.1.4", 51 | "pluralize": "^8.0.0", 52 | "tar-fs": "^2.1.1", 53 | "tiny-invariant": "^1.3.1", 54 | "wrangler": "^3.91.0", 55 | "yargs": "^17.6.2" 56 | }, 57 | "devDependencies": { 58 | "@cloudflare/workers-types": "^4.20241011.0", 59 | "@miniflare/core": "^2.14.2", 60 | "@miniflare/d1": "^2.14.2", 61 | "@miniflare/shared": "^2.14.2", 62 | "@types/bcryptjs": "^2.4.2", 63 | "@types/gunzip-maybe": "^1.4.0", 64 | "@types/node": "^18.14.1", 65 | "@types/pluralize": "^0.0.29", 66 | "@types/tar-fs": "^2.0.1", 67 | "@types/yargs": "^17.0.20", 68 | "better-sqlite3": "^11.5.0", 69 | "tsconfig": "workspace:*", 70 | "tsup": "^6.6.3", 71 | "typescript": "^5", 72 | "vitest": "^0.28.3" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/superflare/src/auth.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel } from "../index.types"; 2 | import { hash } from "./hash"; 3 | import { Session } from "./session"; 4 | 5 | const SESSION_KEY = "superflare:auth:id"; 6 | 7 | export class SuperflareAuth { 8 | constructor(public session: Session) {} 9 | 10 | async attempt( 11 | model: M, 12 | credentials: { email: string; password: string } 13 | ) { 14 | const user = await model.where("email", credentials.email).first(); 15 | 16 | if (!user) return false; 17 | 18 | const passwordMatches = await hash().check( 19 | credentials.password, 20 | // @ts-expect-error I don't know how to indicate that we expect a password field 21 | user.password 22 | ); 23 | 24 | if (!passwordMatches) return false; 25 | 26 | this.login(user); 27 | 28 | return true; 29 | } 30 | 31 | async check InstanceType>(model: M) { 32 | return !!(await this.user(model)); 33 | } 34 | 35 | id() { 36 | return this.session.get(SESSION_KEY); 37 | } 38 | 39 | login(user: any) { 40 | this.session.set(SESSION_KEY, user.id); 41 | } 42 | 43 | async user InstanceType>( 44 | model: M 45 | ): Promise | null> { 46 | const id = this.id(); 47 | 48 | if (!id) return null; 49 | 50 | // @ts-expect-error I do not understand how to make TypeScript happy, and I do not care. 51 | return await model.find(id); 52 | } 53 | 54 | logout() { 55 | this.session.unset(SESSION_KEY); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/superflare/src/channels.ts: -------------------------------------------------------------------------------- 1 | import { ChannelConfig, getChannel, getChannelNames } from "./config"; 2 | 3 | export function getConfigForChannelName( 4 | channelName: string 5 | ): [channelConfig: ChannelConfig | undefined, channelName: string] { 6 | const defaultChannel = getChannel("default"); 7 | const specificChannelName = channelNameToConfigName( 8 | channelName, 9 | getChannelNames() 10 | ); 11 | 12 | return specificChannelName 13 | ? [getChannel(specificChannelName), specificChannelName] 14 | : [defaultChannel, "default"]; 15 | } 16 | 17 | export function getBindingForChannelName(channelName: string) { 18 | const defaultChannel = getChannel("default"); 19 | const [config] = getConfigForChannelName(channelName); 20 | 21 | return config?.binding || defaultChannel?.binding; 22 | } 23 | 24 | /** 25 | * Given that the user may have defined channel names with period-separated *, 26 | * this function attempts to match a given channel name to one of the channel 27 | * names defined in the config. 28 | * 29 | * We use a regex to replace all asterisks with match patterns, then we use 30 | * the match method to find the first match. 31 | */ 32 | export function channelNameToConfigName( 33 | channelName: string, 34 | channelNames: string[] 35 | ): string { 36 | const exactMatch = channelNames.find((name) => name === channelName); 37 | 38 | if (exactMatch) { 39 | return exactMatch; 40 | } 41 | 42 | const channelNamesWithRegexes = channelNames.map((name) => { 43 | return { 44 | name, 45 | regex: new RegExp(name.replace(/\*/g, "[^.]+")), 46 | }; 47 | }); 48 | 49 | const matches = channelNamesWithRegexes.filter((name) => { 50 | return name.regex.exec(channelName); 51 | }); 52 | 53 | return matches.sort( 54 | (a, b) => b.name.split(".").length - a.name.split(".").length 55 | )[0]?.name; 56 | } 57 | -------------------------------------------------------------------------------- /packages/superflare/src/event.ts: -------------------------------------------------------------------------------- 1 | import { getBindingForChannelName } from "./channels"; 2 | import { getListenersForEventClass, getQueue, registerEvent } from "./config"; 3 | import { serializeArguments } from "./serialize"; 4 | import { sanitizeModuleName } from "./string"; 5 | 6 | export class Event { 7 | public static shouldQueue = false; 8 | public static queue = "default"; 9 | 10 | public static async dispatch( 11 | this: { new (...arg: any[]): T; shouldQueue: boolean; queue: string }, 12 | ...args: any[] 13 | ): Promise { 14 | const event = new this(...args); 15 | if (this.shouldQueue) { 16 | /** 17 | * We can't use an internal Job for this, because that creates circular reference issues between 18 | * `job.ts` and `event.ts`. Instead, we stick it directly on the queue. 19 | */ 20 | const queueName = this.queue ?? "default"; 21 | const queue = getQueue(queueName); 22 | 23 | if (!queue) { 24 | throw new Error(`Queue ${queueName} not found.`); 25 | } 26 | 27 | // TODO: Wrap this in ctx.waitUntil 28 | queue.send({ 29 | event: sanitizeModuleName(this.name), 30 | payload: serializeArguments(args), 31 | }); 32 | } else { 33 | dispatchEvent(event); 34 | } 35 | } 36 | 37 | broadcastTo?(): string; 38 | 39 | public static register(event: any): void { 40 | registerEvent(event); 41 | } 42 | } 43 | 44 | export function dispatchEvent(event: any): void { 45 | // console.log(`dispatching`, sanitizeModuleName(event.constructor.name)); 46 | 47 | getListenersForEventClass(event.constructor).forEach((listener) => { 48 | const instance = new listener(); 49 | instance.handle(event); 50 | }); 51 | 52 | if (event.broadcastTo) { 53 | const channelName = event.broadcastTo(); 54 | if (channelName) { 55 | broadcastEvent(event, channelName); 56 | } 57 | } 58 | } 59 | 60 | async function broadcastEvent(event: Event, channelName: string) { 61 | // console.log(`broadcasting`, sanitizeModuleName(event.constructor.name)); 62 | 63 | const binding = getBindingForChannelName(channelName); 64 | 65 | if (!binding) { 66 | throw new Error( 67 | `No channel binding found for ${channelName}. Please update your superflare.config.` 68 | ); 69 | } 70 | 71 | const id = binding.idFromName(channelName); 72 | const channel = binding.get(id); 73 | 74 | const data = { 75 | event: sanitizeModuleName(event.constructor.name), 76 | data: event, 77 | }; 78 | 79 | await channel.fetch("https://example.com/", { 80 | method: "POST", 81 | body: JSON.stringify(data), 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /packages/superflare/src/factory.ts: -------------------------------------------------------------------------------- 1 | import type { Model } from "../index.types"; 2 | 3 | export class Factory { 4 | #definition: () => Record = () => ({}); 5 | constructor(public model: typeof Model) {} 6 | 7 | static for(model: typeof Model) { 8 | return new this(model); 9 | } 10 | 11 | create(attributes?: Record) { 12 | return this.model.create({ 13 | ...this.#definition(), 14 | ...(attributes ?? {}), 15 | }); 16 | } 17 | 18 | definition(definition: () => Record) { 19 | this.#definition = definition; 20 | return this; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/superflare/src/fetch.ts: -------------------------------------------------------------------------------- 1 | import { type DefineConfigReturn } from "./config"; 2 | import { type SuperflareSession } from "./session"; 3 | 4 | export async function handleFetch( 5 | { 6 | request, 7 | env, 8 | ctx, 9 | config, 10 | session, 11 | getSessionCookie, 12 | }: { 13 | request: Request; 14 | env: Env; 15 | ctx: ExecutionContext; 16 | config: DefineConfigReturn; 17 | session: SuperflareSession; 18 | /** 19 | * Superflare will commit changes to the session as a Cookie header on the outgoing response. 20 | * You must provide a way to get that cookie. This likely comes from your session storage. 21 | */ 22 | getSessionCookie: () => Promise; 23 | }, 24 | getResponse: () => Promise 25 | ) { 26 | config({ request, env, ctx }); 27 | 28 | /** 29 | * Some session storage mechanisms might not assign a proper `id`. No worries! 30 | * We will assign our own here as a value in the session itself. 31 | */ 32 | if (!session.has("sessionId")) { 33 | session.set("sessionId", crypto.randomUUID()); 34 | } 35 | 36 | /** 37 | * Run the framework code and get a response. 38 | */ 39 | const response = await getResponse(); 40 | 41 | if (session.isDirty()) { 42 | /** 43 | * Set the session cookie on the outgoing response's headers. 44 | */ 45 | response.headers.set("Set-Cookie", await getSessionCookie()); 46 | } 47 | 48 | return response; 49 | } 50 | -------------------------------------------------------------------------------- /packages/superflare/src/form-data.ts: -------------------------------------------------------------------------------- 1 | import { streamMultipart } from "@web3-storage/multipart-parser"; 2 | 3 | /** 4 | * Copied from Remix: 5 | * @see https://github.com/remix-run/remix/blob/72c22b3deb9e84e97359b481f7f2af6cdc355877/packages/remix-server-runtime/formData.ts 6 | * Copyright 2021 Remix Software Inc. 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | */ 11 | 12 | export type UploadHandlerPart = { 13 | name: string; 14 | filename?: string; 15 | contentType: string; 16 | data: AsyncIterable; 17 | stream: ReadableStream; 18 | }; 19 | 20 | export type UploadHandler = ( 21 | part: UploadHandlerPart 22 | ) => Promise; 23 | 24 | /** 25 | * Allows you to handle multipart forms (file uploads) for your app. 26 | */ 27 | export async function parseMultipartFormData( 28 | request: Request, 29 | uploadHandler: UploadHandler 30 | ): Promise { 31 | let contentType = request.headers.get("Content-Type") || ""; 32 | const [type, boundary] = contentType.split(/\s*;\s*boundary=/); 33 | 34 | if (!request.body || !boundary || type !== "multipart/form-data") { 35 | throw new TypeError("Could not parse content as FormData."); 36 | } 37 | 38 | const formData = new FormData(); 39 | const parts: AsyncIterable = 40 | streamMultipart(request.body, boundary); 41 | 42 | for await (let part of parts) { 43 | if (part.done) break; 44 | 45 | if (typeof part.filename === "string") { 46 | // only pass basename as the multipart/form-data spec recommends 47 | // https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 48 | part.filename = part.filename.split(/[/\\]/).pop(); 49 | } 50 | 51 | /** 52 | * Build a convenience ReadableStream for the part data. 53 | */ 54 | const stream = new ReadableStream({ 55 | async pull(controller) { 56 | for await (let chunk of part.data) { 57 | controller.enqueue(chunk); 58 | } 59 | controller.close(); 60 | }, 61 | }); 62 | 63 | let value = await uploadHandler({ 64 | ...part, 65 | stream, 66 | }); 67 | if (typeof value !== "undefined" && value !== null) { 68 | formData.append(part.name, value as any); 69 | } 70 | } 71 | 72 | return formData; 73 | } 74 | -------------------------------------------------------------------------------- /packages/superflare/src/hash.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | 3 | export function hash() { 4 | return { 5 | async make(input: string) { 6 | return await bcrypt.hash(input, 10); 7 | }, 8 | 9 | async check(input: string, hash: string) { 10 | return await bcrypt.compare(input, hash); 11 | }, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /packages/superflare/src/job.ts: -------------------------------------------------------------------------------- 1 | import { getQueue, registerJob } from "./config"; 2 | import { MessagePayload } from "./queue"; 3 | import { serializeArguments } from "./serialize"; 4 | 5 | export abstract class Job { 6 | /** 7 | * The name of the queue on which to dispatch this job. 8 | */ 9 | queue = "default"; 10 | 11 | constructor() {} 12 | 13 | abstract handle(): Promise; 14 | 15 | /** 16 | * The private instance method we use to dispatch a job onto the queue. 17 | */ 18 | private async dispatch(...args: any[]) { 19 | const queueName = this.queue ?? "default"; 20 | const queue = getQueue(queueName); 21 | 22 | if (!queue) { 23 | throw new Error(`Queue ${queueName} not found.`); 24 | } 25 | 26 | await queue.send(this.toQueuePayload(args)); 27 | } 28 | 29 | /** 30 | * Dispatch the job with the given arguments. 31 | */ 32 | static async dispatch( 33 | this: new (...args: Args) => T, 34 | ...args: Args 35 | ) { 36 | const job = new this(...args); 37 | 38 | return job.dispatch(...args); 39 | } 40 | 41 | /** 42 | * Convert the constructor arguments to a payload that can be sent to the queue. 43 | */ 44 | private toQueuePayload(constructorArgs: any[]): MessagePayload { 45 | return { 46 | job: this.constructor.name, 47 | payload: serializeArguments(constructorArgs), 48 | }; 49 | } 50 | 51 | static register(job: any) { 52 | registerJob(job); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/superflare/src/listener.ts: -------------------------------------------------------------------------------- 1 | export abstract class Listener { 2 | abstract handle(event: any): void; 3 | } 4 | -------------------------------------------------------------------------------- /packages/superflare/src/queue.ts: -------------------------------------------------------------------------------- 1 | import { type DefineConfigReturn, getEvent, getJob, setConfig } from "./config"; 2 | import { dispatchEvent, Event } from "./event"; 3 | import { Job } from "./job"; 4 | import { hydrateArguments } from "./serialize"; 5 | 6 | export interface MessagePayload { 7 | job?: string; 8 | event?: string; 9 | payload: any; 10 | } 11 | 12 | export async function handleQueue( 13 | batch: MessageBatch, 14 | env: Env, 15 | ctx: ExecutionContext, 16 | config: DefineConfigReturn 17 | ) { 18 | /** 19 | * Set the user config into the singleton context. 20 | * TODO: Replace this with AsyncLocalStorage when available. 21 | */ 22 | config({ env, ctx }); 23 | 24 | await Promise.all( 25 | batch.messages.map((message) => handleQueueMessage(message, ctx)) 26 | ); 27 | } 28 | 29 | async function handleQueueMessage(message: Message, ctx: ExecutionContext) { 30 | const instance = await hydrateInstanceFromQueuePayload( 31 | message.body as MessagePayload 32 | ); 33 | 34 | if (instance instanceof Job) { 35 | await instance.handle(); 36 | return; 37 | } 38 | 39 | if (instance instanceof Event) { 40 | dispatchEvent(instance); 41 | 42 | return; 43 | } 44 | 45 | throw new Error(`Could not hydrate instance from queue payload.`); 46 | } 47 | 48 | /** 49 | * Create an instance of a Job or Event class from a Queue message payload. 50 | */ 51 | async function hydrateInstanceFromQueuePayload(payload: MessagePayload) { 52 | if (payload.event) { 53 | const eventClass = getEvent(payload.event) as Constructor; 54 | const constructorArgs = await hydrateArguments(payload.payload); 55 | const event = new eventClass(...constructorArgs); 56 | 57 | return event; 58 | } 59 | 60 | if (payload.job) { 61 | const jobClass = getJob(payload.job) as Constructor; 62 | const constructorArgs = await hydrateArguments(payload.payload); 63 | const job = new jobClass(...constructorArgs); 64 | 65 | return job; 66 | } 67 | 68 | throw new Error(`Job payload does not contain a job or event.`); 69 | } 70 | 71 | type Constructor = new (...args: any[]) => T; 72 | -------------------------------------------------------------------------------- /packages/superflare/src/relations/belongs-to.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "../model"; 2 | import { QueryBuilder } from "../query-builder"; 3 | import { Relation } from "./relation"; 4 | 5 | export class BelongsTo extends Relation { 6 | constructor( 7 | public query: QueryBuilder, 8 | public child: Model, 9 | public foreignKey: string, 10 | public ownerKey: string, 11 | public relationName: string 12 | ) { 13 | super(query); 14 | } 15 | 16 | associate(model: any) { 17 | this.child[this.foreignKey as keyof Model] = model.id; 18 | return this.child; 19 | } 20 | 21 | dissociate() { 22 | this.child[this.foreignKey as keyof Model] = null; 23 | return this.child; 24 | } 25 | 26 | addEagerConstraints(models: any[]): void { 27 | this.query.whereIn( 28 | this.ownerKey, 29 | models.map((model) => model[this.foreignKey as keyof Model]) 30 | ); 31 | } 32 | 33 | match(models: any[], results: any[], relationName: string): any { 34 | return models.map((model) => { 35 | model.setRelation( 36 | relationName, 37 | results.find( 38 | (result) => 39 | result[this.ownerKey as keyof Model] === 40 | model[this.foreignKey as keyof Model] 41 | ) 42 | ); 43 | 44 | return model; 45 | }); 46 | } 47 | 48 | getResults(withConstraints = true) { 49 | if (withConstraints) { 50 | this.query 51 | .where(this.ownerKey, this.child[this.foreignKey as keyof Model]) 52 | .first(); 53 | } 54 | 55 | return ( 56 | this.query 57 | /** 58 | * Cache the results on the child model. 59 | */ 60 | .afterExecute((results) => { 61 | this.child.setRelation(this.relationName, results); 62 | }) 63 | .get() 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/superflare/src/relations/has-many.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "../model"; 2 | import { QueryBuilder } from "../query-builder"; 3 | import { Relation } from "./relation"; 4 | 5 | export class HasMany extends Relation { 6 | constructor( 7 | public query: QueryBuilder, 8 | public parent: Model, 9 | public foreignKey: string, 10 | public ownerKey: string, 11 | public relationName: string 12 | ) { 13 | super(query); 14 | } 15 | 16 | save(models: Model[] | Model) { 17 | models = models instanceof Array ? models : [models]; 18 | 19 | return Promise.all( 20 | models.map(async (model) => { 21 | model[this.foreignKey as keyof Model] = 22 | this.parent[this.ownerKey as keyof Model]; 23 | await model.save(); 24 | return model; 25 | }) 26 | ); 27 | } 28 | 29 | create(attributeSets: Record[] | Record) { 30 | attributeSets = 31 | attributeSets instanceof Array ? attributeSets : [attributeSets]; 32 | 33 | return Promise.all( 34 | attributeSets.map(async (attributes: any) => { 35 | const model = new this.query.modelInstance.constructor(attributes); 36 | model[this.foreignKey as keyof Model] = 37 | this.parent[this.ownerKey as keyof Model]; 38 | await model.save(); 39 | return model; 40 | }) 41 | ); 42 | } 43 | 44 | addEagerConstraints(models: any[]): void { 45 | this.query.whereIn( 46 | this.foreignKey, 47 | models.map((model) => model[this.ownerKey as keyof Model]) 48 | ); 49 | } 50 | 51 | match(models: any[], results: any[], relationName: string): any { 52 | return models.map((model) => { 53 | model.setRelation( 54 | relationName, 55 | results.filter( 56 | (result) => result[this.foreignKey as keyof Model] === model.id 57 | ) 58 | ); 59 | 60 | return model; 61 | }); 62 | } 63 | 64 | getResults(withConstraints = true) { 65 | if (withConstraints) { 66 | this.query.where( 67 | this.foreignKey, 68 | this.parent[this.ownerKey as keyof Model] 69 | ); 70 | } 71 | 72 | return ( 73 | this.query 74 | /** 75 | * Cache the results on the parent model. 76 | */ 77 | .afterExecute((results) => { 78 | this.parent.setRelation(this.relationName, results); 79 | }) 80 | .get() 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/superflare/src/relations/has-one.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "../model"; 2 | import { QueryBuilder } from "../query-builder"; 3 | import { Relation } from "./relation"; 4 | 5 | export class HasOne extends Relation { 6 | constructor( 7 | public query: QueryBuilder, 8 | public parent: Model, 9 | public foreignKey: string, 10 | public ownerKey: string, 11 | public relationName: string 12 | ) { 13 | super(query); 14 | } 15 | 16 | async save(model: any) { 17 | model[this.foreignKey as keyof Model] = 18 | this.parent[this.ownerKey as keyof Model]; 19 | await model.save(); 20 | return model; 21 | } 22 | 23 | async create(attributes: any) { 24 | const model = new this.query.modelInstance.constructor(attributes); 25 | model[this.foreignKey as keyof Model] = 26 | this.parent[this.ownerKey as keyof Model]; 27 | await model.save(); 28 | return model; 29 | } 30 | 31 | addEagerConstraints(models: any[]): void { 32 | this.query.whereIn( 33 | this.foreignKey, 34 | models.map((model) => model[this.ownerKey as keyof Model]) 35 | ); 36 | } 37 | 38 | match(models: any[], results: any[], relationName: string): any { 39 | return models.map((model) => { 40 | model.setRelation( 41 | relationName, 42 | results.find( 43 | (result) => result[this.foreignKey as keyof Model] === model.id 44 | ) 45 | ); 46 | 47 | return model; 48 | }); 49 | } 50 | 51 | getResults(withConstraints = true) { 52 | if (withConstraints) { 53 | this.query 54 | .where(this.foreignKey, this.parent[this.ownerKey as keyof Model]) 55 | .first(); 56 | } 57 | 58 | return ( 59 | this.query 60 | /** 61 | * Cache the results on the parent model. 62 | */ 63 | .afterExecute((results) => { 64 | this.parent.setRelation(this.relationName, results); 65 | }) 66 | .get() 67 | ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/superflare/src/relations/relation.ts: -------------------------------------------------------------------------------- 1 | import { QueryBuilder } from "../query-builder"; 2 | 3 | export abstract class Relation { 4 | constructor(public query: QueryBuilder) { 5 | /** 6 | * Proxy any method calls to the underlying `QueryBuilder` instance. 7 | */ 8 | return new Proxy(this, { 9 | get(target, prop) { 10 | if (prop in target) { 11 | return target[prop as keyof Relation]; 12 | } 13 | 14 | if (prop in target.query) { 15 | return target.query[prop as keyof QueryBuilder]; 16 | } 17 | 18 | return undefined; 19 | }, 20 | }); 21 | } 22 | 23 | abstract getResults(withConstraints: boolean): Promise; 24 | abstract addEagerConstraints(models: any[]): void; 25 | abstract match(models: any[], results: any[], relationName: string): any; 26 | 27 | then( 28 | onfulfilled?: ((value: any) => any) | undefined | null, 29 | onrejected?: ((reason: any) => any) | undefined | null 30 | ) { 31 | const promise = this.getResults(true); 32 | return promise.then(onfulfilled, onrejected); 33 | } 34 | 35 | catch( 36 | onrejected?: ((reason: any) => FR | PromiseLike) | undefined | null 37 | ): Promise { 38 | const promise = this.getResults(true); 39 | return promise.catch(onrejected); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/superflare/src/seeder.ts: -------------------------------------------------------------------------------- 1 | import { setConfig } from "./config"; 2 | 3 | export function seed(callback: () => void) { 4 | return (database: D1Database) => { 5 | setConfig({ database: { default: database } }); 6 | callback(); 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /packages/superflare/src/serialize.ts: -------------------------------------------------------------------------------- 1 | import { BaseModel } from "../index.types"; 2 | import { getModel } from "./config"; 3 | import { Model } from "./model"; 4 | 5 | /** 6 | * Convert the constructor args to an instance and conver them to a payload that can be sent to the queue. 7 | * This takes care of converting any `Model` instances to a JSON representation which can be 8 | * hydrated when the queue processes the job. 9 | */ 10 | export function serializeArguments(args: any[]): string[] { 11 | return args.map((arg) => { 12 | if (arg instanceof Model && arg.id) { 13 | const newValue = arg.toJSON(); 14 | newValue.$model = arg.constructor.name; 15 | arg = { ...arg, id: arg.id }; 16 | return JSON.stringify(newValue); 17 | } else { 18 | return JSON.stringify(arg); 19 | } 20 | }); 21 | } 22 | 23 | export async function hydrateArguments(args: string[]): Promise { 24 | return await Promise.all( 25 | args.map(async (value) => { 26 | const arg = JSON.parse(value); 27 | if (arg instanceof Object && arg.$model && arg.id) { 28 | const modelClass = getModel(arg.$model) as BaseModel; 29 | 30 | if (!modelClass) { 31 | throw new Error(`Model ${arg.$model} not found.`); 32 | } 33 | 34 | return await modelClass.find(arg.id); 35 | } else { 36 | return arg; 37 | } 38 | }) 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/superflare/src/session.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Superflare accepts external implementations of Session which match the following signature: 3 | */ 4 | export interface Session { 5 | id: string; 6 | data: any; 7 | has(key: string): boolean; 8 | get(key: string): any; 9 | set(key: string, value: any): void; 10 | unset(key: string): void; 11 | flash(key: string, value: any): void; 12 | } 13 | 14 | /** 15 | * Superflare exposes its own Session implementation which includes a dirty tracker to allow 16 | * it to automatically commit changes to the session as a Cookie header on the outgoing response. 17 | */ 18 | export class SuperflareSession implements Session { 19 | private dirty = false; 20 | 21 | constructor(private session: Session) {} 22 | 23 | get id() { 24 | return this.session.id; 25 | } 26 | 27 | get data() { 28 | return this.session.data; 29 | } 30 | 31 | has(key: string) { 32 | return this.session.has(key); 33 | } 34 | 35 | get(key: string) { 36 | return this.session.get(key); 37 | } 38 | 39 | /** 40 | * Get a flashed value from the session, and indicate that the session has been modified. 41 | */ 42 | getFlash(key: string) { 43 | this.dirty = true; 44 | return this.session.get(key); 45 | } 46 | 47 | set(key: string, value: any) { 48 | this.dirty = true; 49 | this.session.set(key, value); 50 | } 51 | 52 | unset(key: string) { 53 | this.dirty = true; 54 | this.session.unset(key); 55 | } 56 | 57 | /** 58 | * Flash a value to the session. To read the flashed value on a future request, use `getFlash`. 59 | */ 60 | flash(key: string, value: any) { 61 | this.dirty = true; 62 | this.session.flash(key, value); 63 | } 64 | 65 | isDirty() { 66 | return this.dirty; 67 | } 68 | 69 | getSession() { 70 | return this.session; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/superflare/src/storage.ts: -------------------------------------------------------------------------------- 1 | import { Config, StorageDiskConfig } from "./config"; 2 | 3 | export type R2Input = Parameters[1]; 4 | 5 | export function storage(disk?: string) { 6 | if (!Config.storage?.disks) { 7 | throw new Error( 8 | "No Storage disks configured. Please assign an R2 bucket in your config file." 9 | ); 10 | } 11 | 12 | const diskToUse = disk 13 | ? Config.storage?.disks[disk] 14 | : Config.storage?.disks?.default; 15 | 16 | if (!diskToUse || !diskToUse.binding) { 17 | throw new Error(`R2 bucket "${disk}" could not be found.`); 18 | } 19 | 20 | const diskName = disk || "default"; 21 | 22 | return new Storage(diskName, diskToUse); 23 | } 24 | 25 | class Storage { 26 | constructor(public diskName: string, public disk: StorageDiskConfig) {} 27 | 28 | url(key: string) { 29 | if (this.disk.publicPath) { 30 | return `${this.disk.publicPath}/${key}`; 31 | } 32 | 33 | return null; 34 | } 35 | 36 | get(key: string) { 37 | return this.disk.binding.get(key); 38 | } 39 | 40 | delete(key: string) { 41 | return this.disk.binding.delete(key); 42 | } 43 | 44 | put(...args: Parameters) { 45 | return this.disk.binding.put(...args); 46 | } 47 | 48 | /** 49 | * Takes an input without a key and generates a random filename for you. 50 | * Optionally pass an `{ extension: string }` option to add an extension to the filename. 51 | */ 52 | putRandom( 53 | input: R2Input, 54 | options?: R2PutOptions & { extension?: string; prefix?: string } 55 | ) { 56 | const hash = crypto.randomUUID(); 57 | let key = options?.prefix ? `${options.prefix}/${hash}` : hash; 58 | if (options?.extension) { 59 | key += `.${options.extension}`; 60 | } 61 | 62 | return this.disk.binding.put(key, input, options); 63 | } 64 | } 65 | 66 | export async function servePublicPathFromStorage(path: string) { 67 | const notFoundResponse = new Response("Not found", { status: 404 }); 68 | 69 | if (!Config.storage?.disks) { 70 | return notFoundResponse; 71 | } 72 | 73 | const matchingDiskName = Object.keys(Config.storage.disks).find( 74 | (diskName) => { 75 | const { publicPath } = Config.storage!.disks![diskName]; 76 | return publicPath && path.startsWith(publicPath); 77 | } 78 | ); 79 | 80 | if (!matchingDiskName) { 81 | return notFoundResponse; 82 | } 83 | 84 | const key = path 85 | .replace(Config.storage.disks[matchingDiskName].publicPath!, "") 86 | .replace(/^\//, ""); 87 | 88 | const object = await storage(matchingDiskName).get(key); 89 | 90 | if (!object) { 91 | return notFoundResponse; 92 | } 93 | 94 | return new Response(object.body, { 95 | headers: { 96 | // TODO: Infer content type from file extension 97 | // 'Content-Type': file.type, 98 | "cache-control": "public, max-age=31536000, immutable", 99 | }, 100 | }); 101 | } 102 | -------------------------------------------------------------------------------- /packages/superflare/src/string.ts: -------------------------------------------------------------------------------- 1 | import pluralize from "pluralize"; 2 | 3 | const { plural, singular } = pluralize; 4 | 5 | export function tableNameToModel(tableName: string): string { 6 | return tableName 7 | .split("_") 8 | .map((part) => part[0].toUpperCase() + part.slice(1)) 9 | .map((part) => singular(part)) 10 | .join(""); 11 | } 12 | 13 | /** 14 | * Lowercase, snake_case, pluralize the last word. 15 | */ 16 | export function modelToTableName(modelName: string): string { 17 | const parts = modelName.split(/(?=[A-Z])/); 18 | const last = parts.pop()!; 19 | return parts 20 | .map((part) => part.toLowerCase()) 21 | .concat(plural(last.toLowerCase())) 22 | .join("_"); 23 | } 24 | 25 | export function modelToForeignKeyId(modelName: string): string { 26 | return `${singular(modelToTableName(modelName))}Id`; 27 | } 28 | 29 | export function lowercaseFirstLetter(string: string) { 30 | return string.charAt(0).toLowerCase() + string.slice(1); 31 | } 32 | 33 | /** 34 | * Sometimes, our bundle creates multiple references to the same imported module. This results 35 | * in some references with numbers appended to them. We want to remove those numbers to sanitize 36 | * the event names in order for client listeners to distinguish which events are being emitted. 37 | */ 38 | export function sanitizeModuleName(name: string) { 39 | return name.replace(/\d+$/, ""); 40 | } 41 | 42 | export function toSnakeCase(string: string) { 43 | return string 44 | .replace(/([A-Z])/g, (match) => `_${match.toLowerCase()}`) 45 | .replace(/ /g, "_") 46 | .toLowerCase() 47 | .replace(/^_/, ""); 48 | } 49 | -------------------------------------------------------------------------------- /packages/superflare/tests/channels.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { channelNameToConfigName } from "../src/channels"; 3 | 4 | describe("channelNameToConfigName", () => { 5 | it("works with a single replacement", () => { 6 | expect(channelNameToConfigName("foo.bar", ["foo.*"])).toBe("foo.*"); 7 | }); 8 | 9 | it("works with multiple replacements", () => { 10 | expect( 11 | channelNameToConfigName("foo.bar.baz", ["foo.*.*", "foo.bing.*"]) 12 | ).toBe("foo.*.*"); 13 | }); 14 | 15 | it("works with multiple segments", () => { 16 | expect( 17 | channelNameToConfigName("foo.bar.baz", ["foo.bar.*", "foo.bing.*"]) 18 | ).toBe("foo.bar.*"); 19 | }); 20 | 21 | it("chooses the exact match over a regex", () => { 22 | expect(channelNameToConfigName("foo.bar", ["foo.*", "foo.bar"])).toBe( 23 | "foo.bar" 24 | ); 25 | }); 26 | 27 | it("chooses the most specific match by number of dots", () => { 28 | expect(channelNameToConfigName("foo.bar.baz", ["foo.*", "foo.bar.*"])).toBe( 29 | "foo.bar.*" 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/superflare/tests/db.ts: -------------------------------------------------------------------------------- 1 | import type { D1Database as D1DatabaseType } from "@cloudflare/workers-types"; 2 | import { D1Database, D1DatabaseAPI } from "@miniflare/d1"; 3 | import { createSQLiteDB } from "@miniflare/shared"; 4 | 5 | /** 6 | * This should only be exported locally for tests, not in places that are used in the package, 7 | * because it depends on an installed version of `better-sqlite3`.nvm 8 | */ 9 | export async function createTestDatabase(sql: string) { 10 | const sqliteDb = await createSQLiteDB(":memory:"); 11 | 12 | /** 13 | * Migrating at this step because D1 doesn't allow multi-statement execs. 14 | */ 15 | sqliteDb.exec(sql); 16 | const db = new D1Database(new D1DatabaseAPI(sqliteDb)); 17 | return db as any as D1DatabaseType; 18 | } 19 | -------------------------------------------------------------------------------- /packages/superflare/tests/generate/migration.test.ts: -------------------------------------------------------------------------------- 1 | import { readdir, readFile } from "fs/promises"; 2 | import { join } from "path"; 3 | import { expect, it } from "vitest"; 4 | import { generateMigration } from "../../cli/generate/migration"; 5 | import { withFileSystem } from "../utils"; 6 | 7 | it("starts with 0 if first migration", async () => { 8 | await withFileSystem({}, async (rootPath) => { 9 | await generateMigration("add things", rootPath); 10 | 11 | expect(await readdir(join(rootPath, "db", "migrations"))).toEqual([ 12 | "0000_add_things.ts", 13 | ]); 14 | 15 | expect( 16 | await readFile( 17 | join(rootPath, "db", "migrations", "0000_add_things.ts"), 18 | "utf-8" 19 | ) 20 | ).toEqual(`import { Schema } from 'superflare'; 21 | 22 | export default function () { 23 | // return ... 24 | }`); 25 | }); 26 | }); 27 | 28 | it("increments number for other migrations", async () => { 29 | await withFileSystem( 30 | { 31 | "migrations/0000_add_things.sql": "", 32 | }, 33 | async (rootPath) => { 34 | await generateMigration("add more things", rootPath); 35 | 36 | expect(await readdir(join(rootPath, "db", "migrations"))).toEqual([ 37 | "0001_add_more_things.ts", 38 | ]); 39 | } 40 | ); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/superflare/tests/migrate.test.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 | import { compileMigrations } from "../cli/migrate"; 5 | import { withFileSystem } from "./utils"; 6 | 7 | describe("compileMigrations", () => { 8 | const date = new Date(); 9 | 10 | beforeEach(() => { 11 | vi.useFakeTimers(); 12 | vi.setSystemTime(date); 13 | }); 14 | 15 | afterEach(() => { 16 | vi.useRealTimers(); 17 | }); 18 | 19 | it("compiles a migration to SQL", async () => { 20 | await withFileSystem( 21 | { 22 | "app/migrations/0000_create_users_table.ts": `import { Schema } from 'superflare'; 23 | export default function () { 24 | return Schema.create("users", (builder) => { 25 | builder.increments("id"); 26 | builder.string("email"); 27 | }); 28 | }`, 29 | }, 30 | async (rootPath) => { 31 | await compileMigrations( 32 | join(rootPath, "app", "migrations"), 33 | join(rootPath, "migrations") 34 | ); 35 | 36 | const sql = await readFile( 37 | join(rootPath, "migrations", "0000_create_users_table.sql"), 38 | "utf-8" 39 | ); 40 | const timestamp = new Date().toISOString(); 41 | expect(sql).toEqual( 42 | `-- Migration number: 0000 ${timestamp} 43 | -- Autogenerated by Superflare. Do not edit this file directly. 44 | CREATE TABLE users ( 45 | id INTEGER PRIMARY KEY, 46 | email TEXT NOT NULL 47 | );` 48 | ); 49 | } 50 | ); 51 | }); 52 | 53 | it("compiles a migration with multiple Schema returns to SQL", async () => { 54 | await withFileSystem( 55 | { 56 | "app/migrations/0000_create_tables.ts": `import { Schema } from 'superflare'; 57 | export default function () { 58 | return [ 59 | Schema.create("users", (builder) => { 60 | builder.increments("id"); 61 | builder.string("email"); 62 | }), 63 | Schema.create("posts", (builder) => { 64 | builder.increments("id"); 65 | builder.string("title"); 66 | }), 67 | ]; 68 | }`, 69 | }, 70 | async (rootPath) => { 71 | await compileMigrations( 72 | join(rootPath, "app", "migrations"), 73 | join(rootPath, "migrations") 74 | ); 75 | 76 | const sql = await readFile( 77 | join(rootPath, "migrations", "0000_create_tables.sql"), 78 | "utf-8" 79 | ); 80 | const timestamp = new Date().toISOString(); 81 | expect(sql).toEqual( 82 | `-- Migration number: 0000 ${timestamp} 83 | -- Autogenerated by Superflare. Do not edit this file directly. 84 | CREATE TABLE users ( 85 | id INTEGER PRIMARY KEY, 86 | email TEXT NOT NULL 87 | ); 88 | 89 | CREATE TABLE posts ( 90 | id INTEGER PRIMARY KEY, 91 | title TEXT NOT NULL 92 | );` 93 | ); 94 | } 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/superflare/tests/string.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { modelToTableName } from "../src/string"; 3 | 4 | it("generates the table name from the model name", () => { 5 | expect(modelToTableName("Post")).toBe("posts"); 6 | expect(modelToTableName("User")).toBe("users"); 7 | expect(modelToTableName("UserPost")).toBe("user_posts"); 8 | expect(modelToTableName("Person")).toBe("people"); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/superflare/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; 2 | import { tmpdir } from "node:os"; 3 | import { dirname, join } from "node:path"; 4 | 5 | export async function withFileSystem( 6 | files: Record, 7 | fn: (rootPath: string) => Promise 8 | ) { 9 | const rootPath = await mkdtemp(join(tmpdir(), "superflare-test")); 10 | 11 | // Copy fake Superflare node_module to the root path 12 | await cp( 13 | join(__dirname, "..", "dist"), 14 | join(rootPath, "node_modules", "superflare"), 15 | { 16 | recursive: true, 17 | } 18 | ); 19 | 20 | // Create a package.json file in the root path 21 | await writeFile( 22 | join(rootPath, "package.json"), 23 | JSON.stringify({ 24 | name: "superflare-test", 25 | dependencies: { 26 | superflare: "file:node_modules/superflare", 27 | }, 28 | }) 29 | ); 30 | 31 | const promises = Object.entries(files).map(async ([path, content]) => { 32 | const fullPath = join(rootPath, ...path.split("/")); 33 | await mkdir(dirname(fullPath), { recursive: true }); 34 | await writeFile(fullPath, content); 35 | }); 36 | 37 | const cleanup = () => rm(rootPath, { recursive: true, force: true }); 38 | 39 | await Promise.all(promises) 40 | .then(() => fn(rootPath)) 41 | .catch((e) => { 42 | throw e; 43 | }) 44 | .finally(cleanup); 45 | } 46 | -------------------------------------------------------------------------------- /packages/superflare/tests/websockets.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test, vi } from "vitest"; 2 | import { handleWebSockets } from "../src/websockets"; 3 | 4 | describe("handleWebSockets", () => { 5 | test("should throw if no proper channel name supplied", async () => { 6 | const request = new Request("https://example.com/channels"); 7 | 8 | await expect(handleWebSockets(request)).rejects.toThrowError( 9 | `No channel binding found for "channels". Please update your superflare.config.` 10 | ); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/superflare/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["cloudflare.env.d.ts", "**/*"], 4 | "exclude": ["node_modules", "dist"], 5 | "compilerOptions": { 6 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 7 | "module": "commonjs", 8 | "experimentalDecorators": true, 9 | "isolatedModules": false, 10 | "rootDir": ".", 11 | "outDir": "./dist" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/superflare/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | const nodejsCompatPlugin: any = { 4 | name: "nodejs_compat Plugin", 5 | setup(pluginBuild: any) { 6 | pluginBuild.onResolve({ filter: /node:.*/ }, (opts: any) => { 7 | return { external: true }; 8 | }); 9 | }, 10 | }; 11 | 12 | export default defineConfig([ 13 | { 14 | format: ["esm", "cjs"], 15 | esbuildPlugins: [nodejsCompatPlugin], 16 | entry: ["index.ts", "cli.ts"], 17 | external: ["@miniflare/storage-redis", "ioredis"], 18 | }, 19 | { 20 | entry: ["index.types.ts"], 21 | dts: { 22 | only: true, 23 | }, 24 | }, 25 | ]); 26 | -------------------------------------------------------------------------------- /packages/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # `tsconfig` 2 | 3 | These are base shared `tsconfig.json`s from which all other `tsconfig.json`'s inherit from. 4 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "target": "ES2019" 19 | }, 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json", 7 | "react-library.json" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "lib": ["ES2015"], 8 | "module": "ESNext", 9 | "target": "es6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "examples/*" 3 | - "packages/*" 4 | - "apps/*" 5 | - "templates/*" 6 | -------------------------------------------------------------------------------- /templates/remix/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build/* 5 | !/build/index.d.ts 6 | /public/build 7 | .env 8 | /.wrangler 9 | superflare.env.d.ts 10 | .dev.vars 11 | -------------------------------------------------------------------------------- /templates/remix/README.md: -------------------------------------------------------------------------------- 1 | # Superflare + Remix 2 | 3 | This is a template for using Superflare + Remix. 4 | 5 | ```bash 6 | npm run dev 7 | ``` 8 | -------------------------------------------------------------------------------- /templates/remix/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /templates/remix/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | import { renderToReadableStream } from "react-dom/server"; 7 | import { type EntryContext } from "@remix-run/cloudflare"; 8 | import { RemixServer } from "@remix-run/react"; 9 | import { isbot } from "isbot"; 10 | 11 | export default async function handleRequest( 12 | request: Request, 13 | responseStatusCode: number, 14 | responseHeaders: Headers, 15 | remixContext: EntryContext 16 | ) { 17 | const body = await renderToReadableStream( 18 | , 19 | { 20 | onError(error: unknown) { 21 | responseStatusCode = 500; 22 | // Log streaming rendering errors from inside the shell 23 | console.error(error); 24 | }, 25 | signal: request.signal, 26 | } 27 | ); 28 | 29 | if (isbot(request.headers.get("user-agent") || "")) { 30 | await body.allReady; 31 | } 32 | 33 | responseHeaders.set("Content-Type", "text/html"); 34 | 35 | return new Response(body, { 36 | status: responseStatusCode, 37 | headers: responseHeaders, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /templates/remix/app/models/User.ts: -------------------------------------------------------------------------------- 1 | import { Model } from "superflare"; 2 | 3 | export class User extends Model { 4 | toJSON(): Omit { 5 | const { password, ...rest } = super.toJSON(); 6 | return rest; 7 | } 8 | } 9 | 10 | Model.register(User); 11 | 12 | export interface User extends UserRow {} 13 | -------------------------------------------------------------------------------- /templates/remix/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/cloudflare"; 2 | import { 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "@remix-run/react"; 9 | 10 | export const meta: MetaFunction = () => [ 11 | { charset: "utf-8" }, 12 | { title: "Superflare App" }, 13 | { viewport: "width=device-width,initial-scale=1" }, 14 | ]; 15 | 16 | export default function App() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /templates/remix/app/routes/_auth.login.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useActionData } from "@remix-run/react"; 2 | import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; 3 | import { User } from "~/models/User"; 4 | 5 | export async function action({ 6 | request, 7 | context: { auth }, 8 | }: ActionFunctionArgs) { 9 | if (await auth.check(User)) { 10 | return redirect("/dashboard"); 11 | } 12 | 13 | const formData = new URLSearchParams(await request.text()); 14 | const email = formData.get("email") as string; 15 | const password = formData.get("password") as string; 16 | 17 | if (await auth.attempt(User, { email, password })) { 18 | return redirect("/dashboard"); 19 | } 20 | 21 | return json({ error: "Invalid credentials" }, { status: 400 }); 22 | } 23 | 24 | export default function Login() { 25 | const actionData = useActionData(); 26 | 27 | return ( 28 |

29 |

Log in

30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 | {actionData?.error && ( 42 |
{actionData.error}
43 | )} 44 | 45 | 46 | 47 | Register 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /templates/remix/app/routes/_auth.logout.ts: -------------------------------------------------------------------------------- 1 | import { type ActionFunctionArgs, redirect } from "@remix-run/cloudflare"; 2 | 3 | export async function action({ context: { auth } }: ActionFunctionArgs) { 4 | auth.logout(); 5 | 6 | return redirect("/"); 7 | } 8 | -------------------------------------------------------------------------------- /templates/remix/app/routes/_auth.register.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Link, useActionData } from "@remix-run/react"; 2 | import { json, redirect, type ActionFunctionArgs } from "@remix-run/cloudflare"; 3 | import { User } from "~/models/User"; 4 | import { hash } from "superflare"; 5 | 6 | export async function action({ 7 | request, 8 | context: { auth }, 9 | }: ActionFunctionArgs) { 10 | if (await auth.check(User)) { 11 | return redirect("/dashboard"); 12 | } 13 | 14 | const formData = new URLSearchParams(await request.text()); 15 | const email = formData.get("email") as string; 16 | const password = formData.get("password") as string; 17 | 18 | if (await User.where("email", email).count()) { 19 | return json({ error: "Email already exists" }, { status: 400 }); 20 | } 21 | 22 | const user = await User.create({ 23 | email, 24 | password: await hash().make(password), 25 | }); 26 | 27 | auth.login(user); 28 | 29 | return redirect("/dashboard"); 30 | } 31 | 32 | export default function Register() { 33 | const actionData = useActionData(); 34 | 35 | return ( 36 |
37 |

Register

38 | 39 |
40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 |
48 | 49 | {actionData?.error && ( 50 |
{actionData.error}
51 | )} 52 | 53 | 54 | 55 | Log in 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /templates/remix/app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | export default function Index() { 2 | return

Hello, Superflare

; 3 | } 4 | -------------------------------------------------------------------------------- /templates/remix/app/routes/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { type LoaderFunctionArgs, redirect, json } from "@remix-run/cloudflare"; 2 | import { useLoaderData } from "@remix-run/react"; 3 | import { User } from "~/models/User"; 4 | 5 | export async function loader({ context: { auth } }: LoaderFunctionArgs) { 6 | if (!(await auth.check(User))) { 7 | return redirect("/login"); 8 | } 9 | 10 | return json({ 11 | user: (await auth.user(User)) as User, 12 | }); 13 | } 14 | 15 | export default function Dashboard() { 16 | const { user } = useLoaderData(); 17 | 18 | return ( 19 | <> 20 |

Dashboard

21 |

You're logged in as {user.email}

22 | 23 |
24 | 25 |
26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /templates/remix/db/migrations/0000_create_users.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "superflare"; 2 | 3 | export default function () { 4 | return Schema.create("users", (table) => { 5 | table.increments("id"); 6 | table.string("email").unique(); 7 | table.string("password"); 8 | table.timestamps(); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /templates/remix/db/seed.ts: -------------------------------------------------------------------------------- 1 | import { seed } from "superflare"; 2 | 3 | export default seed(async () => { 4 | // 5 | }); 6 | -------------------------------------------------------------------------------- /templates/remix/load-context.ts: -------------------------------------------------------------------------------- 1 | import { type Cloudflare } from "@superflare/remix"; 2 | 3 | declare module "@remix-run/cloudflare" { 4 | interface AppLoadContext { 5 | cloudflare: Cloudflare; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /templates/remix/migrations/0000_create_users.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0000 2023-03-13T11:07:26.126Z 2 | -- Autogenerated by Superflare. Do not edit this file directly. 3 | CREATE TABLE users ( 4 | id INTEGER PRIMARY KEY, 5 | email TEXT NOT NULL UNIQUE, 6 | password TEXT NOT NULL, 7 | createdAt DATETIME NOT NULL, 8 | updatedAt DATETIME NOT NULL 9 | ); -------------------------------------------------------------------------------- /templates/remix/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superflare-remix-remplate", 3 | "version": "1.0.0", 4 | "description": "A Superflare template for Remix", 5 | "type": "module", 6 | "private": true, 7 | "sideEffects": false, 8 | "scripts": { 9 | "build": "remix vite:build", 10 | "deploy": "wrangler -j deploy", 11 | "dev": "superflare dev", 12 | "start": "wrangler -j dev", 13 | "typegen": "wrangler -j types", 14 | "typecheck": "tsc" 15 | }, 16 | "keywords": [], 17 | "license": "MIT", 18 | "dependencies": { 19 | "@remix-run/cloudflare": "^2.12.1", 20 | "@remix-run/react": "^2.12.1", 21 | "@superflare/remix": "workspace:*", 22 | "isbot": "^5.1.13", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "superflare": "workspace:*", 26 | "tiny-invariant": "^1.3.1" 27 | }, 28 | "devDependencies": { 29 | "@cloudflare/workers-types": "^4.20241011.0", 30 | "@remix-run/dev": "^2.12.1", 31 | "@types/react": "^18.0.28", 32 | "@types/react-dom": "^18.0.11", 33 | "typescript": "^5", 34 | "vite": "^5.3.4", 35 | "vite-tsconfig-paths": "^4.3.2", 36 | "wrangler": "^3.91.0" 37 | }, 38 | "engines": { 39 | "node": ">=16.13" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /templates/remix/superflare.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "superflare"; 2 | 3 | export default defineConfig((ctx) => { 4 | return { 5 | appKey: ctx.env.APP_KEY, 6 | database: { 7 | default: ctx.env.DB, 8 | }, 9 | queues: { 10 | default: ctx.env.QUEUE, 11 | }, 12 | channels: { 13 | default: { 14 | binding: ctx.env.CHANNELS, 15 | }, 16 | }, 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /templates/remix/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["worker-configuration.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "types": ["@remix-run/cloudflare", "vite/client"], 6 | "isolatedModules": true, 7 | "esModuleInterop": true, 8 | "jsx": "react-jsx", 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2022", 12 | "strict": true, 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | 21 | // Remix takes care of building everything in `remix build`. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /templates/remix/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { vitePlugin as remix } from "@remix-run/dev"; 3 | import { superflareDevProxyVitePlugin } from "@superflare/remix/dev"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | superflareDevProxyVitePlugin(), 9 | remix({ 10 | future: { 11 | v3_fetcherPersist: true, 12 | v3_lazyRouteDiscovery: true, 13 | v3_relativeSplatPath: true, 14 | v3_throwAbortReason: true, 15 | }, 16 | }), 17 | tsconfigPaths(), 18 | ], 19 | ssr: { 20 | resolve: { 21 | conditions: ["workerd", "worker", "browser"], 22 | }, 23 | }, 24 | resolve: { 25 | mainFields: ["browser", "module", "main"], 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /templates/remix/worker-configuration.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by Wrangler by running `wrangler types` 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-empty-interface,@typescript-eslint/no-empty-object-type 4 | interface Env { 5 | } 6 | -------------------------------------------------------------------------------- /templates/remix/worker.ts: -------------------------------------------------------------------------------- 1 | import { createRequestHandler, type ServerBuild } from "@remix-run/cloudflare"; 2 | import { handleFetch } from "@superflare/remix"; 3 | import { handleQueue, handleScheduled } from "superflare"; 4 | import config from "./superflare.config"; 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore This file won’t exist if it hasn’t yet been built 7 | import * as build from "./build/server"; // eslint-disable-line import/no-unresolved 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | const handleRequest = createRequestHandler(build as any as ServerBuild); 11 | 12 | export default { 13 | async fetch(request, env, ctx) { 14 | try { 15 | return await handleFetch(request, env, ctx, config, handleRequest); 16 | } catch (error) { 17 | console.log(error); 18 | return new Response("An unexpected error occurred", { status: 500 }); 19 | } 20 | }, 21 | 22 | async queue(batch, env, ctx) { 23 | return handleQueue(batch, env, ctx, config); 24 | }, 25 | 26 | async scheduled(event, env, ctx) { 27 | return await handleScheduled(event, env, ctx, config, (schedule) => { 28 | // Define a schedule 29 | }); 30 | }, 31 | } satisfies ExportedHandler; 32 | -------------------------------------------------------------------------------- /templates/remix/wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-cms", 3 | "compatibility_flags": ["nodejs_compat"], 4 | "main": "./worker.ts", 5 | "compatibility_date": "2024-09-25", 6 | "assets": { 7 | "directory": "./build/client" 8 | }, 9 | "build": { 10 | "command": "npm run build" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalEnv": ["NODE_ENV"], 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["dist/**", ".next/**"] 8 | }, 9 | "lint": { 10 | "outputs": [] 11 | }, 12 | "dev": { 13 | "cache": false 14 | } 15 | } 16 | } 17 | --------------------------------------------------------------------------------