Initial commit

This commit is contained in:
snen 2021-09-23 22:37:47 -04:00
commit 3cb3286b71
24 changed files with 669 additions and 0 deletions

0
.gitignore vendored Normal file
View File

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": true,
"deno.importMap": "./import_map.json"
}

1
Procfile Normal file
View File

@ -0,0 +1 @@
web: deno run --allow-read --unstable --allow-env --allow-net --import-map import_map.json ./server/server.ts

25
README.md Normal file
View File

@ -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`.

BIN
assets/example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

33
client/App.tsx Normal file
View File

@ -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 <LoadingPage />
case 'logged-out': return <LoginPage />
case 'logged-in': {
return (
<UserProvider profile={user.profile}>
<AppPage />
</UserProvider>
)
}
default: return assertNever(user)
}
}
export default function App() {
return (
<AuthProvider>
<AuthRouter />
</AuthProvider>
)
}

37
client/api/auth.ts Normal file
View File

@ -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<string | null> {
// const credentials = await auth.signInWithEmailAndPassword(email, password)
// return credentials.user?.uid ?? null
// }
///
/// Google
///
// export async function loginGoogle(): Promise<string> {
// // @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
// }

83
client/app/useSocket.ts Normal file
View File

@ -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<T> =
| { 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<SocketAPI> {
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)
}
}

View File

@ -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 (
<svg width={size} height={size} viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z"
transform="scale(64)"
fill="#1b1f23"
/>
</svg>
)
}

View File

@ -0,0 +1,7 @@
import React from 'react'
export default function Loading() {
return (
<span>Loading...</span>
)
}

View File

@ -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 (
<div className="page">
<header className="header">
<span className="header-text">
Deno Web Starter
</span>
<a
href="https://github.com/sullivansean27/deno-react-web-starter"
target="_blank"
>
<GithubIcon size={60} />
</a>
</header>
<main className="main">
{children}
</main>
</div>
)
}

14
client/pages/AppPage.tsx Normal file
View File

@ -0,0 +1,14 @@
import React from 'react'
import Page from '../components/Page.tsx'
export default function AppPage() {
return (
<Page>
<div>
<p>Hello world!</p>
<img style={{width: 100, height: 100}} src="/assets/example.png" />
</div>
</Page>
)
}

View File

@ -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 (
<Page>
<Loading />
</Page>
)
}

View File

@ -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 (
<GoogleButton onClick={onClick} />
)
}
export default function LoginPage() {
return (
<Page>
<div>
<GoogleLoginButton />
</div>
</Page>
)
}

38
client/styles.css Normal file
View File

@ -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;
}

75
client/user.tsx Normal file
View File

@ -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<UserHandle | null>(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<AuthState>(initialAuthState)
React.useEffect(() => {
// subscribeToAuthState((user) => {
// if (user) {
// setAuthState({ loginState: 'logged-in'})
// }
// else setAuthState({ loginState: 'logged-out' })
// })
}, [])
return (
<AuthContext.Provider value={authState}>
{children}
</AuthContext.Provider>
)
}
const UserContext = React.createContext<UserProfile | null>(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 (
<UserContext.Provider value={profile}>
{children}
</UserContext.Provider>
)
}

5
common/assertNever.ts Normal file
View File

@ -0,0 +1,5 @@
export default function assertNever(value: never): never {
// TODO an actual error message
throw new Error(`assertNever failed for value ${value}`)
}

3
common/user.ts Normal file
View File

@ -0,0 +1,3 @@
export interface UserProfile {
}

18
import_map.json Normal file
View File

@ -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": {}
}

15
server/client.tsx Normal file
View File

@ -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(<App />, document.getElementById('root'))
})

19
server/routes/api.ts Normal file
View File

@ -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

139
server/routes/static.tsx Normal file
View File

@ -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 = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deno React Web Starter</title>
<meta name="description" content="Template for SSR React apps with Deno, Oak, and Firebase." />
<link href="styles.css" rel="stylesheet" type="text/css">
<style>
body {
margin: 0;
padding: 0;
}
body, #root {
height: 100vh;
width: 100vw;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${ /* ReactDOMServer.renderToString(<App />) */ '' }
</div>
<script src="/bundle.js"></script>
</body>
</html>
`
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

51
server/server.ts Normal file
View File

@ -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}`)

8
tsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext", "deno.ns", "deno.unstable"],
"allowJs": true,
"jsxFactory": "react",
"strictPropertyInitialization": false,
}
}