@urbicon-ui/sveltekit-utils 8.20.0 → 8.22.0

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/README.md CHANGED
@@ -15,7 +15,7 @@ Currently shipping:
15
15
  bun add @urbicon-ui/sveltekit-utils
16
16
  ```
17
17
 
18
- Peer dependencies: `svelte` (^5), `@sveltejs/kit`.
18
+ Peer dependencies: `svelte` (^5.57.0), `@sveltejs/kit`.
19
19
 
20
20
  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.
21
21
 
@@ -47,6 +47,7 @@ Bind a typed, reactive value to a URL search param. When the value changes, the
47
47
  Low-level escape hatch if you prefer to update multiple params at once:
48
48
 
49
49
  <!-- typecheck -->
50
+
50
51
  ```typescript
51
52
  import { updateUrlSearchParams } from '@urbicon-ui/sveltekit-utils/url.svelte';
52
53
 
@@ -56,6 +57,7 @@ updateUrlSearchParams({ page: '1', tag: ['a', 'b'] }, { replaceState: true });
56
57
  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 `set`s its key, an array `append`s 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.
57
58
 
58
59
  <!-- typecheck -->
60
+
59
61
  ```typescript
60
62
  import { withSearchParams } from '@urbicon-ui/sveltekit-utils/search-params';
61
63
 
@@ -69,6 +71,7 @@ withSearchParams(url, { sort: null, page: null }); // '/archive'
69
71
  `./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:
70
72
 
71
73
  <!-- typecheck -->
74
+
72
75
  ```typescript
73
76
  // src/routes/archive/+page.server.ts
74
77
  import { withSearchParams } from '@urbicon-ui/sveltekit-utils/search-params';
@@ -136,7 +139,7 @@ The second argument is optional; every option has a default:
136
139
 
137
140
  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.
138
141
 
139
- 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.
142
+ 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.
140
143
 
141
144
  ```typescript
142
145
  // src/lib/view-defaults.ts — imported by the component and by the load function.
@@ -145,6 +148,7 @@ export const userView = { pageSize: 25, sort: { column: 'joined', direction: 'de
145
148
  ```
146
149
 
147
150
  <!-- typecheck -->
151
+
148
152
  ```typescript
149
153
  // src/routes/users/+page.server.ts
150
154
  import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
@@ -161,7 +165,7 @@ The `./table-query` subpath that used to hold a second copy of this codec — sa
161
165
 
162
166
  **Design notes**
163
167
 
164
- - **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.
168
+ - **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.
165
169
  - **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.
166
170
  - **Namespacing** — `prefix: '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.
167
171
  - **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.
@@ -171,7 +175,10 @@ The `./table-query` subpath that used to hold a second copy of this codec — sa
171
175
 
172
176
  Fire HTTP requests against SvelteKit server endpoints on an interval. Pair with a shared-secret header so endpoints can authenticate scheduled calls.
173
177
 
178
+ **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.
179
+
174
180
  <!-- typecheck -->
181
+
175
182
  ```typescript
176
183
  // src/lib/server/cron.ts
177
184
  import { createCronRunner } from '@urbicon-ui/sveltekit-utils/cron';
@@ -194,11 +201,17 @@ export const cron = createCronRunner({
194
201
  });
195
202
 
196
203
  cron.start();
204
+ // Under `vite dev` this module is re-evaluated on every edit; without the
205
+ // teardown the previous evaluation's timers keep ticking beside the new ones.
206
+ import.meta.hot?.dispose(() => cron.stop());
197
207
  ```
198
208
 
209
+ 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.
210
+
199
211
  Receive the call and verify the secret inside your endpoint:
200
212
 
201
213
  <!-- typecheck -->
214
+
202
215
  ```typescript
203
216
  // src/routes/api/cron/send-digest/+server.ts
204
217
  import { env } from '$env/dynamic/private';
@@ -214,6 +227,51 @@ export const POST: RequestHandler = async ({ request }) => {
214
227
  };
215
228
  ```
216
229
 
230
+ **`onError` is required**
231
+
232
+ 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.
233
+
234
+ 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.
235
+
236
+ **Two runners on one path**
237
+
238
+ 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.
239
+
240
+ **A daily job on an interval runner**
241
+
242
+ 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.
243
+
244
+ That holds together when the endpoint is idempotent, which means
245
+
246
+ - 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;
247
+ - 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;
248
+ - 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.
249
+
250
+ <!-- typecheck -->
251
+
252
+ ```typescript
253
+ // src/routes/api/cron/daily/+server.ts
254
+ import { env } from '$env/dynamic/private';
255
+ import { upsertDailyRollup } from '$lib/server/rollup';
256
+ import type { RequestHandler } from './$types';
257
+
258
+ // Which midnight: the zone your people live in. `en-CA` formats as YYYY-MM-DD.
259
+ const TIME_ZONE = 'Europe/Berlin';
260
+ const dayKey = new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE });
261
+
262
+ export const POST: RequestHandler = async ({ request }) => {
263
+ if (!env.CRON_SECRET || request.headers.get('x-cron-secret') !== env.CRON_SECRET) {
264
+ return new Response('Forbidden', { status: 403 });
265
+ }
266
+ // Keyed on the day and written atomically, so the second call of the day —
267
+ // or the second instance behind the load balancer — rewrites the same row
268
+ // instead of adding one. No "last run" timestamp: that would be scheduler
269
+ // state in your schema, and it is what turns a missed tick into a missed day.
270
+ await upsertDailyRollup(dayKey.format(new Date()));
271
+ return new Response('ok');
272
+ };
273
+ ```
274
+
217
275
  **Design notes**
218
276
 
219
277
  - 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.
@@ -237,7 +295,8 @@ try {
237
295
  else if (ev.event === 'error') throw new Error(JSON.parse(ev.data).message);
238
296
  }
239
297
  } catch (err) {
240
- if (err instanceof SseRequestError) showError(err.body); // raw response body
298
+ if (err instanceof SseRequestError)
299
+ showError(err.body); // raw response body
241
300
  else if ((err as Error).name !== 'AbortError') throw err;
242
301
  }
243
302
 
@@ -248,6 +307,7 @@ controller.abort();
248
307
  Emit the matching frames from the endpoint:
249
308
 
250
309
  <!-- typecheck -->
310
+
251
311
  ```typescript
