@urbicon-ui/sveltekit-utils 8.19.0 → 8.21.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 +109 -9
- package/dist/cron.d.ts +17 -4
- package/dist/cron.js +93 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/search-params.d.ts +41 -0
- package/dist/search-params.js +62 -0
- package/dist/url.svelte.d.ts +10 -11
- package/dist/url.svelte.js +24 -44
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Small, focused SvelteKit helpers that Urbicon apps share. Zero runtime dependenc
|
|
|
4
4
|
|
|
5
5
|
Currently shipping:
|
|
6
6
|
|
|
7
|
-
- **URL-state runes** — reactive `useUrlParam` / `useUrlArrayParam` that keep component state in sync with `?query=` parameters
|
|
7
|
+
- **URL-state runes** — reactive `useUrlParam` / `useUrlArrayParam` that keep component state in sync with `?query=` parameters, and `withSearchParams` for the links that change them
|
|
8
8
|
- **Table view ↔ URL** — `bindViewToUrl`, the URL home for a `@urbicon-ui/table` view object (`?q=…&sort=…&page=…`), plus the pure serializers for the load path
|
|
9
9
|
- **Cron runner** — interval-based background fetcher for scheduled server endpoints
|
|
10
10
|
- **SSE stream reader** — `streamSse`, a spec-correct async-generator client for one-shot POST `text/event-stream` endpoints (LLM relays)
|
|
@@ -53,9 +53,57 @@ import { updateUrlSearchParams } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
|
53
53
|
updateUrlSearchParams({ page: '1', tag: ['a', 'b'] }, { replaceState: true });
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
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
|
+
<!-- typecheck -->
|
|
59
|
+
```typescript
|
|
60
|
+
import { withSearchParams } from '@urbicon-ui/sveltekit-utils/search-params';
|
|
61
|
+
|
|
62
|
+
const url = new URL('https://films.test/archive?sort=title&page=3');
|
|
63
|
+
|
|
64
|
+
withSearchParams(url, { sort: 'year' }); // '/archive?page=3&sort=year'
|
|
65
|
+
withSearchParams(url, { tag: ['noir', 'silent'] }); // '/archive?sort=title&page=3&tag=noir&tag=silent'
|
|
66
|
+
withSearchParams(url, { sort: null, page: null }); // '/archive'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`./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
|
+
|
|
71
|
+
<!-- typecheck -->
|
|
72
|
+
```typescript
|
|
73
|
+
// src/routes/archive/+page.server.ts
|
|
74
|
+
import { withSearchParams } from '@urbicon-ui/sveltekit-utils/search-params';
|
|
75
|
+
import type { PageServerLoad } from './$types';
|
|
76
|
+
|
|
77
|
+
export const load: PageServerLoad = ({ url }) => {
|
|
78
|
+
const current = Number(url.searchParams.get('page') ?? '1');
|
|
79
|
+
return {
|
|
80
|
+
// Page 1 is the default, so its link carries no `page` at all.
|
|
81
|
+
prevHref: withSearchParams(url, { page: current > 2 ? String(current - 1) : null }),
|
|
82
|
+
nextHref: withSearchParams(url, { page: String(current + 1) })
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
In a component the URL to start from is `page.url`, and the result is the `href`:
|
|
88
|
+
|
|
89
|
+
```svelte
|
|
90
|
+
<script lang="ts">
|
|
91
|
+
import { page } from '$app/state';
|
|
92
|
+
import { withSearchParams } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<a href={withSearchParams(page.url, { sort: 'year', page: null })}>Sort by year</a>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
56
102
|
**Design notes**
|
|
57
103
|
|
|
58
104
|
- URL updates use `goto()` with `replaceState: true`, `noScroll: true`, `keepFocus: true` — suited for filter/pagination UIs, not full page transitions.
|
|
105
|
+
- `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.
|
|
106
|
+
- `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.
|
|
59
107
|
- `useUrlParam` returns getters (not Svelte stores) so consumers can read the value lazily inside `$derived`/`$effect`.
|
|
60
108
|
|
|
61
109
|
## Table View ↔ URL (`url.svelte` + `table-view`)
|
|
@@ -123,6 +171,8 @@ The `./table-query` subpath that used to hold a second copy of this codec — sa
|
|
|
123
171
|
|
|
124
172
|
Fire HTTP requests against SvelteKit server endpoints on an interval. Pair with a shared-secret header so endpoints can authenticate scheduled calls.
|
|
125
173
|
|
|
174
|
+
**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.
|
|
175
|
+
|
|
126
176
|
<!-- typecheck -->
|
|
127
177
|
```typescript
|
|
128
178
|
// src/lib/server/cron.ts
|
|
@@ -146,8 +196,13 @@ export const cron = createCronRunner({
|
|
|
146
196
|
});
|
|
147
197
|
|
|
148
198
|
cron.start();
|
|
199
|
+
// Under `vite dev` this module is re-evaluated on every edit; without the
|
|
200
|
+
// teardown the previous evaluation's timers keep ticking beside the new ones.
|
|
201
|
+
import.meta.hot?.dispose(() => cron.stop());
|
|
149
202
|
```
|
|
150
203
|
|
|
204
|
+
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.
|
|
205
|
+
|
|
151
206
|
Receive the call and verify the secret inside your endpoint:
|
|
152
207
|
|
|
153
208
|
<!-- typecheck -->
|
|
@@ -166,6 +221,50 @@ export const POST: RequestHandler = async ({ request }) => {
|
|
|
166
221
|
};
|
|
167
222
|
```
|
|
168
223
|
|
|
224
|
+
**`onError` is required**
|
|
225
|
+
|
|
226
|
+
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.
|
|
227
|
+
|
|
228
|
+
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.
|
|
229
|
+
|
|
230
|
+
**Two runners on one path**
|
|
231
|
+
|
|
232
|
+
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.
|
|
233
|
+
|
|
234
|
+
**A daily job on an interval runner**
|
|
235
|
+
|
|
236
|
+
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.
|
|
237
|
+
|
|
238
|
+
That holds together when the endpoint is idempotent, which means
|
|
239
|
+
|
|
240
|
+
- 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;
|
|
241
|
+
- 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;
|
|
242
|
+
- 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.
|
|
243
|
+
|
|
244
|
+
<!-- typecheck -->
|
|
245
|
+
```typescript
|
|
246
|
+
// src/routes/api/cron/daily/+server.ts
|
|
247
|
+
import { env } from '$env/dynamic/private';
|
|
248
|
+
import { upsertDailyRollup } from '$lib/server/rollup';
|
|
249
|
+
import type { RequestHandler } from './$types';
|
|
250
|
+
|
|
251
|
+
// Which midnight: the zone your people live in. `en-CA` formats as YYYY-MM-DD.
|
|
252
|
+
const TIME_ZONE = 'Europe/Berlin';
|
|
253
|
+
const dayKey = new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE });
|
|
254
|
+
|
|
255
|
+
export const POST: RequestHandler = async ({ request }) => {
|
|
256
|
+
if (!env.CRON_SECRET || request.headers.get('x-cron-secret') !== env.CRON_SECRET) {
|
|
257
|
+
return new Response('Forbidden', { status: 403 });
|
|
258
|
+
}
|
|
259
|
+
// Keyed on the day and written atomically, so the second call of the day —
|
|
260
|
+
// or the second instance behind the load balancer — rewrites the same row
|
|
261
|
+
// instead of adding one. No "last run" timestamp: that would be scheduler
|
|
262
|
+
// state in your schema, and it is what turns a missed tick into a missed day.
|
|
263
|
+
await upsertDailyRollup(dayKey.format(new Date()));
|
|
264
|
+
return new Response('ok');
|
|
265
|
+
};
|
|
266
|
+
```
|
|
267
|
+
|
|
169
268
|
**Design notes**
|
|
170
269
|
|
|
171
270
|
- 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.
|
|
@@ -228,15 +327,16 @@ export const POST: RequestHandler = async ({ request }) => {
|
|
|
228
327
|
|
|
229
328
|
## Exports
|
|
230
329
|
|
|
231
|
-
| Subpath
|
|
232
|
-
|
|
|
233
|
-
| `.`
|
|
234
|
-
| `./url.svelte`
|
|
235
|
-
| `./
|
|
236
|
-
| `./
|
|
237
|
-
| `./
|
|
330
|
+
| Subpath | Contents |
|
|
331
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
332
|
+
| `.` | Barrel of all modules |
|
|
333
|
+
| `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `bindViewToUrl`, types (re-exports `withSearchParams`) |
|
|
334
|
+
| `./search-params` | `withSearchParams`, `SearchParamsPatch` — no `$app/*` import |
|
|
335
|
+
| `./table-view` | `searchParamsToViewSnapshot`, `searchParamsToViewPartial`, `viewSnapshotToSearchParams`, `applyViewToSearchParams`, `assertValidViewSnapshot`, `viewAxesNamedBy`, `viewAxisKeys`, `TABLE_VIEW_AXES`, `TABLE_VIEW_FILTER_OPERATORS`, `TableViewLike`, types |
|
|
336
|
+
| `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
|
|
337
|
+
| `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
|
|
238
338
|
|
|
239
|
-
`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. `./table-view`
|
|
339
|
+
`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.
|
|
240
340
|
|
|
241
341
|
## Development
|
|
242
342
|
|
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
|
-
*
|
|
46
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
142
|
+
await report(job, err);
|
|
57
143
|
return;
|
|
58
144
|
}
|
|
59
|
-
// The request completed; a non-2xx status is still a failure.
|
|
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
|
-
|
|
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/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What {@link withSearchParams} applies to a URL: a plain record, or a
|
|
3
|
+
* `URLSearchParams` whose repeated keys survive the merge.
|
|
4
|
+
*/
|
|
5
|
+
export type SearchParamsPatch = URLSearchParams | Record<string, string | string[] | null | undefined>;
|
|
6
|
+
/**
|
|
7
|
+
* The address `url` has after `patch` — the pure core that
|
|
8
|
+
* `updateUrlSearchParams` and `createUrlParam`'s setter navigate to. Returns
|
|
9
|
+
* `pathname?query`, or the pathname alone once no param is left, so the result
|
|
10
|
+
* serves as a link's `href`, a redirect's location, or the argument of `goto`.
|
|
11
|
+
* Reads nothing from the page, mutates neither argument, navigates nowhere.
|
|
12
|
+
*
|
|
13
|
+
* Merge semantics per key in `patch`: the key is first cleared, then
|
|
14
|
+
* re-applied — a record value `set`s a scalar, `append`s each array element,
|
|
15
|
+
* and **removes** the key entirely for `null`/`undefined`; a `URLSearchParams`
|
|
16
|
+
* re-appends all of its entries, so repeated keys survive. An empty string is a
|
|
17
|
+
* value and keeps its key (`?a=`); an empty array appends nothing and therefore
|
|
18
|
+
* removes it. A param the patch does not name keeps its value and its position;
|
|
19
|
+
* a patched key moves behind them. The hash is not carried.
|
|
20
|
+
*
|
|
21
|
+
* A `url.pathname` that itself begins with `//` comes back origin-qualified
|
|
22
|
+
* (`https://host//films?page=2`): left relative, `//films` is a
|
|
23
|
+
* protocol-relative URL naming `films` as the host.
|
|
24
|
+
*
|
|
25
|
+
* @param url - The URL to start from — `page.url` in a component, `url` in a
|
|
26
|
+
* `load`. SvelteKit makes `url.searchParams` throw while prerendering
|
|
27
|
+
* (`@sveltejs/kit/src/utils/url.js` `disable_search`: the emitted HTML must
|
|
28
|
+
* not depend on a query string), and this reads it.
|
|
29
|
+
* @param patch - Params to apply. A record value of `null`/`undefined` deletes
|
|
30
|
+
* that key.
|
|
31
|
+
* @returns Pathname plus the merged query: `/films?sort=year`.
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* withSearchParams(new URL('https://x.test/films?sort=title&page=3'), {
|
|
35
|
+
* sort: 'year',
|
|
36
|
+
* page: null
|
|
37
|
+
* });
|
|
38
|
+
* // '/films?sort=year'
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function withSearchParams(url: URL, patch: SearchParamsPatch): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The address `url` has after `patch` — the pure core that
|
|
3
|
+
* `updateUrlSearchParams` and `createUrlParam`'s setter navigate to. Returns
|
|
4
|
+
* `pathname?query`, or the pathname alone once no param is left, so the result
|
|
5
|
+
* serves as a link's `href`, a redirect's location, or the argument of `goto`.
|
|
6
|
+
* Reads nothing from the page, mutates neither argument, navigates nowhere.
|
|
7
|
+
*
|
|
8
|
+
* Merge semantics per key in `patch`: the key is first cleared, then
|
|
9
|
+
* re-applied — a record value `set`s a scalar, `append`s each array element,
|
|
10
|
+
* and **removes** the key entirely for `null`/`undefined`; a `URLSearchParams`
|
|
11
|
+
* re-appends all of its entries, so repeated keys survive. An empty string is a
|
|
12
|
+
* value and keeps its key (`?a=`); an empty array appends nothing and therefore
|
|
13
|
+
* removes it. A param the patch does not name keeps its value and its position;
|
|
14
|
+
* a patched key moves behind them. The hash is not carried.
|
|
15
|
+
*
|
|
16
|
+
* A `url.pathname` that itself begins with `//` comes back origin-qualified
|
|
17
|
+
* (`https://host//films?page=2`): left relative, `//films` is a
|
|
18
|
+
* protocol-relative URL naming `films` as the host.
|
|
19
|
+
*
|
|
20
|
+
* @param url - The URL to start from — `page.url` in a component, `url` in a
|
|
21
|
+
* `load`. SvelteKit makes `url.searchParams` throw while prerendering
|
|
22
|
+
* (`@sveltejs/kit/src/utils/url.js` `disable_search`: the emitted HTML must
|
|
23
|
+
* not depend on a query string), and this reads it.
|
|
24
|
+
* @param patch - Params to apply. A record value of `null`/`undefined` deletes
|
|
25
|
+
* that key.
|
|
26
|
+
* @returns Pathname plus the merged query: `/films?sort=year`.
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* withSearchParams(new URL('https://x.test/films?sort=title&page=3'), {
|
|
30
|
+
* sort: 'year',
|
|
31
|
+
* page: null
|
|
32
|
+
* });
|
|
33
|
+
* // '/films?sort=year'
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function withSearchParams(url, patch) {
|
|
37
|
+
const next = new URLSearchParams(url.searchParams);
|
|
38
|
+
if (patch instanceof URLSearchParams) {
|
|
39
|
+
for (const [key] of patch)
|
|
40
|
+
next.delete(key);
|
|
41
|
+
for (const [key, value] of patch)
|
|
42
|
+
next.append(key, value);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
46
|
+
next.delete(key);
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
for (const v of value)
|
|
49
|
+
next.append(key, v);
|
|
50
|
+
}
|
|
51
|
+
else if (value != null) {
|
|
52
|
+
next.set(key, value);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const query = next.toString();
|
|
57
|
+
const address = query ? `${url.pathname}?${query}` : url.pathname;
|
|
58
|
+
// A leading `//` makes the address protocol-relative: as an `href` or a
|
|
59
|
+
// `goto` target `//films` names `films` as the host, not a path on this one.
|
|
60
|
+
// The origin is what disambiguates it; every other pathname stays relative.
|
|
61
|
+
return url.pathname.startsWith('//') ? `${url.origin}${address}` : address;
|
|
62
|
+
}
|
package/dist/url.svelte.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type SearchParamsPatch } from './search-params.js';
|
|
2
|
+
export { type SearchParamsPatch, withSearchParams } from './search-params.js';
|
|
1
3
|
export { bindViewToUrl, type UrlViewBindingOptions } from './view-binding.svelte.js';
|
|
2
4
|
/**
|
|
3
5
|
* How {@link useUrlArrayParam} maps an array onto the URL:
|
|
@@ -30,24 +32,21 @@ export type UrlParamOptions<T> = {
|
|
|
30
32
|
};
|
|
31
33
|
/**
|
|
32
34
|
* Low-level escape hatch to update several params at once via `goto` (without a
|
|
33
|
-
* full navigation).
|
|
34
|
-
* every unrelated param
|
|
35
|
-
*
|
|
36
|
-
* Merge semantics per key in `next`: the key is first cleared, then re-applied
|
|
37
|
-
* — a `URLSearchParams` re-appends all of its entries (repeated keys survive),
|
|
38
|
-
* a record `set`s a scalar, `append`s each array element, and **removes** the
|
|
39
|
-
* key entirely for a `null`/`undefined` value.
|
|
35
|
+
* full navigation). Navigates to the address {@link withSearchParams} builds
|
|
36
|
+
* from the current URL and `next`, so every unrelated param is kept; the merge
|
|
37
|
+
* semantics per key are documented there.
|
|
40
38
|
*
|
|
41
39
|
* @param next - Params to apply, as `URLSearchParams` or a plain record. A
|
|
42
40
|
* record value of `null`/`undefined` deletes that key.
|
|
43
41
|
* @param opts - `replaceState` (default `true`) — replace vs. push history.
|
|
44
42
|
* @example
|
|
45
43
|
* ```typescript
|
|
44
|
+
* // on /films?filter=old
|
|
46
45
|
* updateUrlSearchParams({ page: '1', tag: ['a', 'b'], filter: null });
|
|
47
|
-
* // ?page=1&tag=a&tag=b (
|
|
46
|
+
* // navigates to /films?page=1&tag=a&tag=b (the prior `filter` param is dropped)
|
|
48
47
|
* ```
|
|
49
48
|
*/
|
|
50
|
-
export declare function updateUrlSearchParams(next:
|
|
49
|
+
export declare function updateUrlSearchParams(next: SearchParamsPatch, opts?: {
|
|
51
50
|
replaceState?: boolean;
|
|
52
51
|
}): void;
|
|
53
52
|
/**
|
|
@@ -58,8 +57,8 @@ export declare function updateUrlSearchParams(next: URLSearchParams | Record<str
|
|
|
58
57
|
* page URL (tests, a server `load`).
|
|
59
58
|
*
|
|
60
59
|
* `set` rewrites only the keys that `options.serialize` produces (clear +
|
|
61
|
-
* re-append) and preserves the rest, then navigates with `goto`
|
|
62
|
-
* (`replaceState`, `noScroll`, `keepFocus`).
|
|
60
|
+
* re-append) and preserves the rest, then navigates with `goto` to the address
|
|
61
|
+
* {@link withSearchParams} builds (`replaceState`, `noScroll`, `keepFocus`).
|
|
63
62
|
*
|
|
64
63
|
* @param _key - Ignored — `options.parse`/`options.serialize` already close
|
|
65
64
|
* over the key (see {@link useUrlArrayParam}); kept only for signature parity
|
package/dist/url.svelte.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { building } from '$app/environment';
|
|
2
2
|
import { goto } from '$app/navigation';
|
|
3
3
|
import { page } from '$app/state';
|
|
4
|
+
import { withSearchParams } from './search-params.js';
|
|
5
|
+
// The pure core of the two writers below, re-exported so this import path
|
|
6
|
+
// carries it next to them. Its own module — and its own `./search-params`
|
|
7
|
+
// subpath — because it must stay free of `$app`: importing this one pulls
|
|
8
|
+
// SvelteKit's client runtime, which a `load` or a form action must not.
|
|
9
|
+
export { withSearchParams } from './search-params.js';
|
|
4
10
|
// The v8 view-object binding lives in its own module; re-exported here so the
|
|
5
11
|
// documented import path (`@urbicon-ui/sveltekit-utils/url.svelte`) carries it.
|
|
6
12
|
// The mirror types (TableViewLike, TableViewSnapshot, …) are exported from the
|
|
@@ -12,50 +18,31 @@ import { page } from '$app/state';
|
|
|
12
18
|
export { bindViewToUrl } from './view-binding.svelte.js';
|
|
13
19
|
/**
|
|
14
20
|
* Low-level escape hatch to update several params at once via `goto` (without a
|
|
15
|
-
* full navigation).
|
|
16
|
-
* every unrelated param
|
|
17
|
-
*
|
|
18
|
-
* Merge semantics per key in `next`: the key is first cleared, then re-applied
|
|
19
|
-
* — a `URLSearchParams` re-appends all of its entries (repeated keys survive),
|
|
20
|
-
* a record `set`s a scalar, `append`s each array element, and **removes** the
|
|
21
|
-
* key entirely for a `null`/`undefined` value.
|
|
21
|
+
* full navigation). Navigates to the address {@link withSearchParams} builds
|
|
22
|
+
* from the current URL and `next`, so every unrelated param is kept; the merge
|
|
23
|
+
* semantics per key are documented there.
|
|
22
24
|
*
|
|
23
25
|
* @param next - Params to apply, as `URLSearchParams` or a plain record. A
|
|
24
26
|
* record value of `null`/`undefined` deletes that key.
|
|
25
27
|
* @param opts - `replaceState` (default `true`) — replace vs. push history.
|
|
26
28
|
* @example
|
|
27
29
|
* ```typescript
|
|
30
|
+
* // on /films?filter=old
|
|
28
31
|
* updateUrlSearchParams({ page: '1', tag: ['a', 'b'], filter: null });
|
|
29
|
-
* // ?page=1&tag=a&tag=b (
|
|
32
|
+
* // navigates to /films?page=1&tag=a&tag=b (the prior `filter` param is dropped)
|
|
30
33
|
* ```
|
|
31
34
|
*/
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
35
|
+
// This writer and `createUrlParam`'s setter hand `goto` the pathname-qualified
|
|
36
|
+
// address, never a bare `?query`: `goto` resolves a relative target against
|
|
37
|
+
// `document.baseURI` (`@sveltejs/kit/src/runtime/client/utils.js` `resolve_url`,
|
|
38
|
+
// which falls back to the first `<base>` tag), so with a `<base href>` in the
|
|
39
|
+
// document `?page=2` keeps the base's path, not the page's. No harness can
|
|
40
|
+
// separate the two spellings by outcome — both resolve against `page.url` there
|
|
41
|
+
// — so `url.test.ts` pins the string `goto` was handed instead.
|
|
42
|
+
// The address is not a route id either, so no `resolve()` here; callers are
|
|
43
|
+
// free to call it at their composition point.
|
|
36
44
|
export function updateUrlSearchParams(next, opts) {
|
|
37
|
-
|
|
38
|
-
if (next instanceof URLSearchParams) {
|
|
39
|
-
for (const [k] of next)
|
|
40
|
-
base.delete(k);
|
|
41
|
-
for (const [k, v] of next)
|
|
42
|
-
base.append(k, v);
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
for (const [key, val] of Object.entries(next)) {
|
|
46
|
-
base.delete(key);
|
|
47
|
-
if (Array.isArray(val)) {
|
|
48
|
-
for (const v of val)
|
|
49
|
-
base.append(key, v);
|
|
50
|
-
}
|
|
51
|
-
else if (val != null) {
|
|
52
|
-
base.set(key, String(val));
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const q = base.toString();
|
|
57
|
-
const path = page.url.pathname;
|
|
58
|
-
goto(q ? `${path}?${q}` : path, {
|
|
45
|
+
goto(withSearchParams(page.url, next), {
|
|
59
46
|
replaceState: opts?.replaceState ?? true,
|
|
60
47
|
noScroll: true,
|
|
61
48
|
keepFocus: true
|
|
@@ -69,8 +56,8 @@ export function updateUrlSearchParams(next, opts) {
|
|
|
69
56
|
* page URL (tests, a server `load`).
|
|
70
57
|
*
|
|
71
58
|
* `set` rewrites only the keys that `options.serialize` produces (clear +
|
|
72
|
-
* re-append) and preserves the rest, then navigates with `goto`
|
|
73
|
-
* (`replaceState`, `noScroll`, `keepFocus`).
|
|
59
|
+
* re-append) and preserves the rest, then navigates with `goto` to the address
|
|
60
|
+
* {@link withSearchParams} builds (`replaceState`, `noScroll`, `keepFocus`).
|
|
74
61
|
*
|
|
75
62
|
* @param _key - Ignored — `options.parse`/`options.serialize` already close
|
|
76
63
|
* over the key (see {@link useUrlArrayParam}); kept only for signature parity
|
|
@@ -84,14 +71,7 @@ export function updateUrlSearchParams(next, opts) {
|
|
|
84
71
|
export function createUrlParam(_key, options) {
|
|
85
72
|
const get = (sp) => options.parse(sp) ?? options.initial;
|
|
86
73
|
function setValue(next) {
|
|
87
|
-
|
|
88
|
-
const nextSp = options.serialize(next);
|
|
89
|
-
for (const [k] of nextSp)
|
|
90
|
-
current.delete(k);
|
|
91
|
-
for (const [k, v] of nextSp)
|
|
92
|
-
current.append(k, v);
|
|
93
|
-
const q = current.toString();
|
|
94
|
-
goto(q ? `?${q}` : page.url.pathname, {
|
|
74
|
+
goto(withSearchParams(page.url, options.serialize(next)), {
|
|
95
75
|
replaceState: options.replaceState ?? true,
|
|
96
76
|
noScroll: true,
|
|
97
77
|
keepFocus: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/sveltekit-utils",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.21.0",
|
|
4
4
|
"description": "SvelteKit helper utilities — createCronRunner, streamSse, and URL-state runes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,9 +38,15 @@
|
|
|
38
38
|
"import": "./dist/url.svelte.js",
|
|
39
39
|
"default": "./dist/url.svelte.js"
|
|
40
40
|
},
|
|
41
|
+
"./search-params": {
|
|
42
|
+
"types": "./dist/search-params.d.ts",
|
|
43
|
+
"import": "./dist/search-params.js",
|
|
44
|
+
"default": "./dist/search-params.js"
|
|
45
|
+
},
|
|
41
46
|
"./cron": {
|
|
42
47
|
"types": "./dist/cron.d.ts",
|
|
43
|
-
"import": "./dist/cron.js"
|
|
48
|
+
"import": "./dist/cron.js",
|
|
49
|
+
"default": "./dist/cron.js"
|
|
44
50
|
},
|
|
45
51
|
"./sse": {
|
|
46
52
|
"types": "./dist/sse.d.ts",
|