Experiment to rebuild Diffuse using web applets.

refactor: s3 input

+347 -314
-3
src/pages/configurator/input/_applet.astro
··· 23 23 </a> 24 24 </p> 25 25 </div> 26 - <p> 27 - <small><em><strong>More options coming soon!</strong></em></small> 28 - </p> 29 26 </main> 30 27 31 28 <style is:global>
+13 -287
src/pages/input/s3/_applet.astro
··· 37 37 </style> 38 38 39 39 <script> 40 - import { S3Client } from "@bradenmacdonald/s3-lite-client"; 41 - import { computed, effect, type Signal, signal } from "spellcaster"; 42 - import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 43 - import * as IDB from "idb-keyval"; 44 - import * as URI from "uri-js"; 45 - import QS from "query-string"; 40 + import type { Actions } from "@scripts/input/s3/worker"; 46 41 47 42 import type { Track } from "@applets/core/types.d.ts"; 48 - import { isAudioFile } from "@scripts/input/common"; 49 43 import { register } from "@scripts/applet/common"; 50 - import manifest from "./_manifest.json"; 51 - 52 - type Bucket = { 53 - accessKey: string; 54 - bucketName: string; 55 - host: string; 56 - path: string; 57 - region: string; 58 - secretKey: string; 59 - }; 60 - 61 - const ENCODINGS = { 62 - "\+": "%2B", 63 - "\!": "%21", 64 - '\"': "%22", 65 - "\#": "%23", 66 - "\$": "%24", 67 - "\&": "%26", 68 - "'": "%27", 69 - "\(": "%28", 70 - "\)": "%29", 71 - "\*": "%2A", 72 - "\,": "%2C", 73 - "\:": "%3A", 74 - "\;": "%3B", 75 - "\=": "%3D", 76 - "\?": "%3F", 77 - "\@": "%40", 78 - }; 44 + import { endpoint, inIframe } from "@scripts/common"; 79 45 80 46 //////////////////////////////////////////// 81 47 // SETUP 82 48 //////////////////////////////////////////// 83 - const IDB_PREFIX = "@applets/input/s3"; 84 - const IDB_BUCKETS = `${IDB_PREFIX}/buckets`; 85 - const SCHEME = manifest.input_properties.scheme; 49 + const worker = endpoint<Actions>( 50 + new Worker("../../../scripts/input/s3/worker", { type: "module" }), 51 + ); 86 52 87 53 // Register applet 88 54 const context = register(); 89 55 90 56 //////////////////////////////////////////// 91 - // UI 92 - //////////////////////////////////////////// 93 - const [buckets, setBuckets] = signal<Record<string, Bucket>>(await loadBuckets()); 94 - const [form, setForm] = signal<{ 95 - access_key?: string; 96 - bucket_name?: string; 97 - host?: string; 98 - path?: string; 99 - region?: string; 100 - secret_key?: string; 101 - }>({}); 102 - 103 - const bucketsMap = computed(() => { 104 - return new Map(Object.entries(buckets())); 105 - }); 106 - 107 - effect(() => { 108 - saveBuckets(buckets()); 109 - }); 110 - 111 - //////////////////////////////////////////// 112 - // UI ~ BUCKETS 113 - //////////////////////////////////////////// 114 - const Bucket = (bucket: Signal<Bucket>) => { 115 - const onclick = () => { 116 - const b = bucket(); 117 - const id = bucketId(b); 118 - 119 - const col = { ...buckets() }; 120 - delete col[id]; 121 - 122 - setBuckets(col); 123 - }; 124 - 125 - return tags.li({ onclick, style: "cursor: pointer" }, text(bucket().host)); 126 - }; 127 - 128 - const BucketList = computed(() => { 129 - if (bucketsMap().size === 0) { 130 - return tags.p({ id: "buckets" }, [tags.small({}, text("Nothing added so far."))]); 131 - } 132 - 133 - return tags.ul({ id: "buckets" }, repeat(bucketsMap, Bucket)); 134 - }); 135 - 136 - effect(() => { 137 - document.querySelector("#buckets")?.replaceWith(BucketList()); 138 - }); 139 - 140 - //////////////////////////////////////////// 141 - // UI ~ FORM 142 - //////////////////////////////////////////// 143 - function addBucket(event: Event) { 144 - event.preventDefault(); 145 - 146 - const f = form(); 147 - 148 - const bucket: Bucket = { 149 - accessKey: f.access_key || "", 150 - bucketName: f.bucket_name || "", 151 - host: f.host || "s3.amazonaws.com", 152 - path: f.path || "/", 153 - region: f.region || "us-east-1", 154 - secretKey: f.secret_key || "", 155 - }; 156 - 157 - setBuckets({ 158 - ...buckets(), 159 - [bucketId(bucket)]: bucket, 160 - }); 161 - } 162 - 163 - function Form() { 164 - return tags.form({ onsubmit: addBucket }, [ 165 - tags.fieldset({ className: "grid" }, [ 166 - Input("access_key", "Access key", "r31w7m9c", { required: true }), 167 - Input("secret_key", "Secret key", "v02g2l29", { required: true }), 168 - ]), 169 - tags.fieldset({ className: "grid" }, [ 170 - Input("bucket_name", "Bucket name", "bucket", { required: true }), 171 - Input("region", "Region", "us-east-1", { required: true }), 172 - ]), 173 - tags.fieldset({ className: "grid" }, [ 174 - Input("host", "Host", "s3.amazonaws.com", { required: true }), 175 - Input("path", "Path", "/"), 176 - ]), 177 - tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]), 178 - ]); 179 - } 180 - 181 - function Input(name: string, label: string, placeholder: string, opts: Props = {}) { 182 - return tags.label({}, [ 183 - tags.span({}, [ 184 - tags.span({}, text(label)), 185 - tags.small({}, text("required" in opts ? "" : " (optional)")), 186 - ]), 187 - tags.input({ 188 - ...opts, 189 - name, 190 - placeholder, 191 - oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value), 192 - }), 193 - ]); 194 - } 195 - 196 - function formInput(name: string, value: string) { 197 - setForm({ ...form(), [name]: value }); 198 - } 199 - 200 - // 🚀 201 - document.querySelector("#form")?.replaceWith(Form()); 202 - 203 - //////////////////////////////////////////// 204 57 // ACTIONS 205 58 //////////////////////////////////////////// 206 59 const consult = async (fileUriOrScheme: string) => { 207 - if (!navigator.onLine) 208 - return { supported: false, reason: "Internet connection is not available" }; 209 - 210 - // TODO: Check if bucket is avail*able + CORS works? 211 - return { supported: true }; 60 + return await worker.call.consult(fileUriOrScheme); 212 61 }; 213 62 214 63 const contextualize = async (tracks: Track[]) => { 215 - const b = bucketsFromTracks(tracks); 216 - setBuckets({ ...buckets(), ...b }); 64 + const s = await worker.call.contextualize(tracks); 65 + ui?.setBuckets({ ...ui?.buckets(), ...s }); 217 66 }; 218 67 219 68 const list = async (cachedTracks: Track[] = []) => { 220 - const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 221 - const uri = URI.parse(t.uri); 222 - if (!uri.path) return acc; 223 - return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 224 - }, {}); 225 - 226 - const promises = Object.values(buckets()).map(async (bucket) => { 227 - const client = createClient(bucket); 228 - 229 - const list = await Array.fromAsync( 230 - client.listObjects({ 231 - prefix: bucket.path.replace(/^\//, ""), 232 - }), 233 - ); 234 - 235 - return list 236 - .filter((l) => isAudioFile(l.key)) 237 - .map((l) => { 238 - const cachedTrack = cache[`/${l.key}`]; 239 - 240 - const id = cachedTrack?.id || crypto.randomUUID(); 241 - const stats = cachedTrack?.stats; 242 - const tags = cachedTrack?.tags; 243 - 244 - const track: Track = { 245 - id, 246 - stats, 247 - tags, 248 - uri: buildURI(bucket, l.key), 249 - }; 250 - 251 - return track; 252 - }); 253 - }); 254 - 255 - return (await Promise.all(promises)).flat(1); 69 + return await worker.call.list(cachedTracks); 256 70 }; 257 71 258 - const resolve = async ({ method, uri }: { method: string; uri: string }) => { 259 - const bucket = parseURI(uri); 260 - if (!bucket) return undefined; 261 - 262 - const client = createClient(bucket); 263 - const parsedURI = URI.parse(uri); 264 - const path = ( 265 - bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 266 - ).replace(/^\//, ""); 267 - 268 - const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 269 - const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 270 - const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 271 - 272 - return { expiresAt: expiresAtSeconds, url }; 72 + const resolve = async (args: { method: string; uri: string }) => { 73 + return await worker.call.resolve(args); 273 74 }; 274 75 275 76 const mount = async () => {}; ··· 284 85 context.setActionHandler("unmount", unmount); 285 86 286 87 //////////////////////////////////////////// 287 - // 🛠️ 88 + // UI 288 89 //////////////////////////////////////////// 289 - function bucketsFromTracks(tracks: Track[]) { 290 - return tracks.reduce((acc: Record<string, Bucket>, track: Track) => { 291 - const bucket = parseURI(track.uri); 292 - if (!bucket) return acc; 293 - 294 - const id = bucketId(bucket); 295 - if (acc[id]) return acc; 296 - 297 - return { ...acc, [id]: bucket }; 298 - }, {}); 299 - } 300 - 301 - function bucketId(bucket: Bucket) { 302 - return `${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; 303 - } 304 - 305 - function buildURI(bucket: Bucket, path: string) { 306 - return URI.serialize({ 307 - scheme: SCHEME, 308 - userinfo: `${bucket.accessKey}:${bucket.secretKey}`, 309 - host: bucket.host.replace(/^https?:\/\//, ""), 310 - path: path, 311 - query: QS.stringify({ 312 - bucketName: bucket.bucketName, 313 - bucketPath: bucket.path, 314 - region: bucket.region, 315 - }), 316 - }); 317 - } 318 - 319 - function createClient(bucket: Bucket) { 320 - return new S3Client({ 321 - bucket: bucket.bucketName, 322 - endPoint: `http${bucket.host.startsWith("localhost") ? "" : "s"}://${bucket.host}`, 323 - region: bucket.region, 324 - pathStyle: false, 325 - accessKey: bucket.accessKey, 326 - secretKey: bucket.secretKey, 327 - }); 328 - } 329 - 330 - function encodeAwsUriComponent(a: string) { 331 - return encodeURIComponent(a).replace( 332 - /(\+|!|"|#|\$|&|'|\(|\)|\*|\+|,|:|;|=|\?|@)/gim, 333 - (match) => (ENCODINGS as any)[match] ?? match, 334 - ); 335 - } 336 - 337 - async function loadBuckets() { 338 - const i = await IDB.get(IDB_BUCKETS); 339 - return i ? i : {}; 340 - } 341 - 342 - function parseURI(uriString: string): Bucket | undefined { 343 - const uri = URI.parse(uriString); 344 - if (uri.scheme !== SCHEME) return undefined; 345 - if (!uri.host) return undefined; 346 - 347 - const [accessKey, secretKey] = uri.userinfo?.split(":") ?? []; 348 - if (!accessKey || !secretKey) return undefined; 349 - 350 - const qs = QS.parse(uri.query || ""); 351 - 352 - return { 353 - accessKey, 354 - bucketName: typeof qs.bucketName === "string" ? qs.bucketName : "", 355 - host: uri.host, 356 - path: qs.bucketPath === "string" ? qs.bucketPath : "/", 357 - region: typeof qs.region === "string" ? qs.region : "", 358 - secretKey, 359 - }; 360 - } 361 - 362 - async function saveBuckets(items: Record<string, Bucket>) { 363 - await IDB.set(IDB_BUCKETS, items); 364 - } 90 + const ui = inIframe() ? undefined : await import("@scripts/input/s3/ui"); 365 91 </script>
-1
src/scripts/input/native-fs/common.ts
··· 1 - import { type FileSystemDirectoryHandle } from "native-file-system-adapter"; 2 1 import * as IDB from "idb-keyval"; 3 2 import * as URI from "uri-js"; 4 3 import QS from "query-string";
-2
src/scripts/input/native-fs/types.d.ts
··· 1 - import type { FileSystemDirectoryHandle } from "native-file-system-adapter"; 2 - 3 1 export type Handles = Record<string, FileSystemDirectoryHandle>;
+5 -3
src/scripts/input/native-fs/ui.ts
··· 1 1 import { computed, effect, type Signal } from "spellcaster"; 2 2 import { repeat, tags, text } from "spellcaster/hyperscript.js"; 3 - import { type FileSystemDirectoryHandle } from "native-file-system-adapter"; 4 3 5 - import { IDB_HANDLES } from "./constants"; 6 4 import { mount, mounts, unmount } from "./mounting"; 5 + import { isSupported } from "./common"; 7 6 8 7 //////////////////////////////////////////// 9 8 // SIGNALS 10 9 //////////////////////////////////////////// 11 10 12 11 // Mount button 13 - document.getElementById("mount")?.addEventListener("click", () => mount()); 12 + document.getElementById("mount")?.addEventListener("click", () => { 13 + if (isSupported()) mount(); 14 + else alert("The File System Access API is not supported on this platform."); 15 + }); 14 16 15 17 // Directories 16 18 const dirList = computed(() => {
+2 -18
src/scripts/input/native-fs/worker.ts
··· 1 - import { type FileSystemDirectoryHandle } from "native-file-system-adapter"; 2 1 import * as URI from "uri-js"; 3 2 4 3 import type { Track } from "@applets/core/types.d.ts"; 5 4 import { SCHEME } from "./constants"; 6 - import { 7 - fetchHandles, 8 - fetchHandlesList, 9 - isSupported, 10 - recursiveList, 11 - trackHandleId, 12 - } from "./common"; 5 + import { fetchHandles, fetchHandlesList, recursiveList, trackHandleId } from "./common"; 13 6 import { expose } from "@scripts/common"; 14 7 15 8 //////////////////////////////////////////// ··· 27 20 // Actions 28 21 29 22 export async function consult(fileUriOrScheme: string) { 30 - if (!isSupported()) { 23 + if (!self.FileSystemDirectoryHandle) { 31 24 return { supported: false, reason: "File System Access API is not supported" }; 32 25 } 33 26 ··· 45 38 export async function contextualize(cachedTracks: Track[]) {} 46 39 47 40 export async function list(cachedTracks: Track[] = []) { 48 - if (!isSupported()) { 49 - return cachedTracks; 50 - } 51 - 52 - // Continue if supported 53 41 const handles = await fetchHandlesList(); 54 42 55 43 // Recursive listing of all tracks of available handles ··· 98 86 99 87 export async function resolve(args: { uri: string }) { 100 88 const fileUri = args.uri; 101 - 102 - if (!isSupported()) { 103 - return undefined; 104 - } 105 89 106 90 const uri = URI.parse(fileUri); 107 91 if (uri.scheme !== SCHEME) return undefined;
+88
src/scripts/input/s3/common.ts
··· 1 + import { S3Client } from "@bradenmacdonald/s3-lite-client"; 2 + import * as IDB from "idb-keyval"; 3 + import * as URI from "uri-js"; 4 + import QS from "query-string"; 5 + 6 + import type { Track } from "@applets/core/types.d.ts"; 7 + import { ENCODINGS, IDB_BUCKETS, SCHEME } from "./constants"; 8 + import type { Bucket } from "./types"; 9 + 10 + //////////////////////////////////////////// 11 + // 🛠️ 12 + //////////////////////////////////////////// 13 + export function bucketsFromTracks(tracks: Track[]) { 14 + return tracks.reduce((acc: Record<string, Bucket>, track: Track) => { 15 + const bucket = parseURI(track.uri); 16 + if (!bucket) return acc; 17 + 18 + const id = bucketId(bucket); 19 + if (acc[id]) return acc; 20 + 21 + return { ...acc, [id]: bucket }; 22 + }, {}); 23 + } 24 + 25 + export function bucketId(bucket: Bucket) { 26 + return `${bucket.accessKey}:${bucket.secretKey}@${bucket.host}`; 27 + } 28 + 29 + export function buildURI(bucket: Bucket, path: string) { 30 + return URI.serialize({ 31 + scheme: SCHEME, 32 + userinfo: `${bucket.accessKey}:${bucket.secretKey}`, 33 + host: bucket.host.replace(/^https?:\/\//, ""), 34 + path: path, 35 + query: QS.stringify({ 36 + bucketName: bucket.bucketName, 37 + bucketPath: bucket.path, 38 + region: bucket.region, 39 + }), 40 + }); 41 + } 42 + 43 + export function createClient(bucket: Bucket) { 44 + return new S3Client({ 45 + bucket: bucket.bucketName, 46 + endPoint: `http${bucket.host.startsWith("localhost") ? "" : "s"}://${bucket.host}`, 47 + region: bucket.region, 48 + pathStyle: false, 49 + accessKey: bucket.accessKey, 50 + secretKey: bucket.secretKey, 51 + }); 52 + } 53 + 54 + export function encodeAwsUriComponent(a: string) { 55 + return encodeURIComponent(a).replace( 56 + /(\+|!|"|#|\$|&|'|\(|\)|\*|\+|,|:|;|=|\?|@)/gim, 57 + (match) => (ENCODINGS as any)[match] ?? match, 58 + ); 59 + } 60 + 61 + export async function loadBuckets(): Promise<Record<string, Bucket>> { 62 + const i = await IDB.get(IDB_BUCKETS); 63 + return i ? i : {}; 64 + } 65 + 66 + export function parseURI(uriString: string): Bucket | undefined { 67 + const uri = URI.parse(uriString); 68 + if (uri.scheme !== SCHEME) return undefined; 69 + if (!uri.host) return undefined; 70 + 71 + const [accessKey, secretKey] = uri.userinfo?.split(":") ?? []; 72 + if (!accessKey || !secretKey) return undefined; 73 + 74 + const qs = QS.parse(uri.query || ""); 75 + 76 + return { 77 + accessKey, 78 + bucketName: typeof qs.bucketName === "string" ? qs.bucketName : "", 79 + host: uri.host, 80 + path: qs.bucketPath === "string" ? qs.bucketPath : "/", 81 + region: typeof qs.region === "string" ? qs.region : "", 82 + secretKey, 83 + }; 84 + } 85 + 86 + export async function saveBuckets(items: Record<string, Bucket>) { 87 + await IDB.set(IDB_BUCKETS, items); 88 + }
+24
src/scripts/input/s3/constants.ts
··· 1 + import manifest from "../../../pages/input/s3/_manifest.json"; 2 + 3 + export const IDB_PREFIX = "@applets/input/s3"; 4 + export const IDB_BUCKETS = `${IDB_PREFIX}/buckets`; 5 + export const SCHEME = manifest.input_properties.scheme; 6 + 7 + export const ENCODINGS = { 8 + "\+": "%2B", 9 + "\!": "%21", 10 + '\"': "%22", 11 + "\#": "%23", 12 + "\$": "%24", 13 + "\&": "%26", 14 + "'": "%27", 15 + "\(": "%28", 16 + "\)": "%29", 17 + "\*": "%2A", 18 + "\,": "%2C", 19 + "\:": "%3A", 20 + "\;": "%3B", 21 + "\=": "%3D", 22 + "\?": "%3F", 23 + "\@": "%40", 24 + };
+8
src/scripts/input/s3/types.d.ts
··· 1 + export type Bucket = { 2 + accessKey: string; 3 + bucketName: string; 4 + host: string; 5 + path: string; 6 + region: string; 7 + secretKey: string; 8 + };
+118
src/scripts/input/s3/ui.ts
··· 1 + import { computed, effect, type Signal, signal } from "spellcaster"; 2 + import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 3 + 4 + import type { Bucket } from "./types"; 5 + import { bucketId, loadBuckets, saveBuckets } from "./common"; 6 + 7 + //////////////////////////////////////////// 8 + // UI 9 + //////////////////////////////////////////// 10 + export const [buckets, setBuckets] = signal<Record<string, Bucket>>(await loadBuckets()); 11 + export const [form, setForm] = signal<{ 12 + access_key?: string; 13 + bucket_name?: string; 14 + host?: string; 15 + path?: string; 16 + region?: string; 17 + secret_key?: string; 18 + }>({}); 19 + 20 + export const bucketsMap = computed(() => { 21 + return new Map(Object.entries(buckets())); 22 + }); 23 + 24 + effect(() => { 25 + saveBuckets(buckets()); 26 + }); 27 + 28 + //////////////////////////////////////////// 29 + // UI ~ BUCKETS 30 + //////////////////////////////////////////// 31 + const Bucket = (bucket: Signal<Bucket>) => { 32 + const onclick = () => { 33 + const b = bucket(); 34 + const id = bucketId(b); 35 + 36 + const col = { ...buckets() }; 37 + delete col[id]; 38 + 39 + setBuckets(col); 40 + }; 41 + 42 + return tags.li({ onclick, style: "cursor: pointer" }, text(bucket().host)); 43 + }; 44 + 45 + const BucketList = computed(() => { 46 + if (bucketsMap().size === 0) { 47 + return tags.p({ id: "buckets" }, [tags.small({}, text("Nothing added so far."))]); 48 + } 49 + 50 + return tags.ul({ id: "buckets" }, repeat(bucketsMap, Bucket)); 51 + }); 52 + 53 + effect(() => { 54 + document.querySelector("#buckets")?.replaceWith(BucketList()); 55 + }); 56 + 57 + //////////////////////////////////////////// 58 + // UI ~ FORM 59 + //////////////////////////////////////////// 60 + function addBucket(event: Event) { 61 + event.preventDefault(); 62 + 63 + const f = form(); 64 + 65 + const bucket: Bucket = { 66 + accessKey: f.access_key || "", 67 + bucketName: f.bucket_name || "", 68 + host: f.host || "s3.amazonaws.com", 69 + path: f.path || "/", 70 + region: f.region || "us-east-1", 71 + secretKey: f.secret_key || "", 72 + }; 73 + 74 + setBuckets({ 75 + ...buckets(), 76 + [bucketId(bucket)]: bucket, 77 + }); 78 + } 79 + 80 + function Form() { 81 + return tags.form({ onsubmit: addBucket }, [ 82 + tags.fieldset({ className: "grid" }, [ 83 + Input("access_key", "Access key", "r31w7m9c", { required: true }), 84 + Input("secret_key", "Secret key", "v02g2l29", { required: true }), 85 + ]), 86 + tags.fieldset({ className: "grid" }, [ 87 + Input("bucket_name", "Bucket name", "bucket", { required: true }), 88 + Input("region", "Region", "us-east-1", { required: true }), 89 + ]), 90 + tags.fieldset({ className: "grid" }, [ 91 + Input("host", "Host", "s3.amazonaws.com", { required: true }), 92 + Input("path", "Path", "/"), 93 + ]), 94 + tags.fieldset({ className: "grid" }, [tags.input({ type: "submit", value: "Connect" }, [])]), 95 + ]); 96 + } 97 + 98 + function Input(name: string, label: string, placeholder: string, opts: Props = {}) { 99 + return tags.label({}, [ 100 + tags.span({}, [ 101 + tags.span({}, text(label)), 102 + tags.small({}, text("required" in opts ? "" : " (optional)")), 103 + ]), 104 + tags.input({ 105 + ...opts, 106 + name, 107 + placeholder, 108 + oninput: (event: InputEvent) => formInput(name, (event.target as HTMLInputElement).value), 109 + }), 110 + ]); 111 + } 112 + 113 + function formInput(name: string, value: string) { 114 + setForm({ ...form(), [name]: value }); 115 + } 116 + 117 + // 🚀 118 + document.querySelector("#form")?.replaceWith(Form());
+89
src/scripts/input/s3/worker.ts
··· 1 + import * as URI from "uri-js"; 2 + 3 + import type { Track } from "@applets/core/types.d.ts"; 4 + import { isAudioFile } from "@scripts/input/common"; 5 + import { bucketsFromTracks, buildURI, createClient, loadBuckets, parseURI } from "./common"; 6 + import { expose } from "@scripts/common"; 7 + 8 + //////////////////////////////////////////// 9 + // ACTIONS 10 + //////////////////////////////////////////// 11 + const actions = expose({ 12 + consult, 13 + contextualize, 14 + list, 15 + resolve, 16 + }); 17 + 18 + export type Actions = typeof actions; 19 + 20 + // Actions 21 + 22 + async function consult(fileUriOrScheme: string) { 23 + if (!navigator.onLine) 24 + return { supported: false, reason: "Internet connection is not available" }; 25 + 26 + // TODO: Check if bucket is available + CORS works? 27 + return { supported: true }; 28 + } 29 + 30 + async function contextualize(tracks: Track[]) { 31 + return bucketsFromTracks(tracks); 32 + } 33 + 34 + async function list(cachedTracks: Track[] = []) { 35 + const cache = cachedTracks.reduce((acc: Record<string, Track>, t: Track) => { 36 + const uri = URI.parse(t.uri); 37 + if (!uri.path) return acc; 38 + return { ...acc, [URI.unescapeComponent(uri.path)]: t }; 39 + }, {}); 40 + 41 + const buckets = await loadBuckets(); 42 + const promises = Object.values(buckets).map(async (bucket) => { 43 + const client = createClient(bucket); 44 + 45 + const list = await Array.fromAsync( 46 + client.listObjects({ 47 + prefix: bucket.path.replace(/^\//, ""), 48 + }), 49 + ); 50 + 51 + return list 52 + .filter((l) => isAudioFile(l.key)) 53 + .map((l) => { 54 + const cachedTrack = cache[`/${l.key}`]; 55 + 56 + const id = cachedTrack?.id || crypto.randomUUID(); 57 + const stats = cachedTrack?.stats; 58 + const tags = cachedTrack?.tags; 59 + 60 + const track: Track = { 61 + id, 62 + stats, 63 + tags, 64 + uri: buildURI(bucket, l.key), 65 + }; 66 + 67 + return track; 68 + }); 69 + }); 70 + 71 + return (await Promise.all(promises)).flat(1); 72 + } 73 + 74 + async function resolve({ method, uri }: { method: string; uri: string }) { 75 + const bucket = parseURI(uri); 76 + if (!bucket) return undefined; 77 + 78 + const client = createClient(bucket); 79 + const parsedURI = URI.parse(uri); 80 + const path = ( 81 + bucket.path.replace(/\/$/, "") + URI.unescapeComponent(parsedURI.path || "") 82 + ).replace(/^\//, ""); 83 + 84 + const expiresInSeconds = 60 * 60 * 24 * 7; // 7 days 85 + const expiresAtSeconds = Math.round(Date.now() / 1000) + expiresInSeconds; 86 + const url = await client.getPresignedUrl(method.toUpperCase() as any, path); 87 + 88 + return { expiresAt: expiresAtSeconds, url }; 89 + }