252
312
  // src/routes/api/chat/+server.ts
253
313
  import { runModel } from '$lib/server/model';
@@ -276,16 +336,16 @@ export const POST: RequestHandler = async ({ request }) => {
276
336
 
277
337
  ## Exports
278
338
 
279
- | Subpath | Contents |
280
- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
281
- | `.` | Barrel of all modules |
282
- | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `bindViewToUrl`, types (re-exports `withSearchParams`) |
283
- | `./search-params` | `withSearchParams`, `SearchParamsPatch` — no `$app/*` import |
339
+ | Subpath | Contents |
340
+ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
341
+ | `.` | Barrel of all modules |
342
+ | `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `bindViewToUrl`, types (re-exports `withSearchParams`) |
343
+ | `./search-params` | `withSearchParams`, `SearchParamsPatch` — no `$app/*` import |
284
344
  | `./table-view` | `searchParamsToViewSnapshot`, `searchParamsToViewPartial`, `viewSnapshotToSearchParams`, `applyViewToSearchParams`, `assertValidViewSnapshot`, `viewAxesNamedBy`, `viewAxisKeys`, `TABLE_VIEW_AXES`, `TABLE_VIEW_FILTER_OPERATORS`, `TableViewLike`, types |
285
- | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
286
- | `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
345
+ | `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
346
+ | `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
287
347
 
288
- `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` and `./table-view` are SvelteKit-free (they touch no `$app/*`), which is what lets a `load` function 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.
348
+ `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.
289
349
 
290
350
  ## Development
291
351
 
package/dist/cron.d.ts CHANGED
@@ -42,16 +42,22 @@ export interface CronRunnerConfig {
42
42
  * an `Error` naming the job and status, with the numeric code attached as
43
43
  * `error.status` (e.g. `500`, `403`).
44
44
  *
45
- * Without a handler both failure modes are swallowed silently pass one to
46
- * observe per-run outcomes.
45
+ * Required: it is the runner's only outward channel, and nothing else
46
+ * observes a job that has been answering 403 for weeks. May be `async` — the
47
+ * runner awaits it; one that throws or rejects is reported on `console.error`
48
+ * and does not stop the schedule.
47
49
  */
48
- onError?: (job: CronJob, error: Error) => void;
50
+ onError: (job: CronJob, error: Error) => void | Promise<void>;
49
51
  }
50
52
  /** Handle returned by {@link createCronRunner}. */
