qbat
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Viewing readonly version of main branch: v79View latest version
Here's a Val which describes the minimum neccessary code for client side login
/** @jsxImportSource https://esm.sh/react@19.0.0 */
import { XRPC } from "npm:@atcute/client@2.0.8";
import {
configureOAuth,
createAuthorizationUrl,
finalizeAuthorization,
getSession,
OAuthUserAgent,
resolveFromIdentity,
} from "npm:@atcute/oauth-browser-client@1.0.15";
import { Hono } from "npm:hono@4.7.2";
import React, { useActionState, useEffect, useRef, useState } from "npm:react";
import { createRoot } from "npm:react-dom/client";
const [, , username, valname] = new URL(import.meta.url).pathname.split("/").map(s => s.toLowerCase());
const url = `https://${username}-${valname}.web.val.run`;
const clientMetadata = {
client_id: url + "/client-metadata.json",
client_uri: url,
logo_uri: url + "/favicon.svg",
redirect_uris: [url],
application_type: "web",
client_name: "Example Browser App",
dpop_bound_access_tokens: true,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
scope: "atproto transition:chat.bsky transition:generic",
token_endpoint_auth_method: "none",
};
const useAtCute = () => {
const [state, setState] = useState<{ agent: OAuthUserAgent; xrpc: XRPC }>();
useEffect(() => {
(async () => {
if (location.href.includes("state")) {
const params = new URLSearchParams(location.hash.slice(1));
history.replaceState(null, "", location.pathname + location.search);
const session = await finalizeAuthorization(params);
const agent = new OAuthUserAgent(session);
const xrpc = new XRPC({ handler: agent });
setState({ agent, xrpc });
}
const sessions = localStorage.getItem("atcute-oauth:sessions");
if (!!sessions) {
const did = Object.keys(JSON.parse(sessions))[0];
const session = await getSession(did, { allowStale: true });
const agent = new OAuthUserAgent(session);
const xrpc = new XRPC({ handler: agent });
setState({ agent, xrpc });
}
})();
}, []);
return state;
};
type FollowData = { data: { follows: { handle: string }[] } };
function App() {
const atCute = useAtCute();
const [following, setFollowing] = useState<FollowData["data"]["follows"]>([]);
const [, loginAction] = useActionState(async (_: any, formData: FormData) => {
const username = formData.get("username");
const { identity, metadata } = await resolveFromIdentity(username);
const authUrl = await createAuthorizationUrl({
metadata: metadata,
identity: identity,
scope: clientMetadata.scope,
});
window.location.assign(authUrl);
}, null);
useEffect(() => {
if (atCute?.xrpc) {
window.atcute = atCute;
atCute.xrpc.request({
type: "get",
nsid: "app.bsky.graph.getFollows",
params: {
actor: atCute?.agent.session.info.sub,
limit: 5,
},
}).then((following: FollowData) => setFollowing(following.data.follows));
}
}, [atCute]);
return (
<div>
<form action={loginAction}>
<p>Enter your BlueSky/ATProto handle:</p>
<input type="text" name="username" required placeholder="you.bsky.social"></input>
<button id="login" type="submit">login</button>
</form>
{!!following.length && (
<div id="following">
5 people you're following:
<ul>
{following.map((follow) => <li>{follow.handle}</li>)}
</ul>
</div>
)}
{!!atCute?.xrpc && (
<button
onClick={async () => {
await atCute.xrpc.request({
type: "get",
nsid: "app.bsky.graph.getFollows",
params: {
actor: atCute.agent.session.info.sub,
limit: 5,
},
}).then((following: FollowData) => setFollowing(following.data.follows));
}}
>
refetch following
</button>
)}
</div>
);
}
if (typeof document !== "undefined") {
configureOAuth({
metadata: {
client_id: clientMetadata.client_id,
redirect_uri: clientMetadata.redirect_uris[0],
},
});
createRoot(document.getElementById("root")!).render(<App />);
}
const logo =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🎯</text></svg>`;
const page = `
<!DOCTYPE html>
<html lang="en">
<head>
<title>Username App</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>
<script type="importmap">
{
"imports": {
"npm:@atcute/client@2.0.8": "https://esm.sh/@atcute/client@2.0.8",
"npm:@atcute/oauth-browser-client@1.0.15": "https://esm.sh/@atcute/oauth-browser-client@1.0.15",
"npm:hono@4.7.2": "https://esm.sh/hono@4.7.2",
"npm:react": "https://esm.sh/react@19.0.0",
"npm:react-dom/client": "https://esm.sh/react-dom@19.0.0/client"
}
}
</script>
<div id="root"></div>
<script src="https://esm.town/v/std/catch"></script>
<script type="module" src=${import.meta.url}></script>
</body>
</html>
`;
const app = new Hono();
app.get("/", c => c.html(page));
app.get("/favicon.svg", (c) => c.body(logo, { headers: { "Content-Type": "image/svg+xml" } }));
app.get("/callback", (c) => c.json(c.req));
app.get("/client-metadata.json", (c) => c.json(clientMetadata));
export default app.fetch;