haven 0.1.5

Actix + React + Vite integration for server-rendered applications
Documentation
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);
    }
  }
}