elliott/resources
Elliott Johnson 1 week ago
parent da3260f99a
commit 4d766f87b9

@ -168,6 +168,7 @@
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"acorn": "^8.12.1",
@ -180,6 +181,7 @@
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"path-to-regexp": "^8.3.0",
"zimmerframe": "^1.1.2"
}
}

@ -0,0 +1,66 @@
/** @import { Resource } from './resource.js' */
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
import { compile } from 'path-to-regexp';
import { create_resource } from './create-resource.js';
/**
* @template {Record<string, unknown>} TPathParams
* @typedef {{ searchParams?: ConstructorParameters<typeof URLSearchParams>[0], pathParams?: TPathParams } & RequestInit} FetcherInit
*/
/**
* @template TReturn
* @template {Record<string, unknown>} TPathParams
* @typedef {(init: FetcherInit<TPathParams>) => Resource<TReturn>} Fetcher
*/
/**
* @template {Record<string, unknown>} TPathParams
* @template {typeof Resource} TResource
* @overload
* @param {string} url
* @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support?
* @returns {Fetcher<string | number | Record<string, unknown> | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc)
*/
/**
* @template {Record<string, unknown>} TPathParams
* @template {StandardSchemaV1} TSchema
* @template {typeof Resource} TResource
* @overload
* @param {string} url
* @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options
* @returns {Fetcher<StandardSchemaV1.InferOutput<TSchema>, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc)
*/
/**
* @template {Record<string, unknown>} TPathParams
* @template {typeof Resource} TResource
* @template {StandardSchemaV1} TSchema
* @param {string} url
* @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options]
*/
export function create_fetcher(url, options) {
const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? '';
const populate_path = compile(raw_pathname);
/**
* @param {Parameters<Fetcher<any, any>>[0]} args
* @returns {Promise<any>}
*/
const fn = async (args) => {
const cloned_url = new URL(url);
const new_params = new URLSearchParams(args.searchParams);
const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]);
cloned_url.search = combined_params.toString();
cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API
// TODO how to populate path params
const resp = await fetch(cloned_url, args);
if (!resp.ok) {
throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
if (options?.schema) {
return options.schema['~standard'].validate(json);
}
return json;
};
return create_resource(url.toString(), fn, options);
}

@ -0,0 +1,75 @@
import { hydratable } from '../../context.js';
import { tick } from '../../runtime';
import { render_effect } from '../effects';
import { Resource } from './resource.js';
/** @typedef {{ count: number, resource: Resource<any> }} Entry */
/** @type {Map<string, Entry>} */
const cache = new Map();
/**
* @template TReturn
* @template {unknown[]} [TArgs=[]]
* @template {typeof Resource} [TResource=typeof Resource]
* @param {string} name
* @param {(...args: TArgs) => Promise<TReturn>} fn
* @param {{ Resource?: TResource }} [options]
* @returns {(...args: TArgs) => Resource<TReturn>}
*/
export function create_resource(name, fn, options) {
const ResolvedResource = options?.Resource ?? Resource;
return (...args) => {
const stringified_args = JSON.stringify(args);
const cache_key = `${name}:${stringified_args}`;
let entry = cache.get(cache_key);
const maybe_remove = create_remover(cache_key);
let tracking = true;
try {
render_effect(() => {
if (entry) entry.count++;
return () => {
const entry = cache.get(cache_key);
if (!entry) return;
entry.count--;
maybe_remove(entry, cache);
};
});
} catch {
tracking = false;
}
let resource = entry?.resource;
if (!resource) {
resource = new ResolvedResource(() => hydratable(cache_key, () => fn(...args)));
const entry = {
resource,
count: tracking ? 1 : 0
};
cache.set(cache_key, entry);
resource.then(
() => maybe_remove(entry, cache),
() => maybe_remove(entry, cache)
);
}
return resource;
};
}
/**
* @param {string} key
*/
function create_remover(key) {
/**
* @param {Entry | undefined} entry
* @param {Map<string, Entry>} cache
*/
return (entry, cache) =>
tick().then(() => {
if (!entry?.count && entry === cache.get(key)) {
cache.delete(key);
}
});
}