51
53
  export interface CronRunner {
52
54
  /**
53
55
  * Arm every job's interval timer. Idempotent — calling `start()` while
54
56
  * already running is a no-op (does not double-schedule).
57
+ *
58
+ * When `import.meta.env.DEV` is set, two wiring mistakes are reported on
59
+ * `console.warn`: a path another runner in this process already ticks, and
60
+ * one listed twice in the same `jobs` array.
55
61
  */
56
62
  start(): void;
57
63
  /**
@@ -72,8 +78,14 @@ export interface CronRunner {
72
78
  * for scale-out point a real scheduler (BullMQ, a platform cron) at the same
73
79
  * endpoints instead. The runner starts idle — call `start()` explicitly.
74
80
  *
75
- * @param config - Secret/header, base URL, and the jobs to schedule.
81
+ * Import from `@urbicon-ui/sveltekit-utils/cron`, not from the package root:
82
+ * the root barrel carries `url.svelte`, whose `$app/*` imports have no business
83
+ * in `hooks.server.ts`.
84
+ *
85
+ * @param config - Secret/header, base URL, the jobs to schedule, and the
86
+ * required `onError` handler.
76
87
  * @returns A {@link CronRunner} handle (`start` / `stop` / `isRunning`).
88
+ * @throws TypeError if `onError` is not a function.
77
89
  * @example
78
90
  * ```typescript
79
91
  * // src/lib/server/cron.ts
@@ -95,6 +107,7 @@ export interface CronRunner {
95
107
  * });
96
108
  *
97
109
  * cron.start();
110
+ * import.meta.hot?.dispose(() => cron.stop());
98
111
  * ```
99
112
  */
100
113
  export declare function createCronRunner(config: CronRunnerConfig): CronRunner;
package/dist/cron.js CHANGED
@@ -1,3 +1,44 @@
1
+ // Which job paths this process currently ticks, and how many runners tick each.
2
+ // The registry hangs off `globalThis` under a `Symbol.for` key rather than
3
+ // living in this module's scope: under `vite dev` a server module is
4
+ // re-evaluated on a hot reload, and the fresh evaluation gets a fresh
5
+ // module scope while the previous evaluation's timers keep firing — the very
6
+ // situation the warning below exists for. `globalThis` survives it, the module
7
+ // scope does not.
8
+ const CRON_REGISTRY = Symbol.for('urbicon-ui.sveltekit-utils.cron');
9
+ function armedPaths() {
10
+ const host = globalThis;
11
+ const known = host[CRON_REGISTRY];
12
+ if (known)
13
+ return known;
14
+ const paths = new Map();
15
+ host[CRON_REGISTRY] = paths;
16
+ return paths;
17
+ }
18
+ /** Register one armed path; returns how many runners already ticked it. */
19
+ function claimPath(path) {
20
+ const paths = armedPaths();
21
+ const armed = paths.get(path) ?? 0;
22
+ paths.set(path, armed + 1);
23
+ return armed;
24
+ }
25
+ function releasePath(path) {
26
+ const paths = armedPaths();
27
+ const armed = (paths.get(path) ?? 1) - 1;
28
+ if (armed > 0)
29
+ paths.set(path, armed);
30
+ else
31
+ paths.delete(path);
32
+ }
33
+ function warnDoubleStart(path, armed) {
34
+ const others = armed === 1 ? 'another runner is' : `${armed} other runners are`;
35
+ console.warn(`[createCronRunner] start() armed "${path}" while ${others} already ticking it in this process — every one of them fires and the endpoint sees the traffic of all. A hot reload under \`vite dev\` is the usual cause: put \`import.meta.hot?.dispose(() => runner.stop())\` next to the start() call. Otherwise call stop() on the runner you replaced.`);
36
+ }
37
+ // One runner, one path listed twice: neither remedy above applies, so this is
38
+ // its own sentence rather than a plural of the one above.
39
+ function warnDuplicateJob(path) {
40
+ console.warn(`[createCronRunner] start() armed "${path}" twice from one jobs list; the endpoint gets every tick twice. Remove the duplicate entry.`);
41
+ }
1
42
  /**
2
43
  * Create a background runner that fires HTTP requests at SvelteKit server
3
44
  * endpoints on a fixed interval — a minimal in-process cron for scheduled work
@@ -8,8 +49,14 @@
8
49
  * for scale-out point a real scheduler (BullMQ, a platform cron) at the same
9
50
  * endpoints instead. The runner starts idle — call `start()` explicitly.
10
51
  *
11
- * @param config - Secret/header, base URL, and the jobs to schedule.
52
+ * Import from `@urbicon-ui/sveltekit-utils/cron`, not from the package root:
53
+ * the root barrel carries `url.svelte`, whose `$app/*` imports have no business
54
+ * in `hooks.server.ts`.
55
+ *
56
+ * @param config - Secret/header, base URL, the jobs to schedule, and the
57
+ * required `onError` handler.
12
58
  * @returns A {@link CronRunner} handle (`start` / `stop` / `isRunning`).
59
+ * @throws TypeError if `onError` is not a function.
13
60
  * @example
14
61
  * ```typescript
15
62
  * // src/lib/server/cron.ts
@@ -31,16 +78,55 @@
31
78
  * });
32
79
  *
33
80
  * cron.start();
81
+ * import.meta.hot?.dispose(() => cron.stop());
34
82
  * ```
35
83
  */
36
84
  export function createCronRunner(config) {
85
+ if (typeof config.onError !== 'function') {
86
+ throw new TypeError(`[createCronRunner] onError is required and must be a function (received ${typeof config.onError}). It is the only channel a failing job has: without it a 403 or a 500 on every tick is indistinguishable from a job that works. Pass \`onError: (job, err) => console.error(job.path, err)\` if the server log is where you read it.`);
87
+ }
37
88
  const timers = [];
89
+ // What this runner put into the process-wide registry, so `stop()` returns
90
+ // exactly that — reading `config.jobs` again would release paths this runner
91
+ // never claimed, and nothing at all if the consumer has since emptied the
92
+ // array. Empty unless a `start()` claimed, which is also what makes a second
93
+ // `stop()` a no-op without a flag to ask.
94
+ let claimed = [];
38
95
  let running = false;
96
+ // The handler is the consumer's error channel, so when it fails there is no
97
+ // second one to report on — and letting the failure escape this async
98
+ // interval callback ends the process: an unhandled rejection exits node
99
+ // (25.2.1) and bun (1.4.2) with code 1, so one broken log call would take the
100
+ // app down and stop every other job with it. The `await` is what makes that
101
+ // hold for an `async` handler too: its rejection reaches no `try` block that
102
+ // does not await it, and neither does any other thenable's.
103
+ const report = async (job, error) => {
104
+ try {
105
+ await config.onError(job, error);
106
+ }
107
+ catch (handlerError) {
108
+ console.error(`[createCronRunner] the onError handler threw or rejected while reporting a failure of "${job.path}"; the schedule keeps running. Handler error, then the job error:`, handlerError, error);
109
+ }
110
+ };
39
111
  return {
40
112
  start() {
41
113
  if (running)
42
114
  return;
43
115
  running = true;
116
+ if (import.meta.env?.DEV) {
117
+ for (const path of config.jobs.map((job) => job.path)) {
118
+ // One claim per path per runner: the registry counts runners, and a
119
+ // path listed twice is its own mistake with its own warning.
120
+ if (claimed.includes(path)) {
121
+ warnDuplicateJob(path);
122
+ continue;
123
+ }
124
+ claimed.push(path);
125
+ const armed = claimPath(path);
126
+ if (armed > 0)
127
+ warnDoubleStart(path, armed);
128
+ }
129
+ }
44
130
  for (const job of config.jobs) {
45
131
  const timer = setInterval(async () => {
46
132
  const base = config.baseUrl ?? 'http://localhost:3000';
@@ -53,23 +139,23 @@ export function createCronRunner(config) {
53
139
  }
54
140
  catch (err) {
55
141
  // Network-level failure (DNS, connection refused, abort): fetch rejected.
56
- config.onError?.(job, err);
142
+ await report(job, err);
57
143
  return;
58
144
  }
59
- // The request completed; a non-2xx status is still a failure. Handle it
60
- // outside the try so a throwing `onError` escapes as an unhandled
61
- // rejection rather than being re-caught and re-invoked here — symmetric
62
- // with the rejection path above.
145
+ // The request completed; a non-2xx status is still a failure.
63
146
  if (!response.ok) {
64
147
  const err = new Error(`Cron job "${job.path}" returned ${response.status} ${response.statusText}`);
65
148
  err.status = response.status;
66
- config.onError?.(job, err);
149
+ await report(job, err);
67
150
  }
68
151
  }, job.intervalSeconds * 1000);
69
152
  timers.push(timer);
70
153
  }
71
154
  },
72
155
  stop() {
156
+ for (const path of claimed)
157
+ releasePath(path);
158
+ claimed = [];
73
159
  running = false;
74
160
  timers.forEach(clearInterval);
75
161
  timers.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/sveltekit-utils",
3
- "version": "8.20.0",
3
+ "version": "8.22.0",
4
4
  "description": "SvelteKit helper utilities — createCronRunner, streamSse, and URL-state runes",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -45,7 +45,8 @@
45
45
  },
46
46
  "./cron": {
47
47
  "types": "./dist/cron.d.ts",
48
- "import": "./dist/cron.js"
48
+ "import": "./dist/cron.js",
49
+ "default": "./dist/cron.js"
49
50
  },
50
51
  "./sse": {
51
52
  "types": "./dist/sse.d.ts",