19 Commits

Author SHA1 Message Date
CI cebeee4040 release v1.1.6 [skip ci] 2026-06-24 20:55:04 +00:00
keligrubb bb01e9a06c Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
Push / release (push) Successful in 13s
Push / publish (push) Successful in 1m4s
## Summary

- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.

## Docs

- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates

---------

Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #36
2026-06-24 20:54:50 +00:00
CI a6b87305a1 release v1.1.5 [skip ci] 2026-06-21 04:26:35 +00:00
renovate-bot 829db93065 chore(deps): update https://git.keligrubb.com/actions/checkout action to v7 (#35)
Push / release (push) Successful in 14s
Push / publish (push) Failing after 3h2m29s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6` → `v7` |

---

### Release Notes

<details>
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>

### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)

[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)

- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)

### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)

[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)

- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)

</details>

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzMuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMy40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #35
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-06-21 04:26:21 +00:00
renovate-bot afaf305bda chore(deps): update all non-major dependencies (#32)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.8` → `2.4.10`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.8/2.4.10) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.10?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.8/2.4.10?slim=true) |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.21` → `3.19.22`](https://renovatebot.com/diffs/npm/mediasoup/3.19.21/3.19.22) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.22?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.21/3.19.22?slim=true) |
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.19.0` → `3.20.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.19.0/3.20.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.19.0/3.20.0?slim=true) |
| [nuxt](https://nuxt.com) ([source](https://github.com/nuxt/nuxt/tree/HEAD/packages/nuxt)) | [`4.4.2` → `4.4.4`](https://renovatebot.com/diffs/npm/nuxt/4.4.2/4.4.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/nuxt/4.4.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nuxt/4.4.2/4.4.4?slim=true) |
| [openid-client](https://github.com/panva/openid-client) | [`6.8.3` → `6.8.4`](https://renovatebot.com/diffs/npm/openid-client/6.8.3/6.8.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/openid-client/6.8.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/openid-client/6.8.3/6.8.4?slim=true) |

---

### Release Notes

<details>
<summary>vuejs/test-utils (@&#8203;vue/test-utils)</summary>

### [`v2.4.10`](https://github.com/vuejs/test-utils/compare/v2.4.9...v2.4.10)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.9...v2.4.10)

### [`v2.4.9`](https://github.com/vuejs/test-utils/releases/tag/v2.4.9)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.8...v2.4.9)

[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.8...v2.4.9)

##### 🩹 Fixes

- Tolerate duplicate attachTo cleanup ([#&#8203;2830](https://github.com/vuejs/test-utils/pull/2830))

##### 📖 Documentation

- Document release process ([#&#8203;2834](https://github.com/vuejs/test-utils/pull/2834))

##### 🏡 Chore

- Migrate renovate config ([5d37934](https://github.com/vuejs/test-utils/commit/5d37934))

##### 🤖 CI

- Pin github actions to commit hashes ([75dcef3](https://github.com/vuejs/test-utils/commit/75dcef3))

##### ❤️ Contributors

- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))

</details>

<details>
<summary>versatica/mediasoup (mediasoup)</summary>

### [`v3.19.22`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31922)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.21...3.19.22)

- Node: Avoid "worker died" event when the Node application is closed via signal without calling `worker.close()` ([PR #&#8203;1788](https://github.com/versatica/mediasoup/pull/1788)).

</details>

<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>

### [`v3.20.0`](https://github.com/versatica/mediasoup-client/compare/3.19.0...3.20.0)

[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.19.0...3.20.0)

</details>

<details>
<summary>nuxt/nuxt (nuxt)</summary>

### [`v4.4.4`](https://github.com/nuxt/nuxt/compare/v4.4.2...v4.4.4)

[Compare Source](https://github.com/nuxt/nuxt/compare/v4.4.2...v4.4.4)

</details>

<details>
<summary>panva/openid-client (openid-client)</summary>

### [`v6.8.4`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#684-2026-04-27)

[Compare Source](https://github.com/panva/openid-client/compare/v6.8.3...v6.8.4)

##### Fixes

- apply optional non-repudiation on generic grant ID Tokens ([6202888](https://github.com/panva/openid-client/commit/62028884943a1987eb1ddedc9efc01953189a14f))
- filter jwe decryption keys by algorithm ([34e2ffd](https://github.com/panva/openid-client/commit/34e2ffdea5b3b5afffeff55208aaad1d3b42d0fb))
- preserve poll abort signals on requests ([96a2d17](https://github.com/panva/openid-client/commit/96a2d176b982196f432990ef1f64978cb7cb881c))
- retry dpop nonce errors for generic grants ([498c4d9](https://github.com/panva/openid-client/commit/498c4d9c098340ad2f8b9dbb91bfe7f1166a3fd4))

</details>

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNTAuMCIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #32
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-05-04 15:05:24 +00:00
CI 2a48715ea8 release v1.1.4 [skip ci] 2026-04-29 20:24:17 +00:00
renovate-bot 9d34f5f7c5 chore(deps): update all non-major dependencies (#31)
Push / release (push) Successful in 30s
Push / publish (push) Failing after 3h9m29s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5?slim=true) |
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.6/2.4.8?slim=true) |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.2.0/10.2.1?slim=true) |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.7.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.6.0/5.7.2?slim=true) |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.21?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.19/3.19.21?slim=true) |
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.8/3.19.0?slim=true) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.4/4.1.5?slim=true) |
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.32/3.5.33?slim=true) |
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.4/5.0.6?slim=true) |

---

### Release Notes

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)

#####    🚀 Experimental Features

- **coverage**: Istanbul to support `instrumenter` option  -  by [@&#8203;BartWaardenburg](https://github.com/BartWaardenburg) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)

#####    🐞 Bug Fixes

- \--project negation excludes browser instances  -  by [@&#8203;felamaslen](https://github.com/felamaslen) in [#&#8203;10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
- Project color label on html reporter  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
- Fix `vi.defineHelper` called as object method  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
- Alias `agent` reporter to `minimal`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
- Respect diff config options in soft assertions  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
- Respect diff config options in soft assertions "  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery  -  by [@&#8203;Yejneshwar](https://github.com/Yejneshwar) in [#&#8203;10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
- **coverage**: Descriptive error message when reports directory is removed during test run  -  by [@&#8203;DaveT1991](https://github.com/DaveT1991) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
- **snapshot**: Increase default snapshot max output length  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
- **ui**: Fix jsx/tsx syntax highlight  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
- **web-worker**: Support MessagePort objects referenced inside postMessage data  -  by [@&#8203;whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#&#8203;9927](https://github.com/vitest-dev/vitest/issues/9927) and [#&#8203;10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
- **api**: Make test-specification options writable  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)

</details>

<details>
<summary>vuejs/test-utils (@&#8203;vue/test-utils)</summary>

### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)

[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)

##### 🩹 Fixes

- Correct declaration entrypoint ([#&#8203;2826](https://github.com/vuejs/test-utils/pull/2826))

##### 🤖 CI

- Enable pkg.pr.new ([#&#8203;2827](https://github.com/vuejs/test-utils/pull/2827))

##### ❤️ Contributors

- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))

### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)

[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)

[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)

##### 🚀 Enhancements

- Add Chinese docs translation ([#&#8203;2552](https://github.com/vuejs/test-utils/pull/2552))
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#&#8203;2655](https://github.com/vuejs/test-utils/pull/2655))

##### 🩹 Fixes

- Preserve code from keyboard events ([#&#8203;2434](https://github.com/vuejs/test-utils/pull/2434))
- Switch browser and require exports definitions ([#&#8203;2501](https://github.com/vuejs/test-utils/pull/2501))
- Re-add peer dependencies but with wider range ([#&#8203;2511](https://github.com/vuejs/test-utils/pull/2511))
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
- Resolve TypeScript type errors in .vitepress/config ([#&#8203;2549](https://github.com/vuejs/test-utils/pull/2549))
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
- Text() misses content for array functional component ([#&#8203;2579](https://github.com/vuejs/test-utils/pull/2579))
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
- **deps:** Update dependency vue-component-type-helpers to v3 ([#&#8203;2683](https://github.com/vuejs/test-utils/pull/2683))
- Remove wrapper div when unmount ([#&#8203;2700](https://github.com/vuejs/test-utils/pull/2700))
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#&#8203;2713](https://github.com/vuejs/test-utils/pull/2713))
- Add missing peerDependency [@&#8203;vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))

##### 💅 Refactors

- Enforce consistent usage of type imports ([#&#8203;2734](https://github.com/vuejs/test-utils/pull/2734))

##### 📖 Documentation

- Clarify findComponent vs getComponent ([#&#8203;2435](https://github.com/vuejs/test-utils/pull/2435))
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
- Add note about partial transition stub support ([#&#8203;2431](https://github.com/vuejs/test-utils/pull/2431))
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
- Fix plugin TS declaration example ([#&#8203;2466](https://github.com/vuejs/test-utils/pull/2466))
- Fixed incorrect checkbox value check ([#&#8203;2495](https://github.com/vuejs/test-utils/pull/2495))
- Capital letter in sentence fix ([#&#8203;2499](https://github.com/vuejs/test-utils/pull/2499))
- Import missing DOMWrapper on Implementation of the plugin section ([#&#8203;2519](https://github.com/vuejs/test-utils/pull/2519))
- Add migration step for deprecated ref syntax in findAllComponents ([#&#8203;2498](https://github.com/vuejs/test-utils/pull/2498))
- Correct anchor hash links and fix typo ([#&#8203;2551](https://github.com/vuejs/test-utils/pull/2551))
- Center logo on home ([#&#8203;2559](https://github.com/vuejs/test-utils/pull/2559))
- **zh-cn:** Review a-crash-course ([#&#8203;2563](https://github.com/vuejs/test-utils/pull/2563))
- Use code-group for install commands ([#&#8203;2571](https://github.com/vuejs/test-utils/pull/2571))
- **zh-cn:** Review event-handing.md ([#&#8203;2572](https://github.com/vuejs/test-utils/pull/2572))
- **zh-cn:** Enhance conditional-rendering.md ([#&#8203;2562](https://github.com/vuejs/test-utils/pull/2562))
- **zh-cn:** Review easy-to-test ([#&#8203;2567](https://github.com/vuejs/test-utils/pull/2567))
- **zh-cn:** Review passing-data.md ([#&#8203;2575](https://github.com/vuejs/test-utils/pull/2575))
- **zh-cn:** Review async-suspense.md ([#&#8203;2576](https://github.com/vuejs/test-utils/pull/2576))
- **zh:** 优化 API 文档格式和内容 ([#&#8203;2569](https://github.com/vuejs/test-utils/pull/2569))
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#&#8203;2578](https://github.com/vuejs/test-utils/pull/2578))
- **zh-cn:** Review http-requests.md ([#&#8203;2580](https://github.com/vuejs/test-utils/pull/2580))
- **zh-cn:** Review forms ([#&#8203;2582](https://github.com/vuejs/test-utils/pull/2582))
- **zh-cn:** Guide/advanced/slots.md ([#&#8203;2565](https://github.com/vuejs/test-utils/pull/2565))
- **zh:** Review extending-vtu ([#&#8203;2583](https://github.com/vuejs/test-utils/pull/2583))
- **zh:** Review index ([#&#8203;2584](https://github.com/vuejs/test-utils/pull/2584))
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
- **zh:** Review transitions, component-instance, and reusability-composition ([#&#8203;2616](https://github.com/vuejs/test-utils/pull/2616))
- **zh:** Review v-model and vuex ([#&#8203;2617](https://github.com/vuejs/test-utils/pull/2617))
- **zh:** Review all the rest advanced guide ([#&#8203;2619](https://github.com/vuejs/test-utils/pull/2619))
- **zh:** Review migration ([#&#8203;2623](https://github.com/vuejs/test-utils/pull/2623))
- Fix a typo in transitions.md ([#&#8203;2635](https://github.com/vuejs/test-utils/pull/2635))
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
- Update Essentials section to setup (composition api) ([#&#8203;2647](https://github.com/vuejs/test-utils/pull/2647))
- Typos in examples ([#&#8203;2678](https://github.com/vuejs/test-utils/pull/2678))
- Typo in easy-to-test.md ([#&#8203;2710](https://github.com/vuejs/test-utils/pull/2710))
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
- Updated example TodoApp to script setup ([#&#8203;2727](https://github.com/vuejs/test-utils/pull/2727))
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#&#8203;2743](https://github.com/vuejs/test-utils/pull/2743))
- Follow-up fixes for the conditional rendering guide ([#&#8203;2744](https://github.com/vuejs/test-utils/pull/2744))
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#&#8203;2799](https://github.com/vuejs/test-utils/pull/2799))
- Restore Options API component for data() mounting example ([#&#8203;2804](https://github.com/vuejs/test-utils/pull/2804))
- Promote Vitest as recommended test runner ([#&#8203;2805](https://github.com/vuejs/test-utils/pull/2805))
- **api:** Note that setValue does not accept objects on `<select>` ([#&#8203;2819](https://github.com/vuejs/test-utils/pull/2819))

##### 🏡 Chore

- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
- Expose Stubs type publicly ([#&#8203;2492](https://github.com/vuejs/test-utils/pull/2492))
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
- Use github issue forms ([#&#8203;2673](https://github.com/vuejs/test-utils/pull/2673))
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))

##### 🤖 CI

- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
- Add trusted publishing release workflow ([#&#8203;2825](https://github.com/vuejs/test-utils/pull/2825))

##### ❤️ Contributors

- Lachlan Miller ([@&#8203;lmiller1990](https://github.com/lmiller1990))
- cexbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Nicolas Bonamy ([@&#8203;nbonamy](https://github.com/nbonamy))
- KatWorkGit ([@&#8203;KatWorkGit](https://github.com/KatWorkGit))
- Wouter Kroes ([@&#8203;wouterkroes](https://github.com/wouterkroes))
- Rama Muhammad Murshal ([@&#8203;ramammurshal](https://github.com/ramammurshal))
- Evan You ([@&#8203;yyx990803](https://github.com/yyx990803))
- Vlad Starkovsky ([@&#8203;starkovsky](https://github.com/starkovsky))
- Joe ([@&#8203;joaoprp](https://github.com/joaoprp))
- Priyadarshi Kumar ([@&#8203;Psingh132](https://github.com/Psingh132))
- Sébastien Ronveaux ([@&#8203;sronveaux](https://github.com/sronveaux))
- Gilliam ([@&#8203;Gi11i4m](https://github.com/Gi11i4m))
- Baranov Dmytro ([@&#8203;dimas7001](https://github.com/dimas7001))
- BrendonHenrique ([@&#8203;BrendonHenrique](https://github.com/BrendonHenrique))
- Lorenz van Herwaarden ([@&#8203;lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
- wuzhiqing ([@&#8203;DDDDD12138](https://github.com/DDDDD12138))
- 阿菜 Cai ([@&#8203;RSS1102](https://github.com/RSS1102))
- Jinjiang ([@&#8203;Jinjiang](https://github.com/Jinjiang))
- Kylin ([@&#8203;lxKylin](https://github.com/lxKylin))
- Qianhe Chen ([@&#8203;chenqianhe](https://github.com/chenqianhe))
- 时瑶 ([@&#8203;KiritaniAyaka](https://github.com/KiritaniAyaka))
- h7ml ([@&#8203;h7ml](https://github.com/h7ml))
- Nicander ([@&#8203;Nicander93](https://github.com/Nicander93))
- Take-John ([@&#8203;takejohn](https://github.com/takejohn))
- ilyasherstoboev ([@&#8203;ilyasherstoboev](https://github.com/ilyasherstoboev))
- aimerie ([@&#8203;aimerie](https://github.com/aimerie))
- Miguel Rincon ([@&#8203;miguelrincon](https://github.com/miguelrincon))
- bcastlel ([@&#8203;bcastlel](https://github.com/bcastlel))
- Claudiu ([@&#8203;sofuxro](https://github.com/sofuxro))
- Artem Dragunov ([@&#8203;dragunovartem99](https://github.com/dragunovartem99))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Koen Mertens ([@&#8203;KCMertens](https://github.com/KCMertens))
- meomking ([@&#8203;CaptainWang98](https://github.com/CaptainWang98))
- Pepijn Olivier ([@&#8203;pepijnolivier](https://github.com/pepijnolivier))
- Tomina ([@&#8203;Thomaash](https://github.com/Thomaash))
- Gareth Jones ([@&#8203;G-Rath](https://github.com/G-Rath))
- Jerry Hogan ([@&#8203;hdJerry](https://github.com/hdJerry))
- Marco Pasqualetti ([@&#8203;marcalexiei](https://github.com/marcalexiei))
- guoxk ([@&#8203;guoxk-me](https://github.com/guoxk-me))
- kimulaco ([@&#8203;kimulaco](https://github.com/kimulaco))
- Erwan IQUEL ([@&#8203;Olympus5](https://github.com/Olympus5))
- Matt Van Horn ([@&#8203;mvanhorn](https://github.com/mvanhorn))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))

</details>

<details>
<summary>eslint/eslint (eslint)</summary>

### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)

[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)

#### Bug Fixes

- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#&#8203;20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#&#8203;20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#&#8203;20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#&#8203;20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])

#### Documentation

- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#&#8203;20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh  S M)
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs:  Enable Eleventy incremental mode for watch ([#&#8203;20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh  S M)
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#&#8203;20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#&#8203;20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#&#8203;20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh  S M)
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#&#8203;20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)

#### Chores

- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#&#8203;20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#&#8203;20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#&#8203;20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#&#8203;20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#&#8203;20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test  ([#&#8203;20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#&#8203;20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#&#8203;20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)

</details>

<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>

### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #&#8203;705, #&#8203;817

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)

- allow numerical external entity for backward compatibility
- fix [#&#8203;705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
- fix [#&#8203;817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long

### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @&#8203;nodable/entities and FXB

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)

- Use `@nodable/entities` v2.1.0
  - breaking changes
    - single entity scan. You're not allowed to use entity value to form another entity name.
    - you cant add numeric external entity
    - entity error message when expantion limit is crossed might change
  - typings are updated for new options related to process entity
  - please follow documentation of `@nodable/entities` for more detail.
  - performance
    - if processEntities is false, then there should not be impact on performance.
    - if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
    - if processEntities is true, and you pass entity decoder separately
      - if no entity then performance should be same as before
      - if there are entities then performance should be increased from past versions
  - ignoreAttributes is not required to be set to set xml version for NCR entity value
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content

### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)

</details>

<details>
<summary>versatica/mediasoup (mediasoup)</summary>

### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)

- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #&#8203;1780](https://github.com/versatica/mediasoup/pull/1780)).

### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)

- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #&#8203;1777](https://github.com/versatica/mediasoup/pull/1777)).

</details>

<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>

### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)

[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)

</details>

<details>
<summary>vuejs/core (vue)</summary>

### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)

[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)

##### Bug Fixes

- **compiler-sfc:** handle nested :deep in selector pseudos ([#&#8203;14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#&#8203;14724](https://github.com/vuejs/core/issues/14724)
- **reactivity:** unlink effect scopes on out-of-order off ([#&#8203;14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#&#8203;14733](https://github.com/vuejs/core/issues/14733)
- **runtime-dom:** preserve textarea resize dimensions ([#&#8203;14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#&#8203;14741](https://github.com/vuejs/core/issues/14741)
- **teleport:** don't move teleport children if not mounted ([#&#8203;14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#&#8203;14701](https://github.com/vuejs/core/issues/14701)
- **transition:** preserve placeholder for conditional explicit default slots ([#&#8203;14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#&#8203;14727](https://github.com/vuejs/core/issues/14727)

</details>

<details>
<summary>vuejs/router (vue-router)</summary>

### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)

#####    🐞 Bug Fixes

- Missing closing quote in generated import  -  by [@&#8203;zjy040525](https://github.com/zjy040525) and [@&#8203;posva](https://github.com/posva) in [#&#8203;2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)

### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)

#####    🚀 Features

- Enable standard schema param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
- Normalize param parsers once  -  by [@&#8203;posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)

#####    🐞 Bug Fixes

- Track definePage imports per-file to fix named view race condition  -  by [@&#8203;posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
- Avoid double decoding hash on string location  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)

</details>

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/31
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-29 20:23:47 +00:00
CI 5b4bb6b33a release v1.1.3 [skip ci] 2026-04-19 02:28:28 +00:00
renovate-bot e16b492257 chore(deps): update dependency mediasoup-client to v3.18.8 (#30)
Push / release (push) Successful in 15s
Push / publish (push) Successful in 1m50s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.18.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.7/3.18.8?slim=true) |

---

### Release Notes

<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>

### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)

[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)

</details>

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #30
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-19 02:28:13 +00:00
CI 265e02119a release v1.1.2 [skip ci] 2026-04-15 04:10:14 +00:00
renovate-bot 82d454ade4 Update dependency fast-xml-parser to v5.6.0 (#28)
Push / release (push) Successful in 23s
Push / publish (push) Successful in 1m8s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.12/5.6.0?slim=true) |

---

### Release Notes

<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>

### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @&#8203;nodable/entities to replace entities

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)

- No API change
- No change in performance for basic usage
- No typing change
- No config change
- new dependency
- breaking: error messages for entities might have been changed.
-

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #28
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-15 04:09:54 +00:00
CI a9b300d711 release v1.1.1 [skip ci] 2026-04-15 03:03:37 +00:00
keligrubb fded3a04d4 ci: split push release/publish and harden workflows (#27)
Push / release (push) Successful in 47s
Push / publish (push) Successful in 1m0s
### Added
* Separate release from Docker/Helm publish
* enrich releases with PRbodies when available
* tighten release.sh validation and idempotency
* trim PR docker-build metadata for act-runner stability

Reviewed-on: #27
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 03:03:04 +00:00
CI 0d897f17b5 release v1.1.0 [skip ci] 2026-04-15 02:45:02 +00:00
renovate-bot 216f6f83fe Update all non-major dependencies (#25)
Push / release-docker-helm (push) Failing after 3m26s
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | Type | Update |
|---|---|---|---|---|---|
| [@iconify-json/tabler](https://icon-sets.iconify.design/tabler/) | [`1.2.31` → `1.2.33`](https://renovatebot.com/diffs/npm/@iconify-json%2ftabler/1.2.31/1.2.33) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@iconify-json%2ftabler/1.2.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@iconify-json%2ftabler/1.2.31/1.2.33?slim=true) | devDependencies | patch |
| [@nuxt/test-utils](https://github.com/nuxt/test-utils) | [`4.0.0` → `4.0.2`](https://renovatebot.com/diffs/npm/@nuxt%2ftest-utils/4.0.0/4.0.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@nuxt%2ftest-utils/4.0.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nuxt%2ftest-utils/4.0.0/4.0.2?slim=true) | devDependencies | patch |
| [@playwright/test](https://playwright.dev) ([source](https://github.com/microsoft/playwright)) | [`1.58.2` → `1.59.1`](https://renovatebot.com/diffs/npm/@playwright%2ftest/1.58.2/1.59.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@playwright%2ftest/1.59.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@playwright%2ftest/1.58.2/1.59.1?slim=true) | devDependencies | minor |
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.0` → `4.1.4`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.0/4.1.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.0/4.1.4?slim=true) | devDependencies | patch |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.0.3` → `10.2.0`](https://renovatebot.com/diffs/npm/eslint/10.0.3/10.2.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.0.3/10.2.0?slim=true) | devDependencies | minor |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.3` → `5.5.12`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.3/5.5.12) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.5.12?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.3/5.5.12?slim=true) | dependencies | patch |
| [happy-dom](https://github.com/capricorn86/happy-dom) | [`20.8.3` → `20.9.0`](https://renovatebot.com/diffs/npm/happy-dom/20.8.3/20.9.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/happy-dom/20.9.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/happy-dom/20.8.3/20.9.0?slim=true) | devDependencies | minor |
| [hls.js](https://github.com/video-dev/hls.js) | [`1.6.15` → `1.6.16`](https://renovatebot.com/diffs/npm/hls.js/1.6.15/1.6.16) | ![age](https://developer.mend.io/api/mc/badges/age/npm/hls.js/1.6.16?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/hls.js/1.6.15/1.6.16?slim=true) | dependencies | patch |
| mcr.microsoft.com/playwright | `v1.58.2-noble` → `v1.59.1-noble` | ![age](https://developer.mend.io/api/mc/badges/age/docker/mcr.microsoft.com%2fplaywright/v1.59.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/docker/mcr.microsoft.com%2fplaywright/v1.58.2/v1.59.1?slim=true) | container | minor |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.17` → `3.19.19`](https://renovatebot.com/diffs/npm/mediasoup/3.19.17/3.19.19) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.19?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.17/3.19.19?slim=true) | dependencies | patch |
| [openid-client](https://github.com/panva/openid-client) | [`6.8.2` → `6.8.3`](https://renovatebot.com/diffs/npm/openid-client/6.8.2/6.8.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/openid-client/6.8.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/openid-client/6.8.2/6.8.3?slim=true) | dependencies | patch |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.0` → `4.1.4`](https://renovatebot.com/diffs/npm/vitest/4.1.0/4.1.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.0/4.1.4?slim=true) | devDependencies | patch |
| [vue](https://github.com/vuejs/core/tree/main/packages/vue#readme) ([source](https://github.com/vuejs/core)) | [`3.5.30` → `3.5.32`](https://renovatebot.com/diffs/npm/vue/3.5.30/3.5.32) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.32?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.30/3.5.32?slim=true) | dependencies | patch |
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.3` → `5.0.4`](https://renovatebot.com/diffs/npm/vue-router/5.0.3/5.0.4) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.4?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.3/5.0.4?slim=true) | dependencies | patch |
| [ws](https://github.com/websockets/ws) | [`8.19.0` → `8.20.0`](https://renovatebot.com/diffs/npm/ws/8.19.0/8.20.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/ws/8.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/ws/8.19.0/8.20.0?slim=true) | dependencies | minor |

---

### Release Notes

<details>
<summary>nuxt/test-utils (@&#8203;nuxt/test-utils)</summary>

### [`v4.0.2`](https://github.com/nuxt/test-utils/releases/tag/v4.0.2)

[Compare Source](https://github.com/nuxt/test-utils/compare/v4.0.1...v4.0.2)

#### 👉 Changelog

[compare changes](https://github.com/nuxt/test-utils/compare/v4.0.1...v4.0.2)

##### 🩹 Fixes

- **config:** Respect override dev value ([#&#8203;1602](https://github.com/nuxt/test-utils/pull/1602))

##### 🤖 CI

- Use pnpm publish to resolve workspace dependencies ([#&#8203;1651](https://github.com/nuxt/test-utils/pull/1651))

##### ❤️ Contributors

- Julien Huang ([@&#8203;huang-julien](https://github.com/huang-julien))
- Vasily Kuzin ([@&#8203;ExEr7um](https://github.com/ExEr7um))

### [`v4.0.1`](https://github.com/nuxt/test-utils/releases/tag/v4.0.1)

[Compare Source](https://github.com/nuxt/test-utils/compare/v4.0.0...v4.0.1)

#### 👉 Changelog

[compare changes](https://github.com/nuxt/test-utils/compare/v4.0.0...v4.0.1)

##### 🩹 Fixes

- **config:** Rename deps.optimizer.web to client for vitest4 ([#&#8203;1593](https://github.com/nuxt/test-utils/pull/1593))
- **runtime-utils:** Fix `mockNuxtImport` types when using string target ([#&#8203;1592](https://github.com/nuxt/test-utils/pull/1592))
- **config:** Pass non-project options for non-nuxt simple setup ([#&#8203;1582](https://github.com/nuxt/test-utils/pull/1582))
- **config:** Do not import `defineConfig` from `vite` ([1aa5e8748](https://github.com/nuxt/test-utils/commit/1aa5e8748))
- **runtime:** Handle `ResourceLoader` removal in jsdom v28 ([#&#8203;1611](https://github.com/nuxt/test-utils/pull/1611))
- **config,vitest-environment:** Directly import peerDeps ([#&#8203;1617](https://github.com/nuxt/test-utils/pull/1617))
- **runtime-utils:** Align mount options merge w/ vue-test-utils ([#&#8203;1610](https://github.com/nuxt/test-utils/pull/1610))
- **vitest-environment:** Avoid `vitest/environments` import warning ([#&#8203;1627](https://github.com/nuxt/test-utils/pull/1627))
- **runtime:** Avoid error when vue/test-utils is not installed ([#&#8203;1646](https://github.com/nuxt/test-utils/pull/1646))
- **config:** Prefer project h3 version if present ([#&#8203;1641](https://github.com/nuxt/test-utils/pull/1641))

##### 🏡 Chore

- Bump `vitest-environment-nuxt` versions ([f5ec72127](https://github.com/nuxt/test-utils/commit/f5ec72127))
- Use workspace dependency ([14fb254a7](https://github.com/nuxt/test-utils/commit/14fb254a7))
- Example playwright config improve type annotation for devices ([#&#8203;1581](https://github.com/nuxt/test-utils/pull/1581))
- `pkg-pr-new` prerelease `vitest-environment-nuxt` ([#&#8203;1601](https://github.com/nuxt/test-utils/pull/1601))
- Allow explicit any ([633c93c2a](https://github.com/nuxt/test-utils/commit/633c93c2a))
- Switch unit test target to dir and move type unit tests to test:types ([#&#8203;1618](https://github.com/nuxt/test-utils/pull/1618))
- Update lockfile ([8306abf00](https://github.com/nuxt/test-utils/commit/8306abf00))

#####  Tests

- Add failing test for stubbed global provide ([#&#8203;1314](https://github.com/nuxt/test-utils/pull/1314))
- Update assertions deprecated in vitest 4.1 ([#&#8203;1629](https://github.com/nuxt/test-utils/pull/1629))
- Change example/workspace to use glob based projects setup ([#&#8203;1585](https://github.com/nuxt/test-utils/pull/1585))

##### 🤖 CI

- Pin github actions to full-length commit shas ([2832fd6d5](https://github.com/nuxt/test-utils/commit/2832fd6d5))
- Avoid checkout for reproduction comment ([e4e67ab09](https://github.com/nuxt/test-utils/commit/e4e67ab09))
- Rename workflow ([99318b9fc](https://github.com/nuxt/test-utils/commit/99318b9fc))
- Correctly publish `pkg-pr-new` prerelease ([#&#8203;1598](https://github.com/nuxt/test-utils/pull/1598))

##### ❤️ Contributors

- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))
- Yoshihiro Yamaguchi ([@&#8203;yamachi4416](https://github.com/yamachi4416))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Paul Melero ([@&#8203;paulmelero](https://github.com/paulmelero))

</details>

<details>
<summary>microsoft/playwright (@&#8203;playwright/test)</summary>

### [`v1.59.1`](https://github.com/microsoft/playwright/releases/tag/v1.59.1)

[Compare Source](https://github.com/microsoft/playwright/compare/v1.59.0...v1.59.1)

##### Bug Fixes

- **\[Windows]** Reverted hiding console window when spawning browser processes, which caused regressions including broken `codegen`, `--ui` and `show` commands ([#&#8203;39990](https://github.com/microsoft/playwright/issues/39990))

### [`v1.59.0`](https://github.com/microsoft/playwright/releases/tag/v1.59.0)

[Compare Source](https://github.com/microsoft/playwright/compare/v1.58.2...v1.59.0)

#### 🎬 Screencast

New [page.screencast](https://playwright.dev/docs/api/class-page#page-screencast) API provides a unified interface for capturing page content with:

- Screencast recordings
- Action annotations
- Visual overlays
- Real-time frame capture
- Agentic video receipts

<center>

<img src="https://raw.githubusercontent.com/microsoft/playwright/main/docs/src/images/release-notes-1.59-screencast-demo.gif" alt="Demo" width="500" height="313" />

</center>

**Screencast recording** — record video with precise start/stop control, as an alternative to the [`recordVideo`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-video) option:

```js
await page.screencast.start({ path: 'video.webm' });
// ... perform actions ...
await page.screencast.stop();
```

**Action annotations** — enable built-in visual annotations that highlight interacted elements and display action titles during recording:

```js
await page.screencast.showActions({ position: 'top-right' });
```

[screencast.showActions()](https://playwright.dev/docs/api/class-screencast#screencast-show-actions) accepts `position` (`'top-left'`, `'top'`, `'top-right'`, `'bottom-left'`, `'bottom'`, `'bottom-right'`), `duration` (ms per annotation), and `fontSize` (px). Returns a disposable to stop showing actions.

Action annotations can also be enabled in test fixtures via the `video` option:

```js
// playwright.config.ts
export default defineConfig({
  use: {
    video: {
      mode: 'on',
      show: {
        actions: { position: 'top-left' },
        test: { position: 'top-right' },
      },
    },
  },
});
```

**Visual overlays** — add chapter titles and custom HTML overlays on top of the page for richer narration:

```js
await page.screencast.showChapter('Adding TODOs', {
  description: 'Type and press enter for each TODO',
  duration: 1000,
});

await page.screencast.showOverlay('<div style="color: red">Recording</div>');
```

**Real-time frame capture** — stream JPEG-encoded frames for custom processing like thumbnails, live previews, AI vision, and more:

```js
await page.screencast.start({
  onFrame: ({ data }) => sendToVisionModel(data),
  size: { width: 800, height: 600 },
});
```

**Agentic video receipts** — coding agents can produce video evidence of their work. After completing a task, an agent can record a walkthrough video with rich annotations for human review:

```js
await page.screencast.start({ path: 'receipt.webm' });
await page.screencast.showActions({ position: 'top-right' });

await page.screencast.showChapter('Verifying checkout flow', {
  description: 'Added coupon code support per ticket #&#8203;1234',
});

// Agent performs the verification steps...
await page.locator('#coupon').fill('SAVE20');
await page.locator('#apply-coupon').click();
await expect(page.locator('.discount')).toContainText('20%');

await page.screencast.showChapter('Done', {
  description: 'Coupon applied, discount reflected in total',
});

await page.screencast.stop();
```

The resulting video serves as a receipt: chapter titles provide context, action annotations highlight each interaction, and the visual walkthrough is faster to review than text logs.

#### 🔗 Interoperability

New [browser.bind()](https://playwright.dev/docs/api/class-browser#browser-bind) API makes a launched browser available for `playwright-cli`, `@playwright/mcp`, and other clients to connect to.

**Bind a browser** — start a browser and bind it so others can connect:

```js
const { endpoint } = await browser.bind('my-session', {
  workspaceDir: '/my/project',
});
```

**Connect from playwright-cli** — connect to the running browser from your favorite coding agent.

```bash
playwright-cli attach my-session
playwright-cli -s my-session snapshot
```

**Connect from [@&#8203;playwright/mcp](https://github.com/playwright/mcp)** — or point your MCP server to the running browser.

```bash
@&#8203;playwright/mcp --endpoint=my-session
```

**Connect from a Playwright client** — use API to connect to the browser. Multiple clients at a time are supported!

```js
const browser = await chromium.connect(endpoint);
```

Pass `host` and `port` options to bind over WebSocket instead of a named pipe:

```js
const { endpoint } = await browser.bind('my-session', {
  host: 'localhost',
  port: 0,
});
// endpoint is a ws:// URL
```

Call [browser.unbind()](https://playwright.dev/docs/api/class-browser#browser-unbind) to stop accepting new connections.

#### 📊 Observability

Run `playwright-cli show` to open the Dashboard that lists all the bound browsers, their statuses, and allows interacting with them:

- See what your agent is doing on the background browsers
- Click into the sessions for manual interventions
- Open DevTools to inspect pages from the background browsers.

<center>

<img src="https://raw.githubusercontent.com/microsoft/playwright/main/docs/src/images/release-notes-1.59-dashboard.png" alt="Demo" width="1169" height="835" />

</center>
- `playwright-cli` binds all of its browsers automatically, so you can see what your agents are doing.
- Pass `PLAYWRIGHT_DASHBOARD=1` env variable to see all `@playwright/test` browsers in the dashboard.

#### 🐛 CLI debugger for agents

Coding agents can now run `npx playwright test --debug=cli` to attach and debug tests over `playwright-cli` — perfect for automatically fixing tests in agentic workflows:

```bash
$ npx playwright test --debug=cli

### Debugging Instructions
- Run "playwright-cli attach tw-87b59e" to attach to this test

$ playwright-cli attach tw-87b59e

### Session `tw-87b59e` created, attached to `tw-87b59e`.
Run commands with: playwright-cli --session=tw-87b59e <command>

### Paused
- Navigate to "/" at output/tests/example.spec.ts:4

$ playwright-cli --session tw-87b59e step-over

### Page
- Page URL: https://playwright.dev/
- Page Title: Fast and reliable end-to-end testing for modern web apps | Playwright

### Paused
- Expect "toHaveTitle" at output/tests/example.spec.ts:7
```

#### 📋 CLI trace analysis for agents

Coding agents can run `npx playwright trace` to explore [Playwright Trace](https://playwright.dev/docs/trace-viewer) and understand failing or flaky tests from the command line:

```bash
$ npx playwright trace open test-results/example-has-title-chromium/trace.zip
  Title:        example.spec.ts:3 › has title

$ npx playwright trace actions --grep="expect"
     # Time       Action                                                  Duration
  ──── ─────────  ─────────────────────────────────────────────────────── ────────
    9. 0:00.859  Expect "toHaveTitle"                                        5.1s  ✗

$ npx playwright trace action 9
  Expect "toHaveTitle"
  Error: expect(page).toHaveTitle(expected) failed
    Expected pattern: /Wrong Title/
    Received string:  "Fast and reliable end-to-end testing for modern web apps | Playwright"
    Timeout: 5000ms
  Snapshots
    available: before, after
    usage:     npx playwright trace snapshot 9 --name <before|after>

$ npx playwright trace snapshot 9 --name after

### Page
- Page Title: Fast and reliable end-to-end testing for modern web apps | Playwright

$ npx playwright trace close
```

#### ♻️ `await using`

Many APIs now return [async disposables](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/asyncDispose), enabling the `await using` syntax for automatic cleanup:

```js
await using page = await context.newPage();
{
  await using route = await page.route('**/*', route => route.continue());
  await using script = await page.addInitScript('console.log("init script here")');
  await page.goto('https://playwright.dev');
  // do something
}
// route and init script have been removed at this point
```

#### 🔍 Snapshots and Locators

- Method [page.ariaSnapshot()](https://playwright.dev/docs/api/class-page#page-aria-snapshot) to capture the aria snapshot of the page — equivalent to `page.locator('body').ariaSnapshot()`.
- Options `depth` and `mode` in [locator.ariaSnapshot()](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot).
- Method [locator.normalize()](https://playwright.dev/docs/api/class-locator#locator-normalize) converts a locator to follow best practices like test ids and aria roles.
- Method [page.pickLocator()](https://playwright.dev/docs/api/class-page#page-pick-locator) enters an interactive mode where hovering over elements highlights them and shows the corresponding locator. Click an element to get its [Locator](https://playwright.dev/docs/api/class-locator) back. Use [page.cancelPickLocator()](https://playwright.dev/docs/api/class-page#page-cancel-pick-locator) to cancel.

#### New APIs

##### Screencast

- [page.screencast](https://playwright.dev/docs/api/class-page#page-screencast) provides video recording, real-time frame streaming, and overlay management.
- Methods [screencast.start()](https://playwright.dev/docs/api/class-screencast#screencast-start) and [screencast.stop()](https://playwright.dev/docs/api/class-screencast#screencast-stop) for recording and frame capture.
- Methods [screencast.showActions()](https://playwright.dev/docs/api/class-screencast#screencast-show-actions) and [screencast.hideActions()](https://playwright.dev/docs/api/class-screencast#screencast-hide-actions) for action annotations.
- Methods [screencast.showChapter()](https://playwright.dev/docs/api/class-screencast#screencast-show-chapter) and [screencast.showOverlay()](https://playwright.dev/docs/api/class-screencast#screencast-show-overlay) for visual overlays.
- Methods [screencast.showOverlays()](https://playwright.dev/docs/api/class-screencast#screencast-show-overlays) and [screencast.hideOverlays()](https://playwright.dev/docs/api/class-screencast#screencast-hide-overlays) for overlay visibility control.

##### Storage, Console and Errors

- Method [browserContext.setStorageState()](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-storage-state) clears existing cookies, local storage, and IndexedDB for all origins and sets a new storage state — no need to create a new context.
- Methods [page.clearConsoleMessages()](https://playwright.dev/docs/api/class-page#page-clear-console-messages) and [page.clearPageErrors()](https://playwright.dev/docs/api/class-page#page-clear-page-errors) to clear stored messages and errors.
- Option `filter` in [page.consoleMessages()](https://playwright.dev/docs/api/class-page#page-console-messages) and [page.pageErrors()](https://playwright.dev/docs/api/class-page#page-page-errors) controls which messages are returned.
- Method [consoleMessage.timestamp()](https://playwright.dev/docs/api/class-consolemessage#console-message-timestamp).

##### Miscellaneous

- [browserContext.debugger](https://playwright.dev/docs/api/class-browsercontext#browser-context-debugger) provides programmatic control over the Playwright debugger.
- Method [browserContext.isClosed()](https://playwright.dev/docs/api/class-browsercontext#browser-context-is-closed).
- Method [request.existingResponse()](https://playwright.dev/docs/api/class-request#request-existing-response) returns the response without waiting.
- Method [response.httpVersion()](https://playwright.dev/docs/api/class-response#response-http-version) returns the HTTP version used by the response.
- Events [cdpSession.on('event')](https://playwright.dev/docs/api/class-cdpsession#cdp-session-event-event) and [cdpSession.on('close')](https://playwright.dev/docs/api/class-cdpsession#cdp-session-event-close) for CDP sessions.
- Option `live` in [tracing.start()](https://playwright.dev/docs/api/class-tracing#tracing-start) for real-time trace updates.
- Option `artifactsDir` in [browserType.launch()](https://playwright.dev/docs/api/class-browsertype#browser-type-launch) to configure the artifacts directory.

#### 🛠️ Other improvements

- UI Mode has an option to only show tests affected by source changes.
- UI Mode and Trace Viewer have improved action filtering.
- HTML Reporter shows the list of runs from the same worker.
- HTML Reporter allows filtering test steps for quick search.
- New trace mode `'retain-on-failure-and-retries'` records a trace for each test run and retains all traces when an attempt fails — great for comparing a passing trace with a failing one from a flaky test.

#### Known Issues ⚠️⚠️

- `navigator.platform` emulation can cause Ctrl or Meta dispatching errors ([#&#8203;40009](https://github.com/microsoft/playwright/issues/40009)). Pass `PLAYWRIGHT_NO_UA_PLATFORM = '1'` environment variable while we are issuing a patch release. Let us know in the issue how it affected you.

#### Breaking Changes ⚠️

- Removed macOS 14 support for WebKit. We recommend upgrading your macOS version, or keeping an older Playwright version.
- Removed `@playwright/experimental-ct-svelte` package.

#### Browser Versions

- Chromium 147.0.7727.15
- Mozilla Firefox 148.0.2
- WebKit 26.4

This version was also tested against the following stable channels:

- Google Chrome 146
- Microsoft Edge 146

</details>

<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>

### [`v4.1.4`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.4)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4)

#####    🚀 Experimental Features

- **coverage**:
  - Default to text reporter `skipFull` if agent detected  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10018](https://github.com/vitest-dev/vitest/issues/10018) [<samp>(53757)</samp>](https://github.com/vitest-dev/vitest/commit/53757804c)
- **experimental**:
  - Expose `assertion` as a public field  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10095](https://github.com/vitest-dev/vitest/issues/10095) [<samp>(a120e)</samp>](https://github.com/vitest-dev/vitest/commit/a120e3ab8)
  - Support aria snapshot  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Opus 4.6 (1M context)**, [@&#8203;AriPerkkio](https://github.com/AriPerkkio), **Codex** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9668](https://github.com/vitest-dev/vitest/issues/9668) [<samp>(d4fbb)</samp>](https://github.com/vitest-dev/vitest/commit/d4fbb5cc9)
- **reporter**:
  - Add filterMeta option to json reporter  -  by [@&#8203;nami8824](https://github.com/nami8824) and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10078](https://github.com/vitest-dev/vitest/issues/10078) [<samp>(b77de)</samp>](https://github.com/vitest-dev/vitest/commit/b77de968e)

#####    🐞 Bug Fixes

- Use "black" foreground for labeled terminal message to ensure contrast  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10076](https://github.com/vitest-dev/vitest/issues/10076) [<samp>(203f0)</samp>](https://github.com/vitest-dev/vitest/commit/203f07af7)
- Make `expect(..., message)` consistent as error message prefix  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10068](https://github.com/vitest-dev/vitest/issues/10068) [<samp>(a1b5f)</samp>](https://github.com/vitest-dev/vitest/commit/a1b5f0f4f)
- Do not hoist imports whose names match class properties .  -  by [@&#8203;SunsetFi](https://github.com/SunsetFi) in [#&#8203;10093](https://github.com/vitest-dev/vitest/issues/10093) and [#&#8203;10094](https://github.com/vitest-dev/vitest/issues/10094) [<samp>(0fc4b)</samp>](https://github.com/vitest-dev/vitest/commit/0fc4b47e0)
- **browser**: Spread user server options into browser Vite server in project  -  by [@&#8203;GoldStrikeArch](https://github.com/GoldStrikeArch) in [#&#8203;10049](https://github.com/vitest-dev/vitest/issues/10049) [<samp>(65c9d)</samp>](https://github.com/vitest-dev/vitest/commit/65c9d55eb)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.3...v4.1.4)

### [`v4.1.3`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.3)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3)

#####    🚀 Experimental Features

- Add `experimental.preParse` flag  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10070](https://github.com/vitest-dev/vitest/issues/10070) [<samp>(78273)</samp>](https://github.com/vitest-dev/vitest/commit/7827363bd)
- Support `browser.locators.exact` option  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10013](https://github.com/vitest-dev/vitest/issues/10013) [<samp>(48799)</samp>](https://github.com/vitest-dev/vitest/commit/487990a19)
- Add `TestAttachment.bodyEncoding`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9969](https://github.com/vitest-dev/vitest/issues/9969) [<samp>(89ca0)</samp>](https://github.com/vitest-dev/vitest/commit/89ca0e254)
- Support custom snapshot matcher  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa), **Claude Sonnet 4.6** and **Codex** in [#&#8203;9973](https://github.com/vitest-dev/vitest/issues/9973) [<samp>(59b0e)</samp>](https://github.com/vitest-dev/vitest/commit/59b0e6411)

#####    🐞 Bug Fixes

- Advance fake timers with `expect.poll` interval  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10022](https://github.com/vitest-dev/vitest/issues/10022) [<samp>(3f5bf)</samp>](https://github.com/vitest-dev/vitest/commit/3f5bfa365)
- Add `@vitest/coverage-v8` and `@vitest/coverage-istanbul` as optional dependency  -  by [@&#8203;alan-agius4](https://github.com/alan-agius4) in [#&#8203;10025](https://github.com/vitest-dev/vitest/issues/10025) [<samp>(146d4)</samp>](https://github.com/vitest-dev/vitest/commit/146d4f0a0)
- Fix `defineHelper` for webkit async stack trace + update playwright 1.59.0  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10036](https://github.com/vitest-dev/vitest/issues/10036) [<samp>(5a5fa)</samp>](https://github.com/vitest-dev/vitest/commit/5a5fa49fe)
- Fix suite hook throwing errors for unused auto test-scoped fixture  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10035](https://github.com/vitest-dev/vitest/issues/10035) [<samp>(39865)</samp>](https://github.com/vitest-dev/vitest/commit/398657e8d)
- **expect**:
  - Remove `JestExtendError.context` from verbose error reporting  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9983](https://github.com/vitest-dev/vitest/issues/9983) [<samp>(66751)</samp>](https://github.com/vitest-dev/vitest/commit/66751c9e8)
  - Don't leak "runner" types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10004](https://github.com/vitest-dev/vitest/issues/10004) [<samp>(ec204)</samp>](https://github.com/vitest-dev/vitest/commit/ec2045543)
- **snapshot**:
  - Fix flagging obsolete snapshots for snapshot properties mismatch  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;9986](https://github.com/vitest-dev/vitest/issues/9986) [<samp>(6b869)</samp>](https://github.com/vitest-dev/vitest/commit/6b869156b)
  - Export custom snapshot matcher helper from `vitest`  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10042](https://github.com/vitest-dev/vitest/issues/10042) [<samp>(691d3)</samp>](https://github.com/vitest-dev/vitest/commit/691d341fd)
- **ui**:
  - Don't leak vite types  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10005](https://github.com/vitest-dev/vitest/issues/10005) [<samp>(fdff1)</samp>](https://github.com/vitest-dev/vitest/commit/fdff1bf9a)
- **vm**:
  - Fix external module resolve error with deps optimizer query  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Sonnet 4.6** in [#&#8203;10024](https://github.com/vitest-dev/vitest/issues/10024) [<samp>(9dbf4)</samp>](https://github.com/vitest-dev/vitest/commit/9dbf47786)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.2...v4.1.3)

### [`v4.1.2`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.2)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2)

This release bumps Vitest's `flatted` version and removes version pinning to resolve `flatted`'s CVE related issues ([#&#8203;9975](https://github.com/vitest-dev/vitest/issues/9975)).

#####    🐞 Bug Fixes

- Don't resolve `setupFiles` from parent directory  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9960](https://github.com/vitest-dev/vitest/issues/9960) [<samp>(7aa93)</samp>](https://github.com/vitest-dev/vitest/commit/7aa937776)
- Ensure sequential mock/unmock resolution  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6** in [#&#8203;9830](https://github.com/vitest-dev/vitest/issues/9830) [<samp>(7c065)</samp>](https://github.com/vitest-dev/vitest/commit/7c06598db)
- **browser**: Take failure screenshot if `toMatchScreenshot` can't capture a stable screenshot  -  by [@&#8203;macarie](https://github.com/macarie) in [#&#8203;9847](https://github.com/vitest-dev/vitest/issues/9847) [<samp>(faace)</samp>](https://github.com/vitest-dev/vitest/commit/faace1fbe)
- **coverage**: Correct `coverageConfigDefaults` values and types  -  by [@&#8203;Arthie](https://github.com/Arthie) in [#&#8203;9940](https://github.com/vitest-dev/vitest/issues/9940) [<samp>(b3c99)</samp>](https://github.com/vitest-dev/vitest/commit/b3c992cb2)
- **pretty-format**: Fix output limit over counting  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9965](https://github.com/vitest-dev/vitest/issues/9965) [<samp>(d3b7a)</samp>](https://github.com/vitest-dev/vitest/commit/d3b7a40fa)
- Disable colors if agent is detected  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9851](https://github.com/vitest-dev/vitest/issues/9851) [<samp>(6f97b)</samp>](https://github.com/vitest-dev/vitest/commit/6f97b55dd)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.1...v4.1.2)

### [`v4.1.1`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.1)

[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1)

#####    🚀 Features

- **experimental**:
  - Expose `matchesTags` to test if the current filter matches tags  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9913](https://github.com/vitest-dev/vitest/issues/9913) [<samp>(eec53)</samp>](https://github.com/vitest-dev/vitest/commit/eec53d9f5)
  - Introduce `experimental.vcsProvider`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9928](https://github.com/vitest-dev/vitest/issues/9928) [<samp>(56115)</samp>](https://github.com/vitest-dev/vitest/commit/561150036)

#####    🐞 Bug Fixes

- Mark `TestProject.testFilesList` internal properly  -  by [@&#8203;sapphi-red](https://github.com/sapphi-red) in [#&#8203;9867](https://github.com/vitest-dev/vitest/issues/9867) [<samp>(54f26)</samp>](https://github.com/vitest-dev/vitest/commit/54f2660f5)
- Detect fixture that returns without calling `use`  -  by [@&#8203;oilater](https://github.com/oilater) in [#&#8203;9831](https://github.com/vitest-dev/vitest/issues/9831) and [#&#8203;9861](https://github.com/vitest-dev/vitest/issues/9861) [<samp>(633ae)</samp>](https://github.com/vitest-dev/vitest/commit/633ae2303)
- Drop vite 8.beta support  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9862](https://github.com/vitest-dev/vitest/issues/9862) [<samp>(b78f5)</samp>](https://github.com/vitest-dev/vitest/commit/b78f5389d)
- Type regression in vi.mocked() static class methods  -  by [@&#8203;purepear](https://github.com/purepear) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9857](https://github.com/vitest-dev/vitest/issues/9857) [<samp>(90926)</samp>](https://github.com/vitest-dev/vitest/commit/90926641b)
- Properly re-evaluate actual modules of mocked external  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9898](https://github.com/vitest-dev/vitest/issues/9898) [<samp>(ae5ec)</samp>](https://github.com/vitest-dev/vitest/commit/ae5ec03ef)
- Preserve coverage report when html reporter overlaps  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9889](https://github.com/vitest-dev/vitest/issues/9889) [<samp>(2d81a)</samp>](https://github.com/vitest-dev/vitest/commit/2d81ad897)
- Provide `vi.advanceTimers` to the preview provider  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9891](https://github.com/vitest-dev/vitest/issues/9891) [<samp>(1bc3e)</samp>](https://github.com/vitest-dev/vitest/commit/1bc3e63be)
- Don't leak event listener in playwright provider  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9910](https://github.com/vitest-dev/vitest/issues/9910) [<samp>(d9355)</samp>](https://github.com/vitest-dev/vitest/commit/d93550ff7)
- Open browser in `--standalone` mode without running tests  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9911](https://github.com/vitest-dev/vitest/issues/9911) [<samp>(e78ad)</samp>](https://github.com/vitest-dev/vitest/commit/e78adcf97)
- Guard disposable and optional `body`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9912](https://github.com/vitest-dev/vitest/issues/9912) [<samp>(6fdb2)</samp>](https://github.com/vitest-dev/vitest/commit/6fdb2ba61)
- Resolve `retry.condition` RegExp serialization issue  -  by [@&#8203;nstepien](https://github.com/nstepien) and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9942](https://github.com/vitest-dev/vitest/issues/9942) [<samp>(7b605)</samp>](https://github.com/vitest-dev/vitest/commit/7b6054328)
- **collect**:
  - Don't treat extra props on `test` return as tests  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;9871](https://github.com/vitest-dev/vitest/issues/9871) [<samp>(141e7)</samp>](https://github.com/vitest-dev/vitest/commit/141e72aa1)
- **coverage**:
  - Simplify provider types  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9931](https://github.com/vitest-dev/vitest/issues/9931) [<samp>(aaf9f)</samp>](https://github.com/vitest-dev/vitest/commit/aaf9f18ae)
  - Load built-in provider without module runner  -  by [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;9939](https://github.com/vitest-dev/vitest/issues/9939) [<samp>(bf892)</samp>](https://github.com/vitest-dev/vitest/commit/bf8920817)
- **expect**:
  - Soft assertions continue after .resolves/.rejects promise errors  -  by [@&#8203;mixelburg](https://github.com/mixelburg), **Maks Pikov**, **Claude Opus 4.6 (1M context)** and [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9843](https://github.com/vitest-dev/vitest/issues/9843) [<samp>(6d74b)</samp>](https://github.com/vitest-dev/vitest/commit/6d74b4948)
  - Fix sinon-chai style API  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;9943](https://github.com/vitest-dev/vitest/issues/9943) [<samp>(0f08d)</samp>](https://github.com/vitest-dev/vitest/commit/0f08dda2c)
- **pretty-format**:
  - Limit output for large object  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Claude Opus 4.6 (1M context)** in [#&#8203;9949](https://github.com/vitest-dev/vitest/issues/9949) [<samp>(0d5f9)</samp>](https://github.com/vitest-dev/vitest/commit/0d5f9d6ef)

#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.0...v4.1.1)

</details>

<details>
<summary>eslint/eslint (eslint)</summary>

### [`v10.2.0`](https://github.com/eslint/eslint/releases/tag/v10.2.0)

[Compare Source](https://github.com/eslint/eslint/compare/v10.1.0...v10.2.0)

#### Features

- [`586ec2f`](https://github.com/eslint/eslint/commit/586ec2f43092779acc957866db4abe999112d1e1) feat: Add `meta.languages` support to rules ([#&#8203;20571](https://github.com/eslint/eslint/issues/20571)) (Copilot)
- [`14207de`](https://github.com/eslint/eslint/commit/14207dee3939dc87cfa8b2fcfc271fff2cfd6471) feat: add `Temporal` to `no-obj-calls` ([#&#8203;20675](https://github.com/eslint/eslint/issues/20675)) (Pixel998)
- [`bbb2c93`](https://github.com/eslint/eslint/commit/bbb2c93a2b31bd30924f32fe69a9acf41f9dfe35) feat: add Temporal to ES2026 globals ([#&#8203;20672](https://github.com/eslint/eslint/issues/20672)) (Pixel998)

#### Bug Fixes

- [`542cb3e`](https://github.com/eslint/eslint/commit/542cb3e6442a4e6ee3457c799e2a0ee23bef0c6a) fix: update first-party dependencies ([#&#8203;20714](https://github.com/eslint/eslint/issues/20714)) (Francesco Trotta)

#### Documentation

- [`a2af743`](https://github.com/eslint/eslint/commit/a2af743ea60f683d0e0de9d98267c1e7e4f5e412) docs: add `language` to configuration objects ([#&#8203;20712](https://github.com/eslint/eslint/issues/20712)) (Francesco Trotta)
- [`845f23f`](https://github.com/eslint/eslint/commit/845f23f1370892bf07d819497ac518c9e65090d6) docs: Update README (GitHub Actions Bot)
- [`5fbcf59`](https://github.com/eslint/eslint/commit/5fbcf5958b897cc4df5d652924d18428db37f7ee) docs: remove `sourceType` from ts playground link ([#&#8203;20477](https://github.com/eslint/eslint/issues/20477)) (Tanuj Kanti)
- [`8702a47`](https://github.com/eslint/eslint/commit/8702a474659be786b6b1392e5e7c0c56355ae4a4) docs: Update README (GitHub Actions Bot)
- [`ddeaded`](https://github.com/eslint/eslint/commit/ddeaded2ab36951383ff67c60fb64ec68d29a46a) docs: Update README (GitHub Actions Bot)
- [`2b44966`](https://github.com/eslint/eslint/commit/2b4496691266547784a7f7ad1989ce53381bab91) docs: add Major Releases section to Manage Releases ([#&#8203;20269](https://github.com/eslint/eslint/issues/20269)) (Milos Djermanovic)
- [`eab65c7`](https://github.com/eslint/eslint/commit/eab65c700ebb16a6e790910c720450c9908961fd) docs: update `eslint` versions in examples ([#&#8203;20664](https://github.com/eslint/eslint/issues/20664)) (루밀LuMir)
- [`3e4a299`](https://github.com/eslint/eslint/commit/3e4a29903bf31f0998e45ad9128a265bce1edc56) docs: update ESM Dependencies policies with note for own-usage packages ([#&#8203;20660](https://github.com/eslint/eslint/issues/20660)) (Milos Djermanovic)

#### Chores

- [`8120e30`](https://github.com/eslint/eslint/commit/8120e30f833474f47acc061d24d164e9f022264f) refactor: extract no unmodified loop condition ([#&#8203;20679](https://github.com/eslint/eslint/issues/20679)) (kuldeep kumar)
- [`46e8469`](https://github.com/eslint/eslint/commit/46e8469786be1b2bbb522100e1d44624d98d3745) chore: update dependency markdownlint-cli2 to ^0.22.0 ([#&#8203;20697](https://github.com/eslint/eslint/issues/20697)) (renovate\[bot])
- [`01ed3aa`](https://github.com/eslint/eslint/commit/01ed3aa68477f81a7188e1498cf4906e02015b7c) test: add unit tests for unicode utilities ([#&#8203;20622](https://github.com/eslint/eslint/issues/20622)) (Manish chaudhary)
- [`811f493`](https://github.com/eslint/eslint/commit/811f4930f82ee2b6ac8eae75cade9bed63de0781) ci: remove `--legacy-peer-deps` from types integration tests ([#&#8203;20667](https://github.com/eslint/eslint/issues/20667)) (Milos Djermanovic)
- [`6b86fcf`](https://github.com/eslint/eslint/commit/6b86fcfc5c75d6a3b8a2cf7bcdb3ef60635a9a03) chore: update dependency npm-run-all2 to v8 ([#&#8203;20663](https://github.com/eslint/eslint/issues/20663)) (renovate\[bot])
- [`632c4f8`](https://github.com/eslint/eslint/commit/632c4f83bf32b77981c7d395cacddd1bb172ee25) chore: add `prettier` update commit to `.git-blame-ignore-revs` ([#&#8203;20662](https://github.com/eslint/eslint/issues/20662)) (루밀LuMir)
- [`b0b0f21`](https://github.com/eslint/eslint/commit/b0b0f21927e03ba092400e3c70d7058f537765c8) chore: update dependency eslint-plugin-regexp to ^3.1.0 ([#&#8203;20659](https://github.com/eslint/eslint/issues/20659)) (Milos Djermanovic)
- [`228a2dd`](https://github.com/eslint/eslint/commit/228a2dd4b272c17f516ee3541f1dd69eca0a8ab0) chore: update dependency eslint-plugin-eslint-plugin to ^7.3.2 ([#&#8203;20661](https://github.com/eslint/eslint/issues/20661)) (Milos Djermanovic)
- [`3ab4d7e`](https://github.com/eslint/eslint/commit/3ab4d7e244df244102de9d0d250b2ff12456a785) test: Add tests for eslintrc-style keys ([#&#8203;20645](https://github.com/eslint/eslint/issues/20645)) (kuldeep kumar)

### [`v10.1.0`](https://github.com/eslint/eslint/releases/tag/v10.1.0)

[Compare Source](https://github.com/eslint/eslint/compare/v10.0.3...v10.1.0)

#### Features

- [`ff4382b`](https://github.com/eslint/eslint/commit/ff4382be349035acdb170627a2dc92828e134562) feat: apply fix for `no-var` in `TSModuleBlock` ([#&#8203;20638](https://github.com/eslint/eslint/issues/20638)) (Tanuj Kanti)
- [`0916995`](https://github.com/eslint/eslint/commit/0916995b51528872b15ba4fedb24172cf25fcb3f) feat: Implement api support for bulk-suppressions ([#&#8203;20565](https://github.com/eslint/eslint/issues/20565)) (Blake Sager)

#### Bug Fixes

- [`2b8824e`](https://github.com/eslint/eslint/commit/2b8824e6be4223980e929a20025602df20d05ea2) fix: Prevent `no-var` autofix when a variable is used before declaration ([#&#8203;20464](https://github.com/eslint/eslint/issues/20464)) (Amaresh  S M)
- [`e58b4bf`](https://github.com/eslint/eslint/commit/e58b4bff167e79afd067d1b0ee9360bec2d3393e) fix: update eslint ([#&#8203;20597](https://github.com/eslint/eslint/issues/20597)) (renovate\[bot])

#### Documentation

- [`b7b57fe`](https://github.com/eslint/eslint/commit/b7b57fe9942c572ff651230f1f96cefed787ca52) docs: use correct JSDoc link in require-jsdoc.md ([#&#8203;20641](https://github.com/eslint/eslint/issues/20641)) (mkemna-clb)
- [`58e4cfc`](https://github.com/eslint/eslint/commit/58e4cfc7dbf0fe40c73f09bf0ff94ad944d0ba0e) docs: add deprecation notice partial ([#&#8203;20639](https://github.com/eslint/eslint/issues/20639)) (Milos Djermanovic)
- [`7143dbf`](https://github.com/eslint/eslint/commit/7143dbf99df27c61edf1552da981794e99a0b2f2) docs: update v9 migration guide for `@eslint/js` usage ([#&#8203;20540](https://github.com/eslint/eslint/issues/20540)) (fnx)
- [`035fc4f`](https://github.com/eslint/eslint/commit/035fc4fbe506e3e4524882cf50db37a4e430adf4) docs: note that `globalReturn` applies only with `sourceType: "script"` ([#&#8203;20630](https://github.com/eslint/eslint/issues/20630)) (Milos Djermanovic)
- [`e972c88`](https://github.com/eslint/eslint/commit/e972c88ab7474a74191ee99ac2558b00d0427a8a) docs: merge ESLint option descriptions into type definitions ([#&#8203;20608](https://github.com/eslint/eslint/issues/20608)) (Francesco Trotta)
- [`7f10d84`](https://github.com/eslint/eslint/commit/7f10d8440137f0cfd75f18f4746ba6a1c621b953) docs: Update README (GitHub Actions Bot)
- [`aeed007`](https://github.com/eslint/eslint/commit/aeed0078ca2f73d4744cc522102178d45b5be64e) docs: open playground link in new tab ([#&#8203;20602](https://github.com/eslint/eslint/issues/20602)) (Tanuj Kanti)
- [`a0d1a37`](https://github.com/eslint/eslint/commit/a0d1a3772679d3d74bb860fc65b5b58678acd452) docs: Add AI Usage Policy ([#&#8203;20510](https://github.com/eslint/eslint/issues/20510)) (Nicholas C. Zakas)

#### Chores

- [`a9f9cce`](https://github.com/eslint/eslint/commit/a9f9cce82d80b540a0e3549d0e91c16df28740d8) chore: update dependency eslint-plugin-unicorn to ^63.0.0 ([#&#8203;20584](https://github.com/eslint/eslint/issues/20584)) (Milos Djermanovic)
- [`1f42bd7`](https://github.com/eslint/eslint/commit/1f42bd7876ae4192cf7f7f4faf73b4ef3d2563cb) chore: update `prettier` to 3.8.1 ([#&#8203;20651](https://github.com/eslint/eslint/issues/20651)) (루밀LuMir)
- [`c0a6f4a`](https://github.com/eslint/eslint/commit/c0a6f4a2b4169edeca2a81bf7b47783e39ade366) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to ^1.2.0 ([#&#8203;20652](https://github.com/eslint/eslint/issues/20652)) (renovate\[bot])
- [`cc43f79`](https://github.com/eslint/eslint/commit/cc43f795c42e5ec2f19bb43b1f6d534ef2e469f3) chore: update dependency c8 to v11 ([#&#8203;20650](https://github.com/eslint/eslint/issues/20650)) (renovate\[bot])
- [`2ce4635`](https://github.com/eslint/eslint/commit/2ce4635b036ff2665c7009afddf9c0fb2274dceb) chore: update dependency [@&#8203;eslint/json](https://github.com/eslint/json) to v1 ([#&#8203;20649](https://github.com/eslint/eslint/issues/20649)) (renovate\[bot])
- [`f0406ee`](https://github.com/eslint/eslint/commit/f0406eedcc3dc415babbbf6bbdb5db1eebfd487b) chore: update dependency markdownlint-cli2 to ^0.21.0 ([#&#8203;20646](https://github.com/eslint/eslint/issues/20646)) (renovate\[bot])
- [`dbb4c95`](https://github.com/eslint/eslint/commit/dbb4c9582a00bac604d5c6ac671bb7111468a846) chore: remove trunk ([#&#8203;20478](https://github.com/eslint/eslint/issues/20478)) (sethamus)
- [`c672a2a`](https://github.com/eslint/eslint/commit/c672a2a70579fddf1c6ce33dfa712d705726e1c9) test: fix CLI test for empty output file ([#&#8203;20640](https://github.com/eslint/eslint/issues/20640)) (kuldeep kumar)
- [`c7ada24`](https://github.com/eslint/eslint/commit/c7ada2455680036bbfc42fcb1511ff28afe3c587) ci: bump pnpm/action-setup from 4.3.0 to 4.4.0 ([#&#8203;20636](https://github.com/eslint/eslint/issues/20636)) (dependabot\[bot])
- [`07c4b8b`](https://github.com/eslint/eslint/commit/07c4b8b4a9f49145e60a3448dd57853213ed4de3) test: fix `RuleTester` test without test runners ([#&#8203;20631](https://github.com/eslint/eslint/issues/20631)) (Francesco Trotta)
- [`079bba7`](https://github.com/eslint/eslint/commit/079bba7ff17d0a99fdffe32bf991d005ba797fae) test: Add tests for `isValidWithUnicodeFlag` ([#&#8203;20601](https://github.com/eslint/eslint/issues/20601)) (Manish chaudhary)
- [`5885ae6`](https://github.com/eslint/eslint/commit/5885ae66216bcee9310bbf73786b7d7d5774aeaf) ci: unpin Node.js 25.x in CI ([#&#8203;20615](https://github.com/eslint/eslint/issues/20615)) (Copilot)
- [`f65e5d3`](https://github.com/eslint/eslint/commit/f65e5d3c0df65fdb317ad6d23f7ae113c5f4b6d7) chore: update pnpm/action-setup digest to [`b906aff`](https://github.com/eslint/eslint/commit/b906aff) ([#&#8203;20610](https://github.com/eslint/eslint/issues/20610)) (renovate\[bot])

</details>

<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>

### [`v5.5.12`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/12b041ddeffa8b37ea44d572284c00a89148f2d3...5d8a8916a9310c4e5fad74040b9d1382c7b54b17)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/12b041ddeffa8b37ea44d572284c00a89148f2d3...5d8a8916a9310c4e5fad74040b9d1382c7b54b17)

### [`v5.5.11`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.10...12b041ddeffa8b37ea44d572284c00a89148f2d3)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.10...12b041ddeffa8b37ea44d572284c00a89148f2d3)

### [`v5.5.10`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.10): performance improvment, increase entity expansion default limit

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.9...v5.5.10)

- increase default entity explansion limit as many projects demand for that

```
maxEntitySize: 10000,
maxExpansionDepth: 10000,
maxTotalExpansions: Infinity,
maxExpandedLength: 100000,
maxEntityCount: 1000,
```

- performance improvement
  - reduce calls to toString
  - early return when entities are not present
  - prepare rawAttrsForMatcher only if user sets `jPath: false`

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.9...v5.5.10>

### [`v5.5.9`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.9): fix typins and matcher instance in callbacks

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.8...v5.5.9)

combine typings file to avoid configuration changes
pass readonly instance of matcher to the call backs to avoid accidental push/pop call

### [`v5.5.8`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.7...v5.5.8)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.7...v5.5.8)

### [`v5.5.7`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.7): fix bugs of entity parsing and value parsing

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.6...v5.5.7)

fix: entity expansion limits
update strnum package to 2.2.0

### [`v5.5.6`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.6): fix entity expansion and incorrect replacement and performance

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.5...v5.5.6)

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.5...v5.5.6>

### [`v5.5.5`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.5.5): support onDangerousProperty

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.4...v5.5.5)

**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.3...v5.5.5>

### [`v5.5.4`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.3...v5.5.4)

[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.3...v5.5.4)

</details>

<details>
<summary>capricorn86/happy-dom (happy-dom)</summary>

### [`v20.9.0`](https://github.com/capricorn86/happy-dom/releases/tag/v20.9.0)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.9...v20.9.0)

##### 🎨 Features

- Adds support for event listener properties on Window (e.g. `Window.onkeydown`) - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2131](https://github.com/capricorn86/happy-dom/issues/2131)

### [`v20.8.9`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.9)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.8...v20.8.9)

##### :construction\_worker\_man: Patch fixes

- Fixes issue where cookies from the current origin was being forwarded to the target origin in fetch requests - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2117](https://github.com/capricorn86/happy-dom/issues/2117)
  - A security advisory ([GHSA-w4gp-fjgq-3q4g](https://github.com/capricorn86/happy-dom/security/advisories/GHSA-w4gp-fjgq-3q4g)) was reported for this security vulnerability. Big thanks to [@&#8203;r74tech](https://github.com/r74tech) for reporting this!

### [`v20.8.8`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.8)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.7...v20.8.8)

##### :construction\_worker\_man: Patch fixes

- Fixes issue where export names can be interpolated as executable code in ESM - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2113](https://github.com/capricorn86/happy-dom/issues/2113)
  - A security advisory ([GHSA-6q6h-j7hj-3r64](https://github.com/capricorn86/happy-dom/security/advisories/GHSA-6q6h-j7hj-3r64)) has been reported that shows a security vulnerability where it may be possible to escape the VM context and get access to process level functionality in unsafe environments using CommonJS. Big thanks to [@&#8203;tndud042713](https://github.com/tndud042713) for reporting this!

### [`v20.8.7`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.7)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.6...v20.8.7)

##### :construction\_worker\_man: Patch fixes

- Replace implementing Node.js `Console` with common `IConsole` interface to support latest version of Bun - By **[@&#8203;YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)** in task [#&#8203;1845](https://github.com/capricorn86/happy-dom/issues/1845)

### [`v20.8.6`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.6)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.5...v20.8.6)

##### :construction\_worker\_man: Patch fixes

- `Request.formData()` should honor "Content-Type" header - By **[@&#8203;brianhelba](https://github.com/brianhelba)** in task [#&#8203;2106](https://github.com/capricorn86/happy-dom/issues/2106)

### [`v20.8.5`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.5)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.4...v20.8.5)

##### :construction\_worker\_man: Patch fixes

- Fixes error thrown when modifying DOM structure in `connectedCallback()` - By **[@&#8203;capricorn86](https://github.com/capricorn86)** in task [#&#8203;2110](https://github.com/capricorn86/happy-dom/issues/2110)

### [`v20.8.4`](https://github.com/capricorn86/happy-dom/releases/tag/v20.8.4)

[Compare Source](https://github.com/capricorn86/happy-dom/compare/v20.8.3...v20.8.4)

##### :construction\_worker\_man: Patch fixes

- Replace ConsoleConstructor import with indexed access type - By **[@&#8203;YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)** in task [#&#8203;1845](https://github.com/capricorn86/happy-dom/issues/1845)

</details>

<details>
<summary>video-dev/hls.js (hls.js)</summary>

### [`v1.6.16`](https://github.com/video-dev/hls.js/releases/tag/v1.6.16)

[Compare Source](https://github.com/video-dev/hls.js/compare/v1.6.15...v1.6.16)

### Summary

HLS.js v1.6.16 includes bug fixes and improvements over the last release.

#### Changes Since The Last Release

- Fix Interstitials live start with short sliding window ([#&#8203;7799](https://github.com/video-dev/hls.js/issues/7799))
- Limit buffering while paused outside live sliding window ([#&#8203;7788](https://github.com/video-dev/hls.js/issues/7788))

#### Demo Page

<https://121bff6b.hls-js-dev.pages.dev/demo/>

#### Feedback

Please provide feedback via [Issues in GitHub](https://github.com/video-dev/hls.js/issues/new/choose). For more details on how to contribute to HLS.js, see our [CONTRIBUTING guide](https://github.com/video-dev/hls.js/blob/master/CONTRIBUTING.md).

</details>

<details>
<summary>versatica/mediasoup (mediasoup)</summary>

### [`v3.19.19`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31919)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.18...3.19.19)

- Worker: Ensure 4-byte alignment for network packet receive buffers and test buffers to avoid undefined behavior ([PR #&#8203;1756](https://github.com/versatica/mediasoup/pull/1756).
- Worker: Update liburing from 2.12-1 to 2.14-1 ([PR #&#8203;1761](https://github.com/versatica/mediasoup/pull/1761).

### [`v3.19.18`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31918)

[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.17...3.19.18)

- Worker: Improve `Utils::Crypto::GetRandomUInt()` ([PR #&#8203;1725](https://github.com/versatica/mediasoup/pull/1725).
- Convert `WORKER_CLOSE` into a notification ([PR #&#8203;1729](https://github.com/versatica/mediasoup/pull/1729).
- Node tests: Replace `sctp` unmaintained library with `werift-sctp` ([PR #&#8203;1732](https://github.com/versatica/mediasoup/pull/1732), thanks to [@&#8203;shinyoshiaki](https://github.com/shinyoshiaki) for his help with `werift-sctp`.
- Worker: Require C++20 ([PR #&#8203;1741](https://github.com/versatica/mediasoup/pull/1741).
- Fix "SCTP failed" if no DataChannel is created on a Transport with `enableSctp: true` ([PR #&#8203;1749](https://github.com/versatica/mediasoup/pull/1749).

</details>

<details>
<summary>panva/openid-client (openid-client)</summary>

### [`v6.8.3`](https://github.com/panva/openid-client/blob/HEAD/CHANGELOG.md#683-2026-04-13)

[Compare Source](https://github.com/panva/openid-client/compare/v6.8.2...v6.8.3)

##### Documentation

- note a workaround for redirect\_uri with query string or bare origin ([e9689de](https://github.com/panva/openid-client/commit/e9689de336fed2c9e6678ac804f1c6b7d9bbd338)), closes [#&#8203;868](https://github.com/panva/openid-client/issues/868)

##### Fixes

- **passport:** delete one-time state on callback ([1e7dd2e](https://github.com/panva/openid-client/commit/1e7dd2eced03e729447bfa38c8c455268748c5c3))

</details>

<details>
<summary>vuejs/core (vue)</summary>

### [`v3.5.32`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3532-2026-04-03)

[Compare Source](https://github.com/vuejs/core/compare/v3.5.31...v3.5.32)

##### Bug Fixes

- **runtime-core:** prevent currentInstance leak into sibling render during async setup re-entry ([#&#8203;14668](https://github.com/vuejs/core/issues/14668)) ([f166353](https://github.com/vuejs/core/commit/f1663535a163057788d3285dec54a245c3efb3ad)), closes [#&#8203;14667](https://github.com/vuejs/core/issues/14667)
- **teleport:** handle updates before deferred mount ([#&#8203;14642](https://github.com/vuejs/core/issues/14642)) ([32b44f1](https://github.com/vuejs/core/commit/32b44f19f67aa30899817a7e79a4510f3b52970a)), closes [#&#8203;14640](https://github.com/vuejs/core/issues/14640)
- **types:** allow customRef to have different getter/setter types ([#&#8203;14639](https://github.com/vuejs/core/issues/14639)) ([e20ddb0](https://github.com/vuejs/core/commit/e20ddb00188e9935884930046fa572eab7c9dcba))
- **types:** use private branding for shallowReactive ([#&#8203;14641](https://github.com/vuejs/core/issues/14641)) ([302c47a](https://github.com/vuejs/core/commit/302c47a4994bc8b47b8a2af6693d8cb6bbd4b06b)), closes [#&#8203;14638](https://github.com/vuejs/core/issues/14638) [#&#8203;14493](https://github.com/vuejs/core/issues/14493)

##### Reverts

- Revert "fix(server-renderer): cleanup component effect scopes after SSR render" ([#&#8203;14674](https://github.com/vuejs/core/issues/14674)) ([219d83b](https://github.com/vuejs/core/commit/219d83bd305ce6fc052941acaaf02e7bc70616a4)), closes [#&#8203;14674](https://github.com/vuejs/core/issues/14674) [#&#8203;14669](https://github.com/vuejs/core/issues/14669)

### [`v3.5.31`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3531-2026-03-25)

[Compare Source](https://github.com/vuejs/core/compare/v3.5.30...v3.5.31)

##### Bug Fixes

- **compiler-sfc:** allow Node.js subpath imports patterns in asset urls ([#&#8203;13045](https://github.com/vuejs/core/issues/13045)) ([95c3356](https://github.com/vuejs/core/commit/95c33560c9af369d44a7670d0c3b93fb62323be2)), closes [#&#8203;9919](https://github.com/vuejs/core/issues/9919)
- **compiler-sfc:** support template literal as defineModel name ([#&#8203;14622](https://github.com/vuejs/core/issues/14622)) ([bd7eef0](https://github.com/vuejs/core/commit/bd7eef0161d69bccd48ac303bc6a56ba8d718e2d)), closes [#&#8203;14621](https://github.com/vuejs/core/issues/14621)
- **reactivity:** normalize toRef property keys before dep lookup + improve types ([#&#8203;14625](https://github.com/vuejs/core/issues/14625)) ([1bb28d0](https://github.com/vuejs/core/commit/1bb28d011b31bc75c80d2860bc6484cceec1ff20)), closes [#&#8203;12427](https://github.com/vuejs/core/issues/12427) [#&#8203;12431](https://github.com/vuejs/core/issues/12431)
- **runtime-core:** invalidate detached v-for memo vnodes after unmount ([#&#8203;14624](https://github.com/vuejs/core/issues/14624)) ([560def4](https://github.com/vuejs/core/commit/560def426fc38c1710fda7ddd1ac634d67897960)), closes [#&#8203;12708](https://github.com/vuejs/core/issues/12708) [#&#8203;12710](https://github.com/vuejs/core/issues/12710)
- **runtime-core:** preserve nullish event handlers in mergeProps ([#&#8203;14550](https://github.com/vuejs/core/issues/14550)) ([5725222](https://github.com/vuejs/core/commit/5725222a6bc5d1dd032318349ce0c540b1d63a49))
- **runtime-core:** prevent merging model listener when value is null or undefined ([#&#8203;14629](https://github.com/vuejs/core/issues/14629)) ([b39e032](https://github.com/vuejs/core/commit/b39e0329f67354702f4e417e55c15c61d2439657))
- **runtime-dom:** defer teleport mount/update until suspense resolves ([#&#8203;8619](https://github.com/vuejs/core/issues/8619)) ([88ed045](https://github.com/vuejs/core/commit/88ed04501555b9257df8d7ad86d844c2c2136e50)), closes [#&#8203;8603](https://github.com/vuejs/core/issues/8603)
- **runtime-dom:** handle activeElement check in Shadow DOM for v-model ([#&#8203;14196](https://github.com/vuejs/core/issues/14196)) ([959ded2](https://github.com/vuejs/core/commit/959ded22ab7ea1453f607e0964e1fb6748ece6c7))
- **server-renderer:** cleanup component effect scopes after SSR render ([#&#8203;14548](https://github.com/vuejs/core/issues/14548)) ([862f11e](https://github.com/vuejs/core/commit/862f11ee017d51cb9573a8c0642055b3b17cace8))
- **suspense:** avoid unmount activeBranch twice if wrapped in transition ([#&#8203;9392](https://github.com/vuejs/core/issues/9392)) ([908c6ad](https://github.com/vuejs/core/commit/908c6ad05e1c76ae690d2e50f3bd28278af07e22)), closes [#&#8203;7966](https://github.com/vuejs/core/issues/7966)
- **suspense:** update suspense vnode's el during branch self-update ([#&#8203;12922](https://github.com/vuejs/core/issues/12922)) ([a2c1700](https://github.com/vuejs/core/commit/a2c17004c84e5ce3c4e82e35b806ba381144eed3)), closes [#&#8203;12920](https://github.com/vuejs/core/issues/12920)
- **transition:** skip enter guard while hmr updating ([#&#8203;14611](https://github.com/vuejs/core/issues/14611)) ([be0a2f1](https://github.com/vuejs/core/commit/be0a2f1a7fc3d81d05638798cc628848cfa62cef)), closes [#&#8203;14608](https://github.com/vuejs/core/issues/14608)
- **types:** prevent shallowReactive marker from leaking into value unions ([#&#8203;14493](https://github.com/vuejs/core/issues/14493)) ([3b561db](https://github.com/vuejs/core/commit/3b561db4ab42d06166b002f13c0e97cb2bd4a061)), closes [#&#8203;14490](https://github.com/vuejs/core/issues/14490)

</details>

<details>
<summary>vuejs/router (vue-router)</summary>

### [`v5.0.4`](https://github.com/vuejs/router/releases/tag/v5.0.4)

[Compare Source](https://github.com/vuejs/router/compare/v5.0.3...v5.0.4)

#####    🐞 Bug Fixes

- Avoid iterator helpers for Node 20 compat  -  by [@&#8203;cwandev](https://github.com/cwandev) in [#&#8203;2635](https://github.com/vuejs/router/issues/2635) [<samp>(47130)</samp>](https://github.com/vuejs/router/commit/4713069d)
- Escape backslahes in string literals  -  by [@&#8203;posva](https://github.com/posva) [<samp>(71fdb)</samp>](https://github.com/vuejs/router/commit/71fdbf36)
- Avoid false duplicate route warning for named views  -  by [@&#8203;posva](https://github.com/posva) [<samp>(72012)</samp>](https://github.com/vuejs/router/commit/72012ab0)
- Allow pushing to auto routes  -  by [@&#8203;posva](https://github.com/posva) [<samp>(47f03)</samp>](https://github.com/vuejs/router/commit/47f0334b)
- **loaders**: Restore context in sequential awaits  -  by [@&#8203;posva](https://github.com/posva) [<samp>(fce5d)</samp>](https://github.com/vuejs/router/commit/fce5d1e3)

#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.3...v5.0.4)

</details>

<details>
<summary>websockets/ws (ws)</summary>

### [`v8.20.0`](https://github.com/websockets/ws/releases/tag/8.20.0)

[Compare Source](https://github.com/websockets/ws/compare/8.19.0...8.20.0)

### Features

- Added exports for the `PerMessageDeflate` class and utilities for the
  `Sec-WebSocket-Extensions` and `Sec-WebSocket-Protocol` headers ([`d3503c1`](https://github.com/websockets/ws/commit/d3503c1f)).

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://github.com/renovatebot/renovate/discussions) if that's undesired.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDIuMTAiLCJ1cGRhdGVkSW5WZXIiOiI0My4xMjAuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: https://git.keligrubb.com/keligrubb/kestrelos/pulls/25
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-04-15 02:42:47 +00:00
CI c023bdccae release v1.0.10 [skip ci] 2026-04-15 02:35:40 +00:00
keligrubb 78f3ad8fcc Remove npm overrides for tar (#26)
Push / release-docker-helm (push) Successful in 4m21s
Drop the package.json overrides entry so transitive tar versions follow
what dependencies resolve. Refresh package-lock.json after npm install.

Reviewed-on: #26
Co-authored-by: keligrubb <keligrubb324@gmail.com>
Co-committed-by: keligrubb <keligrubb324@gmail.com>
2026-04-15 02:32:49 +00:00
CI 2e7a52ed15 release v1.0.9 [skip ci] 2026-03-24 14:05:10 +00:00
renovate-bot 221b3533e5 chore(deps): update https://git.keligrubb.com/actions/setup-helm action to v5 (#23)
Push / release-docker-helm (push) Successful in 5m24s
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [https://git.keligrubb.com/actions/setup-helm](https://git.keligrubb.com/actions/setup-helm) | action | major | `v4` → `v5` |

---

### Release Notes

<details>
<summary>actions/setup-helm (https://git.keligrubb.com/actions/setup-helm)</summary>

### [`v5`](https://git.keligrubb.com/actions/setup-helm/compare/v4...v5)

[Compare Source](https://git.keligrubb.com/actions/setup-helm/compare/v4...v5)

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My44Ny4xIiwidXBkYXRlZEluVmVyIjoiNDMuODcuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->

Reviewed-on: #23
Co-authored-by: Renovate Bot <renovate@keligrubb.com>
Co-committed-by: Renovate Bot <renovate@keligrubb.com>
2026-03-24 14:02:21 +00:00
69 changed files with 7455 additions and 2560 deletions
+12 -5
View File
@@ -7,7 +7,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v6
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v6
@@ -24,7 +24,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v6
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v6
@@ -41,9 +41,9 @@ jobs:
e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-noble
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- uses: https://git.keligrubb.com/actions/checkout@v6
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Setup Node.js
uses: https://git.keligrubb.com/actions/setup-node@v6
@@ -65,7 +65,7 @@ jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v6
- uses: https://git.keligrubb.com/actions/checkout@v7
- name: Set Docker image tag
id: image
@@ -79,7 +79,14 @@ jobs:
- name: Build (dry run)
uses: https://git.keligrubb.com/actions/docker-build-push-action@v7
env:
# Keeps GITHUB_OUTPUT small; Gitea act-runner can choke on multiline
# outputs when PR webhook payloads (e.g. Renovate bodies) are huge.
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
push: false
provenance: false
sbom: false
tags: ${{ steps.image.outputs.tag }}
+33 -6
View File
@@ -5,13 +5,25 @@ on:
branches: [main]
jobs:
release-docker-helm:
release:
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v6
- uses: https://git.keligrubb.com/actions/checkout@v7
with:
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Get PR description for changelog
env:
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq jq
RESP=$(curl -sf -H "Authorization: token $GITEA_REPO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/commits/${{ github.sha }}/pull") || true
if [ -n "$RESP" ]; then
echo "$RESP" | jq -r '.body // empty' > .ci_pr_body 2>/dev/null || true
fi
- name: Release (bump, tag, push, create release)
env:
CI_REPO_OWNER: ${{ github.actor }}
@@ -20,9 +32,19 @@ jobs:
CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
GITEA_REPO_TOKEN: ${{ secrets.KESTRELOS_REPO_TOKEN }}
run: |
sudo rm -f /etc/apt/sources.list.d/microsoft*.list /etc/apt/sources.list.d/azure*.list 2>/dev/null || true
sudo apt-get update -qq && sudo apt-get install -y -qq git wget
./scripts/release.sh
publish:
needs: release
runs-on: ubuntu-latest
steps:
- uses: https://git.keligrubb.com/actions/checkout@v7
with:
ref: main
token: ${{ secrets.KESTRELOS_REPO_TOKEN }}
- name: Log in to container registry
uses: https://git.keligrubb.com/actions/docker-login-action@v4
with:
@@ -40,17 +62,22 @@ jobs:
load: true
tags: kestrelos:built
- name: Push Docker image (all tags from .tags)
- name: Push Docker image (version + latest)
run: |
VERSION=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "error: package.json version must be x.y.z (got: $VERSION)"; exit 1 ;;
esac
REGISTRY="git.keligrubb.com"
IMAGE="$REGISTRY/${{ github.repository }}"
while read -r tag; do
for tag in "$VERSION" latest; do
docker tag kestrelos:built "$IMAGE:$tag"
docker push "$IMAGE:$tag"
done < .tags
done
- name: Set up Helm
uses: https://git.keligrubb.com/actions/setup-helm@v4
uses: https://git.keligrubb.com/actions/setup-helm@v5
- name: Package and push Helm chart
env:
+1 -1
View File
@@ -1 +1 @@
setups.@nuxt/test-utils="4.0.0"
setups.@nuxt/test-utils="4.0.3"
+557
View File
@@ -1,3 +1,560 @@
## [1.1.6] - 2026-06-24
### Changed
- Add ADS-B, AIS, and ALPR map layers with live CoT streaming (#36)
## Summary
- **ADS-B & AIS:** OpenSky and AISStream OSINT feeds upsert into the CoT store; tactical tracks still arrive via adsbcot/aiscot on `:8089`. Map clients subscribe via `GET /api/cot/stream` (SSE) with viewport bbox filtering and Air / Surface / Team layer toggles.
- **ALPR (Flock/OSM):** Toggleable license-plate reader layer sourced from OpenStreetMap, with SQLite cache, Overpass fallback, tiled viewport fetching, and clustered markers with direction cones.
- **Map performance:** Ring-based tile selection (fixes zoom-out crash), immutable tile cache, incremental marker sync, split cluster load/query, and padded SSE bbox to reduce reconnect churn.
## Docs
- `docs/tracking.md` — ADS-B/AIS accuracy tiers, freshness, self-hosted receivers, optional OSINT API keys
- `docs/map-and-cameras.md` — ALPR layer and map behavior updates
## [1.1.5] - 2026-06-21
### Changed
- update https://git.keligrubb.com/actions/checkout action to v7 (#35)
This PR contains the following updates:
| Package | Type | Update | Change |
|---|---|---|---|
| [https://git.keligrubb.com/actions/checkout](https://git.keligrubb.com/actions/checkout) | action | major | `v6``v7` |
---
### Release Notes
<details>
<summary>actions/checkout (https://git.keligrubb.com/actions/checkout)</summary>
### [`v7.0.0`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v7...v7)
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)
### [`v7`](https://git.keligrubb.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)
[Compare Source](https://git.keligrubb.com/actions/checkout/compare/v6.0.3...v7)
- Block checking out fork PR for pull\_request\_target and workflow\_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Bump actions/publish-immutable-action from 0.0.3 to 0.0.4 in the minor-actions-dependencies group across 1 directory by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2458](https://github.com/actions/checkout/pull/2458)
- Bump flatted from 3.3.1 to 3.4.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2460](https://github.com/actions/checkout/pull/2460)
- Bump js-yaml from 4.1.0 to 4.2.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2461](https://github.com/actions/checkout/pull/2461)
- Bump [@&#8203;actions/core](https://github.com/actions/core) and [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) and Remove uuid by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2459](https://github.com/actions/checkout/pull/2459)
- upgrade module to esm and update dependencies by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2463](https://github.com/actions/checkout/pull/2463)
- Bump the minor-npm-dependencies group across 1 directory with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;2462](https://github.com/actions/checkout/pull/2462)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4yMzMuNCIsInVwZGF0ZWRJblZlciI6IjQzLjIzMy40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.4] - 2026-04-29
### Changed
- update all non-major dependencies (#31)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [@vitest/coverage-v8](https://vitest.dev/guide/coverage) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vitest%2fcoverage-v8/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vitest%2fcoverage-v8/4.1.4/4.1.5?slim=true) |
| [@vue/test-utils](https://github.com/vuejs/test-utils) | [`2.4.6` → `2.4.8`](https://renovatebot.com/diffs/npm/@vue%2ftest-utils/2.4.6/2.4.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@vue%2ftest-utils/2.4.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@vue%2ftest-utils/2.4.6/2.4.8?slim=true) |
| [eslint](https://eslint.org) ([source](https://github.com/eslint/eslint)) | [`10.2.0` → `10.2.1`](https://renovatebot.com/diffs/npm/eslint/10.2.0/10.2.1) | ![age](https://developer.mend.io/api/mc/badges/age/npm/eslint/10.2.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/eslint/10.2.0/10.2.1?slim=true) |
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.6.0` → `5.7.2`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.6.0/5.7.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.7.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.6.0/5.7.2?slim=true) |
| [mediasoup](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup)) | [`3.19.19` → `3.19.21`](https://renovatebot.com/diffs/npm/mediasoup/3.19.19/3.19.21) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup/3.19.21?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup/3.19.19/3.19.21?slim=true) |
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.8` → `3.19.0`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.8/3.19.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.8/3.19.0?slim=true) |
| [vitest](https://vitest.dev) ([source](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`4.1.4` → `4.1.5`](https://renovatebot.com/diffs/npm/vitest/4.1.4/4.1.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/4.1.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/4.1.4/4.1.5?slim=true) |
| [vue](https://vuejs.org/) ([source](https://github.com/vuejs/core)) | [`3.5.32` → `3.5.33`](https://renovatebot.com/diffs/npm/vue/3.5.32/3.5.33) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue/3.5.33?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue/3.5.32/3.5.33?slim=true) |
| [vue-router](https://router.vuejs.org) ([source](https://github.com/vuejs/router)) | [`5.0.4` → `5.0.6`](https://renovatebot.com/diffs/npm/vue-router/5.0.4/5.0.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vue-router/5.0.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vue-router/5.0.4/5.0.6?slim=true) |
---
### Release Notes
<details>
<summary>vitest-dev/vitest (@&#8203;vitest/coverage-v8)</summary>
### [`v4.1.5`](https://github.com/vitest-dev/vitest/releases/tag/v4.1.5)
[Compare Source](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
#####    🚀 Experimental Features
- **coverage**: Istanbul to support `instrumenter` option  -  by [@&#8203;BartWaardenburg](https://github.com/BartWaardenburg) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10119](https://github.com/vitest-dev/vitest/issues/10119) [<samp>(0e0ff)</samp>](https://github.com/vitest-dev/vitest/commit/0e0ff41c7)
#####    🐞 Bug Fixes
- \--project negation excludes browser instances  -  by [@&#8203;felamaslen](https://github.com/felamaslen) in [#&#8203;10131](https://github.com/vitest-dev/vitest/issues/10131) [<samp>(9423d)</samp>](https://github.com/vitest-dev/vitest/commit/9423dc084)
- Project color label on html reporter  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10142](https://github.com/vitest-dev/vitest/issues/10142) [<samp>(596f7)</samp>](https://github.com/vitest-dev/vitest/commit/596f73986)
- Fix `vi.defineHelper` called as object method  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10163](https://github.com/vitest-dev/vitest/issues/10163) [<samp>(122c2)</samp>](https://github.com/vitest-dev/vitest/commit/122c25b5b)
- Alias `agent` reporter to `minimal`  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10157](https://github.com/vitest-dev/vitest/issues/10157) [<samp>(663b9)</samp>](https://github.com/vitest-dev/vitest/commit/663b99fe3)
- Respect diff config options in soft assertions  -  by [@&#8203;Copilot](https://github.com/Copilot), **sheremet-va** and [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(9787d)</samp>](https://github.com/vitest-dev/vitest/commit/9787dedad)
- Respect diff config options in soft assertions "  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;8696](https://github.com/vitest-dev/vitest/issues/8696) [<samp>(7dc6d)</samp>](https://github.com/vitest-dev/vitest/commit/7dc6d54fd)
- **ast-collect**: Recognize \_*vi\_import* prefix in static test discovery  -  by [@&#8203;Yejneshwar](https://github.com/Yejneshwar) in [#&#8203;10129](https://github.com/vitest-dev/vitest/issues/10129) [<samp>(32546)</samp>](https://github.com/vitest-dev/vitest/commit/325463ab2)
- **coverage**: Descriptive error message when reports directory is removed during test run  -  by [@&#8203;DaveT1991](https://github.com/DaveT1991) and [@&#8203;AriPerkkio](https://github.com/AriPerkkio) in [#&#8203;10117](https://github.com/vitest-dev/vitest/issues/10117) [<samp>(14133)</samp>](https://github.com/vitest-dev/vitest/commit/1413382e1)
- **snapshot**: Increase default snapshot max output length  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) and **Codex** in [#&#8203;10150](https://github.com/vitest-dev/vitest/issues/10150) [<samp>(21e66)</samp>](https://github.com/vitest-dev/vitest/commit/21e66ff63)
- **ui**: Fix jsx/tsx syntax highlight  -  by [@&#8203;hi-ogawa](https://github.com/hi-ogawa) in [#&#8203;10152](https://github.com/vitest-dev/vitest/issues/10152) [<samp>(f1b1f)</samp>](https://github.com/vitest-dev/vitest/commit/f1b1f6c7b)
- **web-worker**: Support MessagePort objects referenced inside postMessage data  -  by [@&#8203;whitphx](https://github.com/whitphx) and **Claude Opus 4.6 (1M context)** in [#&#8203;9927](https://github.com/vitest-dev/vitest/issues/9927) and [#&#8203;10124](https://github.com/vitest-dev/vitest/issues/10124) [<samp>(7ad7d)</samp>](https://github.com/vitest-dev/vitest/commit/7ad7d39af)
- **api**: Make test-specification options writable  -  by [@&#8203;sheremet-va](https://github.com/sheremet-va) in [#&#8203;10154](https://github.com/vitest-dev/vitest/issues/10154) [<samp>(6abd5)</samp>](https://github.com/vitest-dev/vitest/commit/6abd557b7)
#####     [View changes on GitHub](https://github.com/vitest-dev/vitest/compare/v4.1.4...v4.1.5)
</details>
<details>
<summary>vuejs/test-utils (@&#8203;vue/test-utils)</summary>
### [`v2.4.8`](https://github.com/vuejs/test-utils/releases/tag/v2.4.8)
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.7...v2.4.8)
##### 🩹 Fixes
- Correct declaration entrypoint ([#&#8203;2826](https://github.com/vuejs/test-utils/pull/2826))
##### 🤖 CI
- Enable pkg.pr.new ([#&#8203;2827](https://github.com/vuejs/test-utils/pull/2827))
##### ❤️ Contributors
- Cédric Exbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))
### [`v2.4.7`](https://github.com/vuejs/test-utils/releases/tag/v2.4.7)
[Compare Source](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
[compare changes](https://github.com/vuejs/test-utils/compare/v2.4.6...v2.4.7)
##### 🚀 Enhancements
- Add Chinese docs translation ([#&#8203;2552](https://github.com/vuejs/test-utils/pull/2552))
- SetData()/shallowMount with initialData for components using the Composition API / <script setup> ([#&#8203;2655](https://github.com/vuejs/test-utils/pull/2655))
##### 🩹 Fixes
- Preserve code from keyboard events ([#&#8203;2434](https://github.com/vuejs/test-utils/pull/2434))
- Switch browser and require exports definitions ([#&#8203;2501](https://github.com/vuejs/test-utils/pull/2501))
- Re-add peer dependencies but with wider range ([#&#8203;2511](https://github.com/vuejs/test-utils/pull/2511))
- Resolve warnings in docs:dev ([30b7491](https://github.com/vuejs/test-utils/commit/30b7491))
- Resolve TypeScript type errors in .vitepress/config ([#&#8203;2549](https://github.com/vuejs/test-utils/pull/2549))
- Accept FunctionalComponent<any> as selector ([0bb947f](https://github.com/vuejs/test-utils/commit/0bb947f))
- Text() misses content for array functional component ([#&#8203;2579](https://github.com/vuejs/test-utils/pull/2579))
- Use await in test ([c5482b4](https://github.com/vuejs/test-utils/commit/c5482b4))
- **deps:** Update dependency vue-component-type-helpers to v3 ([#&#8203;2683](https://github.com/vuejs/test-utils/pull/2683))
- Remove wrapper div when unmount ([#&#8203;2700](https://github.com/vuejs/test-utils/pull/2700))
- Make mount options slots compatible with noUncheckedIndexedAccess true ([#&#8203;2713](https://github.com/vuejs/test-utils/pull/2713))
- Add missing peerDependency [@&#8203;vue/compiler-dom](https://github.com/vue/compiler-dom) ([75801ba](https://github.com/vuejs/test-utils/commit/75801ba))
- **docs:** Declare css module for vitepress typecheck ([ddaca97](https://github.com/vuejs/test-utils/commit/ddaca97))
##### 💅 Refactors
- Enforce consistent usage of type imports ([#&#8203;2734](https://github.com/vuejs/test-utils/pull/2734))
##### 📖 Documentation
- Clarify findComponent vs getComponent ([#&#8203;2435](https://github.com/vuejs/test-utils/pull/2435))
- Update fr docs ([67064ef](https://github.com/vuejs/test-utils/commit/67064ef))
- Add note about partial transition stub support ([#&#8203;2431](https://github.com/vuejs/test-utils/pull/2431))
- Fix missing data at passing data section essentials guide ([dda205e](https://github.com/vuejs/test-utils/commit/dda205e))
- Fix missing data at passing data section essentials guide fr ([ae2c72c](https://github.com/vuejs/test-utils/commit/ae2c72c))
- Fix plugin TS declaration example ([#&#8203;2466](https://github.com/vuejs/test-utils/pull/2466))
- Fixed incorrect checkbox value check ([#&#8203;2495](https://github.com/vuejs/test-utils/pull/2495))
- Capital letter in sentence fix ([#&#8203;2499](https://github.com/vuejs/test-utils/pull/2499))
- Import missing DOMWrapper on Implementation of the plugin section ([#&#8203;2519](https://github.com/vuejs/test-utils/pull/2519))
- Add migration step for deprecated ref syntax in findAllComponents ([#&#8203;2498](https://github.com/vuejs/test-utils/pull/2498))
- Correct anchor hash links and fix typo ([#&#8203;2551](https://github.com/vuejs/test-utils/pull/2551))
- Center logo on home ([#&#8203;2559](https://github.com/vuejs/test-utils/pull/2559))
- **zh-cn:** Review a-crash-course ([#&#8203;2563](https://github.com/vuejs/test-utils/pull/2563))
- Use code-group for install commands ([#&#8203;2571](https://github.com/vuejs/test-utils/pull/2571))
- **zh-cn:** Review event-handing.md ([#&#8203;2572](https://github.com/vuejs/test-utils/pull/2572))
- **zh-cn:** Enhance conditional-rendering.md ([#&#8203;2562](https://github.com/vuejs/test-utils/pull/2562))
- **zh-cn:** Review easy-to-test ([#&#8203;2567](https://github.com/vuejs/test-utils/pull/2567))
- **zh-cn:** Review passing-data.md ([#&#8203;2575](https://github.com/vuejs/test-utils/pull/2575))
- **zh-cn:** Review async-suspense.md ([#&#8203;2576](https://github.com/vuejs/test-utils/pull/2576))
- **zh:** 优化 API 文档格式和内容 ([#&#8203;2569](https://github.com/vuejs/test-utils/pull/2569))
- **zh:** 更新 Vitest 模拟日期和计时器的说明 ([#&#8203;2578](https://github.com/vuejs/test-utils/pull/2578))
- **zh-cn:** Review http-requests.md ([#&#8203;2580](https://github.com/vuejs/test-utils/pull/2580))
- **zh-cn:** Review forms ([#&#8203;2582](https://github.com/vuejs/test-utils/pull/2582))
- **zh-cn:** Guide/advanced/slots.md ([#&#8203;2565](https://github.com/vuejs/test-utils/pull/2565))
- **zh:** Review extending-vtu ([#&#8203;2583](https://github.com/vuejs/test-utils/pull/2583))
- **zh:** Review index ([#&#8203;2584](https://github.com/vuejs/test-utils/pull/2584))
- Fix modelValue test example ([85bfdf4](https://github.com/vuejs/test-utils/commit/85bfdf4))
- Removes broken link from plugins.md ([69bc1ce](https://github.com/vuejs/test-utils/commit/69bc1ce))
- **zh:** Review transitions, component-instance, and reusability-composition ([#&#8203;2616](https://github.com/vuejs/test-utils/pull/2616))
- **zh:** Review v-model and vuex ([#&#8203;2617](https://github.com/vuejs/test-utils/pull/2617))
- **zh:** Review all the rest advanced guide ([#&#8203;2619](https://github.com/vuejs/test-utils/pull/2619))
- **zh:** Review migration ([#&#8203;2623](https://github.com/vuejs/test-utils/pull/2623))
- Fix a typo in transitions.md ([#&#8203;2635](https://github.com/vuejs/test-utils/pull/2635))
- Update crash-course to script setup ([c81aa79](https://github.com/vuejs/test-utils/commit/c81aa79))
- Update Essentials section to setup (composition api) ([#&#8203;2647](https://github.com/vuejs/test-utils/pull/2647))
- Typos in examples ([#&#8203;2678](https://github.com/vuejs/test-utils/pull/2678))
- Typo in easy-to-test.md ([#&#8203;2710](https://github.com/vuejs/test-utils/pull/2710))
- Add note about mocking requestAnimationFrame for transitions ([2324c65](https://github.com/vuejs/test-utils/commit/2324c65))
- Updated example TodoApp to script setup ([#&#8203;2727](https://github.com/vuejs/test-utils/pull/2727))
- Remove "Using data" section from "Conditional Rendering" guide and fix passing data test example ([#&#8203;2743](https://github.com/vuejs/test-utils/pull/2743))
- Follow-up fixes for the conditional rendering guide ([#&#8203;2744](https://github.com/vuejs/test-utils/pull/2744))
- Mention shallowMount stub name changes in migration guide ([80e051a](https://github.com/vuejs/test-utils/commit/80e051a))
- Update conditional rendering documentation to clarify isVisible() usage with attachTo ([#&#8203;2799](https://github.com/vuejs/test-utils/pull/2799))
- Restore Options API component for data() mounting example ([#&#8203;2804](https://github.com/vuejs/test-utils/pull/2804))
- Promote Vitest as recommended test runner ([#&#8203;2805](https://github.com/vuejs/test-utils/pull/2805))
- **api:** Note that setValue does not accept objects on `<select>` ([#&#8203;2819](https://github.com/vuejs/test-utils/pull/2819))
##### 🏡 Chore
- Add api/index.md to docs:translation:compare ([6b8681c](https://github.com/vuejs/test-utils/commit/6b8681c))
- Remove unnecessary generic arguments ([cfd70c6](https://github.com/vuejs/test-utils/commit/cfd70c6))
- Ignore TS error in config object ([9d0a618](https://github.com/vuejs/test-utils/commit/9d0a618))
- Simplify eslint packages ([c1d0ffd](https://github.com/vuejs/test-utils/commit/c1d0ffd))
- Use eslint v9 with flat config ([2f19fdf](https://github.com/vuejs/test-utils/commit/2f19fdf))
- Expose Stubs type publicly ([#&#8203;2492](https://github.com/vuejs/test-utils/pull/2492))
- Update documentation file path ([9c96594](https://github.com/vuejs/test-utils/commit/9c96594))
- Use pnpm v10 ([e4c2cb3](https://github.com/vuejs/test-utils/commit/e4c2cb3))
- Pnpm approve build ([81c54e9](https://github.com/vuejs/test-utils/commit/81c54e9))
- Use github issue forms ([#&#8203;2673](https://github.com/vuejs/test-utils/pull/2673))
- Exclude class components from test type-checking ([0899008](https://github.com/vuejs/test-utils/commit/0899008))
- Add explicit coverage include for vitest v4 ([51672b9](https://github.com/vuejs/test-utils/commit/51672b9))
- Update to prettier v3.7 ([fed9e7c](https://github.com/vuejs/test-utils/commit/fed9e7c))
- Migrate to oxfmt ([81c1de9](https://github.com/vuejs/test-utils/commit/81c1de9))
- Migrate to oxlint ([a361908](https://github.com/vuejs/test-utils/commit/a361908))
- Prepare TypeScript 6 migration settings ([55e1262](https://github.com/vuejs/test-utils/commit/55e1262))
- Adjust tsd config for TypeScript 6 ([7d23eb5](https://github.com/vuejs/test-utils/commit/7d23eb5))
- Avoid TypeScript 6 target deprecation warning ([81d063c](https://github.com/vuejs/test-utils/commit/81d063c))
##### 🤖 CI
- Remove node v22 build ([7ebf58d](https://github.com/vuejs/test-utils/commit/7ebf58d))
- Add node v22 build ([57540ee](https://github.com/vuejs/test-utils/commit/57540ee))
- Use "pool: threads" instead of vmThreads ([d0cbb54](https://github.com/vuejs/test-utils/commit/d0cbb54))
- Remove node v18 and add v24 ([fd9cf95](https://github.com/vuejs/test-utils/commit/fd9cf95))
- Add trusted publishing release workflow ([#&#8203;2825](https://github.com/vuejs/test-utils/pull/2825))
##### ❤️ Contributors
- Lachlan Miller ([@&#8203;lmiller1990](https://github.com/lmiller1990))
- cexbrayat ([@&#8203;cexbrayat](https://github.com/cexbrayat))
- Nicolas Bonamy ([@&#8203;nbonamy](https://github.com/nbonamy))
- KatWorkGit ([@&#8203;KatWorkGit](https://github.com/KatWorkGit))
- Wouter Kroes ([@&#8203;wouterkroes](https://github.com/wouterkroes))
- Rama Muhammad Murshal ([@&#8203;ramammurshal](https://github.com/ramammurshal))
- Evan You ([@&#8203;yyx990803](https://github.com/yyx990803))
- Vlad Starkovsky ([@&#8203;starkovsky](https://github.com/starkovsky))
- Joe ([@&#8203;joaoprp](https://github.com/joaoprp))
- Priyadarshi Kumar ([@&#8203;Psingh132](https://github.com/Psingh132))
- Sébastien Ronveaux ([@&#8203;sronveaux](https://github.com/sronveaux))
- Gilliam ([@&#8203;Gi11i4m](https://github.com/Gi11i4m))
- Baranov Dmytro ([@&#8203;dimas7001](https://github.com/dimas7001))
- BrendonHenrique ([@&#8203;BrendonHenrique](https://github.com/BrendonHenrique))
- Lorenz van Herwaarden ([@&#8203;lorenzvanherwaarden](https://github.com/lorenzvanherwaarden))
- wuzhiqing ([@&#8203;DDDDD12138](https://github.com/DDDDD12138))
- 阿菜 Cai ([@&#8203;RSS1102](https://github.com/RSS1102))
- Jinjiang ([@&#8203;Jinjiang](https://github.com/Jinjiang))
- Kylin ([@&#8203;lxKylin](https://github.com/lxKylin))
- Qianhe Chen ([@&#8203;chenqianhe](https://github.com/chenqianhe))
- 时瑶 ([@&#8203;KiritaniAyaka](https://github.com/KiritaniAyaka))
- h7ml ([@&#8203;h7ml](https://github.com/h7ml))
- Nicander ([@&#8203;Nicander93](https://github.com/Nicander93))
- Take-John ([@&#8203;takejohn](https://github.com/takejohn))
- ilyasherstoboev ([@&#8203;ilyasherstoboev](https://github.com/ilyasherstoboev))
- aimerie ([@&#8203;aimerie](https://github.com/aimerie))
- Miguel Rincon ([@&#8203;miguelrincon](https://github.com/miguelrincon))
- bcastlel ([@&#8203;bcastlel](https://github.com/bcastlel))
- Claudiu ([@&#8203;sofuxro](https://github.com/sofuxro))
- Artem Dragunov ([@&#8203;dragunovartem99](https://github.com/dragunovartem99))
- Robin ([@&#8203;OrbisK](https://github.com/OrbisK))
- Koen Mertens ([@&#8203;KCMertens](https://github.com/KCMertens))
- meomking ([@&#8203;CaptainWang98](https://github.com/CaptainWang98))
- Pepijn Olivier ([@&#8203;pepijnolivier](https://github.com/pepijnolivier))
- Tomina ([@&#8203;Thomaash](https://github.com/Thomaash))
- Gareth Jones ([@&#8203;G-Rath](https://github.com/G-Rath))
- Jerry Hogan ([@&#8203;hdJerry](https://github.com/hdJerry))
- Marco Pasqualetti ([@&#8203;marcalexiei](https://github.com/marcalexiei))
- guoxk ([@&#8203;guoxk-me](https://github.com/guoxk-me))
- kimulaco ([@&#8203;kimulaco](https://github.com/kimulaco))
- Erwan IQUEL ([@&#8203;Olympus5](https://github.com/Olympus5))
- Matt Van Horn ([@&#8203;mvanhorn](https://github.com/mvanhorn))
- Daniel Roe ([@&#8203;danielroe](https://github.com/danielroe))
</details>
<details>
<summary>eslint/eslint (eslint)</summary>
### [`v10.2.1`](https://github.com/eslint/eslint/releases/tag/v10.2.1)
[Compare Source](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1)
#### Bug Fixes
- [`14be92b`](https://github.com/eslint/eslint/commit/14be92b6d1fa0923b8923830f2208e5e2705b002) fix: model generator yield resumption paths in code path analysis ([#&#8203;20665](https://github.com/eslint/eslint/issues/20665)) (sethamus)
- [`84a19d2`](https://github.com/eslint/eslint/commit/84a19d2c32255db6b9cfc08644a607aae6d5cb62) fix: no-async-promise-executor false positives for shadowed Promise ([#&#8203;20740](https://github.com/eslint/eslint/issues/20740)) (xbinaryx)
- [`af764af`](https://github.com/eslint/eslint/commit/af764af0ec38225755fbf8a6f207f0c77b595a8d) fix: clarify language and processor validation errors ([#&#8203;20729](https://github.com/eslint/eslint/issues/20729)) (Pixel998)
- [`e251b89`](https://github.com/eslint/eslint/commit/e251b89a38280973e468a4a9386c138f4f55d10d) fix: update eslint ([#&#8203;20715](https://github.com/eslint/eslint/issues/20715)) (renovate\[bot])
#### Documentation
- [`ca92ca0`](https://github.com/eslint/eslint/commit/ca92ca0fb4599e8de1e2fb914e695fe7397cbe63) docs: reuse markdown-it instance for markdown filter ([#&#8203;20768](https://github.com/eslint/eslint/issues/20768)) (Amaresh S M)
- [`57d2ee2`](https://github.com/eslint/eslint/commit/57d2ee213305cee0cb55ef08e0480b57396269a9) docs: Enable Eleventy incremental mode for watch ([#&#8203;20767](https://github.com/eslint/eslint/issues/20767)) (Amaresh S M)
- [`c1621b9`](https://github.com/eslint/eslint/commit/c1621b915742276e5f4b25efe790ca62296330dc) docs: fix typos in code-path-analyzer.js ([#&#8203;20700](https://github.com/eslint/eslint/issues/20700)) (Ayush Shukla)
- [`1418d52`](https://github.com/eslint/eslint/commit/1418d522d10bde1960f4942afb548bc7160ec49e) docs: Update README (GitHub Actions Bot)
- [`39771e6`](https://github.com/eslint/eslint/commit/39771e6e600f0b0617fdeafff6dd07e4211ffde6) docs: Update README (GitHub Actions Bot)
- [`71e0469`](https://github.com/eslint/eslint/commit/71e04693def2df57268f08f3072a2749df6bf438) docs: fix incomplete JSDoc param description in no-shadow rule ([#&#8203;20728](https://github.com/eslint/eslint/issues/20728)) (kuldeep kumar)
- [`22119ce`](https://github.com/eslint/eslint/commit/22119ceb93e28f62262fc1d98ff1b1442d6e2dbf) docs: clarify scope of for-direction rule with dead code examples ([#&#8203;20723](https://github.com/eslint/eslint/issues/20723)) (Amaresh S M)
- [`8f3fb77`](https://github.com/eslint/eslint/commit/8f3fb77f122a5641d1833cad5d93f3f54fa3be0b) docs: document `meta.docs.dialects` ([#&#8203;20718](https://github.com/eslint/eslint/issues/20718)) (Pixel998)
#### Chores
- [`7ddfea9`](https://github.com/eslint/eslint/commit/7ddfea9c4f62add1588c5c0b0da568c299246383) chore: update dependency prettier to v3.8.2 ([#&#8203;20770](https://github.com/eslint/eslint/issues/20770)) (renovate\[bot])
- [`fac40e1`](https://github.com/eslint/eslint/commit/fac40e1de2ba7646cc7cd2d3f93fbdd1f8819001) ci: bump pnpm/action-setup from 5.0.0 to 6.0.0 ([#&#8203;20763](https://github.com/eslint/eslint/issues/20763)) (dependabot\[bot])
- [`7246f92`](https://github.com/eslint/eslint/commit/7246f923332522d8b3d46b6ee646fce88535f3fb) test: add tests for SuppressionsService.load() error handling ([#&#8203;20734](https://github.com/eslint/eslint/issues/20734)) (kuldeep kumar)
- [`4f34b1e`](https://github.com/eslint/eslint/commit/4f34b1e592b0f63d766d9903998e8e36eb49d3aa) chore: update pnpm/action-setup action to v5 ([#&#8203;20762](https://github.com/eslint/eslint/issues/20762)) (renovate\[bot])
- [`51080eb`](https://github.com/eslint/eslint/commit/51080eb5c98d619434e4835dbe9f1c6654aca3b8) test: processor service ([#&#8203;20731](https://github.com/eslint/eslint/issues/20731)) (kuldeep kumar)
- [`e7e1889`](https://github.com/eslint/eslint/commit/e7e1889fca9b6044e08f41b38df20a1ce45808c8) chore: remove stale babel-eslint10 fixture and test ([#&#8203;20727](https://github.com/eslint/eslint/issues/20727)) (kuldeep kumar)
- [`4e1a87c`](https://github.com/eslint/eslint/commit/4e1a87cb8fb90e309524bc36bc5f31b9f9cfaa76) test: remove redundant async/await in flat config array tests ([#&#8203;20722](https://github.com/eslint/eslint/issues/20722)) (Pixel998)
- [`066eabb`](https://github.com/eslint/eslint/commit/066eabb3643b12931f991594969bcc0028f71a5f) test: add rule metadata coverage for `languages` and `docs.dialects` ([#&#8203;20717](https://github.com/eslint/eslint/issues/20717)) (Pixel998)
</details>
<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
### [`v5.7.2`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.2): backward compatibility for numerical external entity, fix #&#8203;705, #&#8203;817
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.1...v5.7.2)
- allow numerical external entity for backward compatibility
- fix [#&#8203;705](https://github.com/NaturalIntelligence/fast-xml-parser/issues/705): attributesGroupName working with preserveOrder
- fix [#&#8203;817](https://github.com/NaturalIntelligence/fast-xml-parser/issues/817): stackoverflow when tag expression is very long
### [`v5.7.1`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.7.1): upgrade @&#8203;nodable/entities and FXB
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.7.0...v5.7.1)
- Use `@nodable/entities` v2.1.0
- breaking changes
- single entity scan. You're not allowed to use entity value to form another entity name.
- you cant add numeric external entity
- entity error message when expantion limit is crossed might change
- typings are updated for new options related to process entity
- please follow documentation of `@nodable/entities` for more detail.
- performance
- if processEntities is false, then there should not be impact on performance.
- if processEntities is true, but you dont pass entity decoder separately then performance may degrade by approx 8-10%
- if processEntities is true, and you pass entity decoder separately
- if no entity then performance should be same as before
- if there are entities then performance should be increased from past versions
- ignoreAttributes is not required to be set to set xml version for NCR entity value
- update 'fast-xml-builder' to sanitize malicious CDATA and comment's content
### [`v5.7.0`](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.6.0...v5.7.0)
</details>
<details>
<summary>versatica/mediasoup (mediasoup)</summary>
### [`v3.19.21`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31921)
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.20...3.19.21)
- Worker: Fix regression in `DirectTransport` when closing a `DataProducer` or `DataConsumer` ([PR #&#8203;1780](https://github.com/versatica/mediasoup/pull/1780)).
### [`v3.19.20`](https://github.com/versatica/mediasoup/blob/HEAD/CHANGELOG.md#31920)
[Compare Source](https://github.com/versatica/mediasoup/compare/3.19.19...3.19.20)
- Worker: Add `useBuiltInSctpStack` setting (defaults to `false`) to enable mediasoup built-in SCTP stack ([PR #&#8203;1777](https://github.com/versatica/mediasoup/pull/1777)).
</details>
<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
### [`v3.19.0`](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.8...3.19.0)
</details>
<details>
<summary>vuejs/core (vue)</summary>
### [`v3.5.33`](https://github.com/vuejs/core/blob/HEAD/CHANGELOG.md#3533-2026-04-22)
[Compare Source](https://github.com/vuejs/core/compare/v3.5.32...v3.5.33)
##### Bug Fixes
- **compiler-sfc:** handle nested :deep in selector pseudos ([#&#8203;14725](https://github.com/vuejs/core/issues/14725)) ([bb9d265](https://github.com/vuejs/core/commit/bb9d265d8dcdde2af824fc01b24f9a7b3169f5fa)), closes [#&#8203;14724](https://github.com/vuejs/core/issues/14724)
- **reactivity:** unlink effect scopes on out-of-order off ([#&#8203;14734](https://github.com/vuejs/core/issues/14734)) ([e7659be](https://github.com/vuejs/core/commit/e7659beafc5407e892fa70f3f4ade80263b0905d)), closes [#&#8203;14733](https://github.com/vuejs/core/issues/14733)
- **runtime-dom:** preserve textarea resize dimensions ([#&#8203;14747](https://github.com/vuejs/core/issues/14747)) ([11fb2fd](https://github.com/vuejs/core/commit/11fb2fd4a246e40f6f350701dfea73ec525b4f59)), closes [#&#8203;14741](https://github.com/vuejs/core/issues/14741)
- **teleport:** don't move teleport children if not mounted ([#&#8203;14702](https://github.com/vuejs/core/issues/14702)) ([6a61f44](https://github.com/vuejs/core/commit/6a61f4452ba1a31fc929cadf8abe3337ac4d3a46)), closes [#&#8203;14701](https://github.com/vuejs/core/issues/14701)
- **transition:** preserve placeholder for conditional explicit default slots ([#&#8203;14748](https://github.com/vuejs/core/issues/14748)) ([45990ce](https://github.com/vuejs/core/commit/45990cecf4604b2f39c571ab6aefa49d362af36a)), closes [#&#8203;14727](https://github.com/vuejs/core/issues/14727)
</details>
<details>
<summary>vuejs/router (vue-router)</summary>
### [`v5.0.6`](https://github.com/vuejs/router/releases/tag/v5.0.6)
[Compare Source](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
#####    🐞 Bug Fixes
- Missing closing quote in generated import  -  by [@&#8203;zjy040525](https://github.com/zjy040525) and [@&#8203;posva](https://github.com/posva) in [#&#8203;2688](https://github.com/vuejs/router/issues/2688) [<samp>(32f78)</samp>](https://github.com/vuejs/router/commit/32f78c77)
#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.5...v5.0.6)
### [`v5.0.5`](https://github.com/vuejs/router/releases/tag/v5.0.5)
[Compare Source](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
#####    🚀 Features
- Enable standard schema param parsers  -  by [@&#8203;posva](https://github.com/posva) [<samp>(ea8e3)</samp>](https://github.com/vuejs/router/commit/ea8e3e21)
- Normalize param parsers once  -  by [@&#8203;posva](https://github.com/posva) [<samp>(48087)</samp>](https://github.com/vuejs/router/commit/480877cc)
#####    🐞 Bug Fixes
- Track definePage imports per-file to fix named view race condition  -  by [@&#8203;posva](https://github.com/posva) [<samp>(11191)</samp>](https://github.com/vuejs/router/commit/11191bca)
- Avoid double decoding hash on string location  -  by [@&#8203;posva](https://github.com/posva) [<samp>(1578c)</samp>](https://github.com/vuejs/router/commit/1578c9e9)
#####     [View changes on GitHub](https://github.com/vuejs/router/compare/v5.0.4...v5.0.5)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMzIuMyIsInVwZGF0ZWRJblZlciI6IjQzLjE1MC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.3] - 2026-04-19
### Changed
- update dependency mediasoup-client to v3.18.8 (#30)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [mediasoup-client](https://mediasoup.org) ([source](https://github.com/versatica/mediasoup-client)) | [`3.18.7` → `3.18.8`](https://renovatebot.com/diffs/npm/mediasoup-client/3.18.7/3.18.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/mediasoup-client/3.18.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/mediasoup-client/3.18.7/3.18.8?slim=true) |
---
### Release Notes
<details>
<summary>versatica/mediasoup-client (mediasoup-client)</summary>
### [`v3.18.8`](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
[Compare Source](https://github.com/versatica/mediasoup-client/compare/3.18.7...3.18.8)
</details>
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjkuMCIsInVwZGF0ZWRJblZlciI6IjQzLjEyOS4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.2] - 2026-04-15
### Changed
- Update dependency fast-xml-parser to v5.6.0 (#28)
This PR contains the following updates:
| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) | [`5.5.12` → `5.6.0`](https://renovatebot.com/diffs/npm/fast-xml-parser/5.5.12/5.6.0) | ![age](https://developer.mend.io/api/mc/badges/age/npm/fast-xml-parser/5.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/fast-xml-parser/5.5.12/5.6.0?slim=true) |
---
### Release Notes
<details>
<summary>NaturalIntelligence/fast-xml-parser (fast-xml-parser)</summary>
### [`v5.6.0`](https://github.com/NaturalIntelligence/fast-xml-parser/releases/tag/v5.6.0): use @&#8203;nodable/entities to replace entities
[Compare Source](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0)
- No API change
- No change in performance for basic usage
- No typing change
- No config change
- new dependency
- breaking: error messages for entities might have been changed.
-
**Full Changelog**: <https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.5.12...v5.6.0>
</details>
---
### Configuration
📅 **Schedule**: (UTC)
- Branch creation
- At any time (no schedule defined)
- Automerge
- At any time (no schedule defined)
🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.
**Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 **Ignore**: Close this PR and you won't be reminded about this update again.
---
- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box
---
This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMjAuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEyMC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->
## [1.1.1] - 2026-04-15
### Changed
- split push release/publish and harden workflows (#27)
### Added
* Separate release from Docker/Helm publish
* enrich releases with PRbodies when available
* tighten release.sh validation and idempotency
* trim PR docker-build metadata for act-runner stability
## [1.1.0] - 2026-04-15
### Changed
- Update all non-major dependencies (#25)
## [1.0.10] - 2026-04-15
### Changed
- Remove npm overrides for tar (#26)
## [1.0.9] - 2026-03-24
### Changed
- update https://git.keligrubb.com/actions/setup-helm action to v5 (#23)
## [1.0.8] - 2026-03-12
### Changed
- fix release file (#22)
+2 -2
View File
@@ -56,7 +56,7 @@ See [docs/live-streaming.md](docs/live-streaming.md) for setup and usage.
### ATAK / CoT (Cursor on Target)
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. Optional: set `COT_TTL_MS`, `COT_REQUIRE_AUTH`; CoT runs on port 8089 (default).
KestrelOS can act as a **TAK Server** so ATAK and iTAK devices connect and share positions. No plugins: in ATAK, add a **Server** connection (host = KestrelOS, port **8089** for CoT). Check **Use Authentication** and enter your **KestrelOS username** and **password** (local users use their login password; OIDC users must set an **ATAK password** once under **Account** in the web app). Devices relay CoT to each other (team members see each other on the ATAK map) and appear on the KestrelOS web map; they drop off after ~90 seconds if no updates. CoT runs on port 8089 (default).
## Scripts
@@ -74,7 +74,7 @@ Full docs are in the **[docs/](docs/README.md)** directory: [installation](docs/
## Configuration
- **Devices**: Manage cameras/devices via the API (`/api/devices`); see [Map and cameras](docs/map-and-cameras.md). Each device needs `name`, `device_type`, `lat`, `lng`, `stream_url`, and `source_type` (`mjpeg` or `hls`).
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). Set `COT_TTL_MS=90000`, `COT_REQUIRE_AUTH=true`. For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Environment**: No required env vars for basic run. For production, set `HOST=0.0.0.0` and expose ports 3000 (web/API) and 8089 (CoT). For TLS use `.dev-certs/` or set `COT_SSL_CERT` and `COT_SSL_KEY`.
- **Authentication**: The login page always offers password sign-in (local). Optionally set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before the first run to create the first admin; otherwise a default admin is created and its credentials are printed in the terminal. To also show an OIDC sign-in button, configure `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and optionally `OIDC_LABEL`, `OIDC_REDIRECT_URI`. See [docs/auth.md](docs/auth.md) for local login, OIDC config, and sign up.
- **Bootstrap admin** (when using local auth): The server initializes the database and runs bootstrap at startup. On first run (no users in the database), it creates the first admin. If you set `BOOTSTRAP_EMAIL` and `BOOTSTRAP_PASSWORD` before starting, that account is created. If you don't set them, a default admin is created (identifier: `admin`) with a random password and the credentials are printed in the terminal-copy them and sign in at `/login`, then change the password or add users via Members. Use **Members** to change roles (admin, leader, member). Only admins can change roles; admins and leaders can edit POIs.
- **Database**: SQLite file at `data/kestrelos.db` (created automatically). Contains users, sessions, and POIs.
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage :key="$route.path" />
<NuxtPage />
</NuxtLayout>
</template>
+38 -2
View File
@@ -16,6 +16,9 @@
.kestrel-btn-secondary { @apply rounded border border-kestrel-border px-4 py-2 text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item { @apply block w-full px-3 py-1.5 text-left text-sm text-kestrel-text transition-colors hover:bg-kestrel-border; }
.kestrel-context-menu-item-danger { @apply block w-full px-3 py-1.5 text-left text-sm text-red-400 transition-colors hover:bg-kestrel-border; }
.kestrel-cot-layer-btn { @apply rounded px-1.5 py-0.5 text-kestrel-muted transition-colors hover:text-kestrel-text; }
.kestrel-cot-layer-btn-active { @apply bg-kestrel-border text-kestrel-accent; }
.cot-icon-rotatable { @apply inline-flex origin-center; }
.kestrel-panel-base { @apply flex flex-col border border-kestrel-border bg-kestrel-surface; }
.kestrel-panel-inline { @apply rounded-lg shadow-glow; }
.kestrel-panel-overlay { @apply absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] shadow-glow-panel; }
@@ -84,12 +87,14 @@
}
.kestrel-map-container .leaflet-control-zoom,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar {
@apply rounded-md overflow-hidden font-mono border border-kestrel-glow shadow-glow-sm;
border-color: rgba(34, 201, 201, 0.35) !important;
}
.kestrel-map-container .leaflet-control-zoom a,
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr,
.kestrel-map-container .savetiles.leaflet-bar a {
@apply w-8 h-8 leading-8 bg-kestrel-surface text-kestrel-text border-none rounded-none text-lg font-semibold no-underline transition-all duration-150;
width: 32px !important;
@@ -105,9 +110,14 @@
}
.kestrel-map-container .leaflet-control-zoom a:hover,
.kestrel-map-container .leaflet-control-locate:hover,
.kestrel-map-container .leaflet-control-alpr:hover,
.kestrel-map-container .savetiles.leaflet-bar a:hover {
@apply bg-kestrel-surface-hover text-kestrel-accent shadow-glow-md text-shadow-glow-md;
}
.kestrel-map-container .leaflet-control-alpr[aria-pressed="true"] {
color: #ef4444 !important;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.45);
}
.kestrel-map-container .savetiles.leaflet-bar {
@apply flex flex-col;
}
@@ -119,12 +129,38 @@
padding: 6px 10px !important;
font-size: 11px !important;
}
.kestrel-map-container .leaflet-control-locate {
.kestrel-map-container .leaflet-control-locate,
.kestrel-map-container .leaflet-control-alpr {
@apply flex items-center justify-center p-0 cursor-pointer;
}
.kestrel-map-container .leaflet-control-locate svg {
.kestrel-map-container .leaflet-control-locate svg,
.kestrel-map-container .leaflet-control-alpr svg {
color: currentColor;
}
.kestrel-map-container .alpr-cone {
display: inline-flex;
transform-origin: center center;
}
.kestrel-map-container .alpr-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .alpr-cluster {
@apply flex items-center justify-center rounded-full bg-red-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.kestrel-map-container .cot-cluster-icon {
background: transparent;
border: none;
}
.kestrel-map-container .cot-cluster {
@apply flex items-center justify-center rounded-full bg-sky-500/90 font-mono text-xs font-semibold text-white;
width: 100%;
height: 100%;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
}
.kestrel-map-container .live-session-icon {
animation: live-pulse 1.5s ease-in-out infinite;
}
+6 -1
View File
@@ -26,6 +26,11 @@
:user="user"
@signout="onLogout"
/>
<span
v-else-if="authPending"
class="inline-block h-8 w-8"
aria-hidden="true"
/>
<NuxtLink
v-else
to="/login"
@@ -75,7 +80,7 @@ watch(sidebarCollapsed, (v) => {
}
})
const { user, refresh } = useUser()
const { user, authPending, refresh } = useUser()
watch(isMobile, (mobile) => {
if (mobile) drawerOpen.value = false
+136 -46
View File
@@ -47,11 +47,41 @@
@submit="onPoiSubmit"
@confirm-delete="confirmDeletePoi"
/>
<div
v-if="mapContext"
class="pointer-events-auto absolute right-3 top-3 z-[1000] flex gap-0.5 rounded border border-kestrel-border bg-kestrel-surface/95 p-0.5 text-xs shadow-glow"
data-testid="cot-layer-toggles"
>
<button
v-for="layer in COT_LAYERS"
:key="layer.key"
type="button"
class="kestrel-cot-layer-btn"
:class="{ 'kestrel-cot-layer-btn-active': cotLayers[layer.key] }"
@click="emit('toggleCotLayer', layer.key)"
>
{{ layer.label }}
</button>
</div>
</div>
</template>
<script setup>
import 'leaflet/dist/leaflet.css'
import {
createAlprControl,
createAlprLayer,
setAlprControlPressed,
syncAlprLayer,
} from '~/utils/alprMapLayer.js'
import {
createCotLayer,
getCotClusters,
loadCotCluster,
syncCotLayer,
} from '~/utils/cotMapLayer.js'
import { clearFeatureMarkers } from '~/utils/mapMarkerSync.js'
const props = defineProps({
devices: {
@@ -70,13 +100,25 @@ const props = defineProps({
type: Array,
default: () => [],
},
cotLayers: {
type: Object,
default: () => ({ air: true, surface: true, ground: true }),
},
alprMarkers: {
type: Array,
default: () => [],
},
showAlpr: {
type: Boolean,
default: false,
},
canEditPois: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select', 'selectLive', 'refreshPois'])
const emit = defineEmits(['select', 'selectLive', 'refreshPois', 'boundsChange', 'toggleAlpr', 'toggleCotLayer'])
const CONTEXT_MENU_EMPTY = Object.freeze({ type: null, poi: null, latlng: null, x: 0, y: 0 })
const mapRef = ref(null)
const contextMenuRef = ref(null)
@@ -85,7 +127,9 @@ const mapContext = ref(null)
const markersRef = ref([])
const poiMarkersRef = ref({})
const liveMarkersRef = ref({})
const cotMarkersRef = ref({})
const cotLayerRef = ref(null)
const cotMapView = ref(null)
const alprLayerRef = ref(null)
const contextMenu = ref({ ...CONTEXT_MENU_EMPTY })
const showPoiModal = ref(false)
@@ -104,6 +148,11 @@ const DEFAULT_ZOOM = 17
const MARKER_ICON_PATH = '/'
const POI_TOOLTIP_CLASS = 'kestrel-poi-tooltip'
const POI_ICON_COLORS = { pin: '#22c9c9', flag: '#e53e3e', waypoint: '#a78bfa' }
const COT_LAYERS = Object.freeze([
{ key: 'air', label: 'Air' },
{ key: 'surface', label: 'Surface' },
{ key: 'ground', label: 'Team' },
])
const ICON_SIZE = 28
@@ -141,14 +190,41 @@ function getLiveSessionIcon(L) {
})
}
const COT_ICON_COLOR = '#f59e0b' /* amber - ATAK/CoT devices */
function getCotEntityIcon(L) {
const html = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${COT_ICON_COLOR}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${COT_ICON_COLOR}"/></svg>`
return L.divIcon({
className: 'poi-div-icon cot-entity-icon',
html: `<span class="poi-icon-svg">${html}</span>`,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
function refreshCotMapView(map) {
const bounds = map.getBounds()
cotMapView.value = {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
}
}
function renderCotLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
const layer = cotLayerRef.value
if (!ctx?.map || !L || !layer) return
const view = cotMapView.value
const features = view ? getCotClusters(view) : []
syncCotLayer(L, ctx.map, layer, features)
}
function reloadCotCluster() {
loadCotCluster(props.cotEntities || [])
renderCotLayer()
}
function emitBounds(map) {
refreshCotMapView(map)
const bounds = map.getBounds()
emit('boundsChange', {
south: bounds.getSouth(),
west: bounds.getWest(),
north: bounds.getNorth(),
east: bounds.getEast(),
zoom: map.getZoom(),
})
}
@@ -160,7 +236,7 @@ function createMap(initialCenter) {
? initialCenter
: DEFAULT_VIEW
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false }).setView(center, DEFAULT_ZOOM)
const map = L.map(mapRef.value, { zoomControl: false, attributionControl: false, minZoom: 1, maxZoom: 19 }).setView(center, DEFAULT_ZOOM)
L.control.zoom({ position: 'topleft' }).addTo(map)
const locateControl = L.control({ position: 'topleft' })
@@ -186,6 +262,14 @@ function createMap(initialCenter) {
}
locateControl.addTo(map)
const alprControl = createAlprControl(L, {
showAlpr: props.showAlpr,
onToggle: () => emit('toggleAlpr'),
})
alprControl.addTo(map)
const alprLayer = createAlprLayer(L, map)
const cotLayer = createCotLayer(L, map)
const baseLayer = L.tileLayer(TILE_URL, {
attribution: ATTRIBUTION,
subdomains: TILE_SUBDOMAINS,
@@ -214,12 +298,28 @@ function createMap(initialCenter) {
contextMenu.value = { type: 'map', latlng: e.latlng, x: pt.x, y: pt.y }
})
mapContext.value = { map, layer: baseLayer, control, locateControl }
map.on('moveend', () => {
emitBounds(map)
renderCotLayer()
})
map.on('zoomend', () => {
emitBounds(map)
renderCotLayer()
})
mapContext.value = { map, layer: baseLayer, control, locateControl, alprControl }
alprLayerRef.value = alprLayer
cotLayerRef.value = cotLayer
refreshCotMapView(map)
updateMarkers()
updatePoiMarkers()
updateLiveMarkers()
updateCotMarkers()
nextTick(() => map.invalidateSize())
reloadCotCluster()
updateAlprLayer()
nextTick(() => {
map.invalidateSize()
emitBounds(map)
})
}
function updateMarkers() {
@@ -309,37 +409,16 @@ function updateLiveMarkers() {
liveMarkersRef.value = next
}
function updateCotMarkers() {
function updateAlprLayer() {
const ctx = mapContext.value
const { L } = leafletRef.value || {}
if (!ctx?.map || !L) return
const entities = (props.cotEntities || []).filter(
e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id,
)
const byId = Object.fromEntries(entities.map(e => [e.id, e]))
const prev = cotMarkersRef.value
const icon = getCotEntityIcon(L)
Object.keys(prev).forEach((id) => {
if (!byId[id]) prev[id]?.remove()
})
const next = entities.reduce((acc, entity) => {
const content = `<div class="kestrel-live-popup"><strong>${escapeHtml(entity.label || entity.id)}</strong> <span class="text-kestrel-muted">ATAK</span></div>`
const existing = prev[entity.id]
if (existing) {
existing.setLatLng([entity.lat, entity.lng])
existing.setIcon(icon)
existing.getPopup()?.setContent(content)
return { ...acc, [entity.id]: existing }
}
const marker = L.marker([entity.lat, entity.lng], { icon })
.addTo(ctx.map)
.bindPopup(content, { className: 'kestrel-live-popup-wrap', maxWidth: 360 })
return { ...acc, [entity.id]: marker }
}, {})
cotMarkersRef.value = next
const layer = alprLayerRef.value
if (!ctx?.map || !L || !layer) return
if (!props.showAlpr) {
clearFeatureMarkers(layer)
return
}
syncAlprLayer(L, ctx.map, layer, props.alprMarkers || [])
}
function escapeHtml(text) {
@@ -427,17 +506,22 @@ function destroyMap() {
poiMarkersRef.value = {}
Object.values(liveMarkersRef.value).forEach(m => m?.remove())
liveMarkersRef.value = {}
Object.values(cotMarkersRef.value).forEach(m => m?.remove())
cotMarkersRef.value = {}
clearFeatureMarkers(cotLayerRef.value)
clearFeatureMarkers(alprLayerRef.value)
const ctx = mapContext.value
if (ctx) {
if (ctx.control && ctx.map) ctx.map.removeControl(ctx.control)
if (ctx.locateControl && ctx.map) ctx.map.removeControl(ctx.locateControl)
if (ctx.alprControl && ctx.map) ctx.map.removeControl(ctx.alprControl)
if (cotLayerRef.value && ctx.map) ctx.map.removeLayer(cotLayerRef.value)
if (alprLayerRef.value && ctx.map) ctx.map.removeLayer(alprLayerRef.value)
if (ctx.layer && ctx.map) ctx.map.removeLayer(ctx.layer)
if (ctx.map) ctx.map.remove()
mapContext.value = null
}
cotLayerRef.value = null
alprLayerRef.value = null
}
function initMapWithLocation() {
@@ -503,5 +587,11 @@ onBeforeUnmount(() => {
watch(() => props.devices, () => updateMarkers(), { deep: true })
watch([() => props.pois, () => props.canEditPois], () => updatePoiMarkers(), { deep: true })
watch(() => props.liveSessions, () => updateLiveMarkers(), { deep: true })
watch(() => props.cotEntities, () => updateCotMarkers(), { deep: true })
watch(() => props.cotEntities, () => reloadCotCluster())
watch(() => props.alprMarkers, () => updateAlprLayer())
watch(() => props.showAlpr, (enabled) => {
setAlprControlPressed(mapContext.value?.alprControl, enabled)
if (enabled && mapContext.value?.map) emitBounds(mapContext.value.map)
else updateAlprLayer()
})
</script>
+146
View File
@@ -0,0 +1,146 @@
import { createClusterIndex } from '~/utils/mapCluster.js'
import {
MAX_BBOX_DEGREES,
bboxFetchKey,
bboxToTileKey,
tileKey,
tilesNearCenter,
} from '~/utils/alprViewport.js'
const STORAGE_KEY = 'kestrelos:showAlprLayer'
const MIN_FETCH_ZOOM = 10
const MAX_TILE_FETCHES = 16
const MAX_CACHED_TILES = 64
const TILE_RETENTION = 3
const EMPTY_TILES = Object.freeze({})
const mergeFeatures = lists => Object.values(
lists.flat().reduce((acc, feature) => {
const id = feature.id ?? feature.properties?.osmId
return id == null ? acc : { ...acc, [id]: feature }
}, {}),
)
const offsets = radius => Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
const retentionKeys = bounds => new Set(
tilesNearCenter(bounds, MAX_TILE_FETCHES).flatMap((tile) => {
const r0 = Math.floor(tile.south / MAX_BBOX_DEGREES)
const c0 = Math.floor(tile.west / MAX_BBOX_DEGREES)
return offsets(TILE_RETENTION).flatMap(dr =>
offsets(TILE_RETENTION).map(dc => tileKey(r0 + dr, c0 + dc)),
)
}),
)
const pruneTileCache = (cache, bounds) => {
const keep = retentionKeys(bounds)
const retained = Object.fromEntries(
Object.entries(cache).filter(([key]) => keep.has(key)),
)
const overflow = Object.keys(retained).length - MAX_CACHED_TILES
if (overflow <= 0) return Object.freeze(retained)
const cr = Math.floor((bounds.south + bounds.north) / 2 / MAX_BBOX_DEGREES)
const cc = Math.floor((bounds.west + bounds.east) / 2 / MAX_BBOX_DEGREES)
const drop = new Set(
Object.keys(retained)
.map((key) => {
const [r, c] = key.split(',').map(Number)
return { key, dist: Math.hypot(r - cr, c - cc) }
})
.sort((a, b) => b.dist - a.dist)
.slice(0, overflow)
.map(({ key }) => key),
)
return Object.freeze(
Object.fromEntries(Object.entries(retained).filter(([key]) => !drop.has(key))),
)
}
const cacheChanged = (before, after) => Object.keys(before).length !== Object.keys(after).length
export function useAlprCameras() {
const showAlpr = useState('showAlprLayer', () => {
if (!import.meta.client) return true
return localStorage.getItem(STORAGE_KEY) !== '0'
})
const view = ref(null)
const tiles = ref(EMPTY_TILES)
const cluster = createClusterIndex({ radius: 50, maxZoom: 17 })
const debounceTimer = ref(null)
const requestId = ref(0)
const lastFetchKey = ref('')
const applyTiles = (next) => {
tiles.value = next
cluster.load(mergeFeatures(Object.values(next)))
}
watch(showAlpr, (enabled) => {
if (import.meta.client) localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
if (!enabled) {
applyTiles(EMPTY_TILES)
view.value = null
lastFetchKey.value = ''
}
})
const alprMarkers = computed(() => view.value ? cluster.query(view.value) : [])
const toggleAlpr = () => {
showAlpr.value = !showAlpr.value
}
const onBoundsChange = (bounds) => {
if (!showAlpr.value || !bounds) return
view.value = bounds
const pruned = pruneTileCache(tiles.value, bounds)
if (cacheChanged(tiles.value, pruned)) applyTiles(pruned)
if ((bounds.zoom ?? 14) < MIN_FETCH_ZOOM) return
const key = bboxFetchKey(bounds)
if (key === lastFetchKey.value) return
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
lastFetchKey.value = key
fetchViewport(bounds)
}, 400)
}
const fetchViewport = async (bounds) => {
const id = requestId.value + 1
requestId.value = id
const missing = tilesNearCenter(bounds, MAX_TILE_FETCHES)
.filter(tile => !(bboxToTileKey(tile) in tiles.value))
try {
const fetched = missing.length
? await Promise.all(missing.map(async (tile) => {
const params = new URLSearchParams({
south: String(tile.south),
west: String(tile.west),
north: String(tile.north),
east: String(tile.east),
})
const data = await $fetch(`/api/alpr?${params}`).catch(() => null)
return [bboxToTileKey(tile), data?.features ?? []]
}))
: []
if (id !== requestId.value) return
const merged = Object.freeze({
...tiles.value,
...Object.fromEntries(fetched),
})
const pruned = pruneTileCache(merged, bounds)
applyTiles(pruned)
}
catch { /* keep last good index */ }
}
return Object.freeze({ showAlpr, toggleAlpr, alprMarkers, onBoundsChange })
}
+2 -3
View File
@@ -1,6 +1,6 @@
/** Fetches devices + live sessions; polls when tab visible. */
const POLL_MS = 1500
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [], cotEntities: [] })
const EMPTY_RESPONSE = Object.freeze({ devices: [], liveSessions: [] })
export function useCameras(options = {}) {
const { poll: enablePoll = true } = options
@@ -12,7 +12,6 @@ export function useCameras(options = {}) {
const devices = computed(() => Object.freeze([...(data.value?.devices ?? [])]))
const liveSessions = computed(() => Object.freeze([...(data.value?.liveSessions ?? [])]))
const cotEntities = computed(() => Object.freeze([...(data.value?.cotEntities ?? [])]))
const cameras = computed(() => Object.freeze([...devices.value, ...liveSessions.value]))
const pollInterval = ref(null)
@@ -37,5 +36,5 @@ export function useCameras(options = {}) {
})
onBeforeUnmount(stopPolling)
return Object.freeze({ data, devices, liveSessions, cotEntities, cameras, refresh, startPolling, stopPolling })
return Object.freeze({ data, devices, liveSessions, cameras, refresh, startPolling, stopPolling })
}
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'kestrel-cot-layers'
const DEFAULT_LAYERS = Object.freeze({ air: true, surface: true, ground: true })
function loadLayers() {
if (typeof localStorage === 'undefined') return { ...DEFAULT_LAYERS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_LAYERS }
const parsed = JSON.parse(raw)
return {
air: parsed.air !== false,
surface: parsed.surface !== false,
ground: parsed.ground !== false,
}
}
catch {
return { ...DEFAULT_LAYERS }
}
}
function saveLayers(layers) {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layers))
}
catch { /* ignore quota */ }
}
export function useCotLayers() {
const layers = ref(loadLayers())
const layerQuery = computed(() => {
const parts = []
if (layers.value.air) parts.push('air')
if (layers.value.surface) parts.push('surface')
if (layers.value.ground) parts.push('ground')
return parts.length ? parts.join(',') : 'none'
})
function toggleLayer(name) {
if (!(name in DEFAULT_LAYERS)) return
layers.value = { ...layers.value, [name]: !layers.value[name] }
saveLayers(layers.value)
}
return Object.freeze({ layers, layerQuery, toggleLayer })
}
+117
View File
@@ -0,0 +1,117 @@
import { bboxFetchKey } from '~/utils/alprViewport.js'
const DEBOUNCE_MS = 300
const EMPTY_ENTITIES = Object.freeze({})
const expandBounds = (bounds, factor = 0.25) => {
const latPad = (bounds.north - bounds.south) * factor
const lngPad = (bounds.east - bounds.west) * factor
return {
south: Math.max(-90, bounds.south - latPad),
north: Math.min(90, bounds.north + latPad),
west: bounds.west - lngPad,
east: bounds.east + lngPad,
}
}
const entitiesFromList = list => Object.freeze(
Object.fromEntries(
(list ?? []).filter(entity => entity?.id).map(entity => [entity.id, entity]),
),
)
export function useCotStream(boundsRef, layerQueryRef) {
const entities = ref(EMPTY_ENTITIES)
const cotEntities = computed(() => Object.freeze(Object.values(entities.value)))
const eventSource = ref(null)
const debounceTimer = ref(null)
const subscribedKey = ref('')
const streamBounds = ref(null)
const setEntities = (record) => {
entities.value = Object.freeze(record)
}
const parseEvent = (e, fn) => {
try {
fn(JSON.parse(e.data))
}
catch { /* ignore */ }
}
const closeStream = () => {
eventSource.value?.close()
eventSource.value = null
}
const connect = () => {
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return
const bounds = streamBounds.value ?? boundsRef.value
if (!bounds) return
closeStream()
const q = new URLSearchParams({
bbox: `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`,
layers: unref(layerQueryRef) || 'air,surface,ground',
})
const es = new EventSource(`/api/cot/stream?${q}`)
eventSource.value = es
es.addEventListener('snapshot', e => parseEvent(e, ({ entities: list }) => {
setEntities(entitiesFromList(list))
}))
es.addEventListener('update', e => parseEvent(e, ({ entity }) => {
if (!entity?.id) return
setEntities({ ...entities.value, [entity.id]: entity })
}))
es.addEventListener('remove', e => parseEvent(e, ({ id }) => {
if (!id) return
setEntities(Object.fromEntries(
Object.entries(entities.value).filter(([key]) => key !== String(id)),
))
}))
es.onerror = () => {
closeStream()
scheduleConnect()
}
}
const scheduleConnect = () => {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
debounceTimer.value = setTimeout(() => {
debounceTimer.value = null
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
connect()
}, DEBOUNCE_MS)
}
const maybeReconnect = (bounds) => {
if (!bounds) return
const key = bboxFetchKey(bounds)
if (key === subscribedKey.value) return
subscribedKey.value = key
streamBounds.value = expandBounds(bounds)
scheduleConnect()
}
watch(boundsRef, maybeReconnect, { deep: true })
watch(layerQueryRef, () => {
subscribedKey.value = ''
scheduleConnect()
})
onMounted(() => {
if (typeof document === 'undefined') return
document.addEventListener('visibilitychange', () => {
document.visibilityState === 'visible' ? scheduleConnect() : closeStream()
})
if (document.visibilityState === 'visible') maybeReconnect(boundsRef.value)
})
onBeforeUnmount(() => {
if (debounceTimer.value) clearTimeout(debounceTimer.value)
closeStream()
})
return Object.freeze({ cotEntities })
}
+3 -2
View File
@@ -2,12 +2,13 @@ const EDIT_ROLES = Object.freeze(['admin', 'leader'])
export function useUser() {
const requestFetch = useRequestFetch()
const { data: user, refresh } = useAsyncData(
const { data: user, refresh, status } = useAsyncData(
'user',
() => (requestFetch ?? $fetch)('/api/me').catch(() => null),
{ default: () => null },
)
const authPending = computed(() => status.value === 'pending')
const canEditPois = computed(() => EDIT_ROLES.includes(user.value?.role))
const isAdmin = computed(() => user.value?.role === 'admin')
return Object.freeze({ user, canEditPois, isAdmin, refresh })
return Object.freeze({ user, authPending, canEditPois, isAdmin, refresh })
}
+1 -1
View File
@@ -3,7 +3,7 @@ const LOGIN_PATH = '/login'
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path === LOGIN_PATH) return
const { user, refresh } = useUser()
await refresh()
if (!user.value) await refresh()
if (user.value) return
const redirect = to.fullPath.startsWith('/') ? to.fullPath : `/${to.fullPath}`
return navigateTo({ path: LOGIN_PATH, query: { redirect } }, { replace: true })
+22 -1
View File
@@ -7,11 +7,23 @@
:pois="pois ?? []"
:live-sessions="liveSessions ?? []"
:cot-entities="cotEntities ?? []"
:cot-layers="cotLayers"
:alpr-markers="showAlpr ? (alprMarkers ?? []) : []"
:show-alpr="showAlpr"
:can-edit-pois="canEditPois"
@select="selectedCamera = $event"
@select-live="onSelectLive($event)"
@refresh-pois="refreshPois"
@bounds-change="onMapBoundsChange"
@toggle-alpr="toggleAlpr"
@toggle-cot-layer="toggleLayer"
/>
<template #fallback>
<div
class="h-full min-h-[300px] bg-kestrel-bg"
aria-hidden="true"
/>
</template>
</ClientOnly>
</div>
<CameraViewer
@@ -23,11 +35,20 @@
</template>
<script setup>
const { devices, liveSessions, cotEntities } = useCameras()
const { devices, liveSessions } = useCameras()
const { data: pois, refresh: refreshPois } = usePois()
const { canEditPois } = useUser()
const { showAlpr, toggleAlpr, alprMarkers, onBoundsChange: onAlprBoundsChange } = useAlprCameras()
const { layers: cotLayers, layerQuery, toggleLayer } = useCotLayers()
const mapBounds = ref(null)
const { cotEntities } = useCotStream(mapBounds, layerQuery)
const selectedCamera = ref(null)
function onMapBoundsChange(bounds) {
mapBounds.value = bounds
onAlprBoundsChange(bounds)
}
function onSelectLive(session) {
selectedCamera.value = (liveSessions.value ?? []).find(s => s.id === session?.id) ?? session
}
+220
View File
@@ -0,0 +1,220 @@
import { syncFeatureMarkers } from './mapMarkerSync.js'
const ICON_SIZE = 28
const CONE_SIZE = 52
const ALPR_COLOR = '#ef4444'
const DEFAULT_FOV = 60
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
function conePath(fov) {
const half = Math.min(Math.max(fov / 2, 10), 85)
const cx = 26
const cy = 26
const r = 24
const toRad = deg => ((deg - 90) * Math.PI) / 180
const x1 = cx + r * Math.cos(toRad(-half))
const y1 = cy + r * Math.sin(toRad(-half))
const x2 = cx + r * Math.cos(toRad(half))
const y2 = cy + r * Math.sin(toRad(half))
return `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2} Z`
}
function compassLabel(deg) {
if (!Number.isFinite(deg)) return null
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
const idx = Math.round((((deg % 360) + 360) % 360) / 45) % 8
return dirs[idx]
}
function wikidataLink(label, qid) {
if (!qid) return escapeHtml(label)
const id = escapeHtml(qid)
return `<a href="https://www.wikidata.org/wiki/${id}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
}
function popupLine(parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
function titleHtml(props) {
if (props.name) return escapeHtml(props.name)
if (props.model) {
const mfr = props.manufacturer ? escapeHtml(props.manufacturer) : null
return mfr ? `${mfr} <span class="text-kestrel-accent">${escapeHtml(props.model)}</span>` : escapeHtml(props.model)
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) {
if (props.manufacturerWikidata && !props.model) return wikidataLink(makeModel, props.manufacturerWikidata)
return escapeHtml(makeModel)
}
if (props.operator) return wikidataLink(props.operator, props.operatorWikidata)
if (props.ref) return escapeHtml(props.ref)
return 'ALPR camera'
}
function titleText(props) {
if (props.name) return props.name
if (props.model) {
return [props.manufacturer, props.model].filter(Boolean).join(' ')
}
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
if (makeModel) return makeModel
if (props.operator) return props.operator
if (props.ref) return props.ref
return 'ALPR camera'
}
function modelLineHtml(props) {
if (props.model) {
return `<div class="text-sm mt-0.5"><span class="text-kestrel-muted">Model</span> <strong>${escapeHtml(props.model)}</strong></div>`
}
if (props.modelUnknown) {
return '<div class="text-kestrel-muted text-xs mt-0.5">Model not recorded in OpenStreetMap</div>'
}
return ''
}
/** Human-readable ALPR popup (GeoJSON properties in, HTML out). */
export function formatAlprPopup(props) {
const title = titleHtml(props)
const headline = titleText(props)
const makeModel = [props.manufacturer, props.model].filter(Boolean).join(' ')
const identity = []
if (props.name && makeModel) identity.push(escapeHtml(makeModel))
if (props.operator && headline !== props.operator) {
identity.push(wikidataLink(props.operator, props.operatorWikidata))
}
if (props.ref && headline !== props.ref) identity.push(escapeHtml(props.ref))
if (props.brand) identity.push(escapeHtml(props.brand))
const view = []
if (props.direction != null) {
const deg = Math.round(props.direction)
const compass = compassLabel(props.direction)
view.push(compass ? `Facing ${compass} (${deg}°)` : `Facing ${deg}°`)
}
if (props.fov != null && props.direction != null) view.push(`~${Math.round(props.fov)}° view`)
const note = props.description || props.note
const noteHtml = note ? `<div class="text-kestrel-muted text-xs mt-1">${escapeHtml(note)}</div>` : ''
const identityHtml = identity.length
? `<div class="text-kestrel-muted text-xs mt-0.5">${identity.join(' · ')}</div>`
: ''
const modelHtml = (props.model || props.modelUnknown) ? modelLineHtml(props) : ''
const foot = `<div class="text-kestrel-muted text-xs mt-2"><a href="https://www.openstreetmap.org/node/${props.osmId}" target="_blank" rel="noopener">View on OpenStreetMap</a></div>`
return `<div class="kestrel-live-popup"><strong>${title}</strong> <span class="text-kestrel-muted">License plate reader</span>${modelHtml}${identityHtml}${popupLine(view)}${noteHtml}${foot}</div>`
}
function popupHtml(props) {
return formatAlprPopup(props)
}
function pointIcon(L, direction, fov) {
const hasDirection = Number.isFinite(direction)
if (!hasDirection) {
const html = `<span class="poi-icon-svg"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ALPR_COLOR}" stroke-width="2"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/><path d="M8 21h8"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE],
})
}
const spread = Number.isFinite(fov) ? fov : DEFAULT_FOV
const html = `<span class="alpr-cone" style="transform:rotate(${direction}deg)"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" width="${CONE_SIZE}" height="${CONE_SIZE}"><path d="${conePath(spread)}" fill="${ALPR_COLOR}" fill-opacity="0.3" stroke="${ALPR_COLOR}" stroke-width="1.5"/><circle cx="26" cy="26" r="3" fill="${ALPR_COLOR}"/></svg></span>`
return L.divIcon({
className: 'poi-div-icon alpr-icon',
html,
iconSize: [CONE_SIZE, CONE_SIZE],
iconAnchor: [CONE_SIZE / 2, CONE_SIZE / 2],
})
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'alpr-cluster-icon',
html: `<span class="alpr-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
export function createAlprControl(L, { showAlpr, onToggle }) {
const control = L.control({ position: 'topleft' })
control.onAdd = function () {
const el = document.createElement('button')
el.type = 'button'
el.className = 'leaflet-bar leaflet-control-alpr'
el.title = 'Toggle ALPR cameras (OSM)'
el.setAttribute('aria-label', 'Toggle ALPR cameras')
el.setAttribute('aria-pressed', showAlpr ? 'true' : 'false')
el.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="3" y="5" width="18" height="12" rx="2"/><circle cx="12" cy="11" r="3"/></svg>'
el.addEventListener('click', onToggle)
control._button = el
return el
}
return control
}
export function setAlprControlPressed(control, pressed) {
control?._button?.setAttribute('aria-pressed', pressed ? 'true' : 'false')
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.osmId
return id != null ? `a:${id}` : null
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createAlprLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncAlprLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else marker.bindPopup(popupHtml(props), { className: 'kestrel-live-popup-wrap', maxWidth: 320 })
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : pointIcon(L, props.direction, props.fov)
marker.setIcon(icon)
if (!isCluster) marker.setPopupContent(popupHtml(props))
},
})
}
+77
View File
@@ -0,0 +1,77 @@
export const MAX_BBOX_DEGREES = 0.5
export function tileKey(row, col) {
return `${row},${col}`
}
export function bboxToTileKey(bbox) {
const row = Math.floor(bbox.south / MAX_BBOX_DEGREES)
const col = Math.floor(bbox.west / MAX_BBOX_DEGREES)
return tileKey(row, col)
}
function tileBox(row, col, step = MAX_BBOX_DEGREES) {
const south = row * step
const west = col * step
return { south, west, north: south + step, east: west + step }
}
export function bboxFetchKey(bounds) {
const zoom = bounds.zoom ?? 14
const step = zoom >= 14 ? 0.025 : zoom >= 11 ? 0.1 : zoom >= 8 ? 0.25 : 1
const q = v => Math.round(v / step) * step
return [q(bounds.south), q(bounds.west), q(bounds.north), q(bounds.east)].join(',')
}
function ringOffsets(radius) {
if (radius === 0) return [[0, 0]]
return Array.from({ length: 2 * radius + 1 }, (_, i) => i - radius)
.flatMap(dr => Array.from({ length: 2 * radius + 1 }, (_, j) => j - radius)
.filter(dc => Math.abs(dr) === radius || Math.abs(dc) === radius)
.map(dc => [dr, dc]))
}
function collectTiles(state) {
const {
centerRow, centerCol, minRow, maxRow, minCol, maxCol, step, limit, radius, seen, tiles,
} = state
if (tiles.length >= limit) return tiles
const inBounds = (row, col) => row >= minRow && row <= maxRow && col >= minCol && col <= maxCol
const { nextSeen, added } = ringOffsets(radius)
.map(([dr, dc]) => [centerRow + dr, centerCol + dc])
.filter(([row, col]) => inBounds(row, col))
.reduce((acc, [row, col]) => {
const key = `${row},${col}`
if (acc.nextSeen.has(key)) return acc
return {
nextSeen: new Set([...acc.nextSeen, key]),
added: [...acc.added, tileBox(row, col, step)],
}
}, { nextSeen: seen, added: [] })
if (added.length === 0 && radius > 0) return tiles
const nextTiles = [...tiles, ...added].slice(0, limit)
if (nextTiles.length >= limit) return nextTiles
return collectTiles({ ...state, radius: radius + 1, seen: nextSeen, tiles: nextTiles })
}
export function tilesNearCenter(bounds, limit) {
const step = MAX_BBOX_DEGREES
const lat = (bounds.south + bounds.north) / 2
const lng = (bounds.west + bounds.east) / 2
return collectTiles({
centerRow: Math.floor(lat / step),
centerCol: Math.floor(lng / step),
minRow: Math.floor(bounds.south / step),
maxRow: Math.ceil(bounds.north / step) - 1,
minCol: Math.floor(bounds.west / step),
maxCol: Math.ceil(bounds.east / step) - 1,
step,
limit,
radius: 0,
seen: new Set(),
tiles: [],
})
}
+131
View File
@@ -0,0 +1,131 @@
/** Map CoT / ADS-B entity display: icons and popups. */
export const COT_COLORS = {
air: '#60a5fa',
helicopter: '#fbbf24',
surface: '#38bdf8',
ground: '#f59e0b',
}
export function cotCategory(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/** Whether the entity is a helicopter or fixed-wing aircraft. @returns {'helicopter' | 'fixedWing'} */
export function cotAirIconKind(entity) {
const type = entity?.type ?? ''
if (type.endsWith('-C-H') || type.endsWith('-M-H')) return 'helicopter'
return 'fixedWing'
}
function iconWrap(heading, inner) {
const rotate = Number.isFinite(heading) ? ` style="transform:rotate(${heading}deg)"` : ''
return `<span class="poi-icon-svg cot-icon-rotatable"${rotate}>${inner}</span>`
}
const PLANE_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="${color}"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>`
const HELI_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="1.75" stroke-linecap="round"><circle cx="12" cy="12" r="2.5" fill="${color}"/><path d="M3 8h18M3 12h18"/><path d="M12 8v8"/><path d="M9 16h6"/></svg>`
const SHIP_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><path d="M2 20c2-4 6-6 10-6s8 2 10 6"/><path d="M12 14V4"/><path d="m8 8 4-4 4 4"/></svg>`
const GROUND_SVG = color =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="8" r="2.5" fill="${color}"/></svg>`
export function getCotIconHtml(entity) {
const category = cotCategory(entity?.type)
const heading = Number(entity?.heading)
if (category === 'air') {
const kind = cotAirIconKind(entity)
const color = kind === 'helicopter' ? COT_COLORS.helicopter : COT_COLORS.air
const svg = kind === 'helicopter' ? HELI_SVG(color) : PLANE_SVG(color)
return { html: iconWrap(heading, svg), className: `cot-entity-${kind}` }
}
if (category === 'surface') {
return { html: iconWrap(heading, SHIP_SVG(COT_COLORS.surface)), className: 'cot-entity-surface' }
}
return { html: iconWrap(undefined, GROUND_SVG(COT_COLORS.ground)), className: 'cot-entity-ground' }
}
function msToKnots(ms) {
return Number.isFinite(ms) ? Math.round(ms * 1.94384) : null
}
function metersToFeet(m) {
return Number.isFinite(m) ? Math.round(m * 3.28084) : null
}
function fmtHeading(deg) {
return Number.isFinite(deg) ? `${Math.round(deg)}°` : null
}
function fmtVerticalFpm(ms) {
if (!Number.isFinite(ms) || ms === 0) return null
const fpm = Math.round(ms * 196.85)
return `${fpm > 0 ? '+' : ''}${fpm} fpm`
}
function icaoFromEntity(entity) {
if (entity?.icao) return String(entity.icao).toUpperCase()
if (typeof entity?.id === 'string' && entity.id.startsWith('ICAO.')) {
return entity.id.slice(5).toUpperCase()
}
return null
}
function mmsiFromEntity(entity) {
if (entity?.mmsi) return String(entity.mmsi)
if (typeof entity?.id === 'string' && entity.id.startsWith('MMSI.')) return entity.id.slice(5)
return null
}
function popupLine(escape, parts) {
const line = parts.filter(Boolean).join(' · ')
return line ? `<div class="text-kestrel-muted text-xs mt-1">${line}</div>` : ''
}
/**
* @param {Record<string, unknown>} entity
* @param {(s: string) => string} escape
*/
export function formatCotPopup(entity, escape) {
const category = cotCategory(entity?.type)
const label = escape(entity?.label || entity?.id || 'Unknown')
if (entity?.source === 'adsb' || category === 'air') {
const tag = cotAirIconKind(entity) === 'helicopter' ? 'Helicopter' : 'Aircraft'
const icao = icaoFromEntity(entity)
const meta = [
icao ? `ICAO ${icao}` : null,
entity?.originCountry ? escape(String(entity.originCountry)) : null,
].filter(Boolean).join(' · ')
const alt = metersToFeet(entity?.altitude)
const stats = [
alt != null ? `${alt.toLocaleString()} ft` : null,
entity?.onGround ? 'On ground' : null,
msToKnots(entity?.speed) != null ? `${msToKnots(entity.speed)} kt` : null,
fmtHeading(entity?.heading),
fmtVerticalFpm(entity?.verticalRate),
entity?.squawk ? `Squawk ${escape(String(entity.squawk))}` : null,
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">${tag}</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
if (entity?.source === 'ais' || category === 'surface') {
const mmsi = mmsiFromEntity(entity)
const meta = mmsi ? `MMSI ${escape(mmsi)}` : ''
const stats = [
Number.isFinite(entity?.speed) ? `${Number(entity.speed).toFixed(1)} kt` : null,
fmtHeading(entity?.heading),
]
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Vessel</span>${meta ? `<div class="text-kestrel-muted text-xs mt-0.5">${meta}</div>` : ''}${popupLine(escape, stats)}</div>`
}
return `<div class="kestrel-live-popup"><strong>${label}</strong> <span class="text-kestrel-muted">Team</span></div>`
}
+107
View File
@@ -0,0 +1,107 @@
import { createClusterIndex } from './mapCluster.js'
import { syncFeatureMarkers } from './mapMarkerSync.js'
import { cotCategory, formatCotPopup, getCotIconHtml } from './cotDisplay.js'
const ICON_SIZE = 28
const CLUSTER = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
export function entitiesToFeatures(entities) {
return (entities || [])
.filter(e => typeof e?.lat === 'number' && typeof e?.lng === 'number' && e?.id)
.map(e => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [e.lng, e.lat] },
properties: { entity: e, cotCategory: cotCategory(e.type) },
}))
}
export function loadCotCluster(entities) {
CLUSTER.load(entitiesToFeatures(entities))
}
export function getCotClusters(view) {
return CLUSTER.query(view)
}
function featureKey(feature) {
const props = feature.properties ?? {}
if (props.cluster) return `c:${props.cluster_id}`
const id = props.entity?.id
return id != null ? `e:${id}` : null
}
function clusterIcon(L, count) {
const size = count < 10 ? 28 : count < 100 ? 34 : 40
return L.divIcon({
className: 'cot-cluster-icon',
html: `<span class="cot-cluster">${count}</span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
})
}
function entityIcon(L, entity) {
const { html, className } = getCotIconHtml(entity)
return L.divIcon({
className: `poi-div-icon cot-entity-icon ${className}`,
html,
iconSize: [ICON_SIZE, ICON_SIZE],
iconAnchor: [ICON_SIZE / 2, ICON_SIZE / 2],
})
}
function coords(feature) {
const [lng, lat] = feature.geometry.coordinates
return { lat, lng }
}
function attachClusterClick(marker, feature, map) {
marker.on('click', () => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const zoom = props.expansionZoom ?? map.getZoom() + 2
map.setView([lat, lng], Math.min(zoom, 19), { animate: true })
})
}
export function createCotLayer(L, map) {
return L.layerGroup().addTo(map)
}
export function syncCotLayer(L, map, layer, features) {
syncFeatureMarkers(layer, features, {
keyFor: featureKey,
create: (feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
const marker = L.marker([lat, lng], { icon })
if (isCluster) attachClusterClick(marker, feature, map)
else if (props.entity) {
marker.bindPopup(
formatCotPopup(props.entity, escapeHtml),
{ className: 'kestrel-live-popup-wrap', maxWidth: 360 },
)
}
return marker
},
update: (marker, feature) => {
const { lat, lng } = coords(feature)
const props = feature.properties ?? {}
const isCluster = Boolean(props.cluster)
marker.setLatLng([lat, lng])
const icon = isCluster ? clusterIcon(L, props.point_count) : entityIcon(L, props.entity)
marker.setIcon(icon)
if (!isCluster && props.entity) {
marker.setPopupContent(formatCotPopup(props.entity, escapeHtml))
}
},
})
}
+31
View File
@@ -0,0 +1,31 @@
import Supercluster from 'supercluster'
export function createClusterIndex(options = {}) {
const index = new Supercluster(options)
const state = { features: Object.freeze([]) }
return {
load(features) {
const list = Object.freeze([...(features ?? [])])
index.load(list)
state.features = list
},
query(view) {
if (!view || state.features.length === 0) return []
const { west, south, east, north, zoom } = view
return index.getClusters(
[west, south, east, north],
Math.floor(zoom ?? 14),
).map((feature) => {
if (!feature.properties?.cluster) return feature
return {
...feature,
properties: {
...feature.properties,
expansionZoom: index.getClusterExpansionZoom(feature.properties.cluster_id),
},
}
})
},
}
}
+37
View File
@@ -0,0 +1,37 @@
const SYNC_KEY = '_kestrelMarkerSync'
const pointFeatures = (features, keyFor) => (features ?? [])
.filter(f => f?.geometry?.type === 'Point')
.map(f => ({ feature: f, key: keyFor(f) }))
.filter(({ key }) => key != null)
export function syncFeatureMarkers(layer, features, { keyFor, create, update }) {
const prev = layer[SYNC_KEY] ?? new Map()
const next = pointFeatures(features, keyFor).reduce((map, { feature, key }) => {
const existing = prev.get(key)
if (existing) {
update(existing, feature)
return new Map([...map, [key, existing]])
}
const marker = create(feature)
layer.addLayer(marker)
return new Map([...map, [key, marker]])
}, new Map())
Array.from(prev.entries())
.filter(([key]) => !next.has(key))
.forEach(([, marker]) => layer.removeLayer(marker))
layer[SYNC_KEY] = next
}
export function clearFeatureMarkers(layer) {
if (!layer) return
const prev = layer[SYNC_KEY]
if (prev) {
Array.from(prev.values()).forEach(marker => layer.removeLayer(marker))
layer[SYNC_KEY] = new Map()
return
}
layer.clearLayers()
}
+2 -1
View File
@@ -2,6 +2,8 @@
KestrelOS acts as a **TAK Server**. ATAK (Android) and iTAK (iOS) connect on **port 8089** (CoT). Devices relay positions to each other and appear on the KestrelOS map.
ADS-B and AIS via [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot): see [tracking.md](tracking.md).
## Connection
**Host:** KestrelOS hostname/IP
@@ -59,7 +61,6 @@ OIDC users must set an **ATAK password** first:
| Variable | Default | Description |
|----------|---------|-------------|
| `COT_PORT` | `8089` | CoT server port |
| `COT_TTL_MS` | `90000` | Device timeout (~90s) |
| `COT_REQUIRE_AUTH` | `true` | Require authentication |
| `COT_SSL_CERT` | `.dev-certs/cert.pem` | TLS cert path |
| `COT_SSL_KEY` | `.dev-certs/key.pem` | TLS key path |
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+10
View File
@@ -5,6 +5,7 @@ KestrelOS shows a **map** with devices, POIs, live sessions (Share live), and AT
## Map Layers
- **Devices** - Fixed feeds (IPTV, ALPR, CCTV, NVR, etc.) added via API
- **ALPR (OSM / DeFlock)** - Crowdsourced license-plate cameras from OpenStreetMap; toggle on the map (camera icon control). Reference only, no stream.
- **POIs** - Points of interest (admin/leader can edit)
- **Live sessions** - Mobile devices streaming via Share live
- **CoT (ATAK/iTAK)** - Amber markers for connected TAK devices (position only)
@@ -47,6 +48,15 @@ Stream URLs must be `http://` or `https://`.
**Cameras endpoint:** `GET /api/cameras` returns devices + live sessions + CoT entities.
## ALPR layer (DeFlock / OpenStreetMap)
deflock.me has no bulk download API. KestrelOS queries OpenStreetMap via Overpass (`surveillance:type=ALPR`) and returns **GeoJSON FeatureCollection** from `GET /api/alpr`.
- **Map:** ALPR layer is on by default (toggle top-left to hide). Marker popups show OSM identifying tags (manufacturer, model, operator, ref, Wikidata, etc.) when contributors tagged them.
- **Offline:** Run `npm run import:alpr` to preload SQLite; cache serves automatically when Overpass is unreachable.
Attribution: © OpenStreetMap contributors.
## POIs
Admins/leaders add/edit from **POI** page (sidebar). POIs appear as map pins (reference only, no stream).
+48
View File
@@ -0,0 +1,48 @@
# ADS-B and AIS
Aircraft and vessels use the same **CoT** store as ATAK/iTAK. The map consumes `GET /api/cot/stream` (SSE, viewport bbox). Toggle **Air**, **Surface**, and **Team** on the map.
## Accuracy tiers
1. **Tactical (best):** local SDR/AIS receiver → [adsbcot](https://github.com/snstac/adsbcot) / [aiscot](https://github.com/snstac/aiscot) → KestrelOS CoT `:8089` (sub-second updates).
2. **Vessels (live OSINT):** AISStream WebSocket push as vessels transmit.
3. **Aircraft (awareness OSINT):** OpenSky bbox poll — not a live stream; typical lag ~5s.
For tactical use, run local receivers. Do not rely on OpenSky alone.
## Freshness
Tracks update via SSE `update` events (CoT `:8089`, AISStream) or coalesced `snapshot` after each OpenSky poll. Stale tracks are removed automatically (team ~90s, OSINT ~30s without a new fix).
OSINT feeds run only while a map client is connected (SSE subscriber). Keep the map tab visible for live updates.
## Self-hosted
**ADS-B:** [adsbcot](https://github.com/snstac/adsbcot) → `tls://host:8089`
```ini
[adsbcot]
COT_URL = tls://kestrelos.example.com:8089
FEED_URL = tcp+beast://127.0.0.1:30005
```
**AIS:** [aiscot](https://github.com/snstac/aiscot) → `tls://host:8089`
```ini
[aiscot]
COT_URL = tls://kestrelos.example.com:8089
FEED_URL = tcp://127.0.0.1:10110
```
Use KestrelOS credentials (see [atak-itak.md](atak-itak.md)).
## OSINT APIs (optional)
Set these only if you want viewport OSINT without local receivers:
| Variable | Purpose |
|----------|---------|
| `AISSTREAM_API_KEY` | AISStream WebSocket |
| `OPENSKY_CLIENT_ID` / `OPENSKY_CLIENT_SECRET` | OpenSky OAuth (recommended for production) |
UIDs: `ICAO.*` (ADS-B), `MMSI.*` (AIS). Icons follow CoT type (`a-f-A-*`, `a-f-S-*`, `a-f-G-*`).
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: kestrelos
description: KestrelOS TOC for OSINT feeds - map, camera feeds, offline tiles
type: application
version: 1.0.8
appVersion: "1.0.8"
version: 1.1.6
appVersion: "1.1.6"
+1 -1
View File
@@ -2,7 +2,7 @@ replicaCount: 1
image:
repository: git.keligrubb.com/keligrubb/kestrelos
tag: 1.0.8
tag: 1.1.6
pullPolicy: IfNotPresent
service:
+3 -1
View File
@@ -32,9 +32,11 @@ export default defineNuxtConfig({
public: {
version: pkg.version ?? '',
},
cotTtlMs: 90_000,
cotRequireAuth: true,
cotDebug: false,
aisstreamApiKey: process.env.AISSTREAM_API_KEY || '',
openskyClientId: process.env.OPENSKY_CLIENT_ID || '',
openskyClientSecret: process.env.OPENSKY_CLIENT_SECRET || '',
},
devServer: {
host: '0.0.0.0',
+3458 -2368
View File
File diff suppressed because it is too large Load Diff
+24 -25
View File
@@ -1,6 +1,6 @@
{
"name": "kestrelos",
"version": "1.0.8",
"version": "1.1.6",
"private": true,
"type": "module",
"scripts": {
@@ -16,37 +16,36 @@
"test:e2e:ui": "playwright test --ui test/e2e",
"test:e2e:debug": "playwright test --debug test/e2e",
"test:e2e:install": "playwright install --with-deps webkit chromium firefox",
"lint": "eslint . --max-warnings 0"
"lint": "eslint . --max-warnings 0",
"import:alpr": "node scripts/import-alpr.js"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxt/icon": "^2.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"fast-xml-parser": "^5.3.6",
"hls.js": "^1.5.0",
"fast-xml-parser": "^5.9.3",
"hls.js": "^1.6.16",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"leaflet.offline": "^3.2.0",
"mediasoup": "^3.19.14",
"mediasoup-client": "^3.18.6",
"nuxt": "^4.0.0",
"openid-client": "^6.8.2",
"leaflet.offline": "^3.2.1",
"mediasoup": "^3.20.9",
"mediasoup-client": "^3.21.0",
"nuxt": "^4.4.8",
"openid-client": "^6.8.4",
"qrcode": "^1.5.4",
"vue": "^3.4.0",
"vue-router": "^5.0.0",
"ws": "^8.18.0"
"supercluster": "^8.0.1",
"vue": "^3.5.38",
"vue-router": "^5.1.0",
"ws": "^8.21.0"
},
"devDependencies": {
"@iconify-json/tabler": "^1.2.26",
"@nuxt/eslint": "^1.15.0",
"@nuxt/test-utils": "^4.0.0",
"@playwright/test": "^1.58.2",
"@vitest/coverage-v8": "^4.0.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^10.0.0",
"happy-dom": "^20.6.1",
"vitest": "^4.0.0"
},
"overrides": {
"tar": "^7.5.7"
"@iconify-json/tabler": "^1.2.35",
"@nuxt/eslint": "^1.16.0",
"@nuxt/test-utils": "^4.0.3",
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"@vue/test-utils": "^2.4.11",
"eslint": "^10.5.0",
"happy-dom": "^20.10.6",
"vitest": "^4.1.9"
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { getDb, closeDb } from '../server/utils/db.js'
import { importAllAlprNodes } from '../server/utils/alpr.js'
try {
const db = await getDb()
console.log('[import-alpr] Fetching ALPR nodes from Overpass…')
const count = await importAllAlprNodes(db)
console.log(`[import-alpr] Cached ${count} nodes in SQLite.`)
}
catch (error) {
console.error('[import-alpr] Failed:', error?.message || error)
process.exitCode = 1
}
finally {
closeDb()
}
+9 -2
View File
@@ -20,10 +20,18 @@ echo "$msg" | grep -Eqi '(^|[[:space:]])[a-zA-Z]+(\([^)]*\))?!:' && bump=major
echo "$msg" | grep -qi minor: && [ "$bump" != "major" ] && bump=minor
echo "$msg" | grep -qi major: && bump=major
cur=$(awk '/"version"/ { match($0, /[0-9]+\.[0-9]+\.[0-9]+/); print substr($0, RSTART, RLENGTH); exit }' package.json)
case "$cur" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "error: package.json version must be x.y.z (got: $cur)"; exit 1 ;;
esac
major=$(echo "$cur" | cut -d. -f1); minor=$(echo "$cur" | cut -d. -f2); patch=$(echo "$cur" | cut -d. -f3)
case "$bump" in major) major=$((major+1)); minor=0; patch=0 ;; minor) minor=$((minor+1)); patch=0 ;; patch) patch=$((patch+1)) ;; esac
newVersion="$major.$minor.$patch"
[ -z "$cur" ] && { echo "error: could not read version from package.json"; exit 1; }
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
if [ -n "$(git ls-remote "$url" "refs/tags/v$newVersion" 2>/dev/null)" ]; then
exit 0
fi
# changelog entry (strip explicit bump prefixes & any conventional-commit type(scope):); optional PR description enriches it
changelogEntry=$(
@@ -59,7 +67,6 @@ $changelogFull
git config user.email "ci@kestrelos" && git config user.name "CI"
git add package.json helm/kestrelos/Chart.yaml helm/kestrelos/values.yaml CHANGELOG.md
git commit -m "release v$newVersion [skip ci]"
url="https://${CI_REPO_OWNER}:${GITEA_REPO_TOKEN}@${CI_FORGE_URL#https://}/${CI_REPO_OWNER}/${CI_REPO_NAME}.git"
git tag "v$newVersion"
# artifact for docker (tag list)
printf '%s\n%s\n' "$newVersion" "latest" > .tags
+19
View File
@@ -0,0 +1,19 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getAlprCameras, parseBbox } from '../utils/alpr.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
let bbox
try {
bbox = parseBbox(getQuery(event))
}
catch (error) {
throw createError({
statusCode: error?.statusCode || 400,
message: error?.message || 'invalid bbox',
})
}
const db = await getDb()
return getAlprCameras(db, bbox)
})
+2 -6
View File
@@ -1,19 +1,15 @@
import { getDb } from '../utils/db.js'
import { requireAuth } from '../utils/authHelpers.js'
import { getActiveSessions } from '../utils/liveSessions.js'
import { getActiveEntities } from '../utils/cotStore.js'
import { rowToDevice, sanitizeDeviceForResponse } from '../utils/deviceUtils.js'
export default defineEventHandler(async (event) => {
requireAuth(event)
const config = useRuntimeConfig()
const ttlMs = Number(config.cotTtlMs ?? 90_000) || 90_000
const [db, sessions, cotEntities] = await Promise.all([
const [db, sessions] = await Promise.all([
getDb(),
getActiveSessions(),
getActiveEntities(ttlMs),
])
const rows = await db.all('SELECT id, name, device_type, vendor, lat, lng, stream_url, source_type, config FROM devices ORDER BY id')
const devices = rows.map(rowToDevice).filter(Boolean).map(sanitizeDeviceForResponse)
return { devices, liveSessions: sessions, cotEntities }
return { devices, liveSessions: sessions }
})
+45
View File
@@ -0,0 +1,45 @@
import { createEventStream } from 'h3'
import { requireAuth } from '../../utils/authHelpers.js'
import { getActiveEntitiesInBbox } from '../../utils/cotStore.js'
import { registerSubscriber } from '../../utils/cotSubscribers.js'
import { getCotSnapshotOpts } from '../../utils/cotSnapshot.js'
import { COT_SSE_HEARTBEAT_MS } from '../../utils/constants.js'
import { parseBboxParam, parseLayersParam } from '../../utils/cotEntityUtils.js'
import { scheduleTrackingFeedRefresh } from '../../utils/trackingFeed.js'
export default defineEventHandler((event) => {
requireAuth(event)
const query = getQuery(event)
const bbox = parseBboxParam(typeof query.bbox === 'string' ? query.bbox : undefined)
const layers = parseLayersParam(typeof query.layers === 'string' ? query.layers : undefined)
const snapshotOpts = getCotSnapshotOpts()
const stream = createEventStream(event)
const push = (eventName, data) => stream.push({ event: eventName, data })
const sendSnapshot = async () => {
const entities = await getActiveEntitiesInBbox(bbox, { ...snapshotOpts, layers })
await push('snapshot', JSON.stringify({ entities }))
}
const unregister = registerSubscriber({ bbox, layers, push })
let heartbeat
stream.onClosed(async () => {
clearInterval(heartbeat)
unregister()
scheduleTrackingFeedRefresh()
})
void (async () => {
scheduleTrackingFeedRefresh()
await sendSnapshot()
heartbeat = setInterval(() => {
push('heartbeat', '{}').catch(() => {})
}, COT_SSE_HEARTBEAT_MS)
})()
return stream.send()
})
+1 -1
View File
@@ -108,7 +108,7 @@ async function processFrame(socket, rawMessage, payload, authenticated) {
socket.destroy()
return
}
updateFromCot(parsed).catch((err) => {
updateFromCot({ ...parsed, type: parsed.eventType }).catch((err) => {
console.error('[cot] Error updating from CoT:', err?.message)
})
if (authenticated) broadcast(socket, rawMessage)
+32
View File
@@ -0,0 +1,32 @@
import { registerCleanup } from '../utils/shutdown.js'
import { onCotChange, pruneStaleEntities } from '../utils/cotStore.js'
import { getCotSnapshotOpts } from '../utils/cotSnapshot.js'
import {
notifySubscribersForEntity,
notifySubscribersRemove,
} from '../utils/cotSubscribers.js'
import { COT_PRUNE_INTERVAL_MS } from '../utils/constants.js'
let pruneTimer = null
let offChange = () => {}
export default defineNitroPlugin(() => {
offChange = onCotChange((changeEvent, payload) => {
if (changeEvent === 'update' && payload?.entity) {
notifySubscribersForEntity('update', { entity: payload.entity }, payload.entity).catch(() => {})
}
else if (changeEvent === 'remove' && payload?.id) {
notifySubscribersRemove(payload.id).catch(() => {})
}
})
pruneTimer = setInterval(() => {
pruneStaleEntities(getCotSnapshotOpts()).catch(() => {})
}, COT_PRUNE_INTERVAL_MS)
registerCleanup(() => {
offChange()
if (pruneTimer) clearInterval(pruneTimer)
pruneTimer = null
})
})
+7
View File
@@ -0,0 +1,7 @@
import { registerCleanup } from '../utils/shutdown.js'
import { startTrackingFeed, stopTrackingFeed } from '../utils/trackingFeed.js'
export default defineNitroPlugin(() => {
startTrackingFeed()
registerCleanup(stopTrackingFeed)
})
+263
View File
@@ -0,0 +1,263 @@
import { cameraToFeature, featureCollection } from './alprGeo.js'
const OVERPASS_URL = 'https://overpass.deflock.org/api/interpreter'
const MAX_BBOX_DEGREES = 0.5
const CACHE_TTL_MS = 86_400_000
const TILE_SCALE = 10
function httpError(statusCode, message) {
const err = new Error(message)
err.statusCode = statusCode
return err
}
/**
* @param {unknown} tags
* @returns {Record<string, string>} String OSM tags only
*/
function normalizeTags(tags) {
if (!tags || typeof tags !== 'object') return {}
return Object.fromEntries(
Object.entries(/** @type {Record<string, unknown>} */ (tags))
.filter(([, v]) => typeof v === 'string')
.map(([k, v]) => [k, /** @type {string} */ (v)]),
)
}
/**
* @param {unknown} element
* @returns {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, tags: Record<string, string> } | null} Parsed camera or null
*/
export function parseOverpassElement(element) {
if (!element || typeof element !== 'object') return null
const el = /** @type {Record<string, unknown>} */ (element)
if (el.type !== 'node' || typeof el.id !== 'number') return null
const lat = Number(el.lat)
const lng = Number(el.lon ?? el.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const tags = normalizeTags(el.tags)
const directionRaw = tags['camera:direction'] ?? tags.direction
const direction = directionRaw != null && directionRaw !== '' ? Number(directionRaw) : null
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
return {
osmId: el.id,
lat,
lng,
manufacturer: tags.manufacturer ?? tags.brand ?? null,
direction: Number.isFinite(direction) ? direction : null,
fov: Number.isFinite(fov) ? fov : null,
tags,
}
}
export function parseBbox(query) {
const south = Number(query.south)
const west = Number(query.west)
const north = Number(query.north)
const east = Number(query.east)
if (![south, west, north, east].every(Number.isFinite)) {
throw httpError(400, 'south, west, north, east required')
}
if (south >= north || west >= east) {
throw httpError(400, 'invalid bbox')
}
if (north - south > MAX_BBOX_DEGREES || east - west > MAX_BBOX_DEGREES) {
throw httpError(400, `bbox exceeds ${MAX_BBOX_DEGREES}°`)
}
return { south, west, north, east }
}
/** @param {{ south: number, west: number, north: number, east: number }} bbox */
export function tileKeysForBbox(bbox) {
const latMin = Math.floor(bbox.south * TILE_SCALE)
const latMax = Math.floor(bbox.north * TILE_SCALE)
const lngMin = Math.floor(bbox.west * TILE_SCALE)
const lngMax = Math.floor(bbox.east * TILE_SCALE)
const keys = []
for (let lat = latMin; lat <= latMax; lat++) {
for (let lng = lngMin; lng <= lngMax; lng++) {
keys.push(`${lat}:${lng}`)
}
}
return keys
}
export function buildBboxQuery(bbox) {
const { south, west, north, east } = bbox
return `[out:json][timeout:25];node["surveillance:type"="ALPR"](${south},${west},${north},${east});out body;`
}
export function buildGlobalQuery() {
return '[out:json][timeout:300];node["surveillance:type"="ALPR"];out body;'
}
function rowToCamera(row, source) {
const tags = (() => {
try {
return JSON.parse(row.tags)
}
catch {
return {}
}
})()
const fovRaw = tags['camera:angle'] ?? tags['surveillance:angle']
const fov = fovRaw != null && fovRaw !== '' ? Number(fovRaw) : null
return {
osmId: row.osm_id,
lat: row.lat,
lng: row.lng,
manufacturer: row.manufacturer ?? null,
direction: row.direction ?? null,
fov: Number.isFinite(fov) ? fov : null,
tags,
source,
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function upsertAlprNode(db, camera) {
const now = new Date().toISOString()
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(osm_id) DO UPDATE SET
lat = excluded.lat,
lng = excluded.lng,
manufacturer = excluded.manufacturer,
direction = excluded.direction,
tags = excluded.tags,
fetched_at = excluded.fetched_at`,
[
camera.osmId,
camera.lat,
camera.lng,
camera.manufacturer,
camera.direction,
JSON.stringify(camera.tags),
now,
],
)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function upsertAlprNodesBatch(db, cameras) {
if (!cameras.length) return
await db.run('BEGIN TRANSACTION')
try {
for (const camera of cameras) {
await upsertAlprNode(db, camera)
}
await db.run('COMMIT')
}
catch (error) {
await db.run('ROLLBACK').catch(() => {})
throw error
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function markTilesFetched(db, keys) {
if (!keys.length) return
const now = new Date().toISOString()
await db.run('BEGIN TRANSACTION')
try {
for (const key of keys) {
await db.run('INSERT OR REPLACE INTO alpr_tiles (tile_key, fetched_at) VALUES (?, ?)', [key, now])
}
await db.run('COMMIT')
}
catch (error) {
await db.run('ROLLBACK').catch(() => {})
throw error
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
async function tilesAreFresh(db, keys) {
if (!keys.length) return false
const placeholders = keys.map(() => '?').join(',')
const rows = await db.all(
`SELECT tile_key, fetched_at FROM alpr_tiles WHERE tile_key IN (${placeholders})`,
keys,
)
if (rows.length !== keys.length) return false
const cutoff = Date.now() - CACHE_TTL_MS
return rows.every(row => Date.parse(row.fetched_at) >= cutoff)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function getCachedAlprNodes(db, bbox) {
const rows = await db.all(
`SELECT osm_id, lat, lng, manufacturer, direction, tags
FROM alpr_nodes
WHERE lat >= ? AND lat <= ? AND lng >= ? AND lng <= ?`,
[bbox.south, bbox.north, bbox.west, bbox.east],
)
return rows.map(row => rowToCamera(row, 'cache'))
}
export async function fetchOverpass(query) {
const res = await fetch(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal: AbortSignal.timeout(120_000),
})
if (!res.ok) {
throw httpError(502, `Overpass error: ${res.status}`)
}
const data = await res.json()
const elements = Array.isArray(data?.elements) ? data.elements : []
return elements.map(parseOverpassElement).filter(Boolean)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
async function fetchAndCacheBbox(db, bbox, tileKeys) {
const live = await fetchOverpass(buildBboxQuery(bbox))
await upsertAlprNodesBatch(db, live)
await markTilesFetched(db, tileKeys)
return live.map(c => ({ ...c, source: 'live' }))
}
const refreshInFlight = new Map()
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
function refreshBboxInBackground(db, bbox, tileKeys) {
const key = tileKeys.join('|')
if (refreshInFlight.has(key)) return
const job = fetchAndCacheBbox(db, bbox, tileKeys)
.catch(() => {})
.finally(() => refreshInFlight.delete(key))
refreshInFlight.set(key, job)
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function getAlprCameras(db, bbox) {
const tileKeys = tileKeysForBbox(bbox)
const cached = await getCachedAlprNodes(db, bbox)
if (await tilesAreFresh(db, tileKeys)) {
return featureCollection(cached.map(cameraToFeature), 'cache')
}
if (cached.length > 0) {
refreshBboxInBackground(db, bbox, tileKeys)
return featureCollection(cached.map(cameraToFeature), 'cache')
}
try {
const live = await fetchAndCacheBbox(db, bbox, tileKeys)
return featureCollection(live.map(cameraToFeature), 'live')
}
catch {
return featureCollection(cached.map(cameraToFeature), 'cache')
}
}
/** @param {Awaited<ReturnType<typeof import('./db.js').getDb>>} db */
export async function importAllAlprNodes(db) {
const cameras = await fetchOverpass(buildGlobalQuery())
await upsertAlprNodesBatch(db, cameras)
return cameras.length
}
+175
View File
@@ -0,0 +1,175 @@
const ATTRIBUTION = '© OpenStreetMap contributors'
/** Longest match first — Flock, Genetec, Leonardo, etc. */
const KNOWN_ALPR_MODELS = [
'Falcon Flex',
'AutoVu CR-H2',
'AutoVu Sharp',
'UnicamVELOCITY3',
'Falcon',
'Sparrow',
'Raven',
'Condor',
'Wing',
'Pelican',
]
const GENERIC_CAMERA_NAMES = new Set([
'flock alpr camera',
'flock alpr cameera',
'flock camera',
'flock alpr',
'flock',
'flock camera',
'automatic license plate reader (alpr)',
'alpr camera',
'alpr',
])
const SKIP_EXTRA_TAGS = new Set([
'surveillance:type',
'man_made',
'camera:direction',
'direction',
'camera:angle',
'surveillance:angle',
'manufacturer',
'brand',
'model',
'operator',
'name',
'ref',
'surveillance',
'camera:type',
'description',
'note',
'colour',
'color',
'manufacturer:wikidata',
'model:wikidata',
'operator:wikidata',
])
function escapeRegex(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** @param {string} value */
export function normalizeModelName(value) {
const trimmed = value.trim()
if (!trimmed) return null
const lower = trimmed.toLowerCase()
for (const model of KNOWN_ALPR_MODELS) {
if (model.toLowerCase() === lower) return model
}
return trimmed.split(/\s+/).map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
).join(' ')
}
/** @param {string} text */
function matchKnownModel(text) {
for (const model of KNOWN_ALPR_MODELS) {
const re = new RegExp(`\\b${escapeRegex(model)}\\b`, 'i')
if (re.test(text)) return model
}
const flock = text.match(/\bFlock\s+([a-z][\w -]*)/i)
if (flock) {
const fragment = flock[1].trim()
if (!fragment || /^safety$/i.test(fragment)) return null
return matchKnownModel(fragment)
}
return null
}
/** @param {Record<string, string>} tags */
export function inferModelFromTags(tags) {
const t = tags ?? {}
if (t.model?.trim()) return normalizeModelName(t.model)
for (const key of ['name', 'description', 'note']) {
const value = t[key]
if (!value?.trim()) continue
const model = matchKnownModel(value)
if (model) return model
}
return null
}
/** @param {string | null | undefined} name */
export function isGenericCameraName(name) {
if (!name?.trim()) return false
const normalized = name.trim().toLowerCase()
if (GENERIC_CAMERA_NAMES.has(normalized)) return true
if (/^flock\s+alpr\b/i.test(name) && !matchKnownModel(name)) return true
if (matchKnownModel(name) && /\bflock\b/i.test(name)) return true
return false
}
/** @param {Record<string, string>} tags */
export function displayNameFromTags(tags) {
const name = tags?.name?.trim()
if (!name || isGenericCameraName(name)) return null
if (matchKnownModel(name)) return null
return name
}
/** @param {Record<string, string>} tags */
export function identifyingProperties(tags, manufacturer = null) {
const t = tags ?? {}
const mfr = manufacturer ?? t.manufacturer ?? t.brand ?? null
const model = inferModelFromTags(t)
const name = displayNameFromTags(t)
/** @type {Record<string, string | boolean>} */
const props = {}
if (mfr) props.manufacturer = mfr
if (model) props.model = model
if (mfr && !model) props.modelUnknown = true
if (t.brand && t.brand !== mfr) props.brand = t.brand
if (t.operator) props.operator = t.operator
if (name) props.name = name
if (t.ref) props.ref = t.ref
if (t.surveillance) props.surveillance = t.surveillance
if (t['camera:type']) props.cameraType = t['camera:type']
if (t.description) props.description = t.description
if (t.note) props.note = t.note
const colour = t.colour ?? t.color
if (colour) props.colour = colour
if (t['manufacturer:wikidata']) props.manufacturerWikidata = t['manufacturer:wikidata']
if (t['model:wikidata']) props.modelWikidata = t['model:wikidata']
if (t['operator:wikidata']) props.operatorWikidata = t['operator:wikidata']
const extra = {}
for (const [key, value] of Object.entries(t)) {
if (SKIP_EXTRA_TAGS.has(key) || !value?.trim()) continue
extra[key] = value
}
if (Object.keys(extra).length) props.tags = extra
return props
}
/** @param {{ osmId: number, lat: number, lng: number, manufacturer: string | null, direction: number | null, fov: number | null, tags: Record<string, string> }} camera */
export function cameraToFeature(camera) {
return {
type: 'Feature',
id: camera.osmId,
geometry: { type: 'Point', coordinates: [camera.lng, camera.lat] },
properties: {
osmId: camera.osmId,
direction: camera.direction,
fov: camera.fov,
...identifyingProperties(camera.tags, camera.manufacturer),
},
}
}
/** @param {ReturnType<typeof cameraToFeature>[]} features */
export function featureCollection(features, source) {
return {
type: 'FeatureCollection',
features,
attribution: ATTRIBUTION,
source,
}
}
export { ATTRIBUTION }
+2
View File
@@ -19,6 +19,8 @@ export const SKIP_PATHS = Object.freeze([
])
export const PROTECTED_PATH_PREFIXES = Object.freeze([
'/api/alpr',
'/api/cot',
'/api/cameras',
'/api/devices',
'/api/live',
+13 -1
View File
@@ -2,10 +2,22 @@
* Application constants with environment variable support.
*/
// CoT / tracking (fixed defaults — not env-configurable)
export const COT_TTL_MS = 90_000
/** @deprecated Use COT_TTL_MS */
export const COT_ENTITY_TTL_MS = COT_TTL_MS
export const COT_OSINT_TTL_MS = 30_000
export const COT_PRUNE_INTERVAL_MS = 15_000
export const COT_SSE_HEARTBEAT_MS = 15_000
export const OPENSKY_CACHE_MS = 5_000
export const TRACKING_FEED_DEBOUNCE_MS = 500
export const COT_TAK_FILTER_BBOX = false
export const COT_SSE_MAX_ENTITIES = 2000
export const MAX_OPENSKY_BBOX_DEGREES = 10
// Timeouts (milliseconds)
export const COT_AUTH_TIMEOUT_MS = Number(process.env.COT_AUTH_TIMEOUT_MS) || 15_000
export const LIVE_SESSION_TTL_MS = Number(process.env.LIVE_SESSION_TTL_MS) || 60_000
export const COT_ENTITY_TTL_MS = Number(process.env.COT_ENTITY_TTL_MS) || 90_000
export const POLL_INTERVAL_MS = Number(process.env.POLL_INTERVAL_MS) || 1500
export const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 30_000
+191
View File
@@ -0,0 +1,191 @@
/**
* CoT entity helpers: filters and OSINT CoT mapping.
*/
/**
* @param {string} id
* @returns {'adsb' | 'ais' | 'tak'} Inferred track source.
*/
export function inferSourceFromId(id) {
if (typeof id !== 'string') return 'tak'
if (id.startsWith('ICAO.')) return 'adsb'
if (id.startsWith('MMSI.')) return 'ais'
return 'tak'
}
/**
* @param {string} [type]
* @returns {'air' | 'surface' | 'ground'} CoT display category.
*/
export function cotCategoryFromType(type) {
const t = typeof type === 'string' ? type : ''
if (t.startsWith('a-f-A-')) return 'air'
if (t.startsWith('a-f-S-')) return 'surface'
return 'ground'
}
/**
* @param {{ lat: number, lng: number }} point
* @param {{ west: number, south: number, east: number, north: number }} bbox
*/
export function isInBbox(point, bbox) {
if (!point || !bbox) return false
const { lat, lng } = point
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false
return lat >= bbox.south && lat <= bbox.north && lng >= bbox.west && lng <= bbox.east
}
/**
* @param {Set<string> | string[] | undefined} layers
* @param {{ type?: string, source?: string }} entity
*/
export function matchesLayerFilter(layers, entity) {
if (!layers || layers.size === 0) return true
const category = cotCategoryFromType(entity.type)
if (layers.has('air') && category === 'air') return true
if (layers.has('surface') && category === 'surface') return true
if (layers.has('ground') && category === 'ground') return true
return false
}
/** OpenSky emitter category → MilStd CoT air type. */
function openSkyCategoryToType(category) {
if (category === 8) return 'a-f-A-C-H' // rotorcraft
if (category === 14) return 'a-f-A-C-F' // UAV — plane icon
return 'a-f-A-C-F'
}
/** OpenSky state vector → CoT upsert. */
export function openSkyStateToCot(state) {
if (!Array.isArray(state) || state.length < 11) return null
const icao24 = String(state[0] ?? '').trim().toLowerCase()
if (!icao24) return null
const lat = Number(state[6])
const lng = Number(state[5])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const callsign = typeof state[1] === 'string' ? state[1].trim() : ''
const originCountry = typeof state[2] === 'string' ? state[2].trim() : ''
const category = Number(state[17])
const type = Number.isFinite(category) ? openSkyCategoryToType(category) : 'a-f-A-C-F'
const heading = Number(state[10])
const speed = Number(state[9])
const altitude = Number(state[7])
const verticalRate = Number(state[11])
const onGround = state[8] === true
const squawk = state[14] != null ? String(state[14]).padStart(4, '0') : undefined
return {
id: `ICAO.${icao24}`,
lat,
lng,
label: callsign || icao24.toUpperCase(),
type,
source: 'adsb',
icao: icao24,
originCountry: originCountry || undefined,
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
altitude: Number.isFinite(altitude) ? altitude : undefined,
verticalRate: Number.isFinite(verticalRate) ? verticalRate : undefined,
onGround: onGround || undefined,
squawk: squawk && squawk !== '0000' ? squawk : undefined,
}
}
/** AISStream position report → CoT upsert. */
export function aisStreamMessageToCot(message) {
if (!message || typeof message !== 'object') return null
const meta = /** @type {Record<string, unknown>} */ (message.MetaData)
const msg = /** @type {Record<string, unknown>} */ (message.Message)
if (!msg || typeof msg !== 'object') return null
const report = /** @type {Record<string, unknown>} */ (
msg.PositionReport ?? msg.StandardClassBPositionReport ?? msg.ExtendedClassBPositionReport
)
if (!report || typeof report !== 'object') return null
const mmsi = Number(meta?.MMSI ?? report.UserID)
if (!Number.isFinite(mmsi)) return null
const lat = Number(report.Latitude)
const lng = Number(report.Longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null
const shipName = typeof meta?.ShipName === 'string' ? meta.ShipName.trim() : ''
const heading = Number(report.Cog ?? report.TrueHeading)
const speed = Number(report.Sog)
return {
id: `MMSI.${mmsi}`,
lat,
lng,
label: shipName || `MMSI ${mmsi}`,
type: 'a-f-S-C',
source: 'ais',
mmsi: String(mmsi),
heading: Number.isFinite(heading) ? heading : undefined,
speed: Number.isFinite(speed) ? speed : undefined,
}
}
/**
* Union of subscriber bboxes.
* @param {Array<{ west: number, south: number, east: number, north: number } | null | undefined>} boxes
*/
export function unionBboxes(boxes) {
let west = Infinity
let south = Infinity
let east = -Infinity
let north = -Infinity
let has = false
for (const bbox of boxes) {
if (!bbox) continue
has = true
west = Math.min(west, bbox.west)
south = Math.min(south, bbox.south)
east = Math.max(east, bbox.east)
north = Math.max(north, bbox.north)
}
return has ? { west, south, east, north } : null
}
/**
* Shrink bbox to max span (degrees per axis), centered on midpoint.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {number} maxDegrees
*/
export function clampBbox(bbox, maxDegrees) {
if (!bbox || !Number.isFinite(maxDegrees) || maxDegrees <= 0) return bbox
const latSpan = bbox.north - bbox.south
const lngSpan = bbox.east - bbox.west
if (latSpan <= maxDegrees && lngSpan <= maxDegrees) return bbox
const latMid = (bbox.north + bbox.south) / 2
const lngMid = (bbox.east + bbox.west) / 2
const half = maxDegrees / 2
return {
south: latMid - half,
north: latMid + half,
west: lngMid - half,
east: lngMid + half,
}
}
/**
* @param {string | undefined} raw
* @returns {Set<string>} Enabled layer names.
*/
export function parseLayersParam(raw) {
if (!raw || typeof raw !== 'string') {
return new Set(['air', 'surface', 'ground'])
}
const parts = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (parts.length === 0 || parts.includes('none')) return new Set()
return new Set(parts)
}
/**
* @param {string | undefined} raw
* @returns {{ west: number, south: number, east: number, north: number } | null} Parsed bbox or null.
*/
export function parseBboxParam(raw) {
if (!raw || typeof raw !== 'string') return null
const parts = raw.split(',').map(s => Number(s.trim()))
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) return null
const [west, south, east, north] = parts
if (south > north || west > east) return null
return { west, south, east, north }
}
+15
View File
@@ -0,0 +1,15 @@
import {
COT_OSINT_TTL_MS,
COT_SSE_MAX_ENTITIES,
COT_TAK_FILTER_BBOX,
COT_TTL_MS,
} from './constants.js'
export function getCotSnapshotOpts() {
return {
ttlMs: COT_TTL_MS,
osintTtlMs: COT_OSINT_TTL_MS,
takFilterBbox: COT_TAK_FILTER_BBOX,
maxEntities: COT_SSE_MAX_ENTITIES,
}
}
+171 -27
View File
@@ -1,66 +1,210 @@
/**
* In-memory CoT entity store: upsert by id, prune on read by TTL.
* Single source of truth; getActiveEntities returns new objects (no mutation of returned refs).
* In-memory CoT store (TAK, ADS-B, AIS).
*/
import { acquire } from './asyncLock.js'
import { COT_ENTITY_TTL_MS } from './constants.js'
import { COT_OSINT_TTL_MS, COT_TTL_MS } from './constants.js'
import { inferSourceFromId, isInBbox, matchesLayerFilter } from './cotEntityUtils.js'
const entities = new Map()
/** @type {Set<(event: string, payload: unknown) => void>} */
const listeners = new Set()
/**
* @param {(event: string, payload: unknown) => void} fn
* @returns {() => void} Unsubscribe function.
*/
export function onCotChange(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function emitChange(event, payload) {
for (const fn of listeners) {
try {
fn(event, payload)
}
catch {
/* ignore listener errors */
}
}
}
function pickOptionalNumber(value, fallback) {
if (value === undefined || value === null) return fallback
const n = Number(value)
return Number.isFinite(n) ? n : fallback
}
/**
* @param {Record<string, unknown>} entity
*/
function toSnapshot(entity) {
return {
id: entity.id,
lat: entity.lat,
lng: entity.lng,
label: entity.label ?? entity.id,
type: entity.type ?? '',
source: entity.source ?? 'tak',
heading: entity.heading,
speed: entity.speed,
altitude: entity.altitude,
verticalRate: entity.verticalRate,
onGround: entity.onGround,
originCountry: entity.originCountry,
icao: entity.icao,
mmsi: entity.mmsi,
squawk: entity.squawk,
updatedAt: entity.updatedAt,
}
}
/**
* @param {Record<string, unknown>} entity
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
*/
function entityTtlMs(entity, opts) {
const source = entity.source
if (source === 'adsb' || source === 'ais') {
return opts.osintTtlMs ?? COT_OSINT_TTL_MS
}
return opts.ttlMs ?? COT_TTL_MS
}
function isEntityExpired(entity, now, opts) {
return now - entity.updatedAt > entityTtlMs(entity, opts)
}
/**
* Upsert entity by id. Input is not mutated; stored value is a new object.
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string }} parsed
* @param {{ id: string, lat: number, lng: number, label?: string, eventType?: string, type?: string, source?: string, heading?: number, speed?: number, altitude?: number }} parsed
* @param {{ silent?: boolean }} [options]
*/
export async function updateFromCot(parsed) {
export async function updateFromCot(parsed, options = {}) {
if (!parsed || typeof parsed.id !== 'string') return
const lat = Number(parsed.lat)
const lng = Number(parsed.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
let snapshot = null
await acquire(`cot-${parsed.id}`, async () => {
const now = Date.now()
const existing = entities.get(parsed.id)
const label = typeof parsed.label === 'string' ? parsed.label : (existing?.label ?? parsed.id)
const type = typeof parsed.eventType === 'string' ? parsed.eventType : (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
const type = typeof parsed.eventType === 'string'
? parsed.eventType
: (typeof parsed.type === 'string' ? parsed.type : (existing?.type ?? ''))
const explicitSource = parsed.source
const source = explicitSource === 'adsb' || explicitSource === 'ais' || explicitSource === 'tak'
? explicitSource
: inferSourceFromId(parsed.id)
entities.set(parsed.id, {
const stored = {
id: parsed.id,
lat,
lng,
label,
type,
source,
heading: pickOptionalNumber(parsed.heading, existing?.heading),
speed: pickOptionalNumber(parsed.speed, existing?.speed),
altitude: pickOptionalNumber(parsed.altitude, existing?.altitude),
verticalRate: pickOptionalNumber(parsed.verticalRate, existing?.verticalRate),
onGround: typeof parsed.onGround === 'boolean' ? parsed.onGround : existing?.onGround,
originCountry: typeof parsed.originCountry === 'string' ? parsed.originCountry : existing?.originCountry,
icao: typeof parsed.icao === 'string' ? parsed.icao : existing?.icao,
mmsi: typeof parsed.mmsi === 'string' ? parsed.mmsi : existing?.mmsi,
squawk: typeof parsed.squawk === 'string' ? parsed.squawk : existing?.squawk,
updatedAt: now,
})
}
entities.set(parsed.id, stored)
snapshot = toSnapshot(stored)
})
if (snapshot && !options.silent) emitChange('update', { entity: snapshot })
}
/**
* @param {number} now
* @param {{ ttlMs?: number, osintTtlMs?: number }} opts
*/
function pruneExpired(now, opts) {
const expired = []
for (const entity of entities.values()) {
if (isEntityExpired(entity, now, opts)) expired.push(entity.id)
}
for (const id of expired) {
entities.delete(id)
emitChange('remove', { id })
}
}
/**
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
*/
export async function pruneStaleEntities(opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
await acquire('cot-prune', async () => {
pruneExpired(Date.now(), { ttlMs, osintTtlMs })
})
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {number} [ttlMs]
* @returns {Promise<Array<{ id: string, lat: number, lng: number, label: string, type: string, updatedAt: number }>>} Snapshot of active entities.
* @param {Record<string, unknown>} entity
* @param {{ west: number, south: number, east: number, north: number } | null | undefined} bbox
* @param {boolean} takFilterBbox
*/
export async function getActiveEntities(ttlMs = COT_ENTITY_TTL_MS) {
function passesBboxFilter(entity, bbox, takFilterBbox) {
if (!bbox) return true
const inBox = isInBbox(entity, bbox)
if (entity.source === 'tak' && !takFilterBbox) return true
return inBox
}
/**
* Active entities (updated within ttlMs). Prunes expired. Returns new array of new objects.
* @param {{ ttlMs?: number, osintTtlMs?: number }} [opts]
*/
export async function getActiveEntities(opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
return acquire('cot-prune', async () => {
const now = Date.now()
pruneExpired(now, { ttlMs, osintTtlMs })
return [...entities.values()].map(toSnapshot)
})
}
/**
* Active entities filtered by viewport bbox and layer set.
* @param {{ west: number, south: number, east: number, north: number } | null} bbox
* @param {{ ttlMs?: number, osintTtlMs?: number, layers?: Set<string>, takFilterBbox?: boolean, maxEntities?: number }} [opts]
*/
export async function getActiveEntitiesInBbox(bbox, opts = {}) {
const ttlMs = opts.ttlMs ?? COT_TTL_MS
const osintTtlMs = opts.osintTtlMs ?? COT_OSINT_TTL_MS
const layers = opts.layers
const takFilterBbox = Boolean(opts.takFilterBbox)
const ttlOpts = { ttlMs, osintTtlMs }
return acquire('cot-prune', async () => {
const now = Date.now()
pruneExpired(now, ttlOpts)
const active = []
const expired = []
for (const entity of entities.values()) {
if (now - entity.updatedAt <= ttlMs) {
active.push({
id: entity.id,
lat: entity.lat,
lng: entity.lng,
label: entity.label ?? entity.id,
type: entity.type ?? '',
updatedAt: entity.updatedAt,
})
}
else {
expired.push(entity.id)
}
if (isEntityExpired(entity, now, ttlOpts)) continue
const snap = toSnapshot(entity)
if (!passesBboxFilter(snap, bbox, takFilterBbox)) continue
if (!matchesLayerFilter(layers, snap)) continue
active.push(snap)
}
const maxEntities = opts.maxEntities
if (maxEntities != null && active.length > maxEntities) {
active.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
active.length = maxEntities
}
for (const id of expired) entities.delete(id)
return active
})
}
+70
View File
@@ -0,0 +1,70 @@
/** SSE subscriber registry; bbox union drives OSINT feeds. */
import { getActiveEntitiesInBbox } from './cotStore.js'
import { isInBbox, matchesLayerFilter, unionBboxes } from './cotEntityUtils.js'
/** @typedef {{ west: number, south: number, east: number, north: number }} Bbox */
/** @typedef {(event: string, data: string) => Promise<void> | void} PushFn */
/** @type {Map<string, { bbox: Bbox | null, layers: Set<string>, push: PushFn }>} */
const subscribers = new Map()
let nextId = 1
/**
* @param {{ bbox: Bbox | null, layers: Set<string>, push: PushFn }} sub
* @returns {() => void} Unregister function.
*/
export function registerSubscriber(sub) {
const id = String(nextId++)
subscribers.set(id, sub)
return () => subscribers.delete(id)
}
/** @returns {Bbox | null} Union of all subscriber bboxes. */
export function getSubscriberBboxUnion() {
return unionBboxes([...subscribers.values()].map(s => s.bbox))
}
export function getSubscriberCount() {
return subscribers.size
}
export function clearSubscribers() {
subscribers.clear()
}
export async function notifySubscribersForEntity(event, payload, entity) {
const data = JSON.stringify(payload)
const tasks = []
for (const sub of subscribers.values()) {
if (sub.bbox && !isInBbox(entity, sub.bbox)) continue
if (!matchesLayerFilter(sub.layers, entity)) continue
tasks.push(Promise.resolve(sub.push(event, data)))
}
await Promise.all(tasks)
}
export async function notifySubscribersRemove(id) {
const data = JSON.stringify({ id })
await Promise.all(
[...subscribers.values()].map(sub => Promise.resolve(sub.push('remove', data))),
)
}
/**
* Push a filtered snapshot to each active SSE subscriber.
* @param {{ ttlMs?: number, osintTtlMs?: number, takFilterBbox?: boolean, maxEntities?: number }} snapshotOpts
*/
export async function broadcastSubscriberSnapshots(snapshotOpts) {
const tasks = []
for (const sub of subscribers.values()) {
tasks.push((async () => {
const entities = await getActiveEntitiesInBbox(sub.bbox, {
...snapshotOpts,
layers: sub.layers,
})
await sub.push('snapshot', JSON.stringify({ entities }))
})())
}
await Promise.all(tasks)
}
+39 -1
View File
@@ -8,7 +8,7 @@ import { registerCleanup } from './shutdown.js'
const requireFromRoot = createRequire(join(process.cwd(), 'package.json'))
const { DatabaseSync } = requireFromRoot('node:sqlite')
const SCHEMA_VERSION = 4
const SCHEMA_VERSION = 6
const DB_BUSY_TIMEOUT_MS = 5000
let dbInstance = null
@@ -59,6 +59,20 @@ const SCHEMA = {
source_type TEXT NOT NULL DEFAULT 'mjpeg',
config TEXT
)`,
alpr_nodes: `CREATE TABLE IF NOT EXISTS alpr_nodes (
osm_id INTEGER PRIMARY KEY,
lat REAL NOT NULL,
lng REAL NOT NULL,
manufacturer TEXT,
direction INTEGER,
tags TEXT NOT NULL,
fetched_at TEXT NOT NULL
)`,
alpr_nodes_index: 'CREATE INDEX IF NOT EXISTS idx_alpr_lat_lng ON alpr_nodes(lat, lng)',
alpr_tiles: `CREATE TABLE IF NOT EXISTS alpr_tiles (
tile_key TEXT PRIMARY KEY,
fetched_at TEXT NOT NULL
)`,
}
const getDbPath = () => {
@@ -118,6 +132,19 @@ const migrateToV4 = async (run, all) => {
await run('ALTER TABLE users ADD COLUMN cot_password_hash TEXT')
}
const migrateToV5 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_nodes\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
}
const migrateToV6 = async (run, all) => {
const tables = await all('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'alpr_tiles\'')
if (tables.length > 0) return
await run(SCHEMA.alpr_tiles)
}
const runMigrations = async (run, all, get) => {
const version = await getSchemaVersion(get)
if (version >= SCHEMA_VERSION) return
@@ -133,6 +160,14 @@ const runMigrations = async (run, all, get) => {
await migrateToV4(run, all)
await setSchemaVersion(run, 4)
}
if (version < 5) {
await migrateToV5(run, all)
await setSchemaVersion(run, 5)
}
if (version < 6) {
await migrateToV6(run, all)
await setSchemaVersion(run, 6)
}
}
const initDb = async (db, run, all, get) => {
@@ -149,6 +184,9 @@ const initDb = async (db, run, all, get) => {
await run(SCHEMA.sessions)
await run(SCHEMA.pois)
await run(SCHEMA.devices)
await run(SCHEMA.alpr_nodes)
await run(SCHEMA.alpr_nodes_index)
await run(SCHEMA.alpr_tiles)
if (!testPath) {
// Bootstrap admin user on first run
+280
View File
@@ -0,0 +1,280 @@
/** OSINT feeds (AISStream, OpenSky) → cotStore. */
import WebSocket from 'ws'
import { updateFromCot } from './cotStore.js'
import {
broadcastSubscriberSnapshots,
getSubscriberBboxUnion,
} from './cotSubscribers.js'
import { getCotSnapshotOpts } from './cotSnapshot.js'
import { openSkyStateToCot, aisStreamMessageToCot, clampBbox } from './cotEntityUtils.js'
import { OPENSKY_CACHE_MS, TRACKING_FEED_DEBOUNCE_MS, MAX_OPENSKY_BBOX_DEGREES } from './constants.js'
const COALESCE_MS = 150
const state = {
aisSocket: null,
aisReconnectTimer: null,
aisBackoffMs: 1000,
openSkyTimer: null,
bboxDebounceTimer: null,
coalesceTimer: null,
openSkyToken: null,
openSkyTokenExpiresAt: 0,
/** @type {Map<string, { fetchedAt: number, states: unknown[][] }>} */
openSkyCache: new Map(),
lastAisBbox: null,
stopped: false,
}
function getConfig() {
return useRuntimeConfig()
}
function openskyPollMs() {
return OPENSKY_CACHE_MS
}
async function fetchOpenSkyToken(clientId, clientSecret) {
const now = Date.now()
if (state.openSkyToken && state.openSkyTokenExpiresAt > now + 60_000) {
return state.openSkyToken
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
})
const res = await fetch('https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) throw new Error(`OpenSky auth failed: ${res.status}`)
const json = await res.json()
state.openSkyToken = json.access_token
state.openSkyTokenExpiresAt = now + (Number(json.expires_in) || 1800) * 1000
return state.openSkyToken
}
function cacheKeyForBbox(bbox) {
const round = n => Math.round(n * 10) / 10
return `${round(bbox.west)},${round(bbox.south)},${round(bbox.east)},${round(bbox.north)}`
}
async function fetchOpenSkyForBbox(bbox) {
const config = getConfig()
const clientId = config.openskyClientId
const clientSecret = config.openskyClientSecret
const cacheMs = openskyPollMs()
const key = cacheKeyForBbox(bbox)
const cached = state.openSkyCache.get(key)
if (cached && Date.now() - cached.fetchedAt < cacheMs) {
return cached.states
}
const params = new URLSearchParams({
lamin: String(bbox.south),
lomin: String(bbox.west),
lamax: String(bbox.north),
lomax: String(bbox.east),
})
const headers = {}
if (clientId && clientSecret) {
const token = await fetchOpenSkyToken(clientId, clientSecret)
headers.Authorization = `Bearer ${token}`
}
const res = await fetch(`https://opensky-network.org/api/states/all?${params}`, { headers })
if (!res.ok) {
console.error('[trackingFeed] OpenSky fetch failed:', res.status)
return []
}
const json = await res.json()
const states = Array.isArray(json?.states) ? json.states : []
state.openSkyCache.set(key, { fetchedAt: Date.now(), states })
return states
}
export function scheduleCoalescedSnapshot() {
if (state.coalesceTimer) clearTimeout(state.coalesceTimer)
state.coalesceTimer = setTimeout(() => {
state.coalesceTimer = null
broadcastSubscriberSnapshots(getCotSnapshotOpts()).catch(() => {})
}, COALESCE_MS)
}
async function ingestOpenSkyBbox(bbox) {
try {
const states = await fetchOpenSkyForBbox(bbox)
for (const row of states) {
const parsed = openSkyStateToCot(row)
if (parsed) await updateFromCot(parsed, { silent: true })
}
if (states.length > 0) scheduleCoalescedSnapshot()
}
catch (err) {
console.error('[trackingFeed] OpenSky error:', err?.message)
}
}
async function pollOpenSky() {
if (state.stopped) return
const union = getSubscriberBboxUnion()
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
if (!bbox) return
await ingestOpenSkyBbox(bbox)
}
function stopOpenSkyPoll() {
if (state.openSkyTimer) {
clearInterval(state.openSkyTimer)
state.openSkyTimer = null
}
}
/** Tests only. @internal */
export function scheduleOpenSkyPollForTests() {
scheduleOpenSkyPoll()
}
function scheduleOpenSkyPoll() {
if (state.openSkyTimer || state.stopped) return
if (!getSubscriberBboxUnion()) return
const intervalMs = openskyPollMs()
state.openSkyTimer = setInterval(() => {
pollOpenSky().catch((err) => {
console.error('[trackingFeed] OpenSky poll error:', err?.message)
})
}, intervalMs)
}
function aisBboxKey(bbox) {
if (!bbox) return null
return `${bbox.west},${bbox.south},${bbox.east},${bbox.north}`
}
function closeAisSocket() {
if (state.aisSocket) {
try {
state.aisSocket.removeAllListeners()
state.aisSocket.close()
}
catch { /* ignore */ }
state.aisSocket = null
}
}
function scheduleAisReconnect() {
if (state.stopped || state.aisReconnectTimer) return
state.aisReconnectTimer = setTimeout(() => {
state.aisReconnectTimer = null
connectAisStream()
}, state.aisBackoffMs)
state.aisBackoffMs = Math.min(state.aisBackoffMs * 2, 60_000)
}
function subscribeAisBbox(ws, bbox) {
const apiKey = getConfig().aisstreamApiKey
if (!apiKey || !bbox) return
const key = aisBboxKey(bbox)
if (key === state.lastAisBbox) return
state.lastAisBbox = key
ws.send(JSON.stringify({
APIKey: apiKey,
BoundingBoxes: [[[bbox.south, bbox.west], [bbox.north, bbox.east]]],
}))
}
function connectAisStream() {
const apiKey = getConfig().aisstreamApiKey
if (!apiKey || state.stopped) return
closeAisSocket()
const ws = new WebSocket('wss://stream.aisstream.io/v0/stream')
state.aisSocket = ws
ws.on('open', () => {
state.aisBackoffMs = 1000
const union = getSubscriberBboxUnion()
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
if (bbox) subscribeAisBbox(ws, bbox)
})
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString())
const parsed = aisStreamMessageToCot(message)
if (parsed) updateFromCot(parsed).catch(() => {})
}
catch {
/* ignore malformed AIS messages */
}
})
ws.on('close', () => {
state.aisSocket = null
if (!state.stopped) scheduleAisReconnect()
})
ws.on('error', (err) => {
console.error('[trackingFeed] AISStream error:', err?.message)
})
}
function refreshFeedBboxes() {
if (state.stopped) return
const union = getSubscriberBboxUnion()
const bbox = clampBbox(union, MAX_OPENSKY_BBOX_DEGREES)
if (!bbox) {
stopOpenSkyPoll()
return
}
if (getConfig().aisstreamApiKey) {
if (state.aisSocket?.readyState === WebSocket.OPEN) {
subscribeAisBbox(state.aisSocket, bbox)
}
else if (!state.aisSocket) {
connectAisStream()
}
}
pollOpenSky().catch(() => {})
scheduleOpenSkyPoll()
}
export function scheduleTrackingFeedRefresh() {
if (state.bboxDebounceTimer) clearTimeout(state.bboxDebounceTimer)
state.bboxDebounceTimer = setTimeout(() => {
state.bboxDebounceTimer = null
refreshFeedBboxes()
}, TRACKING_FEED_DEBOUNCE_MS)
}
export function startTrackingFeed() {
state.stopped = false
}
export function stopTrackingFeed() {
state.stopped = true
if (state.bboxDebounceTimer) clearTimeout(state.bboxDebounceTimer)
if (state.coalesceTimer) clearTimeout(state.coalesceTimer)
state.coalesceTimer = null
stopOpenSkyPoll()
if (state.aisReconnectTimer) clearTimeout(state.aisReconnectTimer)
state.aisReconnectTimer = null
closeAisSocket()
state.openSkyCache.clear()
state.lastAisBbox = null
}
/** Tests only. @internal */
export function resetTrackingFeedForTests() {
stopTrackingFeed()
state.stopped = false
}
/** Tests only. @internal */
export function isOpenSkyPollActive() {
return state.openSkyTimer != null
}
+1 -1
View File
@@ -23,7 +23,7 @@ const ensureDevCerts = () => {
)
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function ensureDevCerts() {
console.log('[test] Generated .dev-certs/key.pem and .dev-certs/cert.pem')
}
catch (error) {
throw new Error(`Failed to generate dev certificates: ${error.message}`)
throw new Error(`Failed to generate dev certificates: ${error.message}`, { cause: error })
}
}
+27 -10
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { spawn, execSync } from 'node:child_process'
import { connect } from 'node:tls'
import { createServer } from 'node:net'
import { existsSync, mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -21,11 +22,21 @@ const devCertsDir = join(projectRoot, '.dev-certs')
const devKey = join(devCertsDir, 'key.pem')
const devCert = join(devCertsDir, 'cert.pem')
const API_PORT = 3000
const COT_PORT = 8089
const COT_AUTH_USER = 'test'
const COT_AUTH_PASS = 'test'
function getFreePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.listen(0, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : 0
server.close(() => resolve(port))
})
server.on('error', reject)
})
}
function ensureDevCerts() {
if (existsSync(devKey) && existsSync(devCert)) return
mkdirSync(devCertsDir, { recursive: true })
@@ -37,12 +48,12 @@ function ensureDevCerts() {
const FETCH_TIMEOUT_MS = 5000
async function waitForHealth(timeoutMs = 90000) {
async function waitForHealth(apiPort, timeoutMs = 90000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
for (const protocol of ['https', 'http']) {
try {
const baseURL = `${protocol}://localhost:${API_PORT}`
const baseURL = `${protocol}://localhost:${apiPort}`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
const res = await fetch(`${baseURL}/health`, { method: 'GET', signal: ctrl.signal })
@@ -55,16 +66,20 @@ async function waitForHealth(timeoutMs = 90000) {
}
await new Promise(r => setTimeout(r, 1000))
}
throw new Error(`Health not OK on ${API_PORT} within ${timeoutMs}ms`)
throw new Error(`Health not OK on ${apiPort} within ${timeoutMs}ms`)
}
describe('Server and CoT integration', () => {
const testState = {
serverProcess: null,
apiPort: 0,
cotPort: 0,
}
beforeAll(async () => {
ensureDevCerts()
testState.apiPort = await getFreePort()
testState.cotPort = await getFreePort()
const serverPath = join(projectRoot, '.output', 'server', 'index.mjs')
if (!existsSync(serverPath)) {
execSync('npm run build', { cwd: projectRoot, stdio: 'pipe' })
@@ -72,6 +87,8 @@ describe('Server and CoT integration', () => {
const dbPath = join(tmpdir(), `kestrelos-it-${process.pid}-${Date.now()}.db`)
const env = {
...process.env,
PORT: String(testState.apiPort),
COT_PORT: String(testState.cotPort),
DB_PATH: dbPath,
BOOTSTRAP_EMAIL: COT_AUTH_USER,
BOOTSTRAP_PASSWORD: COT_AUTH_PASS,
@@ -83,7 +100,7 @@ describe('Server and CoT integration', () => {
})
testState.serverProcess.stdout?.on('data', d => process.stdout.write(d))
testState.serverProcess.stderr?.on('data', d => process.stderr.write(d))
await waitForHealth(90000)
await waitForHealth(testState.apiPort, 90000)
}, 120000)
afterAll(() => {
@@ -92,11 +109,11 @@ describe('Server and CoT integration', () => {
}
})
it('serves health on port 3000', async () => {
it('serves health on the configured API port', async () => {
const tryProtocols = async (protocols) => {
if (protocols.length === 0) throw new Error('No protocol succeeded')
try {
const res = await fetch(`${protocols[0]}://localhost:${API_PORT}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
const res = await fetch(`${protocols[0]}://localhost:${testState.apiPort}/health`, { method: 'GET', headers: { Accept: 'application/json' } })
if (res?.ok) return res
return tryProtocols(protocols.slice(1))
}
@@ -112,10 +129,10 @@ describe('Server and CoT integration', () => {
expect(body.endpoints).toHaveProperty('ready', '/health/ready')
})
it('CoT on 8089: TAK client auth with username/password succeeds (socket stays open)', async () => {
it('CoT: TAK client auth with username/password succeeds (socket stays open)', async () => {
const payload = buildAuthCotXml({ username: COT_AUTH_USER, password: COT_AUTH_PASS })
const socket = await new Promise((resolve, reject) => {
const s = connect(COT_PORT, '127.0.0.1', { rejectUnauthorized: false }, () => {
const s = connect(testState.cotPort, '127.0.0.1', { rejectUnauthorized: false }, () => {
s.write(payload, () => resolve(s))
})
s.on('error', reject)
+23 -24
View File
@@ -1,27 +1,24 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { SHUTDOWN_TIMEOUT_MS } from '../../server/utils/constants.js'
import { registerCleanup, graceful, initShutdownHandlers, clearCleanup } from '../../server/utils/shutdown.js'
describe('shutdown integration', () => {
const testState = {
originalExit: null,
exitCalls: [],
originalOn: null,
}
/** @type {import('vitest').MockInstance} */
let exitSpy
/** @type {typeof process.on} */
let originalOn
beforeEach(() => {
clearCleanup()
testState.exitCalls = []
testState.originalExit = process.exit
process.exit = vi.fn((code) => {
testState.exitCalls.push(code)
})
testState.originalOn = process.on
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {})
originalOn = process.on
})
afterEach(() => {
process.exit = testState.originalExit
process.on = testState.originalOn
exitSpy.mockRestore()
process.on = originalOn
clearCleanup()
vi.useRealTimers()
})
it('initializes signal handlers', () => {
@@ -32,7 +29,6 @@ describe('shutdown integration', () => {
initShutdownHandlers()
expect(process.on).toHaveBeenCalledWith('SIGTERM', expect.any(Function))
expect(process.on).toHaveBeenCalledWith('SIGINT', expect.any(Function))
process.on = testState.originalOn
})
it('signal handler calls graceful', async () => {
@@ -43,9 +39,10 @@ describe('shutdown integration', () => {
initShutdownHandlers()
const sigtermHandler = handlers.SIGTERM
expect(sigtermHandler).toBeDefined()
await sigtermHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
sigtermHandler()
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})
})
it('signal handler handles graceful error', async () => {
@@ -59,18 +56,20 @@ describe('shutdown integration', () => {
registerCleanup(async () => {
throw new Error('Force error')
})
await sigintHandler()
expect(testState.exitCalls.length).toBeGreaterThan(0)
process.on = testState.originalOn
sigintHandler()
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalled()
})
})
it('covers timeout path in graceful', async () => {
vi.useFakeTimers()
registerCleanup(async () => {
await new Promise(resolve => setTimeout(resolve, 40000))
await new Promise(resolve => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS + 5_000))
})
graceful()
await new Promise(resolve => setTimeout(resolve, 100))
expect(testState.exitCalls.length).toBeGreaterThan(0)
await vi.advanceTimersByTimeAsync(SHUTDOWN_TIMEOUT_MS + 1)
expect(exitSpy).toHaveBeenCalledWith(1)
})
it('covers graceful catch block', async () => {
@@ -78,6 +77,6 @@ describe('shutdown integration', () => {
throw new Error('Test error')
})
await graceful()
expect(testState.exitCalls.length).toBeGreaterThan(0)
expect(exitSpy).toHaveBeenCalled()
})
})
+8
View File
@@ -75,4 +75,12 @@ describe('KestrelMap', () => {
expect(wrapper.props('pois')).toHaveLength(1)
expect(wrapper.props('canEditPois')).toBe(false)
})
it('includes CoT layer toggles and supercluster layer', async () => {
const componentPath = resolve(__dirname, '../../app/components/KestrelMap.vue')
const source = readFileSync(componentPath, 'utf-8')
expect(source).toContain('cot-layer-toggles')
expect(source).toContain('cotMapLayer')
expect(source).toContain('syncCotLayer')
})
})
-14
View File
@@ -15,26 +15,12 @@ describe('useCameras', () => {
setupEndpoints(() => ({
devices: [{ id: '1', name: 'Test', lat: 37.7, lng: -122.4, streamUrl: '', sourceType: 'mjpeg', device_type: 'feed' }],
liveSessions: [],
cotEntities: [],
}))
const wrapper = await mountSuspended(Index)
await wait()
expect(wrapper.findComponent({ name: 'KestrelMap' }).exists()).toBe(true)
})
it('exposes cotEntities from API', async () => {
const cotEntities = [{ id: 'cot-1', lat: 38, lng: -123, label: 'ATAK1' }]
setupEndpoints(() => ({
devices: [],
liveSessions: [],
cotEntities,
}))
const wrapper = await mountSuspended(Index)
await wait()
const map = wrapper.findComponent({ name: 'KestrelMap' })
expect(map.props('cotEntities')).toEqual(cotEntities)
})
it('handles API error and falls back to empty devices and liveSessions', async () => {
setupEndpoints(() => {
throw new Error('network')
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useCotLayers } from '../../app/composables/useCotLayers.js'
describe('useCotLayers', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('defaults all layers on', () => {
const { layers, layerQuery } = useCotLayers()
expect(layers.value).toEqual({ air: true, surface: true, ground: true })
expect(layerQuery.value).toBe('air,surface,ground')
})
it('toggles layers and persists to localStorage', () => {
const { layers, toggleLayer, layerQuery } = useCotLayers()
toggleLayer('air')
expect(layers.value.air).toBe(false)
expect(layerQuery.value).toBe('surface,ground')
const stored = JSON.parse(localStorage.getItem('kestrel-cot-layers'))
expect(stored.air).toBe(false)
})
})
describe('useCotStream helpers', () => {
it('layer query none when all off', () => {
localStorage.clear()
const { toggleLayer, layerQuery } = useCotLayers()
toggleLayer('air')
toggleLayer('surface')
toggleLayer('ground')
expect(layerQuery.value).toBe('none')
})
})
+154
View File
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
parseOverpassElement,
parseBbox,
tileKeysForBbox,
getAlprCameras,
markTilesFetched,
} from '../../server/utils/alpr.js'
import { cameraToFeature, identifyingProperties, inferModelFromTags, isGenericCameraName } from '../../server/utils/alprGeo.js'
import { getDb, setDbPathForTest, closeDb } from '../../server/utils/db.js'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
describe('alpr utils', () => {
describe('parseOverpassElement', () => {
it('parses a valid OSM node', () => {
const out = parseOverpassElement({
type: 'node',
id: 123,
lat: 33.75,
lon: -84.39,
tags: { 'manufacturer': 'Flock Safety', 'camera:direction': '90' },
})
expect(out?.osmId).toBe(123)
expect(out?.manufacturer).toBe('Flock Safety')
})
})
describe('cameraToFeature', () => {
it('returns GeoJSON Point feature with identifying tags', () => {
const feature = cameraToFeature({
osmId: 1,
lat: 33.5,
lng: -84.5,
manufacturer: 'Flock Safety',
direction: 90,
fov: 45,
tags: {
'model': 'Falcon',
'operator': 'City PD',
'ref': 'CAM-12',
'operator:wikidata': 'Q123',
'fixme': 'verify mount',
},
})
expect(feature.properties.manufacturer).toBe('Flock Safety')
expect(feature.properties.model).toBe('Falcon')
expect(feature.properties.operator).toBe('City PD')
expect(feature.properties.ref).toBe('CAM-12')
expect(feature.properties.operatorWikidata).toBe('Q123')
expect(feature.properties.tags).toEqual({ fixme: 'verify mount' })
})
})
describe('identifyingProperties', () => {
it('omits brand when same as manufacturer', () => {
const props = identifyingProperties({ manufacturer: 'Flock Safety', brand: 'Flock Safety', model: 'Falcon' })
expect(props.manufacturer).toBe('Flock Safety')
expect(props.model).toBe('Falcon')
expect(props.brand).toBeUndefined()
})
it('infers model from lowercase name tag', () => {
const props = identifyingProperties({
manufacturer: 'Flock Safety',
name: 'falcon',
})
expect(props.model).toBe('Falcon')
expect(props.name).toBeUndefined()
expect(props.modelUnknown).toBeUndefined()
})
it('drops generic camera names and flags unknown model', () => {
const props = identifyingProperties({
manufacturer: 'Flock Safety',
name: 'Flock ALPR camera',
})
expect(props.name).toBeUndefined()
expect(props.model).toBeUndefined()
expect(props.modelUnknown).toBe(true)
})
})
describe('inferModelFromTags', () => {
it('reads model tag and mis-tagged Flock Falcon name', () => {
expect(inferModelFromTags({ model: 'Sparrow' })).toBe('Sparrow')
expect(inferModelFromTags({ name: 'Flock Falcon' })).toBe('Falcon')
expect(isGenericCameraName('Flock ALPR camera')).toBe(true)
expect(isGenericCameraName('Peachtree & 5th')).toBe(false)
})
})
describe('parseBbox', () => {
it('parses valid bbox', () => {
expect(parseBbox({ south: 33.0, west: -85.0, north: 33.4, east: -84.6 })).toEqual({
south: 33.0,
west: -85.0,
north: 33.4,
east: -84.6,
})
})
it('rejects oversized bbox', () => {
expect(() => parseBbox({ south: 0, west: 0, north: 2, east: 1 })).toThrow(/bbox exceeds/)
})
})
describe('cache and fetch', () => {
let dbPath
beforeEach(async () => {
dbPath = join(tmpdir(), `kestrelos-alpr-${randomUUID()}.db`)
setDbPathForTest(dbPath)
await getDb()
})
afterEach(() => {
closeDb()
setDbPathForTest(null)
})
it('returns GeoJSON FeatureCollection from cache', async () => {
const db = await getDb()
const bbox = { south: 33.4, west: -85.0, north: 33.6, east: -84.0 }
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[99, 33.5, -84.5, 'Acme', 180, '{}', new Date().toISOString()],
)
await markTilesFetched(db, tileKeysForBbox(bbox))
vi.stubGlobal('fetch', vi.fn())
const result = await getAlprCameras(db, bbox)
expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
expect(result.source).toBe('cache')
vi.unstubAllGlobals()
})
it('falls back to cache when Overpass fails', async () => {
const db = await getDb()
await db.run(
`INSERT INTO alpr_nodes (osm_id, lat, lng, manufacturer, direction, tags, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[42, 33.5, -84.5, null, null, '{}', new Date().toISOString()],
)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const result = await getAlprCameras(db, { south: 33.4, west: -85.0, north: 33.6, east: -84.0 })
expect(result.type).toBe('FeatureCollection')
expect(result.features).toHaveLength(1)
vi.unstubAllGlobals()
})
})
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import { formatAlprPopup } from '../../app/utils/alprMapLayer.js'
describe('formatAlprPopup', () => {
it('summarizes manufacturer, model, and operator', () => {
const html = formatAlprPopup({
osmId: 1,
manufacturer: 'Flock Safety',
model: 'Falcon',
operator: 'City PD',
direction: 90,
fov: 60,
})
expect(html).toContain('Flock Safety')
expect(html).toContain('Falcon')
expect(html).toContain('License plate reader')
expect(html).toContain('City PD')
expect(html).toContain('Facing E (90°)')
expect(html).toContain('~60° view')
expect(html).not.toContain('Manufacturer')
expect(html).not.toContain('fixme')
})
it('uses name as title with make/model below', () => {
const html = formatAlprPopup({
osmId: 2,
name: 'Peachtree & 5th',
manufacturer: 'Flock Safety',
model: 'Falcon',
})
expect(html).toContain('<strong>Peachtree &amp; 5th</strong>')
expect(html).toContain('Model</span> <strong>Falcon</strong>')
expect(html).toContain('Flock Safety Falcon')
})
it('links operator wikidata inline', () => {
const html = formatAlprPopup({
osmId: 3,
operator: 'Atlanta Police',
operatorWikidata: 'Q123',
})
expect(html).toContain('<strong>')
expect(html).toContain('href="https://www.wikidata.org/wiki/Q123"')
expect(html).toContain('Atlanta Police')
expect(html).not.toContain('Wikidata')
})
it('shows model unknown when OSM lacks model tag', () => {
const html = formatAlprPopup({
osmId: 4,
manufacturer: 'Flock Safety',
modelUnknown: true,
})
expect(html).toContain('Flock Safety')
expect(html).toContain('Model not recorded in OpenStreetMap')
})
})
+10
View File
@@ -0,0 +1,10 @@
import { describe, it, expect } from 'vitest'
import { tilesNearCenter } from '../../app/utils/alprViewport.js'
describe('tilesNearCenter', () => {
it('returns a single tile for a small viewport', () => {
const tiles = tilesNearCenter({ south: 37.7, west: -122.5, north: 37.8, east: -122.4 }, 16)
expect(tiles).toHaveLength(1)
expect(tiles[0]).toEqual({ south: 37.5, west: -122.5, north: 38, east: -122 })
})
})
+1
View File
@@ -10,6 +10,7 @@ describe('authSkipPaths', () => {
it('does not skip any protected path', () => {
const protectedPaths = [
...PROTECTED_PATH_PREFIXES,
'/api/alpr',
'/api/cameras',
'/api/devices',
'/api/devices/any-id',
+6
View File
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'vitest'
import {
COT_AUTH_TIMEOUT_MS,
LIVE_SESSION_TTL_MS,
COT_TTL_MS,
COT_ENTITY_TTL_MS,
COT_OSINT_TTL_MS,
COT_PRUNE_INTERVAL_MS,
POLL_INTERVAL_MS,
SHUTDOWN_TIMEOUT_MS,
COT_PORT,
@@ -18,7 +21,10 @@ describe('constants', () => {
it('uses default values when env vars not set', () => {
expect(COT_AUTH_TIMEOUT_MS).toBe(15000)
expect(LIVE_SESSION_TTL_MS).toBe(60000)
expect(COT_TTL_MS).toBe(90000)
expect(COT_ENTITY_TTL_MS).toBe(90000)
expect(COT_OSINT_TTL_MS).toBe(30000)
expect(COT_PRUNE_INTERVAL_MS).toBe(15000)
expect(POLL_INTERVAL_MS).toBe(1500)
expect(SHUTDOWN_TIMEOUT_MS).toBe(30000)
expect(COT_PORT).toBe(8089)
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import {
cotAirIconKind,
cotCategory,
formatCotPopup,
getCotIconHtml,
} from '../../app/utils/cotDisplay.js'
const esc = s => String(s)
describe('cotDisplay', () => {
it('detects helicopter from CoT type', () => {
expect(cotAirIconKind({ type: 'a-f-A-C-H' })).toBe('helicopter')
expect(cotAirIconKind({ type: 'a-f-A-C-F' })).toBe('fixedWing')
})
it('renders distinct air icon kinds', () => {
const plane = getCotIconHtml({ type: 'a-f-A-C-F', heading: 90 })
const heli = getCotIconHtml({ type: 'a-f-A-C-H', heading: 180 })
expect(plane.className).toBe('cot-entity-fixedWing')
expect(heli.className).toBe('cot-entity-helicopter')
expect(plane.html).toContain('rotate(90deg)')
expect(heli.html).toContain('rotate(180deg)')
})
it('formats rich ADS-B popup', () => {
const html = formatCotPopup({
source: 'adsb',
type: 'a-f-A-C-F',
label: 'UAL123',
icao: 'abc123',
originCountry: 'United States',
altitude: 10000,
speed: 200,
heading: 270,
verticalRate: 5,
squawk: '1200',
}, esc)
expect(html).toContain('UAL123')
expect(html).toContain('Aircraft')
expect(html).toContain('ICAO ABC123')
expect(html).toContain('United States')
expect(html).toContain('ft')
expect(html).toContain('kt')
expect(html).toContain('270°')
expect(html).toContain('Squawk 1200')
})
it('formats vessel popup with MMSI', () => {
const html = formatCotPopup({
source: 'ais',
type: 'a-f-S-C',
label: 'TEST SHIP',
id: 'MMSI.366123456',
speed: 12,
heading: 90,
}, esc)
expect(html).toContain('Vessel')
expect(html).toContain('MMSI 366123456')
})
it('formats team popup', () => {
expect(cotCategory('a-f-G-U-C')).toBe('ground')
const html = formatCotPopup({ type: 'a-f-G-U-C', label: 'Alpha 1' }, esc)
expect(html).toContain('Team')
expect(html).toContain('Alpha 1')
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
inferSourceFromId,
cotCategoryFromType,
isInBbox,
matchesLayerFilter,
openSkyStateToCot,
aisStreamMessageToCot,
unionBboxes,
clampBbox,
parseBboxParam,
parseLayersParam,
} from '../../../server/utils/cotEntityUtils.js'
describe('cotEntityUtils', () => {
it('infers source from UID prefix', () => {
expect(inferSourceFromId('ICAO.abc123')).toBe('adsb')
expect(inferSourceFromId('MMSI.366123456')).toBe('ais')
expect(inferSourceFromId('ANDROID-deadbeef')).toBe('tak')
})
it('maps CoT type to category', () => {
expect(cotCategoryFromType('a-f-A-C-F')).toBe('air')
expect(cotCategoryFromType('a-f-S-C')).toBe('surface')
expect(cotCategoryFromType('a-f-G-U-C')).toBe('ground')
})
it('checks bbox membership', () => {
const bbox = { west: -123, south: 37, east: -122, north: 38 }
expect(isInBbox({ lat: 37.5, lng: -122.5 }, bbox)).toBe(true)
expect(isInBbox({ lat: 40, lng: -122.5 }, bbox)).toBe(false)
})
it('filters by layer set', () => {
const airOnly = new Set(['air'])
expect(matchesLayerFilter(airOnly, { type: 'a-f-A-C-F' })).toBe(true)
expect(matchesLayerFilter(airOnly, { type: 'a-f-S-C' })).toBe(false)
})
it('maps OpenSky state vector to CoT', () => {
const state = ['abc123', 'UAL123 ', 'United States', 1, 2, -122.4, 37.7, 10000, false, 200, 90, 5, null, null, 1200, false, 0, 0]
const cot = openSkyStateToCot(state)
expect(cot).toMatchObject({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'UAL123',
source: 'adsb',
type: 'a-f-A-C-F',
icao: 'abc123',
originCountry: 'United States',
heading: 90,
speed: 200,
altitude: 10000,
verticalRate: 5,
squawk: '1200',
})
})
it('maps OpenSky rotorcraft to helicopter CoT type', () => {
const state = ['heli01', 'N123HC ', 'United States', 1, 2, -122.4, 37.7, 500, false, 50, 180, 0, null, null, null, false, 0, 8]
const cot = openSkyStateToCot(state)
expect(cot?.type).toBe('a-f-A-C-H')
})
it('maps AISStream message to CoT', () => {
const cot = aisStreamMessageToCot({
MetaData: { MMSI: 366123456, ShipName: 'TEST SHIP' },
Message: {
PositionReport: {
UserID: 366123456,
Latitude: 37.8,
Longitude: -122.3,
Sog: 12.5,
Cog: 180,
},
},
})
expect(cot).toMatchObject({
id: 'MMSI.366123456',
lat: 37.8,
lng: -122.3,
label: 'TEST SHIP',
source: 'ais',
type: 'a-f-S-C',
})
})
it('unions bboxes', () => {
expect(unionBboxes([
{ west: -123, south: 37, east: -122, north: 38 },
{ west: -124, south: 36, east: -121, north: 39 },
])).toEqual({ west: -124, south: 36, east: -121, north: 39 })
})
it('clamps oversized bbox to max span', () => {
const huge = { west: -125, south: 32, east: -115, north: 42 }
const clamped = clampBbox(huge, 10)
expect(clamped.north - clamped.south).toBeCloseTo(10)
expect(clamped.east - clamped.west).toBeCloseTo(10)
expect((clamped.north + clamped.south) / 2).toBeCloseTo(37)
expect((clamped.east + clamped.west) / 2).toBeCloseTo(-120)
})
it('parses bbox and layers query params', () => {
expect(parseBboxParam('-123,37,-122,38')).toEqual({ west: -123, south: 37, east: -122, north: 38 })
expect(parseBboxParam('bad')).toBeNull()
expect(parseLayersParam('air,surface')).toEqual(new Set(['air', 'surface']))
expect(parseLayersParam('none').size).toBe(0)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import {
entitiesToFeatures,
getCotClusters,
loadCotCluster,
} from '../../app/utils/cotMapLayer.js'
function makeEntities(n, centerLat = 37.7, centerLng = -122.4) {
return Array.from({ length: n }, (_, i) => ({
id: `ICAO.${i}`,
lat: centerLat + (i % 10) * 0.01,
lng: centerLng + Math.floor(i / 10) * 0.01,
type: 'a-f-A-C-F',
label: `AC${i}`,
}))
}
describe('cotMapLayer', () => {
it('converts entities to GeoJSON points', () => {
const features = entitiesToFeatures([
{ id: 'ICAO.1', lat: 37.7, lng: -122.4, type: 'a-f-A-C-F' },
{ id: 'bad', lat: 'x', lng: 0 },
])
expect(features).toHaveLength(1)
expect(features[0].geometry.coordinates).toEqual([-122.4, 37.7])
expect(features[0].properties.entity.id).toBe('ICAO.1')
})
it('clusters dense tracks at low zoom', () => {
loadCotCluster(makeEntities(50))
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 6 }
const clusters = getCotClusters(view)
const clusterCount = clusters.filter(f => f.properties?.cluster).length
const pointCount = clusters.filter(f => !f.properties?.cluster).length
expect(clusterCount).toBeGreaterThan(0)
expect(clusterCount + pointCount).toBeLessThan(50)
})
it('shows individual markers at high zoom', () => {
loadCotCluster(makeEntities(20))
const view = { west: -123, south: 37, east: -122, north: 38, zoom: 15 }
const clusters = getCotClusters(view)
expect(clusters.every(f => !f.properties?.cluster)).toBe(true)
expect(clusters).toHaveLength(20)
})
})
+13
View File
@@ -44,4 +44,17 @@ describe('cotServer (parse-and-store path)', () => {
expect(active[0].lng).toBe(4)
expect(active[0].label).toBe('Updated')
})
it('infers adsb source from ICAO uid when ingesting CoT position', async () => {
await updateFromCot({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'N12345',
eventType: 'a-f-A-C-F',
})
const active = await getActiveEntities()
expect(active[0].source).toBe('adsb')
expect(active[0].type).toBe('a-f-A-C-F')
})
})
+84 -3
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { updateFromCot, getActiveEntities, clearCotStore } from '../../../server/utils/cotStore.js'
import {
updateFromCot,
getActiveEntities,
getActiveEntitiesInBbox,
clearCotStore,
onCotChange,
pruneStaleEntities,
} from '../../../server/utils/cotStore.js'
describe('cotStore', () => {
beforeEach(() => {
@@ -14,6 +21,28 @@ describe('cotStore', () => {
expect(active[0].lat).toBe(37.7)
expect(active[0].lng).toBe(-122.4)
expect(active[0].label).toBe('Alpha')
expect(active[0].source).toBe('tak')
})
it('stores enriched ADS-B fields and infers source', async () => {
await updateFromCot({
id: 'ICAO.abc123',
lat: 37.7,
lng: -122.4,
label: 'UAL1',
type: 'a-f-A-C-F',
heading: 90,
speed: 200,
altitude: 10000,
})
const active = await getActiveEntities()
expect(active[0]).toMatchObject({
source: 'adsb',
heading: 90,
speed: 200,
altitude: 10000,
type: 'a-f-A-C-F',
})
})
it('updates same uid', async () => {
@@ -41,10 +70,10 @@ describe('cotStore', () => {
it('prunes expired entities after getActiveEntities', async () => {
await updateFromCot({ id: 'uid-1', lat: 37, lng: -122 })
const active1 = await getActiveEntities(100)
const active1 = await getActiveEntities({ ttlMs: 100 })
expect(active1).toHaveLength(1)
await new Promise(r => setTimeout(r, 150))
const active2 = await getActiveEntities(100)
const active2 = await getActiveEntities({ ttlMs: 100 })
expect(active2).toHaveLength(0)
})
@@ -55,4 +84,56 @@ describe('cotStore', () => {
expect(active).toHaveLength(2)
expect(active.map(e => e.id).sort()).toEqual(['a', 'b'])
})
it('filters OSINT entities by bbox but keeps team globally', async () => {
await updateFromCot({ id: 'ICAO.abc', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
await updateFromCot({ id: 'MMSI.123', lat: 40, lng: -100, type: 'a-f-S-C' })
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: false })
const ids = active.map(e => e.id).sort()
expect(ids).toEqual(['ANDROID-1', 'ICAO.abc'])
})
it('filters team by bbox when COT_TAK_FILTER_BBOX enabled', async () => {
await updateFromCot({ id: 'ANDROID-1', lat: 50, lng: 10, source: 'tak' })
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { takFilterBbox: true })
expect(active).toHaveLength(0)
})
it('caps bbox query results at maxEntities', async () => {
for (let i = 0; i < 5; i++) {
await updateFromCot({ id: `ICAO.${i}`, lat: 37.5 + i * 0.01, lng: -122.4, type: 'a-f-A-C-F' })
}
const bbox = { west: -123, south: 37, east: -122, north: 38 }
const active = await getActiveEntitiesInBbox(bbox, { maxEntities: 3 })
expect(active).toHaveLength(3)
})
it('skips emit when silent upsert', async () => {
const updates = []
const off = onCotChange((event) => {
if (event === 'update') updates.push(event)
})
await updateFromCot({ id: 'ICAO.silent', lat: 37, lng: -122, type: 'a-f-A-C-F' }, { silent: true })
off()
expect(updates).toHaveLength(0)
const active = await getActiveEntities()
expect(active.some(e => e.id === 'ICAO.silent')).toBe(true)
})
it('pruneStaleEntities uses shorter TTL for OSINT sources', async () => {
await updateFromCot({ id: 'ICAO.old', lat: 37, lng: -122, source: 'adsb', type: 'a-f-A-C-F' })
await updateFromCot({ id: 'ANDROID-1', lat: 37, lng: -122, source: 'tak' })
const removed = []
const off = onCotChange((event, payload) => {
if (event === 'remove') removed.push(payload.id)
})
await new Promise(r => setTimeout(r, 60))
await pruneStaleEntities({ ttlMs: 10_000, osintTtlMs: 50 })
off()
expect(removed).toContain('ICAO.old')
expect(removed).not.toContain('ANDROID-1')
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
registerSubscriber,
getSubscriberBboxUnion,
clearSubscribers,
notifySubscribersForEntity,
notifySubscribersRemove,
broadcastSubscriberSnapshots,
} from '../../../server/utils/cotSubscribers.js'
import { updateFromCot, clearCotStore } from '../../../server/utils/cotStore.js'
describe('cotSubscribers', () => {
beforeEach(() => {
clearSubscribers()
})
it('unions subscriber bboxes', () => {
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: vi.fn(),
})
registerSubscriber({
bbox: { west: -124, south: 36, east: -121, north: 39 },
layers: new Set(['surface']),
push: vi.fn(),
})
expect(getSubscriberBboxUnion()).toEqual({ west: -124, south: 36, east: -121, north: 39 })
})
it('notifies subscribers inside bbox and matching layer', async () => {
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
id: 'ICAO.x',
lat: 37.5,
lng: -122.5,
type: 'a-f-A-C-F',
})
expect(push).toHaveBeenCalledWith('update', expect.any(String))
})
it('skips subscribers when entity outside bbox', async () => {
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await notifySubscribersForEntity('update', { entity: { id: 'ICAO.x' } }, {
id: 'ICAO.x',
lat: 40,
lng: -122.5,
type: 'a-f-A-C-F',
})
expect(push).not.toHaveBeenCalled()
})
it('notifySubscribersRemove pushes to all subscribers', async () => {
const pushA = vi.fn()
const pushB = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: pushA,
})
registerSubscriber({
bbox: { west: -125, south: 35, east: -120, north: 40 },
layers: new Set(['surface']),
push: pushB,
})
await notifySubscribersRemove('ICAO.removed')
expect(pushA).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
expect(pushB).toHaveBeenCalledWith('remove', JSON.stringify({ id: 'ICAO.removed' }))
})
it('broadcastSubscriberSnapshots sends per-subscriber filtered snapshot', async () => {
clearCotStore()
await updateFromCot({ id: 'ICAO.in', lat: 37.5, lng: -122.5, type: 'a-f-A-C-F' })
await updateFromCot({ id: 'ICAO.out', lat: 40, lng: -100, type: 'a-f-A-C-F' })
const push = vi.fn()
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push,
})
await broadcastSubscriberSnapshots({ ttlMs: 90_000, osintTtlMs: 30_000, takFilterBbox: false })
expect(push).toHaveBeenCalledWith('snapshot', expect.any(String))
const payload = JSON.parse(push.mock.calls[0][1])
expect(payload.entities.map(e => e.id)).toEqual(['ICAO.in'])
})
})
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest'
import { createClusterIndex } from '../../app/utils/mapCluster.js'
import { clearFeatureMarkers, syncFeatureMarkers } from '../../app/utils/mapMarkerSync.js'
import { bboxFetchKey, tilesNearCenter } from '../../app/utils/alprViewport.js'
describe('mapCluster', () => {
it('loads once and queries by viewport', () => {
const index = createClusterIndex({ radius: 50, maxZoom: 14, minPoints: 2 })
const features = Array.from({ length: 20 }, (_, i) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [-122.4 + i * 0.01, 37.7] },
properties: { id: i },
}))
index.load(features)
const zoomedOut = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 6 })
expect(zoomedOut.some(f => f.properties?.cluster)).toBe(true)
const zoomedIn = index.query({ west: -123, south: 37, east: -122, north: 38, zoom: 15 })
expect(zoomedIn).toHaveLength(20)
})
})
describe('mapMarkerSync', () => {
it('reuses markers by key', () => {
const layer = {
_layers: [],
addLayer(m) { this._layers.push(m) },
removeLayer(m) { this._layers = this._layers.filter(x => x !== m) },
}
const create = vi.fn(f => ({ id: f.properties.id }))
const update = vi.fn()
const opts = { keyFor: f => f.properties.id, create, update }
const pt = (id, lng, lat) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { id } })
syncFeatureMarkers(layer, [pt(1, -122, 37)], opts)
syncFeatureMarkers(layer, [pt(1, -121.9, 37.1)], opts)
expect(create).toHaveBeenCalledTimes(1)
expect(update).toHaveBeenCalledTimes(1)
clearFeatureMarkers(layer)
expect(layer._layers).toHaveLength(0)
})
})
describe('alprViewport', () => {
it('selects nearby tiles without scanning the world', () => {
const world = { south: -85, west: -180, north: 85, east: 180 }
expect(tilesNearCenter(world, 16)).toHaveLength(16)
expect(tilesNearCenter(world, 1)[0]).toEqual({ south: 0, west: 0, north: 0.5, east: 0.5 })
})
it('coarsens fetch keys when zoomed out', () => {
const bounds = { south: 37.01, west: -122.51, north: 37.99, east: -122.01, zoom: 6 }
expect(bboxFetchKey(bounds)).toBe(bboxFetchKey({ ...bounds, south: bounds.south + 0.05 }))
expect(bboxFetchKey({ ...bounds, zoom: 14 })).not.toBe(bboxFetchKey(bounds))
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { clearSubscribers, registerSubscriber } from '../../../server/utils/cotSubscribers.js'
import {
isOpenSkyPollActive,
resetTrackingFeedForTests,
scheduleOpenSkyPollForTests,
} from '../../../server/utils/trackingFeed.js'
describe('trackingFeed', () => {
beforeEach(() => {
clearSubscribers()
resetTrackingFeedForTests()
})
it('does not start OpenSky poll without SSE subscribers', () => {
scheduleOpenSkyPollForTests()
expect(isOpenSkyPollActive()).toBe(false)
})
it('starts OpenSky poll when a subscriber is registered', () => {
registerSubscriber({
bbox: { west: -123, south: 37, east: -122, north: 38 },
layers: new Set(['air']),
push: vi.fn(),
})
scheduleOpenSkyPollForTests()
expect(isOpenSkyPollActive()).toBe(true)
})
})