import { use, useEffect, useState } from "react";
import { usePage } from "./framework.jsx";
function resourceCache() {
if (!globalThis.__HAVEN_ASYNC_RESOURCE_CACHE__) {
globalThis.__HAVEN_ASYNC_RESOURCE_CACHE__ = new Map();
}
return globalThis.__HAVEN_ASYNC_RESOURCE_CACHE__;
}
function stableKeyPart(value) {
if (value == null) {
return "null";
}
if (typeof value === "string") {
return value;
}
return JSON.stringify(sortJsonValue(value));
}
function sortJsonValue(value) {
if (Array.isArray(value)) {
return value.map(sortJsonValue);
}
if (value && typeof value === "object" && value.constructor === Object) {
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, sortJsonValue(value[key])]),
);
}
return value;
}
function pageScope() {
return globalThis.__RUNTIME_PAGE__?.id ?? "global";
}
function pageResources() {
return globalThis.__RUNTIME_PAGE__?.meta?.resources ?? {};
}
function hasResource(name) {
return Object.prototype.hasOwnProperty.call(pageResources(), name);
}
function readResourceValue(name) {
return pageResources()[name];
}
export function createAsyncResource(name, loader, options = {}) {
function key(input) {
const customKey = options.key?.(input);
const suffix = customKey ?? stableKeyPart(input);
return `${pageScope()}:${name}:${suffix}`;
}
function preload(input) {
const cache = resourceCache();
const cacheKey = key(input);
if (!cache.has(cacheKey)) {
cache.set(cacheKey, Promise.resolve().then(() => loader(input)));
}
return cache.get(cacheKey);
}
function read(input) {
return use(preload(input));
}
function clear(input) {
resourceCache().delete(key(input));
}
return {
name,
key,
preload,
read,
clear,
};
}
export function useAsyncResource(resource, input) {
return resource.read(input);
}
export function usePageResources() {
const page = usePage();
return page.meta?.resources ?? {};
}
export function createServerResource(name, options = {}) {
function read(input) {
if (!hasResource(name)) {
if ("fallback" in options) {
return options.fallback;
}
throw new Error(`Missing server resource: ${name}`);
}
const value = readResourceValue(name);
return options.select ? options.select(value, input) : value;
}
return {
name,
read,
};
}
export function useServerResource(resourceOrName, input, options = {}) {
const resource =
typeof resourceOrName === "string"
? createServerResource(resourceOrName, options)
: resourceOrName;
return resource.read(input);
}
export function createRequestResource(name, loader, options = {}) {
const asyncResource = createAsyncResource(name, loader, options);
function read(input) {
if (hasResource(name)) {
const value = readResourceValue(name);
return options.select ? options.select(value, input) : value;
}
return asyncResource.read(input);
}
function preload(input) {
if (hasResource(name)) {
return Promise.resolve(read(input));
}
return asyncResource.preload(input);
}
return {
name,
key: asyncResource.key,
preload,
read,
clear: asyncResource.clear,
};
}
export async function* streamJsonLines(url, options = {}) {
const response = await fetch(url, {
credentials: "same-origin",
...options,
});
if (!response.ok) {
throw new Error(`Stream request failed with status ${response.status}`);
}
if (!response.body) {
throw new Error("Streaming response body is not available");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
yield JSON.parse(line);
}
newlineIndex = buffer.indexOf("\n");
}
}
const trailing = buffer + decoder.decode();
if (trailing.trim()) {
yield JSON.parse(trailing.trim());
}
}
export function createBrowserStreamResource(name, options = {}) {
function key(input) {
const customKey = options.key?.(input);
const suffix = customKey ?? stableKeyPart(input);
return `${name}:${suffix}`;
}
function resolveUrl(input) {
if (typeof options.url === "function") {
return options.url(input);
}
return options.url;
}
function stream(input, streamOptions = {}) {
const url = resolveUrl(input);
if (!url) {
throw new Error(`Missing stream URL for browser resource: ${name}`);
}
const fetchOptions =
typeof options.fetchOptions === "function"
? options.fetchOptions(input)
: options.fetchOptions;
return streamJsonLines(url, {
...fetchOptions,
...streamOptions,
signal: streamOptions.signal ?? fetchOptions?.signal,
headers: {
Accept: "application/x-ndjson, application/json;q=0.9, text/plain;q=0.8",
...(fetchOptions?.headers ?? {}),
...(streamOptions.headers ?? {}),
},
});
}
return {
name,
key,
stream,
};
}
export function useBrowserStream(resource, input) {
const [state, setState] = useState(() => ({
items: [],
events: [],
loading: true,
done: false,
error: null,
}));
const resourceKey = resource.key?.(input) ?? stableKeyPart(input);
useEffect(() => {
const controller = new AbortController();
let active = true;
setState({
items: [],
events: [],
loading: true,
done: false,
error: null,
});
async function load() {
try {
for await (const event of resource.stream(input, { signal: controller.signal })) {
if (!active) {
return;
}
setState((current) => {
const next = {
...current,
events: [...current.events, event],
};
if (event?.type === "item") {
next.items = [...current.items, event.data];
} else if (event?.type === "done") {
next.done = true;
next.loading = false;
} else if (event?.type === "error") {
next.error = event.message ?? "Stream failed";
next.done = true;
next.loading = false;
}
return next;
});
}
if (active) {
setState((current) => ({
...current,
done: true,
loading: false,
}));
}
} catch (error) {
if (!active || error?.name === "AbortError") {
return;
}
setState((current) => ({
...current,
error: error instanceof Error ? error.message : String(error),
done: true,
loading: false,
}));
}
}
load();
return () => {
active = false;
controller.abort();
};
}, [resource, resourceKey]);
return state;
}
export function clearAsyncResourcesForPage(pageId = pageScope()) {
const cache = resourceCache();
const prefix = `${pageId}:`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
cache.delete(key);
}
}
}