diff --git a/.changeset/add-web-data-subpath.md b/.changeset/add-web-data-subpath.md new file mode 100644 index 000000000..702ac64af --- /dev/null +++ b/.changeset/add-web-data-subpath.md @@ -0,0 +1,11 @@ +--- +"@solidjs/web": minor +--- + +New `@solidjs/web/data` subpath: router-agnostic `query()` and `action()` (ported from @solidjs/router's next branch) plus a single-flight query channel, extracted from app experiments on TanStack Router and @solidjs/router hosts where the same files ran unchanged. + +- `query(fn, name)` — request-deduped async cache. Same key (name + stable argument hash) in a route loader/preload and a component memo returns the same promise; Solid 2 memos unwrap it into the nearest ``. Per-request cache in `event.locals` on the server, module-level with observed/timed freshness on the client. Server functions are auto-declared `GET`. `revalidate`, `query.get/set/delete/clear`, and `collectQueries`/`seedQueries` (an explicit SSR seeding channel for hosts that warm the cache outside the render tree) included. +- `action(fn)` — form-bindable mutations: `toString()` renders the server function's real `.url` into `
` (no-JS posts work natively); a document-level submit listener intercepts matching forms and calls the RPC stub. +- Single-flight: `installQueryFlightConsumer()` (client) applies post-mutation query values from the mutation response via `query.set`; `createQueryFlightCollector(warmQueries)` on `@solidjs/web/data/server` produces the `collectFlightData` hook — the host supplies only `warmQueries(href, outcome)`, re-running that location's data loading (TanStack: build a router and `load()`; @solidjs/router: delegate to `createFlightDataCollector`). The `/server` split keeps `@solidjs/web/storage` (node:async_hooks) out of client bundles. + +Not yet ported from solid-router: submission state (`useSubmission`), redirect/Response handling, and preload intent semantics. diff --git a/packages/solid-web/data/package.json b/packages/solid-web/data/package.json new file mode 100644 index 000000000..1595637a3 --- /dev/null +++ b/packages/solid-web/data/package.json @@ -0,0 +1,30 @@ +{ + "name": "@solidjs/web/data", + "main": "./dist/data.cjs", + "module": "./dist/data.js", + "types": "./types/index.d.ts", + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./types/index.d.ts", + "default": "./dist/data.js" + }, + "require": { + "types": "./types-cjs/index.d.cts", + "default": "./dist/data.cjs" + } + }, + "./server": { + "import": { + "types": "./types/server.d.ts", + "default": "./dist/server.js" + }, + "require": { + "types": "./types-cjs/server.d.cts", + "default": "./dist/server.cjs" + } + } + } +} diff --git a/packages/solid-web/data/src/action.ts b/packages/solid-web/data/src/action.ts new file mode 100644 index 000000000..e8d812e18 --- /dev/null +++ b/packages/solid-web/data/src/action.ts @@ -0,0 +1,49 @@ +/** + * `action()` — form-bindable mutations, after @solidjs/router's `action()`. + * + * Server function references carry a stable `.url`, so `toString()` renders + * the real endpoint into `` — without JS the browser POSTs + * there natively (the transport handles no-JS form posts). With JS, a single + * document-level submit listener intercepts matching forms and calls the RPC + * stub instead; non-GET calls opt into single-flight automatically while a + * flight-data consumer is subscribed, so post-mutation query data rides back + * on the response (see ./flight.ts). + * + * The function receives the form's FormData when it declares a parameter. + * TODO: submission state (useSubmission) and redirect handling. + */ +import { isServer } from "@solidjs/web"; +import type { JSX } from "@solidjs/web"; +import { isServerFunction } from "@solidjs/web/server-functions"; + +export type Action = ((...args: A) => Promise) & + JSX.SerializableAttributeValue & { url: string }; + +const actions = new Map Promise>(); + +if (!isServer) { + document.addEventListener("submit", event => { + const form = event.target as HTMLFormElement; + const submitter = event.submitter; + const url = submitter?.getAttribute("formaction") + ? (submitter as HTMLButtonElement).formAction + : form.action; + const handler = actions.get(url); + if (!handler) return; + event.preventDefault(); + void handler(new FormData(form, submitter)); + }); +} + +export function action(fn: (...args: A) => Promise): Action { + if (!isServerFunction(fn)) throw new Error("action() expects a server function"); + const wrapper = ((...args: A) => fn(...args)) as Action; + wrapper.url = fn.url; + wrapper.toString = () => fn.url; + // key by resolved URL — form.action reads back absolute + if (!isServer) + actions.set(new URL(fn.url, window.location.href).href, formData => + fn.length > 0 ? fn(...([formData] as unknown as A)) : (fn as () => Promise)() + ); + return wrapper; +} diff --git a/packages/solid-web/data/src/flight.ts b/packages/solid-web/data/src/flight.ts new file mode 100644 index 000000000..e9a5c9c3c --- /dev/null +++ b/packages/solid-web/data/src/flight.ts @@ -0,0 +1,49 @@ +/** + * Single-flight mutations, client half + shared contract. + * + * The mutation's response carries the post-mutation values for the queries the + * page is showing, so the UI updates without a follow-up read. The server half + * (`createQueryFlightCollector`) lives on the `/server` subpath — it pulls in + * `provideRequestEvent` (node:async_hooks), which doesn't belong in client + * bundles. + */ +// Bare specifier on purpose: the compiled server-function references import +// '@solidjs/web/server-functions', and bundlers give each specifier its own +// module instance — the '/client' subpath would register the consumer in a copy +// the transport never reads. It also keeps this module importable from an SSR +// graph: the server build exports `subscribeFlightData` too. +import { subscribeFlightData } from "@solidjs/web/server-functions"; +import { isServer } from "@solidjs/web"; +import { query, revalidate } from "./query.js"; + +export interface QueryFlightData { + href: string; + /** Post-mutation query results, resolved on the server: key -> value. */ + queries: Record; +} + +/** + * Registers the flight-data consumer that applies single-flight query payloads. + * Call once on the client, before mutations can run (the client entry is the + * natural place). Subscribing IS the single-flight opt-in: the transport only + * sends the request-leg header, and the server only collects, while a consumer + * is registered — and only one can be active, so register it after any other + * integration that would claim the slot. + */ +export function installQueryFlightConsumer() { + if (isServer) return; + + subscribeFlightData(data => { + if (!data?.queries) return; + // The payload describes the location the mutation ran against; if the + // user navigated (or the mutation redirected) while it was in flight, + // seeding would write another page's data — refetch instead. + if (data.href !== window.location.href) return revalidate(); + + // `query.set` bumps each entry's version signal, so memos reading the + // query re-run with the fresh value — no client refetch. + for (const [key, value] of Object.entries(data.queries)) { + query.set(key, value as never); + } + }); +} diff --git a/packages/solid-web/data/src/index.ts b/packages/solid-web/data/src/index.ts new file mode 100644 index 000000000..6991eaeab --- /dev/null +++ b/packages/solid-web/data/src/index.ts @@ -0,0 +1,6 @@ +export { query, revalidate, cacheKeyOp, collectQueries, seedQueries, hashKey } from "./query.js"; +export type { CachedFunction } from "./query.js"; +export { action } from "./action.js"; +export type { Action } from "./action.js"; +export { installQueryFlightConsumer } from "./flight.js"; +export type { QueryFlightData } from "./flight.js"; diff --git a/packages/solid-web/data/src/query.ts b/packages/solid-web/data/src/query.ts new file mode 100644 index 000000000..a62b3f926 --- /dev/null +++ b/packages/solid-web/data/src/query.ts @@ -0,0 +1,219 @@ +/** + * `query()` — request-deduped async cache for Solid 2, a port of + * @solidjs/router's `query()` (next branch) with no router dependency. + * + * Deduping is by key (`name` + a stable hash of the arguments): calling the + * wrapped function in a route loader/preload and again in a component memo + * returns the SAME promise. Solid 2 memos unwrap promises, so + * `createMemo(() => hello())` suspends into the nearest `` boundary — + * no extra machinery needed. + * + * On the server the cache lives per-request in `getRequestEvent().locals` + * (never module state — an SSR server shares module scope across concurrent + * requests). On the client it is module-level: an entry is fresh while it is + * actively observed (a tracking scope read it and hasn't been cleaned up) or + * within a short window after creation; stale or invalidated entries refetch + * on the next read. + * + * SSR -> hydration usually needs no wiring: the component memo that reads the + * query is serialized by Solid itself, so the client adopts the server's value + * without refetching. `collectQueries`/`seedQueries` exist for hosts that warm + * the cache outside the render tree and need an explicit channel — e.g. + * TanStack Router, where they wire into its `dehydrate`/`hydrate` options. + * + * Deviations from solid-router: server functions are declared GET through + * transport metadata (compiled references carry no `.GET` property), and + * routing intent/preload semantics plus Response/redirect handling are not + * ported (TODO). + */ +import { createSignal, getObserver, onCleanup } from "solid-js"; +import { getRequestEvent, isServer } from "@solidjs/web"; +// Bare specifier on purpose: the compiled server-function references import +// '@solidjs/web/server-functions', and bundlers give each specifier its own +// module instance — the '/client' subpath would attach metadata in a copy the +// transport never reads. +import { GET, getServerFunctionMetadata, isServerFunction } from "@solidjs/web/server-functions"; + +const PRELOAD_TIMEOUT = 5000; +const CACHE_TIMEOUT = 180000; +// [ts, promise, value, versionSignal] — solid-router's layout minus the +// intent slot. ts === 0 marks an invalidated entry. +type CacheEntry = [number, any, any, [() => number, (v: number) => void] & { count: number }]; +let cacheMap = new Map(); + +// cleanup forward/back cache +if (!isServer) { + setInterval(() => { + const now = Date.now(); + for (let [k, v] of cacheMap.entries()) { + if (!v[3].count && now - v[0] > CACHE_TIMEOUT) { + cacheMap.delete(k); + } + } + }, 300000); +} + +function getCache() { + if (!isServer) return cacheMap; + const req = getRequestEvent(); + if (!req) throw new Error("Cannot find cache context"); + return ((req.locals.queryCache as Map) || + (req.locals.queryCache = new Map())) as Map; +} + +/** + * Revalidates the given cache entry/entries (prefix match; omit to + * revalidate everything). + */ +export function revalidate(key?: string | string[] | void, force = true) { + force && cacheKeyOp(key, entry => (entry[0] = 0)); + cacheKeyOp(key, entry => entry[3][1](Date.now())); // retrigger live signals +} + +export function cacheKeyOp(key: string | string[] | void, fn: (entry: CacheEntry) => void) { + key && !Array.isArray(key) && (key = [key]); + const cache = getCache(); + for (let k of cache.keys()) { + if (key === undefined || matchKey(k, key as string[])) fn(cache.get(k)!); + } +} + +export type CachedFunction any> = T & { + keyFor: (...args: Parameters) => string; + key: string; +}; + +function createEntry(ts: number, res: any): CacheEntry { + const entry: CacheEntry = [ts, res, undefined, createSignal(ts) as any]; + entry[3].count = 0; + res && typeof res.then === "function" + ? res.then( + (v: any) => entry[1] === res && (entry[2] = v), + () => {} + ) + : (entry[2] = res); + return entry; +} + +export function query any>(fn: T, name: string): CachedFunction { + // a query is a read: declare server functions GET (keeps them cacheable + // and off the single-flight path, which only opts in non-GET calls) + if (isServerFunction(fn) && getServerFunctionMetadata(fn)?.method !== "GET") + fn = GET(fn) as unknown as T; + const cachedFn = ((...args: Parameters) => { + const cache = getCache(); + const now = Date.now(); + const key = name + hashKey(args); + let cached = cache.get(key); + let tracking; + if (getObserver() && !isServer) { + tracking = true; + onCleanup(() => cached![3].count--); + } + + if (cached && cached[0] && (isServer || cached[3].count || now - cached[0] < PRELOAD_TIMEOUT)) { + if (tracking) { + cached[3].count++; + cached[3][0](); // track + } + return cached[1]; + } + + const res = fn(...(args as any)); + if (cached) { + cached[0] = now; + cached[1] = res; + res && typeof res.then === "function" + ? res.then( + (v: any) => cached![1] === res && (cached![2] = v), + () => {} + ) + : (cached[2] = res); + } else { + cache.set(key, (cached = createEntry(now, res))); + } + if (tracking) { + cached[3].count++; + cached[3][0](); // track + } + return res; + }) as unknown as CachedFunction; + cachedFn.keyFor = (...args: Parameters) => name + hashKey(args); + cachedFn.key = name; + return cachedFn; +} + +query.get = (key: string) => getCache().get(key)?.[2]; + +query.set = (key: string, value: T extends Promise ? never : T) => { + const cache = getCache(); + const now = Date.now(); + let cached = cache.get(key); + if (cached) { + cached[0] = now; + cached[1] = Promise.resolve(value); + cached[2] = value; + cached[3][1](now); // notify observers + } else { + cache.set(key, (cached = createEntry(now, Promise.resolve(value)))); + cached[2] = value; + } +}; + +query.delete = (key: string) => getCache().delete(key); + +query.clear = () => getCache().clear(); + +/** + * Server-only: snapshot the per-request cache's promises, un-awaited. For + * hosts that warm the cache outside the render tree and need an explicit + * SSR -> client channel — e.g. TanStack Router's + * `dehydrate: () => ({ queries: collectQueries() })`, whose serializer streams + * promise resolutions. Not needed when queries are only read inside components: + * Solid serializes the reading memo itself. + */ +export function collectQueries(): Record> { + const queries: Record> = {}; + if (!isServer) return queries; + for (const [k, entry] of getCache()) queries[k] = entry[1]; + return queries; +} + +/** Client-only counterpart to `collectQueries` — install before hydration renders. */ +export function seedQueries(queries?: Record) { + if (isServer || !queries) return; + for (const [key, res] of Object.entries(queries)) { + if (!cacheMap.has(key)) cacheMap.set(key, createEntry(Date.now(), res)); + } +} + +function matchKey(key: string, keys: string[]) { + for (let k of keys) { + if (k && key.startsWith(k)) return true; + } + return false; +} + +// Modified from the amazing TanStack Query library (MIT) +// https://github.com/TanStack/query/blob/main/packages/query-core/src/utils.ts#L168 +export function hashKey>(args: T): string { + return JSON.stringify(args, (_, val) => + isPlainObject(val) + ? Object.keys(val) + .sort() + .reduce((result, key) => { + result[key] = val[key]; + return result; + }, {} as any) + : val + ); +} + +function isPlainObject(obj: object) { + let proto; + return ( + obj != null && + typeof obj === "object" && + (!(proto = Object.getPrototypeOf(obj)) || proto === Object.prototype) + ); +} diff --git a/packages/solid-web/data/src/server.ts b/packages/solid-web/data/src/server.ts new file mode 100644 index 000000000..0ddaf3545 --- /dev/null +++ b/packages/solid-web/data/src/server.ts @@ -0,0 +1,61 @@ +/** + * Single-flight mutations, server half. + * + * `createQueryFlightCollector(warmQueries)` produces the `collectFlightData` + * hook for `configureServerFunctionsServer`: it re-establishes the request + * scope, derives the location the client will show once the mutation settles, + * asks the host to re-run that location's data loading (`warmQueries` — the + * only host-specific piece), then resolves this request's query cache into the + * payload the client applies (see ../flight.ts). + * + * `warmQueries` runs inside the request-event scope; whatever data loading it + * triggers should call the app's `query()` wrappers (typically route + * loaders/preloads doing `void someQuery()`), which is what warms the cache. + * + * Lives on its own subpath because it imports `@solidjs/web/storage` + * (node:async_hooks) — keep it out of client bundles. + */ +import type { + CollectFlightDataHook, + ServerFunctionOutcome +} from "@solidjs/web/server-functions/server"; +import { provideRequestEvent } from "@solidjs/web/storage"; +// Bundled copy of the query module. Sharing an instance with the app's client +// bundle doesn't matter here: the server cache lives per-request in +// `getRequestEvent().locals`, not in module state. +import { collectQueries } from "./query.js"; +import type { QueryFlightData } from "./flight.js"; + +export function createQueryFlightCollector( + warmQueries: (href: string, outcome: ServerFunctionOutcome) => Promise | void +): CollectFlightDataHook { + return (event, outcome) => + // The hook runs outside the request-event scope; re-establish it or the + // per-request query cache has no event to hang on (and in-process server + // function calls throw "Cannot call server function outside of a request"). + provideRequestEvent(event as Parameters[0], async () => { + if (outcome.thrown) return undefined; + // Where the client will be once this settles: a redirect's target, else + // the page it submitted from (same-origin fetches send a full Referer). + const href = + outcome.response?.headers.get("Location") ?? outcome.request.headers.get("referer"); + if (!href) return undefined; + + await warmQueries(href, outcome); + + // Single-flight is the point: the response waits for the data. + const queries: Record = {}; + await Promise.all( + Object.entries(collectQueries()).map(async ([key, promise]) => { + try { + queries[key] = await promise; + } catch { + // failed queries just aren't shipped; the client refetches on demand + } + }) + ); + return { href, queries } satisfies QueryFlightData; + }); +} + +export type { QueryFlightData }; diff --git a/packages/solid-web/data/test/query.spec.ts b/packages/solid-web/data/test/query.spec.ts new file mode 100644 index 000000000..4b703a037 --- /dev/null +++ b/packages/solid-web/data/test/query.spec.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment jsdom + * + * Client-side behavior of @solidjs/web/data's query(): promise-identity + * deduping, revalidation through the per-entry version signal, and + * `query.set` seeding reaching tracking scopes without a refetch. + */ +import { describe, expect, it } from "vitest"; +import { createMemo, createRoot, flush } from "solid-js"; +import { query, revalidate } from "../src/query.js"; + +const tick = () => new Promise(r => setTimeout(r, 0)); + +describe("query()", () => { + it("dedupes by key: same promise for loader-style and tracked reads", () => { + let calls = 0; + const q = query(async () => { + calls++; + return "value"; + }, "dedupe-test"); + + const first = q(); + const second = q(); + expect(second).toBe(first); + expect(calls).toBe(1); + expect(q.key).toBe("dedupe-test"); + expect(q.keyFor()).toBe("dedupe-test[]"); + query.clear(); + }); + + it("hashes arguments into the key", () => { + let calls = 0; + const q = query(async (id: number) => { + calls++; + return id; + }, "args-test"); + + const one = q(1); + expect(q(1)).toBe(one); + expect(q(2)).not.toBe(one); + expect(calls).toBe(2); + expect(q.keyFor(2)).toBe("args-test[2]"); + query.clear(); + }); + + it("revalidate() marks entries stale and retriggers tracking scopes", async () => { + let calls = 0; + const q = query(async () => { + calls++; + return "v" + calls; + }, "revalidate-test"); + + await createRoot(async dispose => { + const value = createMemo(() => q()); + flush(); + await tick(); + expect(calls).toBe(1); + + revalidate("revalidate-test"); + flush(); + await tick(); + // the tracking memo re-ran and the stale entry refetched + expect(calls).toBe(2); + expect(await value()).toBe("v2"); + dispose(); + }); + query.clear(); + }); + + it("query.set seeds a value tracking scopes pick up without a refetch", async () => { + let calls = 0; + const q = query(async () => { + calls++; + return "fetched"; + }, "set-test"); + + await createRoot(async dispose => { + const value = createMemo(() => q()); + flush(); + await tick(); + expect(calls).toBe(1); + + query.set(q.keyFor(), "seeded"); + flush(); + await tick(); + expect(calls).toBe(1); // no refetch — the seeded promise was adopted + expect(await value()).toBe("seeded"); + expect(query.get(q.keyFor())).toBe("seeded"); + dispose(); + }); + query.clear(); + }); +}); diff --git a/packages/solid-web/data/tsconfig.build.json b/packages/solid-web/data/tsconfig.build.json new file mode 100644 index 000000000..90c59a699 --- /dev/null +++ b/packages/solid-web/data/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./types", + "baseUrl": "./src", + "rootDir": "./src", + "types": ["node"], + "paths": { + // Resolve against built declarations (types:web, types:copy-server-functions + // and types:web-storage run earlier in the pipeline): d.ts inputs are exempt + // from rootDir, so the emit lands at data/types/*.d.ts — the paths the + // nested package.json advertises. See storage/tsconfig.build.json for the + // fuller rationale. + "@solidjs/web": ["../../types/index.d.ts"], + "@solidjs/web/server-functions": ["../../types/server-functions/client.d.ts"], + "@solidjs/web/server-functions/server": ["../../types/server-functions/server.d.ts"], + "@solidjs/web/storage": ["../../storage/types/index.d.ts"] + } + }, + "include": ["./src"] +} diff --git a/packages/solid-web/package.json b/packages/solid-web/package.json index e2264b086..78762ac01 100644 --- a/packages/solid-web/package.json +++ b/packages/solid-web/package.json @@ -36,7 +36,11 @@ "server-functions/dist", "server-functions/package.json", "frames/dist", - "frames/package.json" + "frames/package.json", + "data/dist", + "data/types", + "data/types-cjs", + "data/package.json" ], "exports": { ".": { @@ -149,6 +153,26 @@ "default": "./serialization/dist/serialization.cjs" } }, + "./data": { + "import": { + "types": "./data/types/index.d.ts", + "default": "./data/dist/data.js" + }, + "require": { + "types": "./data/types-cjs/index.d.cts", + "default": "./data/dist/data.cjs" + } + }, + "./data/server": { + "import": { + "types": "./data/types/server.d.ts", + "default": "./data/dist/server.js" + }, + "require": { + "types": "./data/types-cjs/server.d.cts", + "default": "./data/dist/server.cjs" + } + }, "./server-functions": { "worker": { "import": { @@ -316,17 +340,18 @@ "build:clean": "rimraf dist/", "build:js": "rollup -c", "link": "symlink-dir . node_modules/@solidjs/web", - "types": "npm-run-all -nl types:clean types:copy-jsx types:web types:copy-web types:web-storage types:copy-server-functions types:web-frames types:copy-serialization types:copy-frames types:cjs", - "types:clean": "rimraf types/ types-cjs/ storage/types/ storage/types-cjs/ serialization/types/ serialization/types-cjs/ frames/types/", + "types": "npm-run-all -nl types:clean types:copy-jsx types:web types:copy-web types:web-storage types:copy-server-functions types:web-data types:web-frames types:copy-serialization types:copy-frames types:cjs", + "types:clean": "rimraf types/ types-cjs/ storage/types/ storage/types-cjs/ data/types/ data/types-cjs/ serialization/types/ serialization/types-cjs/ frames/types/", "types:copy-jsx": "ncp ../../node_modules/@dom-expressions/runtime/src/jsx.d.ts ./src/jsx.d.ts && ncp ../../node_modules/@dom-expressions/runtime/src/jsx-properties.d.ts ./src/jsx-properties.d.ts && dom-expressions-jsx-types --input ./src/jsx.d.ts --element \"SolidElement | Node | ArrayElement\" --import 'import type { Element as SolidElement } from \"solid-js\";'", "types:web": "tsc --project ./tsconfig.build.json", "types:copy-web": "ncp ../../node_modules/@dom-expressions/runtime/src/client.d.ts ./types/client.d.ts && ncp ../../node_modules/@dom-expressions/runtime/src/server.d.ts ./types/server.d.ts && ncp ../../node_modules/@dom-expressions/runtime/src/serializer.d.ts ./types/serializer.d.ts && ncp ../../node_modules/@dom-expressions/runtime/src/response.d.ts ./types/response.d.ts && ncp ./src/jsx.d.ts ./types/jsx.d.ts && ncp ./src/jsx-properties.d.ts ./types/jsx-properties.d.ts", "types:web-storage": "tsc --project ./storage/tsconfig.build.json", + "types:web-data": "tsc --project ./data/tsconfig.build.json", "types:web-frames": "tsc --project ./frames/tsconfig.build.json", "types:copy-serialization": "node -e \"fs.mkdirSync('./serialization/types', { recursive: true }); fs.copyFileSync('../../node_modules/@dom-expressions/runtime/src/serializer.d.ts', './serialization/types/index.d.ts');\"", "types:copy-server-functions": "node -e \"fs.mkdirSync('./types/server-functions', { recursive: true }); for (const f of ['shared', 'flash', 'client', 'server']) fs.copyFileSync('../../node_modules/@dom-expressions/runtime/src/server-functions/' + f + '.d.ts', './types/server-functions/' + f + '.d.ts');\"", "types:copy-frames": "node -e \"fs.mkdirSync('./types/frames', { recursive: true }); for (const f of ['frame-client', 'frame-transport', 'frame-sink', 'serializer']) fs.copyFileSync('../../node_modules/@dom-expressions/runtime/src/' + f + '.d.ts', './types/frames/' + f + '.d.ts'); for (const f of ['client', 'server']) fs.writeFileSync('./types/frames/' + f + '.d.ts', fs.readFileSync('./frames/types/' + f + '.d.ts', 'utf8').replaceAll('@dom-expressions/runtime/src/', './'));\"", - "types:cjs": "node ../../scripts/sync-dual-types.mjs ./types ./types-cjs ./storage/types ./storage/types-cjs ./serialization/types ./serialization/types-cjs", + "types:cjs": "node ../../scripts/sync-dual-types.mjs ./types ./types-cjs ./storage/types ./storage/types-cjs ./serialization/types ./serialization/types-cjs ./data/types ./data/types-cjs", "test": "vitest run && vitest run --config vite.config.server.mjs && vitest run --config vite.config.hydrate.mjs", "test:server": "vitest run --config vite.config.server.mjs", "coverage": "vitest run --coverage", diff --git a/packages/solid-web/rollup.config.js b/packages/solid-web/rollup.config.js index add82a48b..b68bbac1f 100644 --- a/packages/solid-web/rollup.config.js +++ b/packages/solid-web/rollup.config.js @@ -139,6 +139,43 @@ export default [ external: ["seroval", "seroval-plugins/web"], plugins }, + // @solidjs/web/data — router-agnostic query()/action() and the single-flight + // query channel. The isomorphic entry keeps solid-js and the sibling + // subpaths external (each environment's bundler resolves them per its + // conditions); the server entry additionally reaches for storage + // (node:async_hooks), which is why it is a separate subpath. + { + input: "data/src/index.ts", + output: [ + { + file: "data/dist/data.cjs", + format: "cjs", + exports: "auto" + }, + { + file: "data/dist/data.js", + format: "es" + } + ], + external: ["solid-js", "@solidjs/web", "@solidjs/web/server-functions"], + plugins + }, + { + input: "data/src/server.ts", + output: [ + { + file: "data/dist/server.cjs", + format: "cjs", + exports: "auto" + }, + { + file: "data/dist/server.js", + format: "es" + } + ], + external: ["solid-js", "@solidjs/web", "@solidjs/web/server-functions", "@solidjs/web/storage"], + plugins + }, { input: "server-functions/src/client.ts", output: [