tangled
alpha
login
or
join now
danabra.mov
/
inlay
100
fork
atom
social components
inlay.at
atproto
components
sdui
100
fork
atom
overview
issues
pulls
pipelines
add endpoints
danabra.mov
3 weeks ago
4028905b
0f6d09c2
+202
-42
6 changed files
expand all
collapse all
unified
split
packages
@inlay
render
src
index.ts
test
render.test.ts
proto
src
index.tsx
primitives.tsx
render.tsx
resolver.ts
+39
-10
packages/@inlay/render/src/index.ts
reviewed
···
32
32
export type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js";
33
33
export type { CachePolicy } from "../../../../generated/at/inlay/defs.defs.js";
34
34
35
35
+
export type EndpointRecord = {
36
36
+
did: string;
37
37
+
createdAt: string;
38
38
+
};
39
39
+
35
40
export interface Resolver {
36
41
fetchRecord(uri: AtUriString): Promise<unknown | null>;
37
42
xrpc(params: {
···
44
49
personalized?: boolean;
45
50
}): Promise<unknown>;
46
51
resolveLexicon(nsid: string): Promise<unknown | null>;
52
52
+
/**
53
53
+
* Find the first DID (in order) that has a record in the given collection
54
54
+
* with the given rkey. Used for component and endpoint resolution.
55
55
+
*/
56
56
+
resolve(
57
57
+
dids: DidString[],
58
58
+
collection: string,
59
59
+
rkey: string
60
60
+
): Promise<{ did: DidString; uri: AtUriString; record: unknown } | null>;
47
61
}
48
62
49
63
export type RenderOptions = {
···
336
350
componentUri: string;
337
351
component: ComponentRecord;
338
352
}> {
339
339
-
const uris = importStack.map(
340
340
-
(did) => `at://${did}/at.inlay.component/${nsid}` as AtUriString
353
353
+
const result = await resolver.resolve(
354
354
+
importStack,
355
355
+
"at.inlay.component",
356
356
+
nsid
341
357
);
342
342
-
const promises = uris.map((uri) => resolver.fetchRecord(uri));
358
358
+
if (!result) throw new Error(`Unresolved type: ${nsid}`);
359
359
+
return {
360
360
+
componentUri: result.uri,
361
361
+
component: result.record as ComponentRecord,
362
362
+
};
363
363
+
}
343
364
344
344
-
for (let i = 0; i < importStack.length; i++) {
345
345
-
const component = (await promises[i]) as ComponentRecord | null;
346
346
-
if (!component) continue;
347
347
-
return { componentUri: uris[i], component };
348
348
-
}
349
349
-
350
350
-
throw new Error(`Unresolved type: ${nsid}`);
365
365
+
/**
366
366
+
* Resolve an endpoint NSID through the import stack.
367
367
+
* Walks DIDs looking for `at.inlay.endpoint/{nsid}` records.
368
368
+
* Returns the service DID from the first matching record.
369
369
+
*/
370
370
+
export async function resolveEndpoint(
371
371
+
nsid: string,
372
372
+
imports: DidString[],
373
373
+
resolver: Resolver
374
374
+
): Promise<{ did: string; endpointUri: string }> {
375
375
+
const result = await resolver.resolve(imports, "at.inlay.endpoint", nsid);
376
376
+
if (!result) throw new Error(`Unresolved endpoint: ${nsid}`);
377
377
+
const record = result.record as EndpointRecord;
378
378
+
if (!record.did) throw new Error(`Endpoint record missing did: ${nsid}`);
379
379
+
return { did: record.did, endpointUri: result.uri };
351
380
}
352
381
353
382
async function renderTemplate(
+127
-18
packages/@inlay/render/test/render.test.ts
reviewed
···
51
51
import {
52
52
render,
53
53
createContext,
54
54
+
resolveEndpoint,
54
55
MissingError,
55
56
type ComponentRecord,
56
57
type CachePolicy,
···
359
360
},
360
361
resolveLexicon: async (nsid) => {
361
362
log.push(`lexicon ${nsid}`);
363
363
+
return null;
364
364
+
},
365
365
+
async resolve(dids, collection, rkey) {
366
366
+
const uris = dids.map(
367
367
+
(did) => `at://${did}/${collection}/${rkey}` as AtUriString
368
368
+
);
369
369
+
const promises = uris.map((uri) => resolver.fetchRecord(uri));
370
370
+
for (let i = 0; i < dids.length; i++) {
371
371
+
const record = await promises[i];
372
372
+
if (record) return { did: dids[i], uri: uris[i], record };
373
373
+
}
362
374
return null;
363
375
},
364
376
};
···
1811
1823
});
1812
1824
1813
1825
// Greeting's resolver fails — error should bubble with full owner chain.
1826
1826
+
const wrappedFetch = ((original) => async (uri: AtUriString) => {
1827
1827
+
const result = await original(uri);
1828
1828
+
if (result && uri === `at://${APP_DID}/at.inlay.component/${Greeting}`) {
1829
1829
+
// Return a component whose template references a missing type.
1830
1830
+
return {
1831
1831
+
...result,
1832
1832
+
body: {
1833
1833
+
$type: "at.inlay.component#bodyTemplate",
1834
1834
+
node: serializeTree($("test.app.DoesNotExist", {})),
1835
1835
+
},
1836
1836
+
};
1837
1837
+
}
1838
1838
+
return result;
1839
1839
+
})(options.resolver.fetchRecord);
1840
1840
+
1814
1841
const output = await renderToCompletion(
1815
1842
$(Page, {}),
1816
1843
{
1817
1844
...options,
1818
1845
resolver: {
1819
1846
...options.resolver,
1820
1820
-
fetchRecord: ((original) => async (uri: AtUriString) => {
1821
1821
-
const result = await original(uri);
1822
1822
-
if (
1823
1823
-
result &&
1824
1824
-
uri === `at://${APP_DID}/at.inlay.component/${Greeting}`
1825
1825
-
) {
1826
1826
-
// Return a component whose template references a missing type.
1827
1827
-
return {
1828
1828
-
...result,
1829
1829
-
body: {
1830
1830
-
$type: "at.inlay.component#bodyTemplate",
1831
1831
-
node: serializeTree($("test.app.DoesNotExist", {})),
1832
1832
-
},
1833
1833
-
};
1847
1847
+
fetchRecord: wrappedFetch,
1848
1848
+
async resolve(dids, collection, rkey) {
1849
1849
+
const uris = dids.map(
1850
1850
+
(did) => `at://${did}/${collection}/${rkey}` as AtUriString
1851
1851
+
);
1852
1852
+
const promises = uris.map((uri) => wrappedFetch(uri));
1853
1853
+
for (let i = 0; i < dids.length; i++) {
1854
1854
+
const record = await promises[i];
1855
1855
+
if (record) return { did: dids[i], uri: uris[i], record };
1834
1856
}
1835
1835
-
return result;
1836
1836
-
})(options.resolver.fetchRecord),
1837
1837
-
xrpc: options.resolver.xrpc,
1838
1838
-
resolveLexicon: options.resolver.resolveLexicon,
1857
1857
+
return null;
1858
1858
+
},
1839
1859
},
1840
1860
},
1841
1861
createContext(pageComponent, `at://${APP_DID}/at.inlay.component/${Page}`)
···
2005
2025
throw new Error("unreachable");
2006
2026
},
2007
2027
resolveLexicon: async () => null,
2028
2028
+
resolve: async () => {
2029
2029
+
throw new Error("network down");
2030
2030
+
},
2008
2031
},
2009
2032
};
2010
2033
···
4500
4523
assert.equal(capturedPersonalized, false);
4501
4524
});
4502
4525
});
4526
4526
+
4527
4527
+
// ============================================================================
4528
4528
+
// Endpoint resolution
4529
4529
+
// ============================================================================
4530
4530
+
4531
4531
+
describe("endpoint resolution", () => {
4532
4532
+
const QUERY_NSID = "test.app.getItems";
4533
4533
+
const ENDPOINT_DID = "did:plc:endpoint-author";
4534
4534
+
const SERVICE_DID_EP = "did:web:my-val.val.run";
4535
4535
+
4536
4536
+
it("resolves endpoint through import stack", async () => {
4537
4537
+
const { options } = testResolver({
4538
4538
+
[`at://${ENDPOINT_DID}/at.inlay.endpoint/${QUERY_NSID}`]: {
4539
4539
+
did: SERVICE_DID_EP,
4540
4540
+
createdAt: "2025-01-01T00:00:00Z",
4541
4541
+
},
4542
4542
+
} as any);
4543
4543
+
4544
4544
+
const result = await resolveEndpoint(
4545
4545
+
QUERY_NSID,
4546
4546
+
[ENDPOINT_DID as any],
4547
4547
+
options.resolver
4548
4548
+
);
4549
4549
+
assert.equal(result.did, SERVICE_DID_EP);
4550
4550
+
assert.equal(
4551
4551
+
result.endpointUri,
4552
4552
+
`at://${ENDPOINT_DID}/at.inlay.endpoint/${QUERY_NSID}`
4553
4553
+
);
4554
4554
+
});
4555
4555
+
4556
4556
+
it("first DID in import stack wins", async () => {
4557
4557
+
const DID_A = "did:plc:a" as any;
4558
4558
+
const DID_B = "did:plc:b" as any;
4559
4559
+
4560
4560
+
const { options } = testResolver({
4561
4561
+
[`at://${DID_A}/at.inlay.endpoint/${QUERY_NSID}`]: {
4562
4562
+
did: "did:web:val-a.val.run",
4563
4563
+
createdAt: "2025-01-01T00:00:00Z",
4564
4564
+
},
4565
4565
+
[`at://${DID_B}/at.inlay.endpoint/${QUERY_NSID}`]: {
4566
4566
+
did: "did:web:val-b.val.run",
4567
4567
+
createdAt: "2025-01-01T00:00:00Z",
4568
4568
+
},
4569
4569
+
} as any);
4570
4570
+
4571
4571
+
const result = await resolveEndpoint(
4572
4572
+
QUERY_NSID,
4573
4573
+
[DID_A, DID_B],
4574
4574
+
options.resolver
4575
4575
+
);
4576
4576
+
assert.equal(result.did, "did:web:val-a.val.run");
4577
4577
+
});
4578
4578
+
4579
4579
+
it("falls through to later DIDs", async () => {
4580
4580
+
const DID_A = "did:plc:a" as any;
4581
4581
+
const DID_B = "did:plc:b" as any;
4582
4582
+
4583
4583
+
const { options } = testResolver({
4584
4584
+
[`at://${DID_B}/at.inlay.endpoint/${QUERY_NSID}`]: {
4585
4585
+
did: "did:web:val-b.val.run",
4586
4586
+
createdAt: "2025-01-01T00:00:00Z",
4587
4587
+
},
4588
4588
+
} as any);
4589
4589
+
4590
4590
+
const result = await resolveEndpoint(
4591
4591
+
QUERY_NSID,
4592
4592
+
[DID_A, DID_B],
4593
4593
+
options.resolver
4594
4594
+
);
4595
4595
+
assert.equal(result.did, "did:web:val-b.val.run");
4596
4596
+
});
4597
4597
+
4598
4598
+
it("throws when no DID has the endpoint", async () => {
4599
4599
+
const { options } = testResolver({} as any);
4600
4600
+
4601
4601
+
await assert.rejects(
4602
4602
+
() =>
4603
4603
+
resolveEndpoint(
4604
4604
+
QUERY_NSID,
4605
4605
+
["did:plc:nobody" as any],
4606
4606
+
options.resolver
4607
4607
+
),
4608
4608
+
{ message: `Unresolved endpoint: ${QUERY_NSID}` }
4609
4609
+
);
4610
4610
+
});
4611
4611
+
});
+6
-5
proto/src/index.tsx
reviewed
···
15
15
type JSXElement,
16
16
} from "./render.tsx";
17
17
import { deserializeTree, $ } from "@inlay/core";
18
18
+
import { resolveEndpoint } from "@inlay/render";
18
19
import { setQueryString } from "./primitives.tsx";
19
20
import { resolveDidToService } from "./resolve.ts";
20
21
import {
···
42
43
43
44
app.get("/", (c) =>
44
45
c.redirect(
45
45
-
"/at/did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self?componentUri=at%3A%2F%2Fdid%3Aplc%3Afpruhuo22xkm5o7ttr2ktxdo%2Fat.inlay.component%2Fmov.danabra.ProfilePage&layout=page"
46
46
+
"/at/did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.actor.profile/self?componentUri=at%3A%2F%2Fdid%3Aplc%3Afpruhuo22xkm5o7ttr2ktxdo%2Fat.inlay.component%2Fmov.danabra.Profile&layout=page"
46
47
)
47
48
);
48
49
···
283
284
// --- HTMX list pagination endpoint ---
284
285
app.get("/htmx/list", async (c) => {
285
286
const query = c.req.query("query");
286
286
-
const did = c.req.query("did");
287
287
const cursor = c.req.query("cursor");
288
288
const inputStr = c.req.query("input");
289
289
const importsStr = c.req.query("imports");
290
290
291
291
-
if (!query || !did || !cursor) {
291
291
+
if (!query || !cursor) {
292
292
return c.html(<div class="error">Missing params</div>);
293
293
}
294
294
···
300
300
301
301
const ctx: RenderContext = { imports };
302
302
303
303
-
const serviceUrl = await resolveDidToService(did, "#inlay");
303
303
+
const endpoint = await resolveEndpoint(query, imports, sharedResolver);
304
304
+
const serviceUrl = await resolveDidToService(endpoint.did, "#inlay");
304
305
const params = new URLSearchParams();
305
306
for (const [k, v] of Object.entries({ ...input, cursor })) {
306
307
if (v != null) params.set(k, String(v));
···
327
328
328
329
const importsParam = encodeURIComponent(JSON.stringify(imports));
329
330
const sentinelUrl = page.cursor
330
330
-
? `/htmx/list?query=${encodeURIComponent(query)}&did=${encodeURIComponent(did)}&cursor=${encodeURIComponent(page.cursor)}&input=${encodeURIComponent(JSON.stringify(input))}&imports=${importsParam}`
331
331
+
? `/htmx/list?query=${encodeURIComponent(query)}&cursor=${encodeURIComponent(page.cursor)}&input=${encodeURIComponent(JSON.stringify(input))}&imports=${importsParam}`
331
332
: null;
332
333
333
334
c.header("Cache-Control", "private, max-age=120");
+12
-7
proto/src/primitives.tsx
reviewed
···
2
2
import { Suspense } from "hono/jsx/streaming";
3
3
import { ErrorBoundary } from "hono/jsx";
4
4
import type { RenderContext } from "@inlay/render";
5
5
-
import { MissingError } from "@inlay/render";
5
5
+
import { MissingError, resolveEndpoint } from "@inlay/render";
6
6
import { resolveDidToService } from "./resolve.ts";
7
7
import { isValidElement, deserializeTree } from "@inlay/core";
8
8
import "./types.ts";
···
10
10
// Hono's JSX.Element — what all JSX expressions produce
11
11
type JSXElement = HtmlEscapedString | Promise<HtmlEscapedString>;
12
12
13
13
-
// renderNode is injected to avoid circular deps
13
13
+
// renderNode and resolver are injected to avoid circular deps
14
14
let _renderNode: (node: unknown, ctx: RenderContext) => Promise<JSXElement>;
15
15
+
let _resolver: import("@inlay/render").Resolver;
15
16
let _qs: string = "";
16
17
17
18
export function setRenderNode(
18
19
fn: (node: unknown, ctx: RenderContext) => Promise<JSXElement>
19
20
) {
20
21
_renderNode = fn;
22
22
+
}
23
23
+
24
24
+
export function setResolver(resolver: import("@inlay/render").Resolver) {
25
25
+
_resolver = resolver;
21
26
}
22
27
23
28
export function setQueryString(qs: string) {
···
412
417
async function List({ ctx, props }: PrimitiveProps) {
413
418
const p = props as {
414
419
query: string;
415
415
-
did: string;
416
420
input?: Record<string, unknown>;
417
421
};
418
418
-
if (!p.did || !p.query) {
419
419
-
return <div class="error">List: missing did or query</div>;
422
422
+
if (!p.query) {
423
423
+
return <div class="error">List: missing query</div>;
420
424
}
421
425
422
422
-
const serviceUrl = await resolveDidToService(p.did, "#inlay");
426
426
+
const endpoint = await resolveEndpoint(p.query, ctx.imports, _resolver);
427
427
+
const serviceUrl = await resolveDidToService(endpoint.did, "#inlay");
423
428
const params = new URLSearchParams();
424
429
if (p.input) {
425
430
for (const [k, v] of Object.entries(p.input)) {
···
451
456
}
452
457
453
458
const importsParam = encodeURIComponent(JSON.stringify(ctx.imports));
454
454
-
const sentinelUrl = `/htmx/list?query=${encodeURIComponent(p.query)}&did=${encodeURIComponent(p.did)}&cursor=${encodeURIComponent(page.cursor ?? "")}&input=${encodeURIComponent(JSON.stringify(p.input ?? {}))}&imports=${importsParam}`;
459
459
+
const sentinelUrl = `/htmx/list?query=${encodeURIComponent(p.query)}&cursor=${encodeURIComponent(page.cursor ?? "")}&input=${encodeURIComponent(JSON.stringify(p.input ?? {}))}&imports=${importsParam}`;
455
460
456
461
return (
457
462
<>
+2
-1
proto/src/render.tsx
reviewed
···
9
9
type RenderOptions,
10
10
} from "@inlay/render";
11
11
import { isValidElement } from "@inlay/core";
12
12
-
import { componentMap, setRenderNode } from "./primitives.tsx";
12
12
+
import { componentMap, setRenderNode, setResolver } from "./primitives.tsx";
13
13
import "./types.ts";
14
14
15
15
// Hono's JSX.Element type — what all JSX expressions return
···
75
75
76
76
export function initRender(options: RenderOptions) {
77
77
setRenderNode((node, ctx) => renderNode(node, ctx, options));
78
78
+
setResolver(options.resolver);
78
79
}
+16
-1
proto/src/resolver.ts
reviewed
···
35
35
const res = await fetch(
36
36
`${SLINGSHOT}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.host)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`
37
37
);
38
38
-
if (!res.ok) return null;
38
38
+
if (!res.ok) {
39
39
+
// Cache misses to avoid hammering PDS for records that don't exist.
40
40
+
// Uses shorter TTL than hits — the record might be created later.
41
41
+
await cacheSet(key, null, { life: "minutes" });
42
42
+
return null;
43
43
+
}
39
44
40
45
const data = await res.json();
41
46
const value = data.value as Record<string, unknown>;
···
100
105
return {
101
106
async fetchRecord(uri) {
102
107
return fetchRecordFromPds(uri);
108
108
+
},
109
109
+
110
110
+
async resolve(dids, collection, rkey) {
111
111
+
const uris = dids.map((did) => `at://${did}/${collection}/${rkey}`);
112
112
+
const promises = uris.map((uri) => fetchRecordFromPds(uri));
113
113
+
for (let i = 0; i < dids.length; i++) {
114
114
+
const record = await promises[i];
115
115
+
if (record) return { did: dids[i], uri: uris[i] as any, record };
116
116
+
}
117
117
+
return null;
103
118
},
104
119
105
120
async xrpc(params) {