@nifrajs/web-svelte 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/Await.svelte +27 -0
- package/dist/Await.svelte.d.ts +21 -0
- package/dist/Chain.svelte +35 -0
- package/dist/Content.svelte +13 -0
- package/dist/Content.svelte.d.ts +17 -0
- package/dist/I18nProvider.svelte +20 -0
- package/dist/I18nProvider.svelte.d.ts +15 -0
- package/dist/Image.svelte +27 -0
- package/dist/Image.svelte.d.ts +29 -0
- package/dist/Router.svelte +27 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +30 -0
- package/dist/client.js.map +1 -0
- package/dist/error.d.ts +13 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +5 -0
- package/dist/error.js.map +1 -0
- package/dist/fetcher.d.ts +36 -0
- package/dist/fetcher.d.ts.map +1 -0
- package/dist/fetcher.js +55 -0
- package/dist/fetcher.js.map +1 -0
- package/dist/i18n.d.ts +11 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +15 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -0
- package/dist/mdx.d.ts +5 -0
- package/dist/mdx.d.ts.map +1 -0
- package/dist/mdx.js +74 -0
- package/dist/mdx.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +58 -0
- package/dist/plugin.js.map +1 -0
- package/dist/query.d.ts +26 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +55 -0
- package/dist/query.js.map +1 -0
- package/package.json +111 -0
- package/src/Await.svelte +27 -0
- package/src/Await.svelte.d.ts +21 -0
- package/src/Chain.svelte +35 -0
- package/src/Content.svelte +13 -0
- package/src/Content.svelte.d.ts +17 -0
- package/src/I18nProvider.svelte +20 -0
- package/src/I18nProvider.svelte.d.ts +15 -0
- package/src/Image.svelte +27 -0
- package/src/Image.svelte.d.ts +29 -0
- package/src/Router.svelte +27 -0
- package/src/client.ts +33 -0
- package/src/error.ts +15 -0
- package/src/fetcher.ts +66 -0
- package/src/i18n.ts +23 -0
- package/src/index.ts +51 -0
- package/src/mdx.ts +72 -0
- package/src/plugin.ts +60 -0
- package/src/query.ts +66 -0
- package/src/svelte-shim.d.ts +9 -0
package/src/error.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `errorBoundary` — the Svelte `_error.tsx` boundary chain element. Svelte components are compiled (not
|
|
3
|
+
* JS closures), so unlike the other adapters this can't return a component bound to `fallback`. Instead
|
|
4
|
+
* it returns a **marker** that `Chain.svelte` recognises and renders as a `<svelte:boundary>` (Svelte
|
|
5
|
+
* 5.3+) with `fallback` as the `failed` snippet. Re-exported from `@nifrajs/web-svelte/client` so nifra's
|
|
6
|
+
* client codegen inserts it before the page in the matched chain.
|
|
7
|
+
*/
|
|
8
|
+
export interface NifraSvelteErrorBoundary {
|
|
9
|
+
readonly __nifraErrorBoundary: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Wrap a route's `_error` component as a boundary marker for `Chain.svelte` to render. */
|
|
13
|
+
export function errorBoundary(fallback: unknown): NifraSvelteErrorBoundary {
|
|
14
|
+
return { __nifraErrorBoundary: fallback }
|
|
15
|
+
}
|
package/src/fetcher.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
|
|
2
|
+
/**
|
|
3
|
+
* `@nifrajs/web-svelte/fetcher` — Svelte bindings for concurrent fetchers, as **Svelte stores** (plain
|
|
4
|
+
* `.ts`, no runes/compiler needed). `useFetcher(key)` returns a `Readable<FetcherState>` augmented with
|
|
5
|
+
* `load`/`submit` — read it reactively in a component with `$fetcher` (auto-subscription), call
|
|
6
|
+
* `fetcher.load(...)` / `fetcher.submit(...)` imperatively. `useFetchers()` returns the live collection
|
|
7
|
+
* as a store. The store's start/stop notifier subscribes to the agnostic store lazily (on the first
|
|
8
|
+
* `$`-subscription) and unsubscribes on the last — so lifecycle is tied to the component automatically.
|
|
9
|
+
*
|
|
10
|
+
* The router that owns the fetchers is the one `mountRouter` hydrated — it registers itself here via
|
|
11
|
+
* `setMountedRouter`. On the server (no mount) there is no router, so the stores hold an idle value
|
|
12
|
+
* (fetchers are client-only); the first client render starts from the same idle snapshot — no mismatch.
|
|
13
|
+
*/
|
|
14
|
+
import { type Readable, readable } from "svelte/store"
|
|
15
|
+
|
|
16
|
+
// The active client router (set by `mountRouter`). Module-scoped: the browser mounts one app per
|
|
17
|
+
// page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
|
|
18
|
+
let mountedRouter: ClientRouter | undefined
|
|
19
|
+
|
|
20
|
+
/** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
|
|
21
|
+
export function setMountedRouter(router: ClientRouter | undefined): void {
|
|
22
|
+
mountedRouter = router
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const IDLE: FetcherState = { pending: false, data: undefined }
|
|
26
|
+
const noopAsync = async (): Promise<void> => {}
|
|
27
|
+
|
|
28
|
+
/** A fetcher store: a `Readable<FetcherState>` (read via `$`) plus imperative `load`/`submit`. */
|
|
29
|
+
export type FetcherStore = Readable<FetcherState> & {
|
|
30
|
+
/** Load a route path's loader data into this fetcher (concurrent; doesn't touch the active view). */
|
|
31
|
+
readonly load: (path: string) => Promise<void>
|
|
32
|
+
/** Submit an action into this fetcher (concurrent); honors `X-Nifra-Revalidate`. */
|
|
33
|
+
readonly submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Subscribe to the independent fetcher for `key` (created lazily, stable across renders). Returns a
|
|
38
|
+
* store of its state (`pending`/`data`/`actionData`/`submission`) augmented with `load`/`submit`.
|
|
39
|
+
* Multiple `useFetcher` calls with different keys run concurrently without disturbing the active route.
|
|
40
|
+
*/
|
|
41
|
+
export function useFetcher(key: string): FetcherStore {
|
|
42
|
+
const fetcher = mountedRouter?.fetcher(key)
|
|
43
|
+
const store = readable<FetcherState>(fetcher ? fetcher.snapshot() : IDLE, (set) => {
|
|
44
|
+
if (fetcher === undefined) return
|
|
45
|
+
set(fetcher.snapshot())
|
|
46
|
+
return fetcher.subscribe(() => set(fetcher.snapshot()))
|
|
47
|
+
})
|
|
48
|
+
return Object.assign(store, {
|
|
49
|
+
load: fetcher?.load ?? noopAsync,
|
|
50
|
+
submit: fetcher?.submit ?? noopAsync,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to the whole live fetcher collection — for a global busy view (e.g. "3 saving…"). Read
|
|
56
|
+
* each entry's `.snapshot()` for its state. The store updates whenever any fetcher transitions or a
|
|
57
|
+
* new one is created.
|
|
58
|
+
*/
|
|
59
|
+
export function useFetchers(): Readable<readonly Fetcher[]> {
|
|
60
|
+
const router = mountedRouter
|
|
61
|
+
return readable<readonly Fetcher[]>(router ? router.fetchers() : [], (set) => {
|
|
62
|
+
if (router === undefined) return
|
|
63
|
+
set(router.fetchers())
|
|
64
|
+
return router.subscribeFetchers(() => set(router.fetchers()))
|
|
65
|
+
})
|
|
66
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web-svelte/i18n` — Svelte bindings for `@nifrajs/i18n`: the `<I18nProvider>` component (re-exported
|
|
3
|
+
* from `I18nProvider.svelte`) plus `useT()`, which reads the provided `Formatter` from Svelte context.
|
|
4
|
+
* Call `useT()` during a component's initialization (like any `getContext`).
|
|
5
|
+
*/
|
|
6
|
+
import type { Formatter } from "@nifrajs/i18n"
|
|
7
|
+
import { getContext } from "svelte"
|
|
8
|
+
|
|
9
|
+
export { default as I18nProvider, type I18nProviderProps } from "./I18nProvider.svelte"
|
|
10
|
+
|
|
11
|
+
// Must match the string key `I18nProvider.svelte` passes to `setContext` (a string avoids a
|
|
12
|
+
// `.svelte` → `.ts` import that wouldn't resolve once the .svelte is copied to dist).
|
|
13
|
+
const I18N_KEY = "@nifrajs/web-svelte:i18n"
|
|
14
|
+
|
|
15
|
+
/** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above.
|
|
16
|
+
* nifra switches locale by re-navigating, which re-runs the consuming component with the new catalog. */
|
|
17
|
+
export function useT(): Formatter {
|
|
18
|
+
const get = getContext<(() => Formatter) | undefined>(I18N_KEY)
|
|
19
|
+
if (get === undefined) {
|
|
20
|
+
throw new Error("[nifra/web-svelte] useT() must be used within an <I18nProvider>")
|
|
21
|
+
}
|
|
22
|
+
return get()
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/web-svelte — the Svelte 5 render adapter for @nifrajs/web (server side) + the `.svelte` compiler
|
|
3
|
+
* Bun-plugin. SSR via Svelte's `render` (svelte/server), which returns HTML strings (Svelte SSR is
|
|
4
|
+
* string-based, not a stream), wrapped in a one-chunk Web `ReadableStream` (the seam's shape). The
|
|
5
|
+
* layout-chain fold is the recursive `Chain.svelte`. Client hydration lives in `@nifrajs/web-svelte/client`.
|
|
6
|
+
* Svelte components compile from `.svelte` files, so this adapter ships a build plugin (like
|
|
7
|
+
* `@nifrajs/web-solid`'s Babel plugin) — there is no callable-component runtime.
|
|
8
|
+
*/
|
|
9
|
+
import type { RenderAdapter } from "@nifrajs/web"
|
|
10
|
+
import { render } from "svelte/server"
|
|
11
|
+
import Chain from "./Chain.svelte"
|
|
12
|
+
|
|
13
|
+
// Re-export the compiler plugin for convenience. NOTE: the SSR preload must import it from
|
|
14
|
+
// `@nifrajs/web-svelte/plugin` (not from here) — importing this module eagerly loads `Chain.svelte`,
|
|
15
|
+
// which must be compiled by the already-registered plugin.
|
|
16
|
+
export { svelteBunPlugin } from "./plugin.ts"
|
|
17
|
+
|
|
18
|
+
// Svelte SSR yields a complete HTML string; the seam wants a stream, so emit it as one chunk.
|
|
19
|
+
function oneChunk(html: string): ReadableStream<Uint8Array> {
|
|
20
|
+
const bytes = new TextEncoder().encode(html)
|
|
21
|
+
return new ReadableStream({
|
|
22
|
+
start(controller) {
|
|
23
|
+
controller.enqueue(bytes)
|
|
24
|
+
controller.close()
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The Svelte server render adapter — pass to @nifrajs/web's `renderPage`. */
|
|
30
|
+
export const svelteAdapter: RenderAdapter = {
|
|
31
|
+
// Svelte SSR is already synchronous + string-based, so this is the native shape: `renderToString`
|
|
32
|
+
// returns the body directly (renderPage buffers it on the non-deferred fast path), and
|
|
33
|
+
// `renderToStream` wraps the same string in a one-chunk stream for the deferred path. No streaming
|
|
34
|
+
// renderer to skip here — but going straight to a string avoids the per-request stream allocation.
|
|
35
|
+
renderToString(chain, props) {
|
|
36
|
+
return render(Chain, { props: { chain, props } }).body
|
|
37
|
+
},
|
|
38
|
+
renderToStream(chain, props) {
|
|
39
|
+
// `Chain` folds the layout chain (page innermost gets `props`; layouts wrap via their `children`
|
|
40
|
+
// snippet). Svelte's `render` returns { head, body }; the body goes into #root. (Svelte's dynamic
|
|
41
|
+
// `head` — from <svelte:head> — isn't surfaced through the seam's static `hydrationHead`; nifra's
|
|
42
|
+
// own meta/head API manages the document head instead.)
|
|
43
|
+
const { body } = render(Chain, { props: { chain, props } })
|
|
44
|
+
return oneChunk(body)
|
|
45
|
+
},
|
|
46
|
+
// Svelte's client `hydrate` reconciles against the existing DOM; no per-document bootstrap script is
|
|
47
|
+
// needed (contrast Solid's generateHydrationScript) — the seam allows the empty string.
|
|
48
|
+
hydrationHead() {
|
|
49
|
+
return ""
|
|
50
|
+
},
|
|
51
|
+
}
|
package/src/mdx.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { BunPlugin } from "bun"
|
|
2
|
+
import { compile } from "svelte/compiler"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@nifrajs/web-svelte/mdx` — compile `.mdx` routes/files to **Svelte** components. Svelte has no JSX
|
|
6
|
+
* runtime, so (unlike the JSX-family `mdxBunPlugin`) MDX-for-Svelte goes through `mdsvex` — Svelte's
|
|
7
|
+
* own Markdown compiler — which produces a `.svelte` module, then the standard `svelte/compiler` runs
|
|
8
|
+
* (`generate: "server"` for SSR, `"client"` for the browser). Pass it to `buildClient`/`buildServer`'s
|
|
9
|
+
* `plugins` alongside `svelteBunPlugin` (one handles `.svelte`, this one `.mdx`). `mdsvex` is an
|
|
10
|
+
* optional peer, lazy-loaded at build time.
|
|
11
|
+
*
|
|
12
|
+
* `<style>` blocks inside MDX are scoped + bundled exactly like `svelteBunPlugin`'s (own CSS namespace
|
|
13
|
+
* so the two plugins don't collide).
|
|
14
|
+
*/
|
|
15
|
+
const MDSVEX_MODULE = "mdsvex" // non-literal so consumers don't need mdsvex's types to typecheck
|
|
16
|
+
const STYLE_SUFFIX = "?svelte-mdx-css"
|
|
17
|
+
const STYLE_NS = "nifra-svelte-mdx-css"
|
|
18
|
+
|
|
19
|
+
interface Mdsvex {
|
|
20
|
+
compile(source: string, options?: Record<string, unknown>): Promise<{ code: string } | undefined>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Build a `Bun.build` plugin that loads `.mdx` files as Svelte components via mdsvex. `generate`:
|
|
24
|
+
* `"ssr"` for the server build, `"dom"` for the client (matches `svelteBunPlugin`). */
|
|
25
|
+
export function svelteMdxBunPlugin(generate: "dom" | "ssr"): BunPlugin {
|
|
26
|
+
const cssByPath = new Map<string, string>()
|
|
27
|
+
return {
|
|
28
|
+
name: `nifra-svelte-mdx-${generate}`,
|
|
29
|
+
async setup(build) {
|
|
30
|
+
let mdsvexCompile: Mdsvex["compile"]
|
|
31
|
+
try {
|
|
32
|
+
mdsvexCompile = ((await import(MDSVEX_MODULE)) as Mdsvex).compile
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(
|
|
35
|
+
"@nifrajs/web-svelte/mdx: compiling `.mdx` for Svelte needs the `mdsvex` package — install it (`bun add mdsvex`).",
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
build.onLoad({ filter: /\.mdx(\?|$)/ }, async (args) => {
|
|
39
|
+
const path = args.path.split("?")[0] ?? args.path
|
|
40
|
+
// 1. Markdown/MDX → Svelte source (mdsvex). `extensions` must include `.mdx` or mdsvex skips the
|
|
41
|
+
// file (returns undefined — it only processes its configured extensions, default `.svx`).
|
|
42
|
+
const pre = await mdsvexCompile(await Bun.file(path).text(), {
|
|
43
|
+
filename: path,
|
|
44
|
+
extensions: [".mdx", ".svx", ".md"],
|
|
45
|
+
})
|
|
46
|
+
if (!pre) throw new Error(`@nifrajs/web-svelte/mdx: mdsvex produced no output for ${path}`)
|
|
47
|
+
// 2. Svelte source → JS, exactly like `svelteBunPlugin`.
|
|
48
|
+
const { js, css } = compile(pre.code, {
|
|
49
|
+
generate: generate === "ssr" ? "server" : "client",
|
|
50
|
+
filename: path,
|
|
51
|
+
css: "external",
|
|
52
|
+
})
|
|
53
|
+
if (generate === "dom" && css?.code) {
|
|
54
|
+
cssByPath.set(path, css.code)
|
|
55
|
+
return {
|
|
56
|
+
contents: `${js.code}\nimport ${JSON.stringify(path + STYLE_SUFFIX)}\n`,
|
|
57
|
+
loader: "js",
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { contents: js.code, loader: "js" }
|
|
61
|
+
})
|
|
62
|
+
build.onResolve({ filter: /\?svelte-mdx-css$/ }, (args) => ({
|
|
63
|
+
path: args.path,
|
|
64
|
+
namespace: STYLE_NS,
|
|
65
|
+
}))
|
|
66
|
+
build.onLoad({ filter: /.*/, namespace: STYLE_NS }, (args) => ({
|
|
67
|
+
contents: cssByPath.get(args.path.slice(0, -STYLE_SUFFIX.length)) ?? "",
|
|
68
|
+
loader: "css",
|
|
69
|
+
}))
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { BunPlugin } from "bun"
|
|
2
|
+
import { compile } from "svelte/compiler"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@nifrajs/web-svelte/plugin` — the `.svelte` compiler Bun-plugin, in its OWN module (no `.svelte`
|
|
6
|
+
* imports). This matters for the SSR preload: `bun --preload` must register the plugin BEFORE any
|
|
7
|
+
* `.svelte` file is loaded. Importing it from the package root (`./index`) would eagerly load
|
|
8
|
+
* `Chain.svelte` (the adapter's fold) before the plugin is registered — so it lives here instead.
|
|
9
|
+
*
|
|
10
|
+
* `generate: "server"` for SSR, `"client"` for the browser (Svelte 5's client output is hydratable).
|
|
11
|
+
* Preload it for SSR (`bun --preload`) and pass it to `buildClient({ plugins: [...] })` for the client
|
|
12
|
+
* bundle. The compiler runs at build time, on `.svelte` files only.
|
|
13
|
+
*
|
|
14
|
+
* `<style>` blocks: Svelte scopes selectors (`.foo.svelte-<hash>`) and bakes the matching classes into
|
|
15
|
+
* the markup (both client + server output), so SSR HTML is already scoped. The **client** build emits
|
|
16
|
+
* the scoped stylesheet as a virtual `?svelte-css` module that `Bun.build`'s CSS bundler folds into the
|
|
17
|
+
* app stylesheet (served as a `<link>`); the SSR build drops it (the stylesheet ships from the client
|
|
18
|
+
* build — no double-emit). With `nifra dev`, `@sveltejs/vite-plugin-svelte` injects the CSS instead.
|
|
19
|
+
*/
|
|
20
|
+
const STYLE_SUFFIX = "?svelte-css"
|
|
21
|
+
const STYLE_NS = "nifra-svelte-css"
|
|
22
|
+
|
|
23
|
+
export function svelteBunPlugin(generate: "dom" | "ssr"): BunPlugin {
|
|
24
|
+
// Compiled scoped CSS per `.svelte` file (client build only) — read back by the virtual-module loader.
|
|
25
|
+
const cssByPath = new Map<string, string>()
|
|
26
|
+
return {
|
|
27
|
+
name: `nifra-svelte-${generate}`,
|
|
28
|
+
setup(build) {
|
|
29
|
+
// Match `.svelte`, tolerating a `?query` suffix (dev servers append one to bust Bun's import
|
|
30
|
+
// cache); strip it before reading the file off disk.
|
|
31
|
+
build.onLoad({ filter: /\.svelte(\?|$)/ }, async (args) => {
|
|
32
|
+
const path = args.path.split("?")[0] ?? args.path
|
|
33
|
+
const source = await Bun.file(path).text()
|
|
34
|
+
const { js, css } = compile(source, {
|
|
35
|
+
generate: generate === "ssr" ? "server" : "client",
|
|
36
|
+
filename: path,
|
|
37
|
+
css: "external", // emit the scoped stylesheet separately (not injected into the JS)
|
|
38
|
+
})
|
|
39
|
+
if (generate === "dom" && css?.code) {
|
|
40
|
+
cssByPath.set(path, css.code)
|
|
41
|
+
// Import the virtual style module so the bundler pulls the scoped CSS into the app stylesheet.
|
|
42
|
+
return {
|
|
43
|
+
contents: `${js.code}\nimport ${JSON.stringify(path + STYLE_SUFFIX)}\n`,
|
|
44
|
+
loader: "js",
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { contents: js.code, loader: "js" }
|
|
48
|
+
})
|
|
49
|
+
// Virtual CSS module: `<file>.svelte?svelte-css` → the compiled scoped stylesheet (css loader).
|
|
50
|
+
build.onResolve({ filter: /\?svelte-css$/ }, (args) => ({
|
|
51
|
+
path: args.path,
|
|
52
|
+
namespace: STYLE_NS,
|
|
53
|
+
}))
|
|
54
|
+
build.onLoad({ filter: /.*/, namespace: STYLE_NS }, (args) => ({
|
|
55
|
+
contents: cssByPath.get(args.path.slice(0, -STYLE_SUFFIX.length)) ?? "",
|
|
56
|
+
loader: "css",
|
|
57
|
+
}))
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createQueryClient,
|
|
3
|
+
type QueryClient,
|
|
4
|
+
type QueryHandle,
|
|
5
|
+
type QueryState,
|
|
6
|
+
} from "@nifrajs/web"
|
|
7
|
+
/**
|
|
8
|
+
* `@nifrajs/web-svelte/query` — Svelte bindings for the keyed query-cache, as **Svelte stores** (plain
|
|
9
|
+
* `.ts`). `useQuery(key, fn)` returns a `Readable<QueryState<T>>` augmented with `refetch` — read it
|
|
10
|
+
* reactively with `$query`. The store fetches on mount (the start notifier fires on the first
|
|
11
|
+
* `$`-subscription) and subscribes to the cache entry; concurrent `useQuery`s with the same key share
|
|
12
|
+
* one entry + one in-flight fetch (dedup). `useQueryClient` exposes `invalidateQueries`.
|
|
13
|
+
*
|
|
14
|
+
* The query client is created lazily **client-side only** (the `typeof window` guard) — on the server
|
|
15
|
+
* there is none, so `useQuery` holds the idle/pending state (queries are client-first; loaders are the
|
|
16
|
+
* SSR data source). The first client render uses the same idle snapshot, so no mismatch.
|
|
17
|
+
*/
|
|
18
|
+
import { type Readable, readable } from "svelte/store"
|
|
19
|
+
|
|
20
|
+
let client: QueryClient | undefined
|
|
21
|
+
function getClient(): QueryClient | undefined {
|
|
22
|
+
if (typeof window === "undefined") return undefined // SSR → no client (queries render idle)
|
|
23
|
+
if (client === undefined) client = createQueryClient({ now: () => Date.now() })
|
|
24
|
+
return client
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
|
|
28
|
+
export function useQueryClient(): Pick<QueryClient, "invalidateQueries"> {
|
|
29
|
+
return getClient() ?? { invalidateQueries: () => {} }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stable idle snapshot for the server / pre-fetch render.
|
|
33
|
+
const IDLE: QueryState<never> = Object.freeze({
|
|
34
|
+
status: "pending",
|
|
35
|
+
data: undefined,
|
|
36
|
+
error: undefined,
|
|
37
|
+
isFetching: false,
|
|
38
|
+
updatedAt: Number.NEGATIVE_INFINITY,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** A query store: a `Readable<QueryState<T>>` (read via `$`) plus `refetch`. */
|
|
42
|
+
export type QueryStore<T> = Readable<QueryState<T>> & {
|
|
43
|
+
/** Force a refetch (ignores `staleTime`). */
|
|
44
|
+
readonly refetch: () => Promise<T>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Subscribe to the keyed query for `key`, fetched via `fn`. Returns a store of `{ status, data, error,
|
|
49
|
+
* isFetching, updatedAt }` augmented with `refetch`. Fetches on mount (first `$`-subscription); SSR-idle.
|
|
50
|
+
*/
|
|
51
|
+
export function useQuery<T>(key: unknown, fn: () => Promise<T>): QueryStore<T> {
|
|
52
|
+
const handle: QueryHandle<T> | undefined = getClient()?.query<T>(key, fn)
|
|
53
|
+
const store = readable<QueryState<T>>(handle ? handle.snapshot() : IDLE, (set) => {
|
|
54
|
+
if (handle === undefined) return
|
|
55
|
+
set(handle.snapshot())
|
|
56
|
+
const unsubscribe = handle.subscribe(() => set(handle.snapshot()))
|
|
57
|
+
handle.fetch().catch(() => {}) // fetch on mount (the first subscriber attached)
|
|
58
|
+
return unsubscribe
|
|
59
|
+
})
|
|
60
|
+
const refetch = handle
|
|
61
|
+
? handle.refetch
|
|
62
|
+
: ((async () => {
|
|
63
|
+
throw new Error("[nifra/web-svelte] useQuery.refetch called with no query client (server?)")
|
|
64
|
+
}) as () => Promise<T>)
|
|
65
|
+
return Object.assign(store, { refetch })
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `.svelte` files are compiled by `svelteBunPlugin` (or the consumer's Svelte bundler), not by tsc.
|
|
2
|
+
// This ambient declaration lets the .ts adapter/client import them with a usable type. The default
|
|
3
|
+
// export is a Svelte 5 `Component`; our helpers take a loose props bag (the chain + the loader props).
|
|
4
|
+
declare module "*.svelte" {
|
|
5
|
+
import type { Component } from "svelte"
|
|
6
|
+
|
|
7
|
+
const component: Component<Record<string, unknown>>
|
|
8
|
+
export default component
|
|
9
|
+
}
|