@ -1,6 +1,6 @@
/** @import { Source, Derived } from '#client' */
import { state, derived, set, get, tick } from 'svelte/internal/client';
import { deferred, noop } from '../internal/shared/utils';
import { state, derived, set, get, tick } from '../../index.js';
import { deferred, noop } from '../../../shared/utils.js';
/**
* @template T
@ -33,8 +33,6 @@ export class Resource {
return get(this.#raw);
});
#onrefresh;
/** {@type Source<any>} */
#error = state(undefined);
@ -58,19 +56,18 @@ export class Resource {
/**
* @param {() => Promise<T>} fn
* @param {() => Promise<T>} [init]
* @param {() => void} [onrefresh]
*/
constructor(fn, init = fn, onrefresh = noop) {
constructor(fn) {
this.#fn = fn;
this.#promise = state(this.#run(init));
this.#onrefresh = onrefresh;
this.#promise = state(this.#run());
}
/** @param {() => Promise<T>} fn */
#run(fn = this.#fn) {
#run() {
if (this.#init) {
set(this.#loading, true);
tick().then(() => {
// opt this out of async coordination
set(this.#loading, true);
});
} else {
this.#init = true;
}
@ -79,7 +76,7 @@ export class Resource {
this.#latest.push(resolve);
Promise.resolve(fn())
Promise.resolve(this.#fn())
.then((value) => {
// Skip the response if resource was refreshed with a later promise while we were waiting for this one to resolve
const idx = this.#latest.indexOf(resolve);
@ -152,21 +149,20 @@ export class Resource {
/**
* @returns {Promise<void>}
*/
refresh() {
this.#onrefresh();
refresh = () => {
const promise = this.#run();
set(this.#promise, promise);
return promise;
}
};
/**
* @param {T} value
*/
set(value) {
set = (value) => {
set(this.#ready, true);
set(this.#loading, false);
set(this.#error, undefined);
set(this.#raw, value);
set(this.#promise, Promise.resolve());
}
};
}

@ -5,4 +5,6 @@ export { SvelteURL } from './url.js';
export { SvelteURLSearchParams } from './url-search-params.js';
export { MediaQuery } from './media-query.js';
export { createSubscriber } from './create-subscriber.js';
export { Resource } from './resource.js';
export { Resource } from '../internal/client/reactivity/resources/resource.js';
export { create_resource as createResource } from '../internal/client/reactivity/resources/create-resource.js';
export { create_fetcher as createFetcher } from '../internal/client/reactivity/resources/create-fetcher.js';

@ -1,3 +1,7 @@
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
import { compile } from 'path-to-regexp';
import { hydratable } from '../internal/server/context.js';
export const SvelteDate = globalThis.Date;
export const SvelteSet = globalThis.Set;
export const SvelteMap = globalThis.Map;
@ -38,10 +42,9 @@ export class Resource {
/**
* @param {() => Promise<T>} fn
* @param {() => Promise<T>} [init]
*/
constructor(fn, init = fn) {
this.#promise = Promise.resolve(init()).then(
constructor(fn) {
this.#promise = Promise.resolve(fn()).then(
(val) => {
this.#ready = true;
this.#loading = false;
@ -94,18 +97,107 @@ export class Resource {
return this.#ready;
}
refresh() {
refresh = () => {
throw new Error('TODO Cannot refresh a resource on the server');
}
};
/**
* @param {T} value
*/
set(value) {
set = (value) => {
this.#ready = true;
this.#loading = false;
this.#error = undefined;
this.#current = value;
this.#promise = Promise.resolve();
}
};
}
/** @type {Map<string, Resource<any>>} */
// TODO scope to render, clear after render
const cache = new Map();
/**
* @template TReturn
* @template {unknown[]} [TArgs=[]]
* @template {typeof Resource} [TResource=typeof Resource]
* @param {string} name
* @param {(...args: TArgs) => Promise<TReturn>} fn
* @param {{ Resource?: TResource }} [options]
* @returns {(...args: TArgs) => Resource<TReturn>}
*/
export function createResource(name, fn, options) {
const ResolvedResource = options?.Resource ?? Resource;
return (...args) => {
const stringified_args = JSON.stringify(args);
const cache_key = `${name}:${stringified_args}`;
const entry = cache.get(cache_key);
if (entry) {
return entry;
}
const resource = new ResolvedResource(() => hydratable(cache_key, () => fn(...args)));
cache.set(cache_key, resource);
return resource;
};
}
/**
* @template {Record<string, unknown>} TPathParams
* @typedef {{ searchParams?: ConstructorParameters<typeof URLSearchParams>[0], pathParams?: TPathParams } & RequestInit} FetcherInit
*/
/**
* @template TReturn
* @template {Record<string, unknown>} TPathParams
* @typedef {(init: FetcherInit<TPathParams>) => Resource<TReturn>} Fetcher
*/
/**
* @template {Record<string, unknown>} TPathParams
* @template {typeof Resource} TResource
* @overload
* @param {string} url
* @param {{ Resource?: TResource, schema?: undefined }} [options] - TODO what options should we support?
* @returns {Fetcher<string | number | Record<string, unknown> | unknown[] | boolean | null, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc)
*/
/**
* @template {Record<string, unknown>} TPathParams
* @template {StandardSchemaV1} TSchema
* @template {typeof Resource} TResource
* @overload
* @param {string} url
* @param {{ Resource?: TResource, schema: StandardSchemaV1 }} options
* @returns {Fetcher<StandardSchemaV1.InferOutput<TSchema>, TPathParams>} - TODO this return type has to be gnarly unless we do schema validation, as it could be any JSON value (including string, number, etc)
*/
/**
* @template {Record<string, unknown>} TPathParams
* @template {typeof Resource} TResource
* @template {StandardSchemaV1} TSchema
* @param {string} url
* @param {{ Resource?: TResource, schema?: StandardSchemaV1 }} [options]
*/
export function createFetcher(url, options) {
const raw_pathname = url.split('//')[1].match(/(\/[^?#]*)/)?.[1] ?? '';
const populate_path = compile(raw_pathname);
/**
* @param {Parameters<Fetcher<any, any>>[0]} args
* @returns {Promise<any>}
*/
const fn = async (args) => {
const cloned_url = new URL(url);
const new_params = new URLSearchParams(args.searchParams);
const combined_params = new URLSearchParams([...cloned_url.searchParams, ...new_params]);
cloned_url.search = combined_params.toString();
cloned_url.pathname = populate_path(args.pathParams ?? {}); // TODO we definitely should get rid of this lib for launch, I just wanted to play with the API
// TODO how to populate path params
const resp = await fetch(cloned_url, args);
if (!resp.ok) {
throw new Error(`Fetch error: ${resp.status} ${resp.statusText}`);
}
const json = await resp.json();
if (options?.schema) {
return options.schema['~standard'].validate(json);
}
return json;
};
return createResource(url.toString(), fn, options);
}

@ -2129,6 +2129,7 @@ declare module 'svelte/motion' {
}
declare module 'svelte/reactivity' {
import type { StandardSchemaV1 } from '@standard-schema/spec';
/**
* A reactive version of the built-in [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object.
* Reading the date (whether with methods like `date.getTime()` or `date.toString()`, or via things like [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat))
@ -2394,7 +2395,7 @@ declare module 'svelte/reactivity' {
export function createSubscriber(start: (update: () => void) => (() => void) | void): () => void;
export class Resource<T> implements Partial<Promise<T>> {
constructor(fn: () => Promise<T>, init?: (() => Promise<T>) | undefined);
constructor(fn: () => Promise<T>);
get then(): <TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined) => Promise<TResult1 | TResult2>;
get catch(): (reject: any) => Promise<T>;
get finally(): (fn: any) => Promise<any>;
@ -2409,11 +2410,28 @@ declare module 'svelte/reactivity' {
*/
get ready(): boolean;
refresh(): Promise<void>;
refresh: () => Promise<void>;
set(value: T): void;
set: (value: T) => void;
#private;
}
export function createResource<TReturn, TArgs extends unknown[] = [], TResource extends typeof Resource = typeof Resource>(name: string, fn: (...args: TArgs) => Promise<TReturn>, options?: {
Resource?: TResource;
} | undefined): (...args: TArgs) => Resource<TReturn>;
export function createFetcher<TPathParams extends Record<string, unknown>, TResource extends typeof Resource>(url: string, options?: {
Resource?: TResource;
schema?: undefined;
} | undefined): Fetcher<string | number | Record<string, unknown> | unknown[] | boolean | null, TPathParams>;
export function createFetcher<TPathParams extends Record<string, unknown>, TSchema extends StandardSchemaV1, TResource extends typeof Resource>(url: string, options: {
Resource?: TResource;
schema: StandardSchemaV1;
}): Fetcher<StandardSchemaV1.InferOutput<TSchema>, TPathParams>;
type FetcherInit<TPathParams extends Record<string, unknown>> = {
searchParams?: ConstructorParameters<typeof URLSearchParams>[0];
pathParams?: TPathParams;
} & RequestInit;
type Fetcher<TReturn, TPathParams extends Record<string, unknown>> = (init: FetcherInit<TPathParams>) => Resource<TReturn>;
class ReactiveValue<T> {
constructor(fn: () => T, onsubscribe: (update: () => void) => void);

@ -27,7 +27,7 @@ polka()
const { default: App } = await vite.ssrLoadModule('/src/App.svelte');
const { head, body } = await render(App);
console.log(head);
const html = transformed_template
.replace(`<!--ssr-head-->`, head)
.replace(`<!--ssr-body-->`, body)

@ -71,6 +71,9 @@ importers:
'@jridgewell/sourcemap-codec':
specifier: ^1.5.0
version: 1.5.0
'@standard-schema/spec':
specifier: ^1.0.0
version: 1.0.0
'@sveltejs/acorn-typescript':
specifier: ^1.0.5
version: 1.0.5(acorn@8.15.0)
@ -107,6 +110,9 @@ importers:
magic-string:
specifier: ^0.30.11
version: 0.30.17
path-to-regexp:
specifier: ^8.3.0
version: 8.3.0
zimmerframe:
specifier: ^1.1.2
version: 1.1.2
@ -825,6 +831,9 @@ packages:
cpu: [x64]
os: [win32]
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@stylistic/eslint-plugin-js@1.8.0':
resolution: {integrity: sha512-jdvnzt+pZPg8TfclZlTZPiUbbima93ylvQ+wNgHLNmup3obY6heQvgewSu9i2CfS61BnRByv+F9fxQLPoNeHag==}
engines: {node: ^16.0.0 || >=18.0.0}
@ -1921,6 +1930,9 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
@ -3102,6 +3114,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.50.1':
optional: true
'@standard-schema/spec@1.0.0': {}
'@stylistic/eslint-plugin-js@1.8.0(eslint@9.9.1)':
dependencies:
'@types/eslint': 8.56.12
@ -4283,6 +4297,8 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-to-regexp@8.3.0: {}
path-type@4.0.0: {}
pathe@1.1.2: {}

Loading…
Cancel
Save