commit 3cb3286b713c9ee5378bee33f780797d1ef9c8a8 Author: Sean Thomas Sullivan Date: Thu Sep 23 22:37:47 2021 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d597c19 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true, + "deno.importMap": "./import_map.json" +} \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..247bf3e --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: deno run --allow-read --unstable --allow-env --allow-net --import-map import_map.json ./server/server.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..fae20a1 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Deno React Web Starter + +This is a template repository for bootstrapping simple React SSR applications using [Deno](https://deno.land/), [Oak](https://deno.land/x/oak), [React](https://reactjs.org/), and [Firebase](https://console.firebase.google.com/u/0/). + +## Why + +Using Deno means being able to work with modern JS features in a direct way without needing transpilation steps. `Deno.emit()` creates a client bundle which is exposed by a simple webserver. + +## Features + +Quickly supports websockets connections, static file serving, and authentication with Firebase. Simply uncomment the appropriate locations in code and add your configuration variables. + +## Code layout + +- `~/client`: any code that is compiled and shipped to the browser. + - `~/client/styles.css`: the stylesheet for the application. more could certainly be integrated here. + - `~/client/App.tsx`: the entrypoint for the app, which imports everything else in the folder. +- `~/common`: modules shared between browser and server code. + - `~/common/wasm`: doesn't exist in this repository, but can be added (using e.g. [`wasm-pack`](https://github.com/rustwasm/wasm-pack)) to easily support webassembly modules in both client and server. + - other common utilities as a common import example. +- `~/server`: a simple webserver with example websocket and REST endpoints. +- `~/import_map.json`: the import map! starts with any relevant dependencies. +- `~/Procfile`: an example Procfile showing how to run the service. To convert this to any other form of script, simply trim the prefix `web:`. + +Enable SSR by un-commenting the React and ReactDOMServer lines in `~/server/routes/static.tsx`. diff --git a/assets/example.png b/assets/example.png new file mode 100644 index 0000000..413b0da Binary files /dev/null and b/assets/example.png differ diff --git a/client/App.tsx b/client/App.tsx new file mode 100644 index 0000000..c9f8f10 --- /dev/null +++ b/client/App.tsx @@ -0,0 +1,33 @@ +import React from 'react' + +import assertNever from '~/common/assertNever.ts' + +import AppPage from './pages/AppPage.tsx' +import LoadingPage from './pages/LoadingPage.tsx' +import LoginPage from './pages/LoginPage.tsx' +import { AuthProvider, UserProvider, useAuth } from './user.tsx' + +function AuthRouter() { + const user = useAuth() + + switch (user.loginState) { + case 'pending': return + case 'logged-out': return + case 'logged-in': { + return ( + + + + ) + } + default: return assertNever(user) + } +} + +export default function App() { + return ( + + + + ) +} diff --git a/client/api/auth.ts b/client/api/auth.ts new file mode 100644 index 0000000..a8342ba --- /dev/null +++ b/client/api/auth.ts @@ -0,0 +1,37 @@ +// import firebase from 'firebase' +// import 'firebase/auth' + +// const app = firebase.initializeApp({ +// // your-config-here +// }) + +// const auth = app.auth() + +// auto-login +// export function subscribeToAuthState(handleUser: (user: {uid: string; displayName: string} | null) => void) { +// return auth.onAuthStateChanged(handleUser) +// } + +/// +/// Email/password +/// + +// export async function registerBasic(email: string, password: string) { +// const user = await auth.createUserWithEmailAndPassword(email, password) +// } + +// export async function loginBasic(email: string, password: string): Promise { +// const credentials = await auth.signInWithEmailAndPassword(email, password) +// return credentials.user?.uid ?? null +// } + +/// +/// Google +/// + +// export async function loginGoogle(): Promise { +// // @ts-ignore deno does not recognize firebase.auth the way it is imported, but it exists +// const provider = new firebase.auth.GoogleAuthProvider(); +// const {accessToken, credential, user} = await auth.signInWithPopup(provider) +// return user.uid +// } diff --git a/client/app/useSocket.ts b/client/app/useSocket.ts new file mode 100644 index 0000000..dd0e3d4 --- /dev/null +++ b/client/app/useSocket.ts @@ -0,0 +1,83 @@ +import React from 'react' +import useWebSocket from 'react-use-websocket' + +import assertNever from '~/common/assertNever.ts' + +import { useUser } from '../user.tsx' + +function shouldReconnect() { + console.log('Reconnecting...') + return true +} + +type AsyncHandle = + | { status: 'not-connected' } + | { status: 'connecting' } + | { status: 'connected'; handle: T} + +interface SocketAPI {} + +type State = + | { status: 'not-connected' } + | { status: 'connecting' } + | { status: 'connected'} + +type Action = 'open' | 'close' | 'error' +function reducer(_state: State, action: Action): State { + switch (action) { + case 'open': return { status: 'connected' } + case 'close': return { status: 'not-connected' } + case 'error': return { status: 'connecting' } + default: return assertNever(action) + } +} + +const initialState: State = {status: 'not-connected'} + +export default function useSocket(): AsyncHandle { + const _user = useUser() + const [state, dispatch] = React.useReducer(reducer, initialState) + + const onMessage = React.useCallback((message: WebSocketEventMap['message']) => { + const data = JSON.parse(message.data) + dispatch({ type: 'update', ...data }) + }, []) + + const onOpen = React.useCallback(() => { + console.log('socket opened') + dispatch('open') + }, []) + + const onError = React.useCallback((event: WebSocketEventMap['error']) => { + console.error(event) + dispatch('error') + }, []) + + const onClose = React.useCallback((_event: WebSocketEventMap['close']) => { + console.log('socket closed') + dispatch('close') + }, []) + + const url = React.useMemo(() => + `ws://${window.location.hostname}${window.location.port ? `:${window.location.port}` : ''}/api/ws?name=${name}`, + [], + ) + const socket = useWebSocket( + url, + {onMessage, onOpen, onError, onClose, shouldReconnect}, + ) + + const handle = React.useMemo(() => ({ + sendJson: (value: {}) => socket.send(JSON.stringify(value)), + }), [socket]) + + switch (state.status) { + case 'connected': { + return {status: 'connected', handle} + } + case 'connecting': + case 'not-connected': + return state + default: return assertNever(state) + } +} diff --git a/client/components/GithubIcon.tsx b/client/components/GithubIcon.tsx new file mode 100644 index 0000000..61a9614 --- /dev/null +++ b/client/components/GithubIcon.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +interface IconProps { + size?: number +} + +/** + * Github icon implemented as specified in https://github.com/logos. + * Reference svg provided by https://commons.wikimedia.org/wiki/File:Octicons-mark-github.svg. + * + * @returns JSX.Element + */ +export default function GithubIcon({size = 24}: IconProps) { + return ( + + + + ) +} diff --git a/client/components/Loading.tsx b/client/components/Loading.tsx new file mode 100644 index 0000000..c59fcde --- /dev/null +++ b/client/components/Loading.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export default function Loading() { + return ( + Loading... + ) +} diff --git a/client/components/Page.tsx b/client/components/Page.tsx new file mode 100644 index 0000000..b49bdda --- /dev/null +++ b/client/components/Page.tsx @@ -0,0 +1,28 @@ +import React from 'react' + +import GithubIcon from './GithubIcon.tsx' + +interface PageProps { + children: React.ReactNode +} + +export default function Page({children}: PageProps): JSX.Element { + return ( +
+
+ + Deno Web Starter + + + + +
+
+ {children} +
+
+ ) +} diff --git a/client/pages/AppPage.tsx b/client/pages/AppPage.tsx new file mode 100644 index 0000000..b541c61 --- /dev/null +++ b/client/pages/AppPage.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +import Page from '../components/Page.tsx' + +export default function AppPage() { + return ( + +
+

Hello world!

+ +
+
+ ) +} diff --git a/client/pages/LoadingPage.tsx b/client/pages/LoadingPage.tsx new file mode 100644 index 0000000..811d29e --- /dev/null +++ b/client/pages/LoadingPage.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +import Loading from '../components/Loading.tsx' +import Page from '../components/Page.tsx' + +export default function LoadingPage() { + return ( + + + + ) +} diff --git a/client/pages/LoginPage.tsx b/client/pages/LoginPage.tsx new file mode 100644 index 0000000..b790f98 --- /dev/null +++ b/client/pages/LoginPage.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import GoogleButton from 'react-google-button' + +// import { loginGoogle } from '../api/auth.ts' +import Page from '../components/Page.tsx' + +// https://developers.google.com/identity/branding-guidelines +// https://developers.google.com/identity/sign-in/web/sign-in +function GoogleLoginButton() { + const onClick = React.useCallback(async () => { + // await loginGoogle() + }, []) + + return ( + + ) +} + +export default function LoginPage() { + return ( + +
+ +
+
+ ) +} diff --git a/client/styles.css b/client/styles.css new file mode 100644 index 0000000..d29641a --- /dev/null +++ b/client/styles.css @@ -0,0 +1,38 @@ +.page { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; +} + +.header { + height: 90px; + flex: 0 0 auto; + background-color: #4a6670; + padding-left: 2em; + padding-right: 2em; + box-shadow: 0 1em 1em rgba(0, 0, 0, 0.25); + + display: flex; + align-items: center; + justify-content: space-between; +} + +.header-text { + font-size: 32px; + font-family: sans-serif; + color: #18212b; +} + +.main { + flex: auto; + display: flex; + align-items: center; + justify-content: center; + background-color: #e6eaef; +} + +.frame { + display: flex; + justify-content: space-evenly; +} diff --git a/client/user.tsx b/client/user.tsx new file mode 100644 index 0000000..1ad7940 --- /dev/null +++ b/client/user.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import type { UserProfile } from '~/common/user.ts' + +// import { subscribeToAuthState } from './api/auth.ts' + +type AuthState = + | {loginState: 'pending'} + | {loginState: 'logged-out'} + // | {loginState: 'logged-in'; userId: string; profile: UserProfile} + | {loginState: 'logged-in'; profile: {}} +type UserHandle = AuthState + +const AuthContext = React.createContext(null) + +export function useAuth(): UserHandle { + const value = React.useContext(AuthContext) + if (value === null) throw new Error('useAuth must be used inside an AuthProvider') + return value +} + +// TODO: UPDATE HERE TO ENABLE AUTH +const initialAuthState: AuthState = {loginState: 'logged-in', profile: {}} + +interface AuthProviderProps { + children: React.ReactNode +} + +/** + * Handles authentication and token storage. + * + * Attaches a provider with login and logout callbacks. Handles authenticating using + * a token found in the device keychain and attaching/removing the token on login/logout. + * + * @props children + */ +export function AuthProvider({children}: AuthProviderProps): JSX.Element { + const [authState, setAuthState] = React.useState(initialAuthState) + + React.useEffect(() => { + // subscribeToAuthState((user) => { + // if (user) { + // setAuthState({ loginState: 'logged-in'}) + // } + // else setAuthState({ loginState: 'logged-out' }) + // }) + }, []) + + return ( + + {children} + + ) +} + +const UserContext = React.createContext(null) + +export function useUser(): UserProfile { + const value = React.useContext(UserContext) + if (value === null) throw new Error('useUser must be used inside a UserProvider') + return value +} + +interface UserProviderProps { + profile: {} + children: React.ReactNode +} + +export function UserProvider({children, profile}: UserProviderProps) { + return ( + + {children} + + ) +} diff --git a/common/assertNever.ts b/common/assertNever.ts new file mode 100644 index 0000000..b0c5a90 --- /dev/null +++ b/common/assertNever.ts @@ -0,0 +1,5 @@ +export default function assertNever(value: never): never { + // TODO an actual error message + throw new Error(`assertNever failed for value ${value}`) + } + \ No newline at end of file diff --git a/common/user.ts b/common/user.ts new file mode 100644 index 0000000..917ca98 --- /dev/null +++ b/common/user.ts @@ -0,0 +1,3 @@ +export interface UserProfile { +} + \ No newline at end of file diff --git a/import_map.json b/import_map.json new file mode 100644 index 0000000..593c766 --- /dev/null +++ b/import_map.json @@ -0,0 +1,18 @@ +{ + "imports": { + "~/": "./", + + "firebase": "https://cdn.skypack.dev/firebase@8.7.0/app", + "firebase/auth": "https://cdn.skypack.dev/firebase@8.7.0/auth", + "firebase/firestore": "https://cdn.skypack.dev/firebase@8.7.0/firestore", + "http": "https://deno.land/std@0.105.0/http/mod.ts", + "media-types": "https://deno.land/x/media_types@v2.10.0/mod.ts", + "oak": "https://deno.land/x/oak@v9.0.1/mod.ts", + "react": "https://cdn.esm.sh/react@17", + "react-dom": "https://cdn.esm.sh/react-dom@17", + "react-dom/": "https://cdn.esm.sh/react-dom@17/", + "react-google-button": "https://cdn.esm.sh/react-google-button@0.7.2?deps=react@17", + "react-use-websocket": "https://cdn.esm.sh/react-use-websocket@2.7?deps=react@17" + }, + "scopes": {} +} diff --git a/server/client.tsx b/server/client.tsx new file mode 100644 index 0000000..717e569 --- /dev/null +++ b/server/client.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +//// import any necessary wasm file here +// import initWasm from '~/common/wasm/wasm_chess.js' + +import App from '~/client/App.tsx' + +//// init wasm file here +//// fetch file contents from webserver so browser has access +// initWasm(fetch('wasm_chess.wasm')) + +window.addEventListener('load', (event) => { + ReactDOM.hydrate(, document.getElementById('root')) +}) diff --git a/server/routes/api.ts b/server/routes/api.ts new file mode 100644 index 0000000..792dc22 --- /dev/null +++ b/server/routes/api.ts @@ -0,0 +1,19 @@ +import { Router } from 'oak' + +function handleSocket(socket: WebSocket) { + console.log(socket) +} + +const apiRouter = new Router() + +apiRouter + .get('/api/ws', async (context) => { + if (!context.isUpgradable) throw new Error('Context not upgradable.') + const ws = await context.upgrade() + handleSocket(ws) + }) + .get('/api/hello', (context) => { + context.response.body = "Hello world!"; + }) + +export default apiRouter diff --git a/server/routes/static.tsx b/server/routes/static.tsx new file mode 100644 index 0000000..3d7f595 --- /dev/null +++ b/server/routes/static.tsx @@ -0,0 +1,139 @@ +// import React from 'react' +// import ReactDOMServer from 'react-dom/server' +import { contentType } from 'media-types' +import { Router } from 'oak' + +// import App from '~/client/App.tsx' + +async function createClientBundle() { + const {files} = await Deno.emit('server/client.tsx', { + bundle: 'module', + importMapPath: 'import_map.json', + compilerOptions: { + lib: ["dom", "dom.iterable", "esnext"], + allowJs: true, + jsx: "react", + strictPropertyInitialization: false, + }, + }) + return files +} + +const bundle = await createClientBundle() + +function getBundleFile(path: string) { + return bundle[`deno:///${path}`] +} + +function getAssetFile(file: string): Uint8Array | null { + try { + const asset = Deno.readFileSync(`assets/${file}`) + return asset + } catch { + return null + } +} + +const html = ` + + + + + + Deno React Web Starter + + + + + + +
+ ${ /* ReactDOMServer.renderToString() */ '' } +
+ + + +` + +const staticRouter = new Router() + +// handle static routes by proxying to web resources +// https://deno.com/deploy/docs/serve-static-assets +staticRouter + .get('/', (ctx) => { + ctx.response.body = html + }) + // // serve wasm file + // .get('/wasm.wasm', async (ctx) => { + // const headers = new Headers(ctx.response.headers) + // headers.set('Content-Type', 'application/wasm') + // ctx.response.headers = headers + // const body = await Deno.readFile('common/wasm/wasm_bg.wasm') + // ctx.response.body = body + // }) + .get('/styles.css', (ctx) => { + const styles = Deno.readTextFileSync('client/styles.css') + const contentTypeValue = contentType('styles.css') + const headers = new Headers(ctx.response.headers) + if (contentTypeValue) { + headers.set('Content-Type', contentTypeValue) + } + ctx.response.headers = headers + ctx.response.body = styles + }) + .get('/assets/:pathname', async (ctx, next) => { + const assetPath = ctx.params.pathname + if (!assetPath) { + return await next() + } + + const file = getAssetFile(assetPath) + if (!file) { + return await next() + } + + const filePathParts = assetPath.split('/') + const contentTypeValue = contentType(filePathParts[filePathParts.length - 1]) + const headers = new Headers(ctx.response.headers) + if (contentTypeValue) { + headers.set('Content-Type', contentTypeValue) + } + ctx.response.headers = headers + ctx.response.body = file + }) + .get('/:pathname', async (ctx, next) => { + const pathname = ctx.params.pathname + if (!pathname) { + return await next() + } + + const file = getBundleFile(pathname) + if (!file) { + return await next() + } + + // get just the last bit so we can determine the correct filetype + // contentType from media-types@v2.10.0 checks path.includes('/') + const filePathParts = pathname.split('/') + const contentTypeValue = contentType(filePathParts[filePathParts.length - 1]) + const headers = new Headers(ctx.response.headers) + if (contentTypeValue) { + headers.set('Content-Type', contentTypeValue) + } + ctx.response.headers = headers + if (pathname.endsWith('.js')) { + ctx.response.type = 'application/javascript' + } + ctx.response.body = file + }) + +export default staticRouter diff --git a/server/server.ts b/server/server.ts new file mode 100644 index 0000000..9226b3c --- /dev/null +++ b/server/server.ts @@ -0,0 +1,51 @@ +import { Application, Status } from 'oak' + +//// import any necessary wasm file here +// import init from '~/common/wasm.js' + +import apiRouter from './routes/api.ts' +import staticRouter from './routes/static.tsx' + +const app = new Application() + +app.addEventListener("error", (event) => { + console.error(event.error); +}) + +app.use(async (ctx, next) => { + try { + await next(); + } catch (error) { + ctx.response.body = "Internal server error"; + throw error; + } +}); +app.use((ctx, next) => { + ctx.response.headers.set('Access-Control-Allow-Origin', '*') + // avoid CSP concerns + // ctx.response.headers.delete("content-security-policy"); + console.log(ctx.request.url.pathname) + return next() +}) + +app.use(apiRouter.routes()) +app.use(apiRouter.allowedMethods()) + +app.use(staticRouter.routes()) +app.use(staticRouter.allowedMethods()) + +// 404 +app.use((context) => { + context.response.status = Status.NotFound + context.response.body = `"${context.request.url}" not found` +}) + +//// init wasm file here +//// https://deno.com/deploy/docs/serve-static-assets +// const WASM_PATH = new URL('../common/wasm/wasm_bg.wasm', import.meta.url) +//// https://github.com/rustwasm/wasm-pack/issues/672#issuecomment-813630435 +// await init(Deno.readFile(WASM_PATH)) + +const port = +(Deno.env.get('PORT') ?? 8080) +console.log(`Listening on port ${port}...`) +app.listen(`:${port}`) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..16dab82 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext", "deno.ns", "deno.unstable"], + "allowJs": true, + "jsxFactory": "react", + "strictPropertyInitialization": false, + } +}