npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@urbicon-ui/sveltekit-utils

v8.21.0

Published

SvelteKit helper utilities — createCronRunner, streamSse, and URL-state runes

Downloads

3,288

Readme

@urbicon-ui/sveltekit-utils

Small, focused SvelteKit helpers that Urbicon apps share. Zero runtime dependencies.

Currently shipping:

  • URL-state runes — reactive useUrlParam / useUrlArrayParam that keep component state in sync with ?query= parameters, and withSearchParams for the links that change them
  • Table view ↔ URLbindViewToUrl, the URL home for a @urbicon-ui/table view object (?q=…&sort=…&page=…), plus the pure serializers for the load path
  • Cron runner — interval-based background fetcher for scheduled server endpoints
  • SSE stream readerstreamSse, a spec-correct async-generator client for one-shot POST text/event-stream endpoints (LLM relays)

Installation

bun add @urbicon-ui/sveltekit-utils

Peer dependencies: svelte (^5), @sveltejs/kit.

The declared @sveltejs/kit range is 2.x. The package runs under SvelteKit 3 next as well; the incorrect-peer warning bun add prints there is expected and stays until Kit 3 has a release candidate, when the range widens.

URL State (url.svelte)

Bind a typed, reactive value to a URL search param. When the value changes, the URL is updated (and vice versa) without a full navigation.

<script lang="ts">
  import { useUrlParam, useUrlArrayParam } from '@urbicon-ui/sveltekit-utils/url.svelte';

  // Single string param, typed
  const [page, setPage] = useUrlParam<number>('page', {
    parse: (sp) => Number(sp.get('page') ?? '1'),
    serialize: (v) => new URLSearchParams({ page: String(v) }),
    initial: 1
  });

  // Repeated-key array param: ?tag=a&tag=b
  const [tags, setTags] = useUrlArrayParam('tag', { initial: [], strategy: 'repeat' });

  // CSV array param: ?tag=a,b
  const [categories, setCategories] = useUrlArrayParam('cat', { initial: [], strategy: 'csv' });
</script>

<button onclick={() => setPage(page() + 1)}>Next — current: {page()}</button>

Low-level escape hatch if you prefer to update multiple params at once:

import { updateUrlSearchParams } from '@urbicon-ui/sveltekit-utils/url.svelte';

updateUrlSearchParams({ page: '1', tag: ['a', 'b'] }, { replaceState: true });

A link needs an address, not a setter. withSearchParams(url, patch) is the pure core that updateUrlSearchParams and createUrlParam's setter navigate to: the address url has after patch — a scalar sets its key, an array appends each element, null removes the key, every other param stays as it is. An empty string is a value and keeps its key (?a=); an empty array appends nothing and so removes it. It reads nothing from the page and navigates nowhere, so the same call builds a link's href and a redirect's location.

import { withSearchParams } from '@urbicon-ui/sveltekit-utils/search-params';

const url = new URL('https://films.test/archive?sort=title&page=3');

withSearchParams(url, { sort: 'year' }); // '/archive?page=3&sort=year'
withSearchParams(url, { tag: ['noir', 'silent'] }); // '/archive?sort=title&page=3&tag=noir&tag=silent'
withSearchParams(url, { sort: null, page: null }); // '/archive'

