Scratch space for learning atproto app development

Merge pull request #3 from bluesky-social/paul/status-app

Implement status update app

+1
.env.template
···
PORT="8080" # The port your server will listen on
HOST="localhost" # Hostname for the server
PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
# CORS Settings
CORS_ORIGIN="http://localhost:*" # Allowed CORS origin, adjust as necessary
···
PORT="8080" # The port your server will listen on
HOST="localhost" # Hostname for the server
PUBLIC_URL="" # Set when deployed publicly, e.g. "https://mysite.com". Informs OAuth client id.
+
DB_PATH=":memory:" # The SQLite database path. Leave as ":memory:" to use a temporary in-memory database.
# CORS Settings
CORS_ORIGIN="http://localhost:*" # Allowed CORS origin, adjust as necessary
+7 -2
lexicons/status.json
···
{
"lexicon": 1,
-
"id": "example.lexicon.status",
"defs": {
"main": {
"type": "record",
···
"type": "object",
"required": ["status", "updatedAt"],
"properties": {
-
"status": { "type": "string" },
"updatedAt": { "type": "string", "format": "datetime" }
}
}
···
{
"lexicon": 1,
+
"id": "com.example.status",
"defs": {
"main": {
"type": "record",
···
"type": "object",
"required": ["status", "updatedAt"],
"properties": {
+
"status": {
+
"type": "string",
+
"minLength": 1,
+
"maxGraphemes": 1,
+
"maxLength": 32
+
},
"updatedAt": { "type": "string", "format": "datetime" }
}
}
+9 -2
package.json
···
"test": "vitest run"
},
"dependencies": {
"@atproto/jwk-jose": "0.1.2-rc.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
"vitest": "^2.0.0"
},
"lint-staged": {
-
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": ["biome check --apply --no-errors-on-unmatched"]
},
"tsup": {
-
"entry": ["src", "!src/**/__tests__/**", "!src/**/*.test.*"],
"splitting": false,
"sourcemap": true,
"clean": true
···
"test": "vitest run"
},
"dependencies": {
+
"@atproto/identity": "^0.4.0",
"@atproto/jwk-jose": "0.1.2-rc.0",
"@atproto/lexicon": "0.4.1-rc.0",
"@atproto/oauth-client-node": "0.0.2-rc.2",
···
"vitest": "^2.0.0"
},
"lint-staged": {
+
"*.{js,ts,cjs,mjs,d.cts,d.mts,json,jsonc}": [
+
"biome check --apply --no-errors-on-unmatched"
+
]
},
"tsup": {
+
"entry": [
+
"src",
+
"!src/**/__tests__/**",
+
"!src/**/*.test.*"
+
],
"splitting": false,
"sourcemap": true,
"clean": true
+2
src/config.ts
···
import type pino from 'pino'
import type { Database } from '#/db'
import type { Ingester } from '#/firehose/ingester'
export type AppContext = {
db: Database
ingester: Ingester
logger: pino.Logger
oauthClient: OAuthClient
}
···
import type pino from 'pino'
import type { Database } from '#/db'
import type { Ingester } from '#/firehose/ingester'
+
import { Resolver } from '#/ident/types'
export type AppContext = {
db: Database
ingester: Ingester
logger: pino.Logger
oauthClient: OAuthClient
+
resolver: Resolver
}
+12 -4
src/db/migrations.ts
···
migrations['001'] = {
async up(db: Kysely<unknown>) {
await db.schema
-
.createTable('post')
-
.addColumn('uri', 'varchar', (col) => col.primaryKey())
-
.addColumn('text', 'varchar', (col) => col.notNull())
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
.execute()
await db.schema
···
async down(db: Kysely<unknown>) {
await db.schema.dropTable('auth_state').execute()
await db.schema.dropTable('auth_session').execute()
-
await db.schema.dropTable('post').execute()
},
}
···
migrations['001'] = {
async up(db: Kysely<unknown>) {
await db.schema
+
.createTable('did_cache')
+
.addColumn('did', 'varchar', (col) => col.primaryKey())
+
.addColumn('doc', 'varchar', (col) => col.notNull())
+
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
+
.execute()
+
await db.schema
+
.createTable('status')
+
.addColumn('authorDid', 'varchar', (col) => col.primaryKey())
+
.addColumn('status', 'varchar', (col) => col.notNull())
+
.addColumn('updatedAt', 'varchar', (col) => col.notNull())
.addColumn('indexedAt', 'varchar', (col) => col.notNull())
.execute()
await db.schema
···
async down(db: Kysely<unknown>) {
await db.schema.dropTable('auth_state').execute()
await db.schema.dropTable('auth_session').execute()
+
await db.schema.dropTable('status').execute()
+
await db.schema.dropTable('did_cache').execute()
},
}
+12 -4
src/db/schema.ts
···
export type DatabaseSchema = {
-
post: Post
auth_session: AuthSession
auth_state: AuthState
}
-
export type Post = {
-
uri: string
-
text: string
indexedAt: string
}
···
export type DatabaseSchema = {
+
did_cache: DidCache
+
status: Status
auth_session: AuthSession
auth_state: AuthState
}
+
export type DidCache = {
+
did: string
+
doc: string
+
updatedAt: string
+
}
+
+
export type Status = {
+
authorDid: string
+
status: string
+
updatedAt: string
indexedAt: string
}
+5 -1
src/env.ts
···
dotenv.config()
export const env = cleanEnv(process.env, {
-
NODE_ENV: str({ devDefault: testOnly('test'), choices: ['development', 'production', 'test'] }),
HOST: host({ devDefault: testOnly('localhost') }),
PORT: port({ devDefault: testOnly(3000) }),
PUBLIC_URL: str({}),
COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }),
CORS_ORIGIN: str({ devDefault: testOnly('http://localhost:3000') }),
COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }),
···
dotenv.config()
export const env = cleanEnv(process.env, {
+
NODE_ENV: str({
+
devDefault: testOnly('test'),
+
choices: ['development', 'production', 'test'],
+
}),
HOST: host({ devDefault: testOnly('localhost') }),
PORT: port({ devDefault: testOnly(3000) }),
PUBLIC_URL: str({}),
+
DB_PATH: str({ devDefault: ':memory:' }),
COOKIE_SECRET: str({ devDefault: '00000000000000000000000000000000' }),
CORS_ORIGIN: str({ devDefault: testOnly('http://localhost:3000') }),
COMMON_RATE_LIMIT_MAX_REQUESTS: num({ devDefault: testOnly(1000) }),
+18 -11
src/firehose/ingester.ts
···
import type { Database } from '#/db'
import { Firehose } from '#/firehose/firehose'
export class Ingester {
firehose: Firehose | undefined
···
for await (const evt of firehose.run()) {
if (evt.event === 'create') {
-
if (evt.collection !== 'app.bsky.feed.post') continue
-
const post: any = evt.record // @TODO fix types
-
await this.db
-
.insertInto('post')
-
.values({
-
uri: evt.uri.toString(),
-
text: post.text as string,
-
indexedAt: new Date().toISOString(),
-
})
-
.onConflict((oc) => oc.doNothing())
-
.execute()
}
}
}
···
import type { Database } from '#/db'
import { Firehose } from '#/firehose/firehose'
+
import * as Status from '#/lexicon/types/com/example/status'
export class Ingester {
firehose: Firehose | undefined
···
for await (const evt of firehose.run()) {
if (evt.event === 'create') {
+
const record = evt.record
+
if (
+
evt.collection === 'com.example.status' &&
+
Status.isRecord(record) &&
+
Status.validateRecord(record).success
+
) {
+
await this.db
+
.insertInto('status')
+
.values({
+
authorDid: evt.author,
+
status: record.status,
+
updatedAt: record.updatedAt,
+
indexedAt: new Date().toISOString(),
+
})
+
.onConflict((oc) => oc.doNothing())
+
.execute()
+
}
}
}
}
+88
src/ident/resolver.ts
···
···
+
import { IdResolver, DidDocument, CacheResult } from '@atproto/identity'
+
import type { Database } from '#/db'
+
+
const HOUR = 60e3 * 60
+
const DAY = HOUR * 24
+
+
export function createResolver(db: Database) {
+
const resolver = new IdResolver({
+
didCache: {
+
async cacheDid(did: string, doc: DidDocument): Promise<void> {
+
await db
+
.insertInto('did_cache')
+
.values({
+
did,
+
doc: JSON.stringify(doc),
+
updatedAt: new Date().toISOString(),
+
})
+
.onConflict((oc) =>
+
oc.column('did').doUpdateSet({
+
doc: JSON.stringify(doc),
+
updatedAt: new Date().toISOString(),
+
})
+
)
+
.execute()
+
},
+
+
async checkCache(did: string): Promise<CacheResult | null> {
+
const row = await db
+
.selectFrom('did_cache')
+
.selectAll()
+
.where('did', '=', did)
+
.executeTakeFirst()
+
if (!row) return null
+
const now = Date.now()
+
const updatedAt = +new Date(row.updatedAt)
+
return {
+
did,
+
doc: JSON.parse(row.doc),
+
updatedAt,
+
stale: now > updatedAt + HOUR,
+
expired: now > updatedAt + DAY,
+
}
+
},
+
+
async refreshCache(
+
did: string,
+
getDoc: () => Promise<DidDocument | null>
+
): Promise<void> {
+
const doc = await getDoc()
+
if (doc) {
+
await this.cacheDid(did, doc)
+
}
+
},
+
+
async clearEntry(did: string): Promise<void> {
+
await db.deleteFrom('did_cache').where('did', '=', did).execute()
+
},
+
+
async clear(): Promise<void> {
+
await db.deleteFrom('did_cache').execute()
+
},
+
},
+
})
+
+
return {
+
async resolveDidToHandle(did: string): Promise<string> {
+
const didDoc = await resolver.did.resolveAtprotoData(did)
+
const resolvedHandle = await resolver.handle.resolve(didDoc.handle)
+
if (resolvedHandle === did) {
+
return didDoc.handle
+
}
+
return did
+
},
+
+
async resolveDidsToHandles(
+
dids: string[]
+
): Promise<Record<string, string>> {
+
const didHandleMap: Record<string, string> = {}
+
const resolves = await Promise.all(
+
dids.map((did) => this.resolveDidToHandle(did).catch((_) => did))
+
)
+
for (let i = 0; i < dids.length; i++) {
+
didHandleMap[dids[i]] = resolves[i]
+
}
+
return didHandleMap
+
},
+
}
+
}
+4
src/ident/types.ts
···
···
+
export interface Resolver {
+
resolveDidToHandle(did: string): Promise<string>
+
resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>
+
}
+6 -6
src/lexicon/index.ts
···
export class Server {
xrpc: XrpcServer
-
example: ExampleNS
constructor(options?: XrpcOptions) {
this.xrpc = createXrpcServer(schemas, options)
-
this.example = new ExampleNS(this)
}
}
-
export class ExampleNS {
_server: Server
-
lexicon: ExampleLexiconNS
constructor(server: Server) {
this._server = server
-
this.lexicon = new ExampleLexiconNS(server)
}
}
-
export class ExampleLexiconNS {
_server: Server
constructor(server: Server) {
···
export class Server {
xrpc: XrpcServer
+
com: ComNS
constructor(options?: XrpcOptions) {
this.xrpc = createXrpcServer(schemas, options)
+
this.com = new ComNS(this)
}
}
+
export class ComNS {
_server: Server
+
example: ComExampleNS
constructor(server: Server) {
this._server = server
+
this.example = new ComExampleNS(server)
}
}
+
export class ComExampleNS {
_server: Server
constructor(server: Server) {
+6 -3
src/lexicon/lexicons.ts
···
import { LexiconDoc, Lexicons } from '@atproto/lexicon'
export const schemaDict = {
-
ExampleLexiconStatus: {
lexicon: 1,
-
id: 'example.lexicon.status',
defs: {
main: {
type: 'record',
···
properties: {
status: {
type: 'string',
},
updatedAt: {
type: 'string',
···
}
export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[]
export const lexicons: Lexicons = new Lexicons(schemas)
-
export const ids = { ExampleLexiconStatus: 'example.lexicon.status' }
···
import { LexiconDoc, Lexicons } from '@atproto/lexicon'
export const schemaDict = {
+
ComExampleStatus: {
lexicon: 1,
+
id: 'com.example.status',
defs: {
main: {
type: 'record',
···
properties: {
status: {
type: 'string',
+
minLength: 1,
+
maxGraphemes: 1,
+
maxLength: 32,
},
updatedAt: {
type: 'string',
···
}
export const schemas: LexiconDoc[] = Object.values(schemaDict) as LexiconDoc[]
export const lexicons: Lexicons = new Lexicons(schemas)
+
export const ids = { ComExampleStatus: 'com.example.status' }
+2 -3
src/lexicon/types/example/lexicon/status.ts src/lexicon/types/com/example/status.ts
···
return (
isObj(v) &&
hasProp(v, '$type') &&
-
(v.$type === 'example.lexicon.status#main' ||
-
v.$type === 'example.lexicon.status')
)
}
export function validateRecord(v: unknown): ValidationResult {
-
return lexicons.validate('example.lexicon.status#main', v)
}
···
return (
isObj(v) &&
hasProp(v, '$type') &&
+
(v.$type === 'com.example.status#main' || v.$type === 'com.example.status')
)
}
export function validateRecord(v: unknown): ValidationResult {
+
return lexicons.validate('com.example.status#main', v)
}
+97 -28
src/pages/home.ts
···
import { AtUri } from '@atproto/syntax'
-
import type { Post } from '#/db/schema'
import { html } from '../view'
import { shell } from './shell'
-
type Props = { posts: Post[]; profile?: { displayName?: string; handle: string } }
export function home(props: Props) {
return shell({
···
})
}
-
function content({ posts, profile }: Props) {
return html`<div id="root">
-
<h1>Welcome to the Atmosphere</h1>
-
${
-
profile
-
? html`<form action="/logout" method="post">
-
<p>
-
Hi, <b>${profile.displayName || profile.handle}</b>. It's pretty special here.
-
<button type="submit">Log out.</button>
-
</p>
-
</form>`
-
: html`<p>
-
It's pretty special here.
-
<a href="/login">Log in.</a>
-
</p>`
-
}
-
<ul>
-
${posts.map((post) => {
-
return html`<li>
-
<a href="${toBskyLink(post.uri)}" target="_blank">🔗</a>
-
${post.text}
-
</li>`
})}
-
</ul>
-
<a href="/">Give me more</a>
</div>`
}
-
function toBskyLink(uriStr: string) {
-
const uri = new AtUri(uriStr)
-
return `https://bsky.app/profile/${uri.host}/post/${uri.rkey}`
}
···
import { AtUri } from '@atproto/syntax'
+
import type { Status } from '#/db/schema'
import { html } from '../view'
import { shell } from './shell'
+
const STATUS_OPTIONS = [
+
'👍',
+
'👎',
+
'💙',
+
'🥹',
+
'😧',
+
'😤',
+
'🙃',
+
'😉',
+
'😎',
+
'🤓',
+
'🤨',
+
'🥳',
+
'😭',
+
'😤',
+
'🤯',
+
'🫡',
+
'💀',
+
'✊',
+
'🤘',
+
'👀',
+
'🧠',
+
'👩‍💻',
+
'🧑‍💻',
+
'🥷',
+
'🧌',
+
'🦋',
+
'🚀',
+
]
+
+
type Props = {
+
statuses: Status[]
+
didHandleMap: Record<string, string>
+
profile?: { displayName?: string; handle: string }
+
}
export function home(props: Props) {
return shell({
···
})
}
+
function content({ statuses, didHandleMap, profile }: Props) {
return html`<div id="root">
+
<div class="error"></div>
+
<div id="header">
+
<h1>Statusphere</h1>
+
<p>Set your status on the Atmosphere.</p>
+
</div>
+
<div class="container">
+
<div class="card">
+
${profile
+
? html`<form action="/logout" method="post" class="session-form">
+
<div>
+
Hi, <strong>${profile.displayName || profile.handle}</strong>.
+
what's your status today?
+
</div>
+
<div>
+
<button type="submit">Log out</button>
+
</div>
+
</form>`
+
: html`<div class="session-form">
+
<div><a href="/login">Log in</a> to set your status!</div>
+
<div>
+
<a href="/login" class="button">Log in</a>
+
</div>
+
</div>`}
+
</div>
+
<div class="status-options">
+
${STATUS_OPTIONS.map(
+
(status) =>
+
html`<div
+
class="status-option"
+
data-value="${status}"
+
data-authed=${profile ? '1' : '0'}
+
>
+
${status}
+
</div>`
+
)}
+
</div>
+
${statuses.map((status, i) => {
+
const handle = didHandleMap[status.authorDid] || status.authorDid
+
return html`
+
<div class=${i === 0 ? 'status-line no-line' : 'status-line'}>
+
<div>
+
<div class="status">${status.status}</div>
+
</div>
+
<div class="desc">
+
<a class="author" href=${toBskyLink(handle)}>@${handle}</a>
+
is feeling ${status.status} on ${ts(status)}
+
</div>
+
</div>
+
`
})}
+
</div>
+
<script src="/public/home.js"></script>
</div>`
}
+
function toBskyLink(did: string) {
+
return `https://bsky.app/profile/${did}`
+
}
+
+
function ts(status: Status) {
+
const indexedAt = new Date(status.indexedAt)
+
const updatedAt = new Date(status.updatedAt)
+
if (updatedAt > indexedAt) return updatedAt.toDateString()
+
return indexedAt.toDateString()
}
+16 -5
src/pages/login.ts
···
function content({ error }: Props) {
return html`<div id="root">
-
<form action="/login" method="post">
-
<input type="text" name="handle" placeholder="handle" required />
-
<button type="submit">Log in.</button>
-
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
-
</form>
</div>`
}
···
function content({ error }: Props) {
return html`<div id="root">
+
<div id="header">
+
<h1>Statusphere</h1>
+
<p>Set your status on the Atmosphere.</p>
+
</div>
+
<div class="container">
+
<form action="/login" method="post" class="login-form">
+
<input
+
type="text"
+
name="handle"
+
placeholder="Enter your handle (eg alice.bsky.social)"
+
required
+
/>
+
<button type="submit">Log in</button>
+
${error ? html`<p>Error: <i>${error}</i></p>` : undefined}
+
</form>
+
</div>
</div>`
}
+1 -1
src/pages/shell.ts
···
return html`<html>
<head>
<title>${title}</title>
-
<link rel="stylesheet" href="/public/styles.css">
</head>
<body>
${content}
···
return html`<html>
<head>
<title>${title}</title>
+
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
${content}
+32
src/public/home.js
···
···
+
Array.from(document.querySelectorAll('.status-option'), (el) => {
+
el.addEventListener('click', async (ev) => {
+
setError('')
+
+
if (el.dataset.authed !== '1') {
+
window.location = '/login'
+
return
+
}
+
+
const res = await fetch('/status', {
+
method: 'POST',
+
headers: { 'content-type': 'application/json' },
+
body: JSON.stringify({ status: el.dataset.value }),
+
})
+
const body = await res.json()
+
if (body?.error) {
+
setError(body.error)
+
} else {
+
location.reload()
+
}
+
})
+
})
+
+
function setError(str) {
+
const errMsg = document.querySelector('.error')
+
if (str) {
+
errMsg.classList.add('visible')
+
errMsg.textContent = str
+
} else {
+
errMsg.classList.remove('visible')
+
}
+
}
+161 -3
src/public/styles.css
···
body {
font-family: Arial, Helvetica, sans-serif;
-
}
-
#root {
-
padding: 20px;
}
/*
···
#root, #__next {
isolation: isolate;
}
···
body {
font-family: Arial, Helvetica, sans-serif;
+
--border-color: #ddd;
+
--gray-100: #fafafa;
+
--gray-500: #666;
+
--gray-700: #333;
+
--primary-400: #2e8fff;
+
--primary-500: #0078ff;
+
--primary-600: #0066db;
+
--error-500: #f00;
+
--error-100: #fee;
}
/*
···
#root, #__next {
isolation: isolate;
}
+
+
/*
+
Common components
+
*/
+
button, .button {
+
display: inline-block;
+
border: 0;
+
background-color: var(--primary-500);
+
border-radius: 50px;
+
color: #fff;
+
padding: 2px 10px;
+
cursor: pointer;
+
text-decoration: none;
+
}
+
button:hover, .button:hover {
+
background: var(--primary-400);
+
}
+
+
/*
+
Custom components
+
*/
+
.error {
+
background-color: var(--error-100);
+
color: var(--error-500);
+
text-align: center;
+
padding: 1rem;
+
display: none;
+
}
+
.error.visible {
+
display: block;
+
}
+
+
#header {
+
background-color: #fff;
+
text-align: center;
+
padding: 0.5rem 0 1.5rem;
+
}
+
+
#header h1 {
+
font-size: 5rem;
+
}
+
+
.container {
+
display: flex;
+
flex-direction: column;
+
gap: 4px;
+
margin: 0 auto;
+
max-width: 600px;
+
padding: 20px;
+
}
+
+
.card {
+
/* border: 1px solid var(--border-color); */
+
border-radius: 6px;
+
padding: 10px 16px;
+
background-color: #fff;
+
}
+
.card > :first-child {
+
margin-top: 0;
+
}
+
.card > :last-child {
+
margin-bottom: 0;
+
}
+
+
.session-form {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
justify-content: space-between;
+
}
+
+
.login-form {
+
display: flex;
+
flex-direction: row;
+
gap: 6px;
+
border: 1px solid var(--border-color);
+
border-radius: 6px;
+
padding: 10px 16px;
+
background-color: #fff;
+
}
+
+
.login-form input {
+
flex: 1;
+
border: 0;
+
}
+
+
.status-options {
+
display: flex;
+
flex-direction: row;
+
flex-wrap: wrap;
+
gap: 8px;
+
margin: 10px 0;
+
}
+
+
.status-option {
+
font-size: 2rem;
+
width: 3rem;
+
height: 3rem;
+
background-color: #fff;
+
border: 1px solid var(--border-color);
+
border-radius: 3rem;
+
text-align: center;
+
box-shadow: 0 1px 4px #0001;
+
cursor: pointer;
+
}
+
+
.status-option:hover {
+
background-color: var(--gray-100);
+
}
+
+
.status-line {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
gap: 10px;
+
position: relative;
+
margin-top: 15px;
+
}
+
+
.status-line:not(.no-line)::before {
+
content: '';
+
position: absolute;
+
width: 2px;
+
background-color: var(--border-color);
+
left: 1.45rem;
+
bottom: calc(100% + 2px);
+
height: 15px;
+
}
+
+
.status-line .status {
+
font-size: 2rem;
+
background-color: #fff;
+
width: 3rem;
+
height: 3rem;
+
border-radius: 1.5rem;
+
text-align: center;
+
border: 1px solid var(--border-color);
+
}
+
+
.status-line .desc {
+
color: var(--gray-500);
+
}
+
+
.status-line .author {
+
color: var(--gray-700);
+
font-weight: 600;
+
text-decoration: none;
+
}
+
+
.status-line .author:hover {
+
text-decoration: underline;
+
}
+91 -12
src/routes/index.ts
···
import { login } from '#/pages/login'
import { page } from '#/view'
import { handler } from './util'
export const createRouter = (ctx: AppContext) => {
const router = express.Router()
···
'/client-metadata.json',
handler((_req, res) => {
return res.json(ctx.oauthClient.clientMetadata)
-
}),
)
router.get(
···
return res.redirect('/?error')
}
return res.redirect('/')
-
}),
)
router.get(
'/login',
handler(async (_req, res) => {
return res.type('html').send(page(login({})))
-
}),
)
router.post(
···
return res.type('html').send(
page(
login({
-
error: err instanceof OAuthResolverError ? err.message : "couldn't initiate login",
-
}),
-
),
)
}
-
}),
)
router.post(
···
handler(async (req, res) => {
await destroySession(req, res)
return res.redirect('/')
-
}),
)
router.get(
···
await destroySession(req, res)
return null
}))
-
const posts = await ctx.db.selectFrom('post').selectAll().orderBy('indexedAt', 'desc').limit(10).execute()
if (!agent) {
-
return res.type('html').send(page(home({ posts })))
}
const { data: profile } = await agent.getProfile({ actor: session.did })
-
return res.type('html').send(page(home({ posts, profile })))
-
}),
)
return router
···
import { login } from '#/pages/login'
import { page } from '#/view'
import { handler } from './util'
+
import * as Status from '#/lexicon/types/com/example/status'
export const createRouter = (ctx: AppContext) => {
const router = express.Router()
···
'/client-metadata.json',
handler((_req, res) => {
return res.json(ctx.oauthClient.clientMetadata)
+
})
)
router.get(
···
return res.redirect('/?error')
}
return res.redirect('/')
+
})
)
router.get(
'/login',
handler(async (_req, res) => {
return res.type('html').send(page(login({})))
+
})
)
router.post(
···
return res.type('html').send(
page(
login({
+
error:
+
err instanceof OAuthResolverError
+
? err.message
+
: "couldn't initiate login",
+
})
+
)
)
}
+
})
)
router.post(
···
handler(async (req, res) => {
await destroySession(req, res)
return res.redirect('/')
+
})
)
router.get(
···
await destroySession(req, res)
return null
}))
+
const statuses = await ctx.db
+
.selectFrom('status')
+
.selectAll()
+
.orderBy('indexedAt', 'desc')
+
.limit(10)
+
.execute()
+
const didHandleMap = await ctx.resolver.resolveDidsToHandles(
+
statuses.map((s) => s.authorDid)
+
)
if (!agent) {
+
return res.type('html').send(page(home({ statuses, didHandleMap })))
}
const { data: profile } = await agent.getProfile({ actor: session.did })
+
return res
+
.type('html')
+
.send(page(home({ statuses, didHandleMap, profile })))
+
})
+
)
+
+
router.post(
+
'/status',
+
handler(async (req, res) => {
+
const session = await getSession(req, res)
+
const agent =
+
session &&
+
(await ctx.oauthClient.restore(session.did).catch(async (err) => {
+
ctx.logger.warn({ err }, 'oauth restore failed')
+
await destroySession(req, res)
+
return null
+
}))
+
if (!agent) {
+
return res.status(401).json({ error: 'Session required' })
+
}
+
+
const record = {
+
$type: 'com.example.status',
+
status: req.body?.status,
+
updatedAt: new Date().toISOString(),
+
}
+
if (!Status.validateRecord(record).success) {
+
return res.status(400).json({ error: 'Invalid status' })
+
}
+
+
try {
+
await agent.com.atproto.repo.putRecord({
+
repo: agent.accountDid,
+
collection: 'com.example.status',
+
rkey: 'self',
+
record,
+
validate: false,
+
})
+
} catch (err) {
+
ctx.logger.warn({ err }, 'failed to write record')
+
return res.status(500).json({ error: 'Failed to write record' })
+
}
+
+
try {
+
await ctx.db
+
.insertInto('status')
+
.values({
+
authorDid: agent.accountDid,
+
status: record.status,
+
updatedAt: record.updatedAt,
+
indexedAt: new Date().toISOString(),
+
})
+
.onConflict((oc) =>
+
oc.column('authorDid').doUpdateSet({
+
status: record.status,
+
updatedAt: record.updatedAt,
+
indexedAt: new Date().toISOString(),
+
})
+
)
+
.execute()
+
} catch (err) {
+
ctx.logger.warn(
+
{ err },
+
'failed to update computed view; ignoring as it should be caught by the firehose'
+
)
+
}
+
+
res.status(200).json({})
+
})
)
return router
+9 -6
src/server.ts
···
import errorHandler from '#/middleware/errorHandler'
import requestLogger from '#/middleware/requestLogger'
import { createRouter } from '#/routes'
-
import { createClient } from './auth/client'
-
import type { AppContext } from './config'
export class Server {
constructor(
public app: express.Application,
public server: http.Server,
-
public ctx: AppContext,
) {}
static async create() {
-
const { NODE_ENV, HOST, PORT } = env
const logger = pino({ name: 'server start' })
-
const db = createDb(':memory:')
await migrateToLatest(db)
const ingester = new Ingester(db)
const oauthClient = await createClient(db)
ingester.start()
const ctx = {
db,
ingester,
logger,
oauthClient,
}
const app: Express = express()
···
formAction: null,
},
},
-
}),
)
// Request logging
···
import errorHandler from '#/middleware/errorHandler'
import requestLogger from '#/middleware/requestLogger'
import { createRouter } from '#/routes'
+
import { createClient } from '#/auth/client'
+
import { createResolver } from '#/ident/resolver'
+
import type { AppContext } from '#/config'
export class Server {
constructor(
public app: express.Application,
public server: http.Server,
+
public ctx: AppContext
) {}
static async create() {
+
const { NODE_ENV, HOST, PORT, DB_PATH } = env
const logger = pino({ name: 'server start' })
+
const db = createDb(DB_PATH)
await migrateToLatest(db)
const ingester = new Ingester(db)
const oauthClient = await createClient(db)
+
const resolver = await createResolver(db)
ingester.start()
const ctx = {
db,
ingester,
logger,
oauthClient,
+
resolver,
}
const app: Express = express()
···
formAction: null,
},
},
+
})
)
// Request logging