Scratch space for learning atproto app development

Merge pull request #8 from bluesky-social/paul/further-repo-cleanup

Further repo cleanup

+1 -1
src/auth/client.ts
···
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import type { Database } from '#/db'
-
import { env } from '#/env'
+
import { env } from '#/lib/env'
import { SessionStore, StateStore } from './storage'
export const createClient = async (db: Database) => {
+1 -1
src/auth/session.ts
···
import assert from 'node:assert'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { getIronSession } from 'iron-session'
-
import { env } from '#/env'
+
import { env } from '#/lib/env'
import { AppContext } from '#/index'
export type Session = { did: string }
src/env.ts src/lib/env.ts
+6 -1
src/firehose/firehose.ts
···
if (isCommit(evt) && !this.opts.excludeCommit) {
const parsed = await parseCommit(evt)
for (const write of parsed) {
-
if (!this.opts.filterCollections || this.opts.filterCollections.includes(write.uri.collection)) {
+
if (
+
!this.opts.filterCollections ||
+
this.opts.filterCollections.includes(write.uri.collection)
+
) {
yield write
}
}
···
type Update = CommitMeta & {
event: 'update'
+
record: RepoRecord
+
cid: CID
}
type Delete = CommitMeta & {
+12 -2
src/firehose/ingester.ts
···
const firehose = new Firehose({})
for await (const evt of firehose.run()) {
-
if (evt.event === 'create') {
+
// Watch for write events
+
if (evt.event === 'create' || evt.event === 'update') {
const record = evt.record
+
+
// If the write is a valid status update
if (
evt.collection === 'com.example.status' &&
Status.isRecord(record) &&
Status.validateRecord(record).success
) {
+
// Store the status in our SQLite
await this.db
.insertInto('status')
.values({
···
updatedAt: record.updatedAt,
indexedAt: new Date().toISOString(),
})
-
.onConflict((oc) => oc.doNothing())
+
.onConflict((oc) =>
+
oc.column('authorDid').doUpdateSet({
+
status: record.status,
+
updatedAt: record.updatedAt,
+
indexedAt: new Date().toISOString(),
+
})
+
)
.execute()
}
}
+14 -12
src/index.ts
···
import type { OAuthClient } from '@atproto/oauth-client-node'
import { createDb, migrateToLatest } from '#/db'
-
import { env } from '#/env'
+
import { env } from '#/lib/env'
import { Ingester } from '#/firehose/ingester'
import { createRouter } from '#/routes'
import { createClient } from '#/auth/client'
import { createResolver, Resolver } from '#/firehose/resolver'
import type { Database } from '#/db'
+
// Application state passed to the router and elsewhere
export type AppContext = {
db: Database
ingester: Ingester
···
static async create() {
const { NODE_ENV, HOST, PORT, DB_PATH } = env
-
const logger = pino({ name: 'server start' })
+
+
// Set up the SQLite database
const db = createDb(DB_PATH)
await migrateToLatest(db)
-
const ingester = new Ingester(db)
+
+
// Create the atproto utilities
const oauthClient = await createClient(db)
+
const ingester = new Ingester(db)
const resolver = createResolver()
-
ingester.start()
const ctx = {
db,
ingester,
···
resolver,
}
-
const app: Express = express()
+
// Subscribe to events on the firehose
+
ingester.start()
-
// Set the application to trust the reverse proxy
+
// Create our server
+
const app: Express = express()
app.set('trust proxy', true)
-
// Middlewares
+
// Routes & middlewares
+
const router = createRouter(ctx)
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
-
-
// Routes
-
const router = createRouter(ctx)
app.use(router)
-
-
// Error handlers
app.use((_req, res) => res.sendStatus(404))
+
// Bind our server to the port
const server = app.listen(env.PORT)
await events.once(server, 'listening')
logger.info(`Server (${NODE_ENV}) running on port http://${HOST}:${PORT}`)
+1 -2
src/pages/home.ts
···
-
import { AtUri } from '@atproto/syntax'
import type { Status } from '#/db/schema'
-
import { html } from '../view'
+
import { html } from '../lib/view'
import { shell } from './shell'
const TODAY = new Date().toDateString()
+1 -1
src/pages/login.ts
···
-
import { html } from '../view'
+
import { html } from '../lib/view'
import { shell } from './shell'
type Props = { error?: string }
+1 -1
src/pages/shell.ts
···
-
import { type Hole, html } from '../view'
+
import { type Hole, html } from '../lib/view'
export function shell({ title, content }: { title: string; content: Hole }) {
return html`<html>
+32 -1
src/routes.ts
···
import type { AppContext } from '#/index'
import { home } from '#/pages/home'
import { login } from '#/pages/login'
-
import { page } from '#/view'
+
import { page } from '#/lib/view'
import * as Status from '#/lexicon/types/com/example/status'
+
// Helper function for defining routes
const handler =
(fn: express.Handler) =>
async (
···
export const createRouter = (ctx: AppContext) => {
const router = express.Router()
+
// Static assets
router.use('/public', express.static(path.join(__dirname, 'pages', 'public')))
+
// OAuth metadata
router.get(
'/client-metadata.json',
handler((_req, res) => {
···
})
)
+
// OAuth callback to complete session creation
router.get(
'/oauth/callback',
handler(async (req, res) => {
···
})
)
+
// Login page
router.get(
'/login',
handler(async (_req, res) => {
···
})
)
+
// Login handler
router.post(
'/login',
handler(async (req, res) => {
+
// Validate
const handle = req.body?.handle
if (typeof handle !== 'string' || !isValidHandle(handle)) {
return res.type('html').send(page(login({ error: 'invalid handle' })))
}
+
+
// Initiate the OAuth flow
try {
const url = await ctx.oauthClient.authorize(handle)
return res.redirect(url.toString())
···
})
)
+
// Logout handler
router.post(
'/logout',
handler(async (req, res) => {
···
})
)
+
// Homepage
router.get(
'/',
handler(async (req, res) => {
+
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
+
+
// Fetch data stored in our SQLite
const statuses = await ctx.db
.selectFrom('status')
.selectAll()
···
.where('authorDid', '=', agent.accountDid)
.executeTakeFirst()
: undefined
+
+
// Map user DIDs to their domain-name handles
const didHandleMap = await ctx.resolver.resolveDidsToHandles(
statuses.map((s) => s.authorDid)
)
+
if (!agent) {
+
// Serve the logged-out view
return res.type('html').send(page(home({ statuses, didHandleMap })))
}
+
+
// Fetch additional information about the logged-in user
const { data: profile } = await agent.getProfile({
actor: agent.accountDid,
})
+
didHandleMap[profile.handle] = agent.accountDid
+
+
// Serve the logged-in view
return res
.type('html')
.send(page(home({ statuses, didHandleMap, profile, myStatus })))
})
)
+
// "Set status" handler
router.post(
'/status',
handler(async (req, res) => {
+
// If the user is signed in, get an agent which communicates with their server
const agent = await getSessionAgent(req, res, ctx)
if (!agent) {
return res.status(401).json({ error: 'Session required' })
}
+
// Construct & validate their status record
const record = {
$type: 'com.example.status',
status: req.body?.status,
···
}
try {
+
// Write the status record to the user's repository
await agent.com.atproto.repo.putRecord({
repo: agent.accountDid,
collection: 'com.example.status',
···
}
try {
+
// Optimistically update our SQLite
+
// This isn't strictly necessary because the write event will be
+
// handled in #/firehose/ingestor.ts, but it ensures that future reads
+
// will be up-to-date after this method finishes.
await ctx.db
.insertInto('status')
.values({
src/view.ts src/lib/view.ts