./search-params is the import path that reaches no $app/* module, so a load, a form action or a plain test can use it; importing withSearchParams from ./url.svelte (or from the package root) is the same function, but pulls SvelteKit's client runtime along:

// src/routes/archive/+page.server.ts
import { withSearchParams } from '@urbicon-ui/sveltekit-utils/search-params';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = ({ url }) => {
  const current = Number(url.searchParams.get('page') ?? '1');
  return {
    // Page 1 is the default, so its link carries no `page` at all.
    prevHref: withSearchParams(url, { page: current > 2 ? String(current - 1) : null }),
    nextHref: withSearchParams(url, { page: String(current + 1) })
  };
};

In a component the URL to start from is page.url, and the result is the href:

<script lang="ts">
  import { page } from '$app/state';
  import { withSearchParams } from '@urbicon-ui/sveltekit-utils/url.svelte';
</script>

<a href={withSearchParams(page.url, { sort: 'year', page: null })}>Sort by year</a>

Not on a prerendered page: SvelteKit makes url.searchParams throw there — the emitted HTML must not depend on a query string that will not exist at request time — and withSearchParams reads it. useUrlParam guards that case for you by yielding its initial while building; a link on such a page has to be built after hydration, or from a URL you construct rather than the page's.

Link or binding: an href from withSearchParams where the reader picks a destination — a sort header, a pagination step, a filter chip — and the address should exist before the click, so it can be hovered, middle-clicked and crawled; useUrlParam where a control owns a value that keeps changing — a search box, a slider — and the URL follows it.

Design notes

  • URL updates use goto() with replaceState: true, noScroll: true, keepFocus: true — suited for filter/pagination UIs, not full page transitions.
  • updateUrlSearchParams and createUrlParam's setter hand goto the pathname-qualified address withSearchParams returns, never a bare ?query. goto resolves a relative target against document.baseURI, which equals the page's own URL only while the document carries no <base href> — with one, ?page=2 keeps the base's path, not the page's.
  • bindViewToUrl is a third URL writer and does not go through withSearchParams: it merges the view axes itself and carries the URL hash across, where withSearchParams drops it. Do not read one policy off the other.
  • useUrlParam returns getters (not Svelte stores) so consumers can read the value lazily inside $derived/$effect.

Table View ↔ URL (url.svelte + table-view)

bindViewToUrl gives the view object of @urbicon-ui/table — search, sort, page, page size, filters, grouping — the URL as its home: the axes are mirrored onto query parameters (?q=…&sort=…&page=…), so the view survives a reload, can be shared as a link, and — unlike localStorage — is visible to the server.

<script lang="ts">
  import { Table, createTableView } from '@urbicon-ui/table';
  import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';

  const view = createTableView({ defaults: { pageSize: 25 } });
  bindViewToUrl(view);
</script>

<Table {items} {columns} {view} />

Both calls belong in the component's initialisation. The init half runs synchronously — a ?sort=… link renders sorted server HTML — and the runtime halves are effects: URL navigations apply to the view, the reader's changes reach the URL debounced.

The second argument is optional; every option has a default:

| Option | Default | Effect | | ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | axes | all six | Which axes this binding manages. An unbound axis never reaches the URL, whatever the view holds. | | debounceMs | 300 | Delay before a view change is written to the URL. | | replaceState | true | Replace the current history entry instead of pushing one, so rapid sort/filter/page edits do not flood the back button. | | prefix | '' | Key namespace (prefix: 't_'?t_q=…&t_page=…) for a second bound table on the same page. | | reflectExternal | false | Mirror an externally applied value (a storage seed) into the URL immediately. Off by default: the address bar does not change without reader interaction, and the seed reaches the URL with the first one anyway. |

Because the binding re-reads the URL rather than capturing it, the browser's back button works: navigating back to a URL that no longer names ?sort returns the table to its default sort.

The pure serializers work without SvelteKit — e.g. to parse the incoming query in a server load and fetch the first page during SSR. Use searchParamsToViewSnapshot from ./table-view: it takes the same defaults object the component hands createTableView, so the server cannot resolve an absent param differently from the client, and it hands back the very shape a managed source.query receives.

// src/lib/view-defaults.ts — imported by the component and by the load function.
// `as const` keeps `direction` a `'desc'`, not a `string` the snapshot rejects.
export const userView = { pageSize: 25, sort: { column: 'joined', direction: 'desc' } } as const;
// src/routes/users/+page.server.ts
import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
import { fetchUsers } from '$lib/server/users';
import { userView } from '$lib/view-defaults';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ url }) => ({
  initialResult: await fetchUsers(searchParamsToViewSnapshot(url.searchParams, userView))
});

The ./table-query subpath that used to hold a second copy of this codec — same URL scheme, wire-vocabulary spellings, no field for a default filter set — retired with the vocabulary split it served (#162).

Design notes

  • Default elision — an axis whose value equals its default is not written; a table in its default state leaves the URL clean. The baseline is view.defaults, read off the object the binding decorates, so there is no second copy of the defaults to keep in step with the table's own.
  • Read tolerant, write strict — an unparsable value on a param the URL actually carries falls back to that axis' default, and malformed filter entries are skipped individually. assertValidViewSnapshot is the strict half: it throws on a structurally invalid view (non-positive page, unknown operator) instead of writing corrupt state, and applyViewToSearchParams calls it. viewSnapshotToSearchParams deliberately does not — it runs inside the binding on every view change, where a throw would cost the page rather than the URL.
  • Namespacingprefix: 't_' scopes all keys (?t_q=…) for multiple bound tables on one page; unrelated params are always preserved. Two prefixless bindings would manage the same keys, so that throws at registration instead of producing a link that loads the wrong table.
  • One writer per page — every binding submits into one coalescing URL writer, so two tables land in a single navigation, each replacing only its own keys. A landing URL the writer itself sent is not applied back onto the view: an edit made while that navigation was in flight survives instead of being overwritten by the URL it raced.
  • TypesTableViewLike, TableViewSnapshot and TableViewFilter mirror the table's view object structurally, so this package carries no dependency on @urbicon-ui/table. A parity test in the table package (viewMirror.parity.test.ts) pins the mirror: the shapes must stay mutually assignable, and the real TableView must satisfy TableViewLike, which is the entire mechanism by which this binding decorates a view it never imports.

Cron Runner (cron)

Fire HTTP requests against SvelteKit server endpoints on an interval. Pair with a shared-secret header so endpoints can authenticate scheduled calls.

Import from @urbicon-ui/sveltekit-utils/cron, not from the package root. The runner is wired up in server code — hooks.server.ts, or a module it imports — and the root barrel carries url.svelte along, whose $app/navigation and $app/state imports are SvelteKit's client runtime. The subpath reaches no $app/* module at all.

// src/lib/server/cron.ts
import { createCronRunner } from '@urbicon-ui/sveltekit-utils/cron';
import { env } from '$env/dynamic/private';

// Runtime env, not `$env/static/private`: a node server reads its secret at
// start, not at build. The runner needs a `string`, so a missing one is a
// startup failure here — not an unauthenticated cron loop.
const secret = env.CRON_SECRET;
if (!secret) throw new Error('CRON_SECRET is not set');

export const cron = createCronRunner({
  secret,
  baseUrl: env.BASE_URL,
  jobs: [
    { path: '/api/cron/send-digest', intervalSeconds: 3600 },
    { path: '/api/cron/cleanup', intervalSeconds: 900, method: 'POST' }
  ],
  onError: (job, err) => console.error(`Cron ${job.path} failed`, err)
});

cron.start();
// Under `vite dev` this module is re-evaluated on every edit; without the
// teardown the previous evaluation's timers keep ticking beside the new ones.
import.meta.hot?.dispose(() => cron.stop());

The first fire happens after one interval: start() arms the timers, it does not call anything. A job with intervalSeconds: 3600 armed at boot first knocks an hour later, so nothing runs at deploy time — if you need work done at startup, do it at startup.

Receive the call and verify the secret inside your endpoint:

// src/routes/api/cron/send-digest/+server.ts
import { env } from '$env/dynamic/private';
import { sendDigest } from '$lib/server/digest';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  if (!env.CRON_SECRET || request.headers.get('x-cron-secret') !== env.CRON_SECRET) {
    return new Response('Forbidden', { status: 403 });
  }
  await sendDigest();
  return new Response('ok');
};

onError is required

It is the only channel a failing job has. The runner calls it when the fetch rejects and when the endpoint answers non-2xx (with the status on error.status), and it reports nothing anywhere else — a nightly job that has been answering 403 since a secret rotation looks exactly like a job that works. A missing handler, or one that is not a function, throws a TypeError from createCronRunner: at wiring time, where a startup failure is read, rather than on the first failed tick at 3 a.m.

The handler may be async — a webhook, a row in a table — and the runner awaits it. One that throws or rejects does not stop the schedule: the runner writes both errors, the handler's and the job's, to console.error and keeps ticking. Letting either escape the interval callback would end the process as an unhandled rejection, and one broken log call would take every other job with it.

Two runners on one path

In development the runner warns on console.warn when start() arms a path another runner in the same process is already ticking — the situation a hot reload produces, where both runners fire and the endpoint sees twice the traffic. import.meta.hot?.dispose(() => cron.stop()) next to the start() call is the fix; the warning names it too. Two runners aimed at one path on purpose (different intervals, say) look the same from the inside and will be warned about as well. One runner whose jobs array names the same path twice is a different mistake — no second runner to stop — and gets its own warning.

A daily job on an interval runner

There are intervals here and no cron expressions — no "at 03:00". A job that should happen once a day therefore ticks hourly and lets the endpoint decide whether there is work: the first tick after midnight does the day's work, the rest of the day's ticks find it done. The phase is the boot time, not the full hour, so that first tick lands up to one interval after midnight and every deploy moves it — in exchange, setInterval counts duration rather than wall-clock, so a daylight-saving change neither skips a tick nor fires one twice.

That holds together when the endpoint is idempotent, which means

  • the outcome is a function of the calendar day, not a counter that advances once per call — and a day needs a zone, so the key below is formatted in one. toISOString() would key on UTC, where the day turns at 02:00 local in a Berlin summer and 01:00 in winter;
  • running it twice does nothing twice: the second call writes the same row. Behind a load balancer two instances tick at once, so that write has to be atomic — INSERT … ON CONFLICT DO UPDATE, not read-modify-write;
  • a restart loses nothing within a day. Whether it can lose a whole one depends on the shape: a job that computes from state — last activity, say — heals a skipped day on its next tick, while a per-day rollup like the one below only ever writes today and needs a backfill for the day the process was down.
// src/routes/api/cron/daily/+server.ts
import { env } from '$env/dynamic/private';
import { upsertDailyRollup } from '$lib/server/rollup';
import type { RequestHandler } from './$types';

// Which midnight: the zone your people live in. `en-CA` formats as YYYY-MM-DD.
const TIME_ZONE = 'Europe/Berlin';
const dayKey = new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE });

export const POST: RequestHandler = async ({ request }) => {
  if (!env.CRON_SECRET || request.headers.get('x-cron-secret') !== env.CRON_SECRET) {
    return new Response('Forbidden', { status: 403 });
  }
  // Keyed on the day and written atomically, so the second call of the day —
  // or the second instance behind the load balancer — rewrites the same row
  // instead of adding one. No "last run" timestamp: that would be scheduler
  // state in your schema, and it is what turns a missed tick into a missed day.
  await upsertDailyRollup(dayKey.format(new Date()));
  return new Response('ok');
};

Design notes

  • Simple setInterval-based scheduler. No drift compensation, no distributed locking, no exponential backoff — intended for single-process SvelteKit deployments. For scale-out scenarios use a real scheduler (e.g. BullMQ) and point it at the same HTTP endpoints.
  • Header name defaults to x-cron-secret; override via secretHeader.

SSE Stream Reader (sse)

Read a POST endpoint that answers text/event-stream — the pattern where a SvelteKit API route relays an LLM (or any) stream to the browser. streamSse is an async generator: for await over it and each data:/event: frame arrives as a parsed SseEvent.

import { streamSse, SseRequestError } from '@urbicon-ui/sveltekit-utils/sse';

const controller = new AbortController();

try {
  for await (const ev of streamSse('/api/chat', {
    body: { messages }, // JSON-encoded, content-type set for you
    signal: controller.signal
  })) {
    if (ev.event === 'token') appendToken(JSON.parse(ev.data).text);
    else if (ev.event === 'error') throw new Error(JSON.parse(ev.data).message);
  }
} catch (err) {
  if (err instanceof SseRequestError) showError(err.body); // raw response body
  else if ((err as Error).name !== 'AbortError') throw err;
}

// Cancelling the stream closes the HTTP connection:
controller.abort();

Emit the matching frames from the endpoint:

// src/routes/api/chat/+server.ts
import { runModel } from '$lib/server/model';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request }) => {
  const stream = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder();
      const send = (event: string, data: unknown) =>
        controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
      for await (const token of runModel(await request.json())) send('token', { text: token });
      controller.close();
    }
  });
  return new Response(stream, { headers: { 'content-type': 'text/event-stream' } });
};

Design notes

  • Chunk-decomposition-invariant — the emitted event sequence is identical no matter how the byte stream splits into network chunks, including a split inside a CRLF pair or in the middle of a multi-byte UTF-8 character. Implements the core of the WHATWG SSE parser: \r\n/\n/\r terminators, multi-data: join with \n, one-leading-space stripping, :-comment lines, id persistence (NUL-poisoned ids ignored), leading-BOM strip, no dispatch without a data line or a final blank line.
  • Not an EventSource — it POSTs a body and takes an injectable fetch (pass SvelteKit's load fetch to stream during SSR). It deliberately does not reconnect; retry: and unknown fields are parsed and ignored, and a dropped connection surfaces as the underlying fetch/read error.
  • Fail loud — a non-2xx status, or a 2xx response with no body, throws SseRequestError carrying the status and a best-effort raw body. An abort propagates as an AbortError rather than ending the loop silently.
  • Body shaping — a string body is sent verbatim (no forced content-type); any other value is JSON-stringified with content-type: application/json. accept: text/event-stream is always sent; caller headers override both defaults.

Exports

| Subpath | Contents | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | . | Barrel of all modules | | ./url.svelte | useUrlParam, useUrlArrayParam, createUrlParam, updateUrlSearchParams, bindViewToUrl, types (re-exports withSearchParams) | | ./search-params | withSearchParams, SearchParamsPatch — no $app/* import | | ./table-view | searchParamsToViewSnapshot, searchParamsToViewPartial, viewSnapshotToSearchParams, applyViewToSearchParams, assertValidViewSnapshot, viewAxesNamedBy, viewAxisKeys, TABLE_VIEW_AXES, TABLE_VIEW_FILTER_OPERATORS, TableViewLike, types | | ./cron | createCronRunner, CronJob, CronRunnerConfig, CronRunner | | ./sse | streamSse, SseEvent, StreamSseOptions, SseRequestError |

bindViewToUrl lives in its own module (view-binding.svelte.ts) and is re-exported from ./url.svelte, which is its documented import path — it has no subpath of its own. ./search-params, ./table-view and ./cron are SvelteKit-free (they touch no $app/*), which is what lets a load function, hooks.server.ts and a plain test use them; ./url.svelte is the half that needs the router — importing it from server code pulls SvelteKit's client runtime in, which is why withSearchParams has a subpath of its own as well as the re-export.

Development

bun --filter='@urbicon-ui/sveltekit-utils' run build    # svelte-package
bun --filter='@urbicon-ui/sveltekit-utils' run check    # svelte-check

Scope & Roadmap

Candidate additions under consideration: form-helper runes, layout-runes, shared load-helpers.