Constellation, Spacedust, Slingshot, UFOs: atproto crates and services for microcosm

cancel expiring tasks and demooooooo

Changed files
+186 -54
who-am-i
+23 -9
who-am-i/demo/index.html
···
<!doctype html>
-
<style>
-
body {
-
background: #333;
-
color: #ccc;
-
font-family: sans-serif;
-
}
-
</style>
+
<html>
+
<head>
+
<style>
+
body {
+
background: #333;
+
color: #ccc;
+
font-family: sans-serif;
+
}
+
</style>
+
</head>
-
<h1>hey</h1>
+
<body>
+
<h1>hey <span id="who"></span></h1>
-
<iframe src="http://127.0.0.1:9997/prompt" style="border: none" height="140" width="280" />
+
<iframe src="http://127.0.0.1:9997/prompt" id="whoami" style="border: none" height="130" width="280"></iframe>
+
+
<script type="text/javascript">
+
window.onmessage = message => {
+
if (!message || !message.data || message.data.source !== 'whoami') return;
+
document.getElementById('whoami').remove();
+
document.getElementById('who').textContent = message.data.handle;
+
};
+
</script>
+
</body>
+
</html>
+28 -16
who-am-i/src/expiring_task_map.rs
···
use std::sync::Arc;
use std::time::Duration;
use tokio::task::{JoinHandle, spawn};
-
use tokio::time::sleep; // 0.8
+
use tokio::time::sleep;
+
use tokio_util::sync::{CancellationToken, DropGuard};
#[derive(Clone)]
-
pub struct ExpiringTaskMap<T>(Arc<TaskMap<T>>);
+
pub struct ExpiringTaskMap<T>(TaskMap<T>);
impl<T: Send + 'static> ExpiringTaskMap<T> {
pub fn new(expiration: Duration) -> Self {
let map = TaskMap {
-
map: DashMap::new(),
+
map: Arc::new(DashMap::new()),
expiration,
};
-
Self(Arc::new(map))
+
Self(map)
}
-
pub fn dispatch(&self, task: impl Future<Output = T> + Send + 'static) -> String {
+
pub fn dispatch<F>(&self, task: F, cancel: CancellationToken) -> String
+
where
+
F: Future<Output = T> + Send + 'static,
+
{
+
let TaskMap {
+
ref map,
+
expiration,
+
} = self.0;
let task_key: String = rand::rng()
.sample_iter(&Alphanumeric)
.take(24)
···
.collect();
// spawn a tokio task and put the join handle in the map for later retrieval
-
self.0.map.insert(task_key.clone(), spawn(task));
+
map.insert(task_key.clone(), (cancel.clone().drop_guard(), spawn(task)));
// spawn a second task to clean up the map in case it doesn't get claimed
-
spawn({
-
let me = self.0.clone();
-
let key = task_key.clone();
-
async move {
-
sleep(me.expiration).await;
-
let _ = me.map.remove(&key);
-
// TODO: also use a cancellation token so taking and expiring can mutually cancel
+
let k = task_key.clone();
+
let map = map.clone();
+
spawn(async move {
+
if cancel
+
.run_until_cancelled(sleep(expiration))
+
.await
+
.is_some()
+
{
+
map.remove(&k);
+
cancel.cancel();
}
});
···
}
pub fn take(&self, key: &str) -> Option<JoinHandle<T>> {
-
eprintln!("trying to take...");
-
self.0.map.remove(key).map(|(_, handle)| handle)
+
// when the _guard drops, the token gets cancelled for us
+
self.0.map.remove(key).map(|(_, (_guard, handle))| handle)
}
}
+
#[derive(Clone)]
struct TaskMap<T> {
-
map: DashMap<String, JoinHandle<T>>,
+
map: Arc<DashMap<String, (DropGuard, JoinHandle<T>)>>,
expiration: Duration,
}
+8 -6
who-am-i/src/identity_resolver.rs
···
use atrium_oauth::DefaultHttpClient;
use std::sync::Arc;
-
pub async fn resolve_identity(did: String) -> String {
+
pub async fn resolve_identity(did: String) -> Option<String> {
let http_client = Arc::new(DefaultHttpClient::default());
let resolver = CommonDidResolver::new(CommonDidResolverConfig {
plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
http_client: Arc::clone(&http_client),
});
let doc = resolver.resolve(&Did::new(did).unwrap()).await.unwrap(); // TODO: this is only half the resolution? or is atrium checking dns?
-
if let Some(aka) = doc.also_known_as {
-
if let Some(f) = aka.first() {
-
return f.to_string();
+
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+
doc.also_known_as.and_then(|mut aka| {
+
if aka.is_empty() {
+
None
+
} else {
+
Some(aka.remove(0))
}
-
}
-
"who knows".to_string()
+
})
}
+22 -9
who-am-i/src/server.rs
···
Router,
extract::{FromRef, Query, State},
http::header::{HeaderMap, REFERER},
-
response::{Html, IntoResponse, Redirect},
+
response::{Html, IntoResponse, Json, Redirect},
routing::get,
};
use axum_extra::extract::cookie::{Cookie, Key, SameSite, SignedCookieJar};
···
use handlebars::{Handlebars, handlebars_helper};
use serde::{Deserialize, Serialize};
-
use serde_json::Value;
+
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
···
pub key: Key,
pub engine: AppEngine,
pub client: Arc<Client>,
-
pub resolving: ExpiringTaskMap<String>,
+
pub resolving: ExpiringTaskMap<Option<String>>,
+
pub shutdown: CancellationToken,
}
impl FromRef<AppState> for Key {
···
key: Key::from(app_secret.as_bytes()), // TODO: via config
client: Arc::new(client()),
resolving: ExpiringTaskMap::new(task_pickup_expiration),
+
shutdown: shutdown.clone(),
};
let app = Router::new()
···
}
async fn prompt(
State(AppState {
-
engine, resolving, ..
+
engine,
+
resolving,
+
shutdown,
+
..
}): State<AppState>,
jar: SignedCookieJar,
headers: HeaderMap,
···
let m = if let Some(did) = jar.get(DID_COOKIE_KEY) {
let did = did.value_trimmed().to_string();
-
let fetch_key = resolving.dispatch(resolve_identity(did.clone()));
+
let task_shutdown = shutdown.child_token();
+
let fetch_key = resolving.dispatch(resolve_identity(did.clone()), task_shutdown);
let json_did = Value::String(did);
let json_fetch_key = Value::String(fetch_key);
···
State(AppState { resolving, .. }): State<AppState>,
Query(params): Query<UserInfoParams>,
) -> impl IntoResponse {
-
// let fetch_key: [char; 16] = params.fetch_key.chars().collect::<Vec<_>>().try_into().unwrap();
-
let Some(handle) = resolving.take(&params.fetch_key) else {
+
let Some(task_handle) = resolving.take(&params.fetch_key) else {
return "oops, task does not exist or is gone".into_response();
};
-
let s = handle.await.unwrap();
-
format!("sup: {s}").into_response()
+
if let Some(handle) = task_handle.await.unwrap() {
+
// TODO: get active state etc.
+
// ...but also, that's a bsky thing?
+
let Some(handle) = handle.strip_prefix("at://") else {
+
return "hmm, handle did not start with at://".into_response();
+
};
+
Json(json!({ "handle": handle })).into_response()
+
} else {
+
"no handle?".into_response()
+
}
}
#[derive(Debug, Deserialize)]
+105 -14
who-am-i/templates/prompt-known.hbs
···
header > * {
flex-basis: 33%;
}
+
header > .empty {
+
font-size: 0.8rem;
+
opacity: 0.5;
+
}
header > .title {
text-align: center;
}
···
opacity: 1;
}
main {
-
padding: 0.25rem 0.5rem;
background: #ccc;
+
display: flex;
+
flex-direction: column;
flex-grow: 1;
+
padding: 0.25rem 0.5rem;
}
p {
margin: 0.5rem 0;
}
+
+
#loader {
+
display: flex;
+
flex-grow: 1;
+
justify-content: center;
+
align-items: center;
+
margin-bottom: 1rem;
+
}
+
.spinner {
+
animation: rotation 1.618s ease-in-out infinite;
+
border-radius: 50%;
+
border: 3px dashed #434;
+
box-sizing: border-box;
+
display: inline-block;
+
height: 1.5em;
+
width: 1.5em;
+
}
+
@keyframes rotation {
+
0% { transform: rotate(0deg) }
+
100% { transform: rotate(360deg) }
+
}
+
+
#user-info {
+
flex-grow: 1;
+
display: flex;
+
flex-direction: column;
+
justify-content: center;
+
margin-bottom: 1rem;
+
}
+
#action {
+
background: #eee;
+
display: flex;
+
justify-content: space-between;
+
padding: 0.5rem 0.25rem 0.5rem 0.5rem;
+
font-size: 0.8rem;
+
align-items: baseline;
+
border-radius: 0.5rem;
+
border: 1px solid #bbb;
+
cursor: pointer;
+
}
+
#action:hover {
+
background: #fff;
+
}
+
#allow {
+
background: transparent;
+
border: none;
+
border-left: 1px solid #bbb;
+
padding: 0 0.5rem;
+
color: #375;
+
font: inherit;
+
cursor: pointer;
+
}
+
#action:hover #allow {
+
color: #396;
+
}
+
+
+
.hidden {
+
display: none !important;
+
}
+
</style>
<div class="wrap">
<header>
-
<div class="empty"></div>
+
<div class="empty">🔒</div>
<code class="title" style="font-family: monospace;"
>who-am-i</code>
<a href="https://microcosm.blue" target="_blank" class="micro"
···
<main>
<p>Share your identity with {{ parent_host }}?</p>
-
<div id="user-info">Loading&hellip;</div>
+
<div id="loader">
+
<span class="spinner"></span>
+
</div>
+
<div id="user-info" class="hidden">
+
<div id="action">
+
<span id="handle"></span>
+
<button id="allow">Allow</button>
+
</div>
+
</div>
</main>
</div>
<script>
-
const infoEl = document.getElementById('user-info');
+
var loaderEl = document.getElementById('loader');
+
var infoEl = document.getElementById('user-info');
+
var actionEl = document.getElementById('action');
+
var handleEl = document.getElementById('handle');
+
var allowEl = document.getElementById('allow');
+
var DID = {{{json did}}};
let user_info = new URL('/user-info', window.location);
user_info.searchParams.set('fetch-key', {{{json fetch_key}}});
-
fetch(user_info).then(
-
info => {
-
infoEl.textContent = 'yay';
-
console.log(info);
-
},
-
err => {
-
infoEl.textContent = 'ohno';
-
console.error(err);
-
},
-
);
+
fetch(user_info)
+
.then(resp => {
+
if (!resp.ok) throw new Error('request failed');
+
return resp.json();
+
})
+
.then(
+
({ handle }) => {
+
loaderEl.remove();
+
handleEl.textContent = `@${handle}`;
+
infoEl.classList.remove('hidden');
+
actionEl.addEventListener('click', () => share(handle));
+
},
+
err => {
+
infoEl.textContent = 'ohno';
+
console.error(err);
+
},
+
);
+
+
function share(handle) {
+
top.postMessage({ source: 'whoami', handle }, '*'); // TODO: pass the referrer back from server
+
}
</script>