Skip to content
Source
Sign inSign up

longLivedAssetUrls.ts

87 lines3.4 KB

1import { useAtomValue } from "@effect/atom-react";
2import {
3 type AssetUrlState,
4 assetUrlStateFromResult,
5 blockedAssetUrlState,
6 EMPTY_ASSET_URL_ATOM,
7} from "@t3tools/client-runtime/state/assets";
8import { AssetResource, type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts";
9import * as Schema from "effect/Schema";
10import { AsyncResult, Atom } from "effect/reactivity";
11​
12import { assetEnvironment } from "~/state/assets";
13import { useFilesystemReadAccess } from "~/state/filesystem";
14import { usePreparedConnection } from "~/state/session";
15​
16type AssetUrlTarget = {
17 readonly environmentId: EnvironmentId;
18 readonly input: { readonly resource: AssetResource };
19};
20​
21const decodeAssetUrlKey = Schema.decodeUnknownSync(Schema.Tuple([EnvironmentId, AssetResource]));
22​
23/**
24 * Asset URLs for documents that cannot report a failed load, such as frames. A
25 * cached URL with `minLifeMs` left is reused; one closer to expiry is re-minted.
26 * A node mints at most once, so a skewed client clock cannot mint on every update.
27 */
28export function makeLongLivedAssetUrlFamily(
29 createUrl: (
30 target: AssetUrlTarget,
31 ) => Atom.Atom<AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>>,
32 options: { readonly minLifeMs: number; readonly now?: () => number },
33) {
34 const now = options.now ?? Date.now;
35 const family = Atom.family((key: string) => {
36 const [environmentId, resource] = decodeAssetUrlKey(JSON.parse(key));
37 const query = createUrl({ environmentId, input: { resource } });
38 return Atom.transform(
39 query,
40 (get) => {
41 let minting = false;
42 const settle = (
43 value: AsyncResult.AsyncResult<AssetCreateUrlResult, unknown>,
44 ): AsyncResult.AsyncResult<AssetCreateUrlResult, unknown> => {
45 if (value._tag !== "Success") return value;
46 const lasts = value.value.expiresAt - now() > options.minLifeMs;
47 // An expiring URL must not be handed out while its successor mints,
48 // whichever reader started the mint.
49 if (value.waiting) return lasts ? value : AsyncResult.initial(true);
50 if (minting || lasts) return value;
51 minting = true;
52 get.refresh(query);
53 return AsyncResult.initial(true);
54 };
55 get.subscribe(query, (value) => get.setSelf(settle(value)));
56 return settle(get.once(query));
57 },
58 { initialValueTarget: query },
59 // Each mount gets its own node, so a later mount may mint again.
60 ).pipe(Atom.setIdleTTL(0));
61 });
62 return (target: AssetUrlTarget) =>
63 family(JSON.stringify([target.environmentId, target.input.resource]));
65​
66const longLivedAssetUrl = makeLongLivedAssetUrlFamily(assetEnvironment.createUrl, {
67 minLifeMs: 5 * 60_000,
68});
69​
70/** Pass a null environment once the URL is adopted, so later mints do not re-render. */
71export function useLongLivedAssetUrl(
72 environmentId: EnvironmentId | null,
73 resource: AssetResource,
74): AssetUrlState {
75 const blocked = blockedAssetUrlState(resource, useFilesystemReadAccess(environmentId));
76 const preparedConnection = usePreparedConnection(environmentId);
77 const result = useAtomValue(
78 blocked !== null || environmentId === null
79 ? EMPTY_ASSET_URL_ATOM
80 : longLivedAssetUrl({ environmentId, input: { resource } }),
81 );
82 if (blocked !== null) return blocked;
83 return assetUrlStateFromResult(
84 result,
85 preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : null,
86 );