diff --git a/.claude/settings.json b/.claude/settings.json index 07ee9748..bcefeaee 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "pnpm quick || exit 2" + "command": "out=$(pnpm -w quick 2>&1) || { printf '%s\\n' \"$out\" >&2; exit 2; }" } ] } diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3ac0be25..ae0cdef9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,19 +2,22 @@ # so every developer will have the same environment # registry.fedoraproject.org/fedora:44 -FROM registry.fedoraproject.org/fedora@sha256:590825dbaee41a97a162ecdffc3305264bd11cb3ff1e9cfd710d41ca5f936134 +FROM registry.fedoraproject.org/fedora@sha256:7cbdc865d55e6b19a2861e8b58f989421281b5ea2273c50c61c0fa8b5075afbd ARG TARGETARCH -ENV NODE_VERSION=26.5.1 \ - NODE_CHECKSUM_X64=cc7b3484ade63bd203a9d304f21ec37a3b622b988d7bdecf1dc4d68fc44a91b7 \ - NODE_CHECKSUM_ARM64=0b6b0cc2a1eecbe736f9918de8b5a6c9a48d286b88bec1298a3c1e3376182ea8 +ENV NODE_VERSION=26.6.0 \ + NODE_CHECKSUM_X64=e53a8c8ca2ee7135de38e63859bd4f43002ae2d222f5b3a98ac1945db9f544bb \ + NODE_CHECKSUM_ARM64=974e6f0332fb26179b34d49c57f35339e089a89faf8b82a81e44c0e1164b7923 -ENV PNPM_VERSION=11.18.0 \ - PNPM_CHECKSUM_X64=f4e62794c56f7a1ad01097f94b90ca95680080aa2e83ebc3d0a751fb8aa8e4ad \ - PNPM_CHECKSUM_ARM64=f9c09bb564421d9d39dfff60af41df88a4864af5637afdabe5f54d12853f8426 +ENV PNPM_VERSION=11.20.0 \ + PNPM_CHECKSUM_X64=b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572 \ + PNPM_CHECKSUM_ARM64=f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5 RUN dnf install -yq zsh micro git tig psmisc procps-ng awk libatomic wget \ + gtk3 nss nspr alsa-lib cups-libs mesa-libgbm dbus-libs libX11-xcb \ + libXcomposite libXdamage libXrandr libXcursor libXi libxkbcommon \ + google-noto-sans-fonts google-noto-emoji-fonts \ && dnf clean all \ && rm -rf /var/cache/dnf diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 570cb675..10b8790f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,6 +4,9 @@ }, "runArgs": [ "--net=bridge", + "--shm-size=1g", + "--security-opt", + "seccomp=unconfined", "-p", "2553:2553", "-p", diff --git a/.github/actions/deploy/action.yml b/.github/actions/deploy/action.yml index 04c0b413..75bdf091 100644 --- a/.github/actions/deploy/action.yml +++ b/.github/actions/deploy/action.yml @@ -35,7 +35,7 @@ runs: INPUTS_TAG: ${{ inputs.tag }} INPUTS_CONTEXT: ${{ inputs.context }} - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 01aacbfb..34865d74 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -70,7 +70,7 @@ jobs: - name: Load Docker image run: docker load < image.tar - name: Login to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml index 7d2d4fc3..b660679a 100644 --- a/.github/workflows/workflows.yml +++ b/.github/workflows/workflows.yml @@ -24,6 +24,6 @@ jobs: with: persist-credentials: false - name: Lint CI workflows - uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 + uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2 with: advanced-security: false diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..a918b86d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,44 @@ +{ + "mcpServers": { + "chromium": { + "args": [ + "-c", + "./node_modules/.bin/playwright-mcp install-browser chromium 1>&2 && exec ./node_modules/.bin/playwright-mcp \"$@\"", + "playwright-mcp", + "--headless", + "--browser", + "chromium", + "--caps", + "devtools", + "--viewport-size", + "1280,800", + "--user-data-dir", + "/home/developer/.local/share/playwright-chromium", + "--output-dir", + "/home/developer/.local/share/playwright-output/chromium", + "--save-session" + ], + "command": "sh" + }, + "firefox": { + "args": [ + "-c", + "./node_modules/.bin/playwright-mcp install-browser firefox 1>&2 && exec ./node_modules/.bin/playwright-mcp \"$@\"", + "playwright-mcp", + "--headless", + "--browser", + "firefox", + "--caps", + "devtools", + "--viewport-size", + "1280,800", + "--user-data-dir", + "/home/developer/.local/share/playwright-firefox", + "--output-dir", + "/home/developer/.local/share/playwright-output/firefox", + "--save-session" + ], + "command": "sh" + } + } +} diff --git a/.node-version b/.node-version index 0a2925f0..2e6fb0f2 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -26.5.1 +26.6.0 diff --git a/AGENTS.md b/AGENTS.md index 67831f7b..77379148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ In all interactions, plans, and commit messages, be extremely concise and sacrif ## Architecture - Avoid adding dependencies. +- Keep non-browser logic in `core/`. Client folders should have only environment-specific code. +- Update `README.md` of the package on every new script, folder, or tool. ## LLMS @@ -27,3 +29,7 @@ In all interactions, plans, and commit messages, be extremely concise and sacrif ## Testing - Run specific test by `pnpm bnt path/to/test.test.ts -t 'test name'` + +## Browser + +- Use Playwright MCP for visual issues and client database bugs. See [docs/browser.md](./docs/browser.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62c4eea6..0cd02627 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,11 +81,10 @@ To fill your web clients with real posts: 4. Open browser DevTools. 5. Run `fillFeedsWithPosts()`. -To run the visual testing tool (Storybook), run it from the `web/` folder: +To run the visual testing tool (Storybook): ```sh -cd web/ -pnpm visual +pnpm -F web visual ``` It will show all UI components and all possible states of pages. It is the best tool to test browser compatibility or change CSS. diff --git a/README.md b/README.md index 54ca22b7..463e9182 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The source of truth in the client is a list of changes (action log). An action i } ``` -To render the UI, the client reduces actions from the log into the state (objects of feeds, posts, etc.). We store the state cache in [Nano Stores](https://github.com/nanostores/nanostores). +To render the UI, the client reduces actions from the log into SQL tables (feeds, posts, etc.) defined in [`core/schema.ts`](./core/schema.ts). The UI subscribes to reactive SQL queries from [Nano Stores SQL](https://github.com/nanostores/sql). Web keeps the database in SQLite compiled to WebAssembly, Node.js tests use in-memory SQLite. The log simplifies synchronization. We just need to track the last synchronized action and send all actions after that one. @@ -137,11 +137,11 @@ Each project has its own tools, too. - `pnpm test`: run all tests. - `pnpm quick`: run quick checks for changed files. - `pnpm offline`: run all tests expect which need Internet. It is useful for developing in airplane/train. -- `pnpm start`: run proxy and web client development server. +- `pnpm start`: run server and web client development server. - `pnpm format`: fix code style in all files. - `pnpm clean`: remove all temporary files. -- `pnpm check-opml`: test loaders with user’s OPML RSS export. -- `pnpm test-loaders`: test loaders with different blogging platforms. +- `pnpm -F loader-tests check-opml`: test loaders with user’s OPML RSS export. +- `pnpm -F loader-tests test`: test loaders with different blogging platforms. - `pnpm unused-messages`: check that all messages are used. - `pnpm update-env`: check for Node.js and pnpm updates. - `pnpm update-review`: run Multiocular to review updates. @@ -239,7 +239,7 @@ How we choose dependencies: You can use [bundlejs.com](https://bundlejs.com/) and [npmgraph.js.org](https://npmgraph.js.org) to get the size in bundle, `node_modules`, and number of sub-dependencies. -After adding a web client dependency, do not forget to call `cd web && pnpm size` to check the real size of dependency in our JS bundle. +After adding a web client dependency, do not forget to call `pnpm -F web size` to check the real size of dependency in our JS bundle. We put to `dependencies` only dependencies we need for production deploy. All other dependencies you should put to `devDependencies`. During production deploy we will use `pnpm install --prod` to reduce security risks of having malicious code in some dependency. diff --git a/api/http/utils.ts b/api/http/utils.ts index b96c51f3..6c4b55b7 100644 --- a/api/http/utils.ts +++ b/api/http/utils.ts @@ -76,9 +76,7 @@ export function createRequester( } export interface Endpoint< - // Need to put it inside type to pass all types to server in a single variable - // eslint-disable-next-line @typescript-eslint/no-unused-vars - Response, + _Response, Request, UrlParams extends Record = Record > { diff --git a/api/package.json b/api/package.json index a7d24c84..bfa7d850 100644 --- a/api/package.json +++ b/api/package.json @@ -7,13 +7,13 @@ "./package.json": "./package.json" }, "dependencies": { - "@logux/actions": "0.5.0" + "@logux/actions": "github:logux/actions#next" }, "devDependencies": { - "@logux/core": "0.10.0" + "@logux/core": "github:logux/core#next" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/core/README.md b/core/README.md index 48da7088..1265981d 100644 --- a/core/README.md +++ b/core/README.md @@ -32,7 +32,7 @@ In the best scenario, the client should just subscribe to stores, render UI acco ## Scripts -- `cd core && pnpm test`: run core unit tests and check coverage. +- `pnpm -F core test`: run core unit tests and check coverage. ## Client Environments @@ -71,10 +71,10 @@ We run unit tests by `node --test` with [`better-node-test`](https://github.com/ ```sh # Run all tests with coverage -cd core && pnpm test +pnpm -F core test # Run all tests without coverage (a little faster) -cd core && n bnt +pnpm -F core exec bnt # Run specific test file n bnt core/test/html.test.ts diff --git a/core/benchmark.ts b/core/benchmark.ts new file mode 100644 index 00000000..d0282931 --- /dev/null +++ b/core/benchmark.ts @@ -0,0 +1,368 @@ +import { persistentAtom } from '@nanostores/persistent' + +import { busyDuring } from './busy.ts' +import { addCategory } from './category.ts' +import { client } from './client.ts' +import { addFeed } from './feed.ts' +import { setLoaderReporter } from './loader.ts' +import { + addPost, + type NewPost, + type PostValue, + stringifyMedia +} from './post.ts' +import { setRequestMethod } from './request.ts' +import { encryptionKey, userId } from './settings.ts' + +export interface LoaderSpan { + end: number | undefined + name: string + start: number +} + +let rendered = new Set() +let spans: LoaderSpan[] | undefined + +setLoaderReporter(name => { + let span: LoaderSpan = { end: undefined, name, start: performance.now() } + rendered.add(span) + spans?.push(span) + return () => { + span.end = performance.now() + rendered.delete(span) + } +}) + +/** + * Start to collect spans of all rendered loaders. Returns function to stop + * and to get all spans. + */ +export function recordLoaders(): () => LoaderSpan[] { + let started: LoaderSpan[] = [] + spans = started + return () => { + if (spans === started) spans = undefined + return started + } +} + +/** + * Loaders, which are rendered right now. + */ +export function getRenderedLoaders(): LoaderSpan[] { + return [...rendered] +} + +export interface FillStatistics { + biggestCategory: string + debug: boolean + duration: number + feeds: number + posts: number + slowFeeds: string[] +} + +let debug = false + +/** + * Use small database and single run of every scenario to develop benchmark + * faster. Client should set it from `DEBUG=1` or `?benchmark=debug`. + */ +export function setBenchmarkDebug(value: boolean): void { + debug = value +} + +/** + * How many times every scenario should be repeated. + */ +export function getBenchmarkRuns(): number { + return debug ? 1 : 5 +} + +/** + * How many failed runs stop the scenario. + */ +export const BENCHMARK_GIVE_UP = 2 + +const SEED = 1 + +const MINUTE = 60 + +const HOUR = 60 * 60 + +const YEAR = 365 * 24 * 60 * 60 + +let newest = Math.round(Date.now() / 1000) + +const DISTRIBUTION: [number, number, number][] = [ + [0.4, 0, 10], + [0.35, 10, 50], + [0.15, 50, 200], + [0.08, 200, 800], + [0.02, 800, 2000] +] + +const WORDS = `about after again below could every first found great house +large learn light might never other place plant point right small sound spell +still study their there these thing think three under water where which world +would write years animal answer became better between change during enough +follow ground itself listen mother number people person picture problem school +second sentence several southern together toward without`.split(/\s+/) + +const IMAGE = + 'data:image/svg+xml,' + + encodeURIComponent( + '' + + '' + ) + +function createRandom(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let value = Math.imul(state ^ (state >>> 15), 1 | state) + value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value + return ((value ^ (value >>> 14)) >>> 0) / 4294967296 + } +} + +function between(random: () => number, from: number, to: number): number { + return from + Math.floor(random() * (to - from)) +} + +function words(random: () => number, from: number, to: number): string { + let list: string[] = [] + for (let i = between(random, from, to); i > 0; i--) { + list.push(WORDS[between(random, 0, WORDS.length)]!) + } + return list.join(' ') +} + +function sentence(random: () => number, from: number, to: number): string { + let text = words(random, from, to) + return text[0]!.toUpperCase() + text.slice(1) +} + +function paragraphs(random: () => number): string { + let list: string[] = [] + for (let i = between(random, 2, 6); i > 0; i--) { + list.push(`

${sentence(random, 20, 60)}.

`) + } + return list.join('') +} + +function item(id: string, postId: string, published: number): string { + return ( + `${postId}` + + `${postId}` + + `https://example.com/${id}/${postId}` + + `${new Date(published * 1000).toUTCString()}` + + `Benchmark post ${postId}` + ) +} + +function rss(url: string): string { + let id = new URL(url).pathname.replace(/^\//, '').replace(/\.xml$/, '') + let index = Number(id.match(/\d+$/)?.[0] ?? 0) + let old = Math.round(Date.now() / 1000) - HOUR + + let items = '' + if (index % 5 === 0) { + for (let i = 0; i < 3; i++) { + newest += MINUTE + items += item(id, `${id}-new-${newest}`, newest) + } + } + for (let i = 0; i < 10; i++) { + items += item(id, `${id}-post-${i}`, old) + } + + return ( + `` + + `${id}https://example.com/${id}` + + `${items}` + ) +} + +// Random distribution can’t promise them on small database of debug mode. +const ANCHORS: [PostValue['reading'], number][] = [ + ['slow', 300], + ['slow', 150], + ['fast', 300] +] + +function postsInFeed(random: () => number): number { + let point = random() + let sum = 0 + for (let [share, from, to] of DISTRIBUTION) { + sum += share + if (point <= sum) return between(random, from, to) + } + return 0 +} + +function createPost( + random: () => number, + feedId: string, + index: number, + reading: PostValue['reading'], + now: number +): NewPost { + return { + feedId, + full: paragraphs(random), + id: `${feedId}-post-${index}`, + intro: `

${sentence(random, 15, 40)}.

`, + media: + random() < 0.2 + ? stringifyMedia([{ type: 'image/svg+xml', url: IMAGE }]) + : undefined, + originId: `${feedId}-post-${index}`, + publishedAt: now - Math.floor(random() ** 2 * YEAR), + read: 0, + reading, + title: sentence(random, 3, 9), + url: `https://example.com/${feedId}/post-${index}` + } +} + +async function fillClient( + progress: (done: number, total: number) => void +): Promise { + let size = debug + ? { categories: 2, feeds: 10 } + : // Remove after fixing app’s memory usage. Right now the full benchmark + // is killing the browser. + // : { categories: 20, feeds: 1000 } + { categories: 10, feeds: 100 } + let random = createRandom(SEED) + let started = performance.now() + let now = Math.round(Date.now() / 1000) + + let categories = ['general'] + for (let i = 1; i < size.categories; i++) { + categories.push(await addCategory({ title: sentence(random, 1, 3) })) + } + + let biggestCategory = 'general' + let biggestFast = 0 + let posts = 0 + let slow: [string, number][] = [] + for (let i = 0; i < size.feeds; i++) { + let anchor = ANCHORS[i] + let reading: PostValue['reading'] = + anchor?.[0] ?? (random() < 0.7 ? 'fast' : 'slow') + let categoryId = categories[between(random, 0, categories.length)]! + let feedId = `feed-${i}` + await addFeed({ + categoryId, + id: feedId, + lastOriginId: `${feedId}-post-0`, + lastPublishedAt: now, + loader: 'rss', + reading, + refreshedAt: now, + title: sentence(random, 1, 4), + url: `https://example.com/${feedId}.xml` + }) + + let count = anchor?.[1] ?? postsInFeed(random) + let batch: Promise[] = [] + for (let j = 0; j < count; j++) { + batch.push(addPost(createPost(random, feedId, j, reading, now))) + if (batch.length === 100) { + await Promise.all(batch) + batch = [] + } + } + await Promise.all(batch) + + if (reading === 'slow') { + slow.push([feedId, count]) + } else if (count > biggestFast) { + biggestCategory = categoryId + biggestFast = count + } + posts += count + progress(i + 1, size.feeds) + } + + return { + biggestCategory, + debug, + duration: performance.now() - started, + feeds: size.feeds, + posts, + slowFeeds: slow + .toSorted((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(i => i[0]) + } +} + +/** + * Statistics of the last created benchmark data. + */ +export const benchmarkStatistics = persistentAtom( + 'slowreader:benchmark', + undefined, + { + decode(value) { + return JSON.parse(value) as FillStatistics + }, + encode(value) { + return JSON.stringify(value) + } + } +) + +/** + * Answer feed requests with generated RSS instead of the network. + */ +export function mockBenchmarkRequests(): void { + setRequestMethod(url => { + let known = /^\/(new-)?feed-\d+\.xml$/.test(new URL(url).pathname) + let response = new Response(known ? rss(url) : '', { + headers: { + 'Content-Type': known ? 'application/rss+xml' : 'text/plain' + } + }) + Object.defineProperty(response, 'url', { value: url }) + return Promise.resolve(response) + }) +} + +/** + * Create local benchmark user to get the client without the server. + */ +export async function signInBenchmark(): Promise { + if (userId.get() !== '9999999999999999') { + encryptionKey.set('benchmarkKey') + userId.set('9999999999999999') + } + if (!client.get()) { + await new Promise(resolve => { + let unbind = client.subscribe(logux => { + if (logux) { + setTimeout(() => { + unbind() + resolve() + }) + } + }) + }) + } +} + +/** + * Create categories, feeds and posts and save statistics about them. + */ +export async function fillBenchmarkData(): Promise { + let statistics = await busyDuring(setProgress => { + return fillClient((done, total) => { + setProgress(done / total) + }) + }, 'Creating benchmark data') + benchmarkStatistics.set(statistics) + return statistics +} diff --git a/core/busy.ts b/core/busy.ts index 2fae237e..5a99310e 100644 --- a/core/busy.ts +++ b/core/busy.ts @@ -1,20 +1,43 @@ -import { atom, computed } from 'nanostores' +import { atom } from 'nanostores' -let $tasks = atom(0) +export interface BusyValue { + label?: string + progress?: number +} + +let tasks: BusyValue[] = [] /** * Show loader over whole app until task is running. */ -export async function busyDuring(cb: () => Promise): Promise { - $tasks.set($tasks.get() + 1) - try { - await cb() - } finally { - $tasks.set($tasks.get() - 1) +export const busy = atom(false) + +function update(): void { + if (tasks.length === 0) { + busy.set(false) + } else { + let last = tasks.findLast(i => i.label || i.progress !== undefined) + busy.set({ label: last?.label, progress: last?.progress }) } } /** - * Do we need to show loader over whole app. + * Show loader over whole app until task is running. */ -export const busy = computed($tasks, tasks => tasks > 0) +export async function busyDuring( + cb: (setProgress: (progress: number) => void) => Promise, + label?: string +): Promise { + let task: BusyValue = { label } + tasks.push(task) + update() + try { + return await cb(progress => { + task.progress = progress + update() + }) + } finally { + tasks = tasks.filter(i => i !== task) + update() + } +} diff --git a/core/category.ts b/core/category.ts index 952c34bd..408d4e6e 100644 --- a/core/category.ts +++ b/core/category.ts @@ -1,78 +1,69 @@ -import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type FilterStore, - loadValue, - type SyncMapStore, - syncMapTemplate -} from '@logux/client' -import { nanoid } from 'nanoid' -import { atom } from 'nanostores' - -import { getClient } from './client.ts' -import { changeFeed, type FeedValue, getFeeds } from './feed.ts' +import type { SqlStore } from '@nanostores/sql' +import { atom, type ReadableAtom } from 'nanostores' + +import { changeFeed, loadFeedsByCategory } from './feed.ts' +import { firstRow } from './lib/stores.ts' import { commonMessages as t } from './messages/index.ts' -import type { UsefulReaderName } from './readers/common.ts' +import { + type CategoryChanges, + type CategoryValue, + createRow, + type FeedValue, + getTables, + type NewCategory, + select +} from './schema.ts' -export type CategoryValue = { - fastReader?: UsefulReaderName - id: string - slowReader?: UsefulReaderName - title: string -} +export type { CategoryValue, NewCategory } export type FeedsByCategory = [CategoryValue, FeedValue[]][] -export const Category = syncMapTemplate('categories', { - offline: true, - remote: false -}) - -export function getCategory(categoryId: string): SyncMapStore { +export function getCategory( + categoryId: string +): ReadableAtom { if (categoryId === 'general') { /* node:coverage ignore next 2 */ return atom(getGeneralCategory()) } else { - return Category(categoryId, getClient()) + return firstRow(getTables().categories.select`WHERE "id" = ${categoryId}`) } } -export function getCategories(): FilterStore { - return createFilter(getClient(), Category) +export function getCategories(): SqlStore { + return getTables().categories.select`ORDER BY "title"` +} + +export async function loadCategory( + categoryId: string +): Promise { + if (categoryId === 'general') return getGeneralCategory() + let rows = await select` + SELECT * FROM "categories" WHERE "id" = ${categoryId} + ` + return rows[0] +} + +export function loadCategories(): Promise { + return select`SELECT * FROM "categories" ORDER BY "title"` } -export async function addCategory( - fields: Omit -): Promise { - let id = nanoid() - await createSyncMap(getClient(), Category, { id, ...fields }) - return id +export function addCategory(fields: NewCategory): Promise { + return createRow(getTables().categories, fields) } -export async function changeCategory( +export function changeCategory( categoryId: string, - changes: Partial + changes: CategoryChanges ): Promise { - return changeSyncMapById(getClient(), Category, categoryId, changes) + return getTables().categories.update(categoryId, changes) } export async function deleteCategory(categoryId: string): Promise { - let feeds = await loadValue(getFeeds({ categoryId })) + let feeds = await loadFeedsByCategory(categoryId) await Promise.all( - feeds.list.map(async feed => { - if (!feeds.stores.get(feed.id)?.deleted) { - await changeFeed(feed.id, { categoryId: 'general' }) - } - }) + feeds.map(feed => changeFeed(feed.id, { categoryId: 'general' })) ) - return deleteSyncMapById(getClient(), Category, categoryId) -} - -export const GENERAL_CATEGORY: CategoryValue = { - id: 'general', - title: '' + return getTables().categories.delete(categoryId) } export function feedsByCategory( @@ -115,5 +106,11 @@ export function feedsByCategory( } export function getGeneralCategory(): CategoryValue { - return { id: 'general', title: t.value?.generalCategory || 'General' } + return { + fastReader: null, + id: 'general', + slowReader: null, + title: t.value?.generalCategory || 'General', + updatedAt: '{}' + } } diff --git a/core/current-page.ts b/core/current-page.ts index 6b846f9e..c068039b 100644 --- a/core/current-page.ts +++ b/core/current-page.ts @@ -40,7 +40,7 @@ function getPageParams( for (let i in page.params) { let name = i as keyof SomeRoute['params'] // @ts-expect-error Too complex types for TS - // eslint-disable-next-line + // oxlint-disable-next-line params[name] = page.params[name].get() } return params diff --git a/core/devtools.ts b/core/devtools.ts index 16b1d95e..b9ba8897 100644 --- a/core/devtools.ts +++ b/core/devtools.ts @@ -1,16 +1,14 @@ -import { loadValue } from '@logux/client' - import { busyDuring } from './busy.ts' import { HTTPStatusError } from './errors.ts' -import { getFeed, getFeedLatestPosts, getFeeds } from './feed.ts' -import { loadFilters } from './filter.ts' +import { getFeedLatestPosts, loadFeed, loadFeeds } from './feed.ts' +import { loadFilterChecker } from './filter.ts' import { createDownloadTask } from './lib/download.ts' import { loaders } from './loader/index.ts' import { addPost, deletePost, - getPost, - getPosts, + loadPost, + loadPostsByFeed, processOriginPost } from './post.ts' import { proxyDebug } from './request.ts' @@ -22,15 +20,15 @@ import { router } from './router.ts' export async function fillFeedsWithPosts(): Promise { await busyDuring(async () => { let task = createDownloadTask() - let feeds = await loadValue(getFeeds()) + let feeds = await loadFeeds() await Promise.all( - feeds.list.map(async feed => { - let old = await loadValue(getPosts({ feedId: feed.id })) - for (let post of old.list) { + feeds.map(async feed => { + let old = await loadPostsByFeed(feed.id) + for (let post of old) { await deletePost(post.id) } let posts = await getFeedLatestPosts(feed, task).next() - let filters = await loadFilters({ feedId: feed.id }) + let filters = await loadFilterChecker(feed.id) for (let origin of posts) { let reading = filters(origin) ?? feed.reading if (reading !== 'delete') { @@ -56,9 +54,9 @@ export function enablePostDebug(): void { id = popup.param.slice(5) } if (id) { - let post = await loadValue(getPost(id)) + let post = await loadPost(id) if (!post) return - let feed = await loadValue(getFeed(post.feedId)) + let feed = await loadFeed(post.feedId) let source = await loaders[feed!.loader].getPostSource( feed!, post.originId diff --git a/core/environment.ts b/core/environment.ts index 0cfe63bc..65051b23 100644 --- a/core/environment.ts +++ b/core/environment.ts @@ -9,6 +9,7 @@ import { type PersistentStore, setPersistentEngine } from '@nanostores/persistent' +import type { Database } from '@nanostores/sql' import { atom, type ReadableAtom, type StoreValue } from 'nanostores' import type { BaseRouter, Route, Routes } from './router.ts' @@ -17,6 +18,10 @@ interface LogStoreCreator { (): ClientOptions['store'] } +interface DatabaseCreator { + (): Database +} + export type NetworkType = 'free' | 'paid' | 'unknown' | undefined export interface NetworkTypeDetector { @@ -71,10 +76,13 @@ export interface Environment { cleanStorage(): void /** - * Object like `window` in web or `process` in Node.js to track unhandled - * errors. - * - * For instance, we are using it to catch not-found errors. + * SQL database engine. Like SQLocal in Web, in-memory SQLite in Node.js, + * Expo for mobile, etc. + */ + databaseCreator: DatabaseCreator + + /** + * `window` in web or `process` in Node.js to track unhandled errors. */ errorEvents: ErrorEvents @@ -191,6 +199,7 @@ export function setupEnvironment( currentEnvironment = { baseRouter: env.baseRouter, cleanStorage: env.cleanStorage, + databaseCreator: env.databaseCreator, errorEvents: env.errorEvents, getSession: env.getSession, locale: env.locale, diff --git a/core/feed.ts b/core/feed.ts index a898249d..02373bb2 100644 --- a/core/feed.ts +++ b/core/feed.ts @@ -1,73 +1,72 @@ -import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type Filter, - type FilterStore, - loadValue, - type SyncMapStore, - syncMapTemplate -} from '@logux/client' -import { nanoid } from 'nanoid' -import { atom, onMount } from 'nanostores' - -import { getClient } from './client.ts' +import type { SqlStore } from '@nanostores/sql' +import { atom, onMount, type ReadableAtom } from 'nanostores' + import { createDownloadTask, type TextResponse } from './lib/download.ts' -import type { OptionalId } from './lib/stores.ts' -import { type FeedLoader, type LoaderName, loaders } from './loader/index.ts' -import { deletePost, getPosts, recalcPostsReading } from './post.ts' +import { firstRow } from './lib/stores.ts' +import { type FeedLoader, loaders } from './loader/index.ts' +import { deletePost, loadPostsByFeed, recalcPostsReading } from './post.ts' import type { PostsList } from './posts-list.ts' -import type { UsefulReaderName } from './readers/common.ts' - -export type FeedValue = { - categoryId: string - fastReader?: UsefulReaderName - id: string - lastOriginId?: string - lastPublishedAt?: number - loader: LoaderName - reading: 'fast' | 'slow' - refreshedAt?: number - slowReader?: UsefulReaderName - title: string - url: string +import { + createRow, + type FeedChanges, + type FeedValue, + getDatabase, + getTables, + type NewFeed, + select +} from './schema.ts' + +export type { FeedValue, NewFeed } + +export function getFeed(feedId: string): ReadableAtom { + return firstRow(getTables().feeds.select`WHERE "id" = ${feedId}`) } -export const Feed = syncMapTemplate('feeds', { - offline: true, - remote: false -}) +export function getFeeds(): SqlStore { + return getTables().feeds.select`ORDER BY "title"` +} -export function getFeed(feedId: string): SyncMapStore { - return Feed(feedId, getClient()) +export function getFeedsByUrl(url: string): SqlStore { + return getTables().feeds.select`WHERE "url" = ${url}` } -export function getFeeds( - filter: Filter = {} -): FilterStore { - return createFilter(getClient(), Feed, filter) +export function loadFeed(feedId: string): Promise { + return select`SELECT * FROM "feeds" WHERE "id" = ${feedId}`.then( + rows => rows[0] + ) +} + +export function loadFeeds(): Promise { + return select`SELECT * FROM "feeds" ORDER BY "title"` +} + +export function loadFeedsByCategory(categoryId: string): Promise { + return select` + SELECT * FROM "feeds" WHERE "categoryId" = ${categoryId} ORDER BY "title" + ` +} + +export function loadFeedByUrl(url: string): Promise { + return select`SELECT * FROM "feeds" WHERE "url" = ${url}`.then( + rows => rows[0] + ) } -export async function addFeed(fields: OptionalId): Promise { - let id = fields.id ?? nanoid() - await createSyncMap(getClient(), Feed, { id, ...fields }) - return id +export function addFeed(fields: NewFeed): Promise { + return createRow(getTables().feeds, fields) } export async function deleteFeed(feedId: string): Promise { - let feed = Feed.cache[feedId] - if (feed) feed.deleted = true - let posts = await loadValue(getPosts({ feedId })) - await Promise.all(posts.list.map(post => deletePost(post.id))) - return deleteSyncMapById(getClient(), Feed, feedId) + let posts = await loadPostsByFeed(feedId) + await Promise.all(posts.map(post => deletePost(post.id))) + return getTables().feeds.delete(feedId) } export async function changeFeed( feedId: string, - changes: Partial + changes: FeedChanges ): Promise { - await changeSyncMapById(getClient(), Feed, feedId, changes) + await getTables().feeds.update(feedId, changes) await recalcPostsReading(feedId) } @@ -76,7 +75,7 @@ export async function changeFeed( */ export async function addCandidate( candidate: FeedLoader, - fields: Partial = {}, + fields: Partial = {}, task = createDownloadTask(), response?: TextResponse ): Promise { @@ -104,7 +103,7 @@ export function getFeedLatestPosts( task, feed.url, undefined, - feed.refreshedAt + feed.refreshedAt ?? undefined ) } @@ -114,12 +113,16 @@ export function testFeed(feed: Partial = {}): FeedValue { testFeedId += 1 return { categoryId: 'general', + fastReader: null, id: `feed-${testFeedId}`, - lastOriginId: undefined, - lastPublishedAt: undefined, + lastOriginId: null, + lastPublishedAt: null, loader: 'rss', reading: 'fast', + refreshedAt: null, + slowReader: null, title: `Test ${testFeedId}`, + updatedAt: '{}', url: `http://example.com/${testFeedId}`, ...feed } @@ -127,7 +130,10 @@ export function testFeed(feed: Partial = {}): FeedValue { export const needWelcome = atom() onMount(needWelcome, () => { - return getFeeds().subscribe(feeds => { - needWelcome.set(feeds.isLoading ? undefined : feeds.isEmpty) + let $total = getDatabase().store<{ + total: number + }>`SELECT COUNT("url") AS "total" FROM "feeds"` + return $total.subscribe(value => { + needWelcome.set(value.isLoading ? undefined : value.value[0]!.total === 0) }) }) diff --git a/core/filter.ts b/core/filter.ts index d415f61f..a5d1240e 100644 --- a/core/filter.ts +++ b/core/filter.ts @@ -1,18 +1,15 @@ +import { type PostContent, recalcPostsReading } from './post.ts' import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type FilterStore, - loadValue, - type Filter as LoguxFilter, - syncMapTemplate -} from '@logux/client' -import { nanoid } from 'nanoid' - -import { getClient } from './client.ts' -import type { FeedValue } from './feed.ts' -import { type OriginPost, recalcPostsReading } from './post.ts' + createRow, + type FeedValue, + type FilterChanges, + type FilterValue, + getTables, + type NewFilter, + select +} from './schema.ts' + +export type { FilterValue, NewFilter } const QUERY_REGEXP = /^(not\s+)?([\w]+)(?:\(([^]+)\))?$/ @@ -30,11 +27,11 @@ const QUERIES = { } } satisfies { [Key in string]?: keyof typeof QUERIES_WITH_PARAM extends Key - ? (post: OriginPost, param: string) => boolean - : (post: OriginPost) => boolean + ? (post: PostContent, param: string) => boolean + : (post: PostContent) => boolean } -export type FilterAction = 'delete' | 'fast' | 'slow' +export type FilterAction = FilterValue['action'] function maxPriority(filters: FilterValue[]): number { let max = 0 @@ -44,34 +41,29 @@ function maxPriority(filters: FilterValue[]): number { return max } -export type FilterValue = { - action: FilterAction - feedId: string - id: string - priority: number - query: string +export function loadFilters(): Promise { + return select`SELECT * FROM "filters" ORDER BY "priority", "id"` } -export const Filter = syncMapTemplate('filters', { - offline: true, - remote: false -}) +export function loadFiltersByFeed(feedId: string): Promise { + return select` + SELECT * FROM "filters" WHERE "feedId" = ${feedId} + ORDER BY "priority", "id" + ` +} -export function getFilters( - filter: LoguxFilter = {} -): FilterStore { - return createFilter(getClient(), Filter, filter) +export function loadFilter(filterId: string): Promise { + return select` + SELECT * FROM "filters" WHERE "id" = ${filterId} + `.then(rows => rows[0]) } export async function addFilter( - fields: Omit + fields: { priority?: number } & Omit ): Promise { - let id = nanoid() - let other = await loadValue(getFilters({ feedId: fields.feedId })) - let priority = maxPriority(other.list) + 100 - await createSyncMap(getClient(), Filter, { id, priority, ...fields }) + let priority = maxPriority(await loadFiltersByFeed(fields.feedId)) + 100 + let id = await createRow(getTables().filters, { priority, ...fields }) await recalcPostsReading(fields.feedId) - return id } @@ -84,9 +76,9 @@ export async function addFilterForFeed(feed: FeedValue): Promise { } export async function deleteFilter(filterId: string): Promise { - let filter = await loadValue(Filter(filterId, getClient())) + let filter = await loadFilter(filterId) - await deleteSyncMapById(getClient(), Filter, filterId) + await getTables().filters.delete(filterId) if (filter) { await recalcPostsReading(filter.feedId) @@ -95,10 +87,10 @@ export async function deleteFilter(filterId: string): Promise { export async function changeFilter( filterId: string, - changes: Partial + changes: FilterChanges ): Promise { - await changeSyncMapById(getClient(), Filter, filterId, changes) - let filter = await loadValue(Filter(filterId, getClient())) + await getTables().filters.update(filterId, changes) + let filter = await loadFilter(filterId) if (filter) { await recalcPostsReading(filter.feedId) } @@ -121,11 +113,9 @@ export function sortFilters(filters: FilterValue[]): FilterValue[] { * relative to neighboring filters to maintain sort order. */ async function move(filterId: string, diff: -1 | 1): Promise { - let store = Filter(filterId, getClient()) - let filter = await loadValue(store) + let filter = await loadFilter(filterId) if (!filter) return - let feedId = filter.feedId - let sorted = sortFilters((await loadValue(getFilters({ feedId }))).list) + let sorted = sortFilters(await loadFiltersByFeed(filter.feedId)) let last = sorted.length - 1 let index = sorted.findIndex(i => i.id === filterId) let next = index + diff @@ -192,7 +182,7 @@ export function isValidFilterQuery(query: string): boolean { } export interface FilterChecker { - (post: OriginPost): FilterAction | undefined + (post: PostContent): FilterAction | undefined } export function prepareFilters(filters: FilterValue[]): FilterChecker { @@ -224,9 +214,8 @@ export function prepareFilters(filters: FilterValue[]): FilterChecker { } } -export async function loadFilters( - filter: LoguxFilter = {} +export async function loadFilterChecker( + feedId: string ): Promise { - let filters = await loadValue(getFilters(filter)) - return prepareFilters(filters.list) + return prepareFilters(await loadFiltersByFeed(feedId)) } diff --git a/core/index.ts b/core/index.ts index f491e597..55b1ce84 100644 --- a/core/index.ts +++ b/core/index.ts @@ -16,6 +16,7 @@ export * from './lib/html.ts' export * from './lib/pagination.ts' export { subscribeUntil, waitLoading } from './lib/stores.ts' export * from './lib/validators.ts' +export * from './loader.ts' export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' @@ -29,4 +30,5 @@ export * from './readers/index.ts' export * from './refresh.ts' export * from './request.ts' export * from './router.ts' +export * from './schema.ts' export * from './settings.ts' diff --git a/core/lib/stores.ts b/core/lib/stores.ts index 84a5cdac..3f0992c0 100644 --- a/core/lib/stores.ts +++ b/core/lib/stores.ts @@ -1,17 +1,20 @@ // Helpers to work with Nano Stores and Logux to simplify code // by moving complexity to helper. -import type { Action } from '@logux/core' +import type { SqlStore } from '@nanostores/sql' import { + computed, type MapStore, onMount, type ReadableAtom, type StoreValue } from 'nanostores' -import { client } from '../client.ts' - -export type OptionalId = { id?: string } & Omit +export function firstRow( + store: SqlStore +): ReadableAtom { + return computed(store, rows => (rows.isLoading ? undefined : rows.value[0])) +} type NumberKeys = { [K in keyof T]: T[K] extends number ? K : never @@ -21,7 +24,7 @@ export function increaseKey( store: Store, key: NumberKeys> ): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // oxlint-disable-next-line typescript/no-unsafe-member-access let value = store.get()[key] as number store.setKey(key, value + 1) } @@ -48,30 +51,15 @@ export function waitLoading(store: ReadableAtom): Promise { }) } -export type LoadedValue = { - isLoading: false -} & Type - -export type LoadableStore = { - readonly loading: Promise -} & ReadableAtom<{ isLoading: boolean }> - -/** - * Return promise which wait until `store.isLoading` is stop to have `true`. - */ -export async function waitSyncLoading( - store: Store -): Promise>>> { - let value = store.get() - if (value.isLoading) { - let unbind = store.listen(() => {}) - try { - await store.loading - } finally { - unbind() - } +export async function waitSql(store: SqlStore): Promise { + let unbind = store.listen(() => {}) + try { + await store.loading + let value = store.get() + return value.isLoading ? [] : value.value + } finally { + unbind() } - return store as unknown as ReadableAtom>> } /** @@ -100,27 +88,6 @@ export function onMountAny(stores: ReadableAtom[], cb: () => () => void): void { } } -/** - * Run callback on every Logux action. - * - * It hides complexity of client re-creating on sign-in to re-subscribing. - */ -export function onLogAction(cb: (action: Action) => void): () => void { - let unbindLog: (() => void) | undefined - let unbindClient = client.subscribe(loguxClient => { - unbindLog?.() - unbindLog = undefined - if (loguxClient) { - unbindLog = loguxClient.on('add', cb) - } - }) - - return () => { - unbindLog?.() - unbindClient() - } -} - /** * Subscribe to store and run callback on every store’s change until callback * return `true`. diff --git a/core/loader.ts b/core/loader.ts new file mode 100644 index 00000000..d5da50c0 --- /dev/null +++ b/core/loader.ts @@ -0,0 +1,23 @@ +// Loaders report themselves to the benchmark. Reporter is empty by default +// and `benchmark.ts` sets the real one, so clients will not download +// benchmark tools. + +export interface LoaderReporter { + (name: string): () => void +} + +let reporter: LoaderReporter = () => { + return () => {} +} + +export function setLoaderReporter(cb: LoaderReporter): void { + reporter = cb +} + +/** + * Tell benchmark that the loader is rendered. Returns function to call + * when the loader was removed from the screen. + */ +export function reportLoader(name: string): () => void { + return reporter(name) +} diff --git a/core/menu.ts b/core/menu.ts index 0b66bdcf..de847fd3 100644 --- a/core/menu.ts +++ b/core/menu.ts @@ -1,20 +1,14 @@ -import { ensureLoaded, loadValue } from '@logux/client' import { persistentAtom } from '@nanostores/persistent' -import { atom, computed, keepMount } from 'nanostores' +import { atom, computed, effect, keepMount } from 'nanostores' import { busyDuring } from './busy.ts' -import { - type CategoryValue, - getCategories, - getGeneralCategory -} from './category.ts' +import { type CategoryValue, getGeneralCategory } from './category.ts' import { client } from './client.ts' import { layoutType } from './environment.ts' -import { type FeedValue, getFeeds } from './feed.ts' -import { getFilters } from './filter.ts' -import { onLogAction, onMountAny, waitLoading } from './lib/stores.ts' -import { getPosts } from './post.ts' +import type { FeedValue } from './feed.ts' +import { onMountAny, waitLoading } from './lib/stores.ts' import { isOtherRoute, router } from './router.ts' +import { getDatabase } from './schema.ts' export type MenuType = 'fast' | 'other' | 'slow' @@ -78,79 +72,58 @@ export let fastMenu = atom([]) export let slowMenu = atom([]) export let menuLoading = atom(true) +type UnreadCount = { feedId: string; unread: number } + /** * Performance optimization is postponed after the prototype. * * So we rebuild the fast/slow feeds menu on every feed/category/filter changes. */ -async function rebuild(): Promise { - let [posts, feeds, categories, fastFilters] = await Promise.all([ - loadValue(getPosts()), - loadValue(getFeeds()), - loadValue(getCategories()), - loadValue(getFilters({ action: 'fast' })) - ]) - - let slowPosts = posts.list.filter(i => i.reading === 'slow' && !i.read) - let fastFeeds = feeds.list.filter(i => i.reading === 'fast') - - let feedsWithFastFilters = fastFilters.list.map(i => { - return feeds.stores.get(i.feedId)!.get() - }) - - let uniqueFastCategories: Record = {} - for (let feed of [...fastFeeds, ...feedsWithFastFilters]) { - let id = ensureLoaded(feed).categoryId - if (!uniqueFastCategories[id]) { - if (id === 'general') { - uniqueFastCategories[id] = getGeneralCategory() - } else { - let found = categories.list.find(i => i.id === id) - if (found) uniqueFastCategories[id] = found - } +function rebuild( + categories: CategoryValue[], + fastCategories: Pick[], + slowFeeds: FeedValue[], + unread: UnreadCount[] +): void { + let byId = new Map(categories.map(category => [category.id, category])) + + let fast: CategoryValue[] = [] + for (let { categoryId } of fastCategories) { + if (categoryId === 'general') { + fast.push(getGeneralCategory()) + } else { + let found = byId.get(categoryId) + if (found) fast.push(found) } } + fast.sort((a, b) => a.title.localeCompare(b.title)) + fastMenu.set(fast.length > 0 ? fast : [getGeneralCategory()]) - let fast = Object.values(uniqueFastCategories).toSorted((a, b) => { - return a.title.localeCompare(b.title) - }) - - if (fast.length > 0) { - fastMenu.set(fast) - } else { - fastMenu.set([getGeneralCategory()]) - } - - let byCategory: Record = {} + let unreadByFeed = new Map(unread.map(i => [i.feedId, i.unread])) + let byCategory = new Map() let general = false - - let unreadByFeed: Record = {} - for (let post of slowPosts) { - unreadByFeed[post.feedId] = (unreadByFeed[post.feedId] ?? 0) + 1 - } - for (let [feedId, unread] of Object.entries(unreadByFeed)) { - let feedStore = feeds.stores.get(feedId) - if (!feedStore) continue - let feed = ensureLoaded(feedStore.get()) - let category = feed.categoryId - if (category === 'general') { + for (let feed of slowFeeds) { + if (feed.categoryId === 'general') { general = true - } else if (!categories.stores.has(category)) { + } else if (!byId.has(feed.categoryId)) { continue } - if (!byCategory[category]) byCategory[category] = [] - byCategory[category].push([feed, unread]) + let list = byCategory.get(feed.categoryId) + if (!list) { + list = [] + byCategory.set(feed.categoryId, list) + } + list.push([feed, unreadByFeed.get(feed.id)!]) } - let allCategories = [...categories.list] as CategoryValue[] + let allCategories = [...categories] if (general) allCategories.push(getGeneralCategory()) - let categoriesByName = allCategories.toSorted((a, b) => { - return a.title.localeCompare(b.title) - }) let result: SlowMenu = [] - for (let category of categoriesByName) { - let list = byCategory[category.id] + for (let category of allCategories.toSorted((a, b) => { + return a.title.localeCompare(b.title) + })) { + let list = byCategory.get(category.id) if (list) { list.sort((a, b) => a[0].title.localeCompare(b[0].title)) result.push([category, list]) @@ -163,22 +136,49 @@ async function rebuild(): Promise { onMountAny([fastMenu, slowMenu], () => { menuLoading.set(true) - void rebuild().then(() => { - menuLoading.set(false) - }) - let unbindAction = onLogAction(action => { - if ( - action.type.startsWith('categories/') || - action.type.startsWith('feeds/') || - action.type.startsWith('posts/') || - action.type.startsWith('filters/') - ) { - void rebuild() + let database = getDatabase() + let unbind = effect( + [ + database.store`SELECT * FROM "categories"`, + database.store>` + SELECT DISTINCT "categoryId" FROM "feeds" + WHERE "reading" = 'fast' + OR "id" IN (SELECT "feedId" FROM "filters" WHERE "action" = 'fast') + `, + database.store` + SELECT * FROM "feeds" WHERE "id" IN ( + SELECT "feedId" FROM "posts" + WHERE "reading" = 'slow' AND "read" = 0 + ) + `, + database.store` + SELECT "feedId", COUNT("originId") AS "unread" FROM "posts" + WHERE "reading" = 'slow' AND "read" = 0 + GROUP BY "feedId" + ` + ], + (categories, fastCategories, slowFeeds, unread) => { + if ( + categories.isLoading || + fastCategories.isLoading || + slowFeeds.isLoading || + unread.isLoading + ) { + return + } + // TODO Logux DB: replace with reducer + rebuild( + categories.value, + fastCategories.value, + slowFeeds.value, + unread.value + ) + menuLoading.set(false) } - }) + ) return () => { - unbindAction() + unbind() fastMenu.set([]) slowMenu.set([]) menuLoading.set(true) diff --git a/core/package.json b/core/package.json index ca21a44b..5b957cd0 100644 --- a/core/package.json +++ b/core/package.json @@ -4,39 +4,41 @@ "type": "module", "exports": { ".": "./index.ts", + "./benchmark": "./benchmark.ts", "./package.json": "./package.json", "./test": "./test.ts" }, "scripts": { "test": "FORCE_COLOR=1 pnpm run /^test:/", - "test:coverage": "bnt --coverage 100 --coverage-exclude '{devtools.ts,errors.ts,test.ts,../api/**,../server/**,../proxy/**,**/test/**}'" + "test:coverage": "bnt --coverage 100 --coverage-exclude '{benchmark*,devtools.ts,errors.ts,test.ts,../api/**,../server/**,../proxy/**,**/test/**}'" }, "dependencies": { - "@logux/actions": "0.5.0", - "@logux/client": "0.23.2", - "@logux/core": "0.10.0", + "@logux/actions": "github:logux/actions#next", + "@logux/client": "github:logux/client#next", + "@logux/core": "github:logux/core#next", "@nanostores/i18n": "1.3.3", "@nanostores/persistent": "1.3.5", + "@nanostores/sql": "0.3.1", "@slowreader/api": "workspace:*", "@slowreader/server": "workspace:*", "dompurify": "3.4.12", "just-debounce-it": "3.2.0", "lz-string": "1.5.0", "nanodelay": "2.0.2", - "nanoid": "6.0.0", - "nanostores": "1.4.1" + "nanoid": "6.0.1", + "nanostores": "1.4.2" }, "devDependencies": { - "@logux/server": "0.14.0", + "@logux/server": "github:logux/server", "@slowreader/proxy": "workspace:*", "@types/jsdom": "28.0.3", "@types/trusted-types": "2.0.7", "better-node-test": "1.0.0", - "jsdom": "30.0.0", + "jsdom": "30.0.1", "nanospy": "2.0.2" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/core/pages/export.ts b/core/pages/export.ts index d365aa13..9d408994 100644 --- a/core/pages/export.ts +++ b/core/pages/export.ts @@ -1,16 +1,15 @@ -import type { SyncMapValues } from '@logux/actions' -import { type FilterStore, loadValue } from '@logux/client' import { atom } from 'nanostores' import { type CategoryValue, feedsByCategory, - getCategories + loadCategories } from '../category.ts' import { getEnvironment } from '../environment.ts' -import { type FeedValue, getFeeds } from '../feed.ts' -import { type FilterValue, getFilters } from '../filter.ts' -import { getPosts, type PostValue } from '../post.ts' +import { type FeedValue, loadFeeds } from '../feed.ts' +import { type FilterValue, loadFilters } from '../filter.ts' +import { loadPosts, type PostValue } from '../post.ts' +import { withoutMeta } from '../schema.ts' import { preloadImages, type Settings, @@ -21,10 +20,10 @@ import { import { createPage } from './common.ts' export interface StateExport { - categories: Omit[] - feeds: Omit[] - filters: Omit[] - posts: Omit[] + categories: Omit[] + feeds: Omit[] + filters: Omit[] + posts: Omit[] settings: Settings } @@ -45,16 +44,6 @@ export function isStateExportFile(state: unknown): state is StateExport { ) } -async function loadList( - filter: FilterStore -): Promise[]> { - return (await loadValue(filter)).list.map(i => { - let copy = { ...i } as Omit - delete copy.isLoading - return copy - }) -} - const NO_OPML_CATEGORY: Record = { general: true } @@ -88,14 +77,14 @@ export const exportPage = createPage('export', () => { ' \n' let [categories, allFeeds] = await Promise.all([ - loadValue(getCategories()), - loadValue(getFeeds()) + loadCategories(), + loadFeeds() ]) if (stopped) { $exportingOpml.set(false) return } - let tree = feedsByCategory(categories.list, allFeeds.list) + let tree = feedsByCategory(categories, allFeeds) for (let [category, feeds] of tree) { if (!NO_OPML_CATEGORY[category.id]) { @@ -125,10 +114,10 @@ export const exportPage = createPage('export', () => { async function exportBackup(): Promise { $exportingBackup.set(true) let state = { - categories: await loadList(getCategories()), - feeds: await loadList(getFeeds()), - filters: await loadList(getFilters()), - posts: await loadList(getPosts()), + categories: withoutMeta(await loadCategories()), + feeds: withoutMeta(await loadFeeds()), + filters: withoutMeta(await loadFilters()), + posts: withoutMeta(await loadPosts()), settings: { preloadImages: preloadImages.get(), theme: theme.get(), diff --git a/core/pages/feeds-by-categories.ts b/core/pages/feeds-by-categories.ts index 96ccd78a..b840c8cc 100644 --- a/core/pages/feeds-by-categories.ts +++ b/core/pages/feeds-by-categories.ts @@ -19,7 +19,7 @@ export const feedsByCategoriesPage = createPage('feedsByCategories', () => { let $loading = atom(true) let unbind = effect([$categories, $feeds], (categories, feeds) => { if (!categories.isLoading && !feeds.isLoading) { - $groups.set(feedsByCategory(categories.list, feeds.list)) + $groups.set(feedsByCategory(categories.value, feeds.value)) $loading.set(false) } else { $loading.set(true) diff --git a/core/pages/feeds.ts b/core/pages/feeds.ts index 940db694..d3eb92bc 100644 --- a/core/pages/feeds.ts +++ b/core/pages/feeds.ts @@ -5,7 +5,7 @@ import { changeFeed, type FeedValue, getFeed, needWelcome } from '../feed.ts' import { fastMenu, menuLoading, slowMenu } from '../menu.ts' import { deletePost, fastPostsCount, slowPostsCount } from '../post.ts' import { - loadPosts, + loadReadPosts, type PostFilter, type ReaderHelpers, type UsefulReaderName @@ -37,12 +37,9 @@ let pages = (['slow', 'fast'] as const).map(reading => { async function deleteRead(): Promise { if (lastFilter) { - let posts = await loadPosts(lastFilter) - await Promise.all( - posts.map(async post => { - if (post.get().read) await deletePost(post.get().id) - }) - ) + // TODO Logux DB: do it in one SQL query + let posts = await loadReadPosts(lastFilter) + await Promise.all(posts.map(post => deletePost(post.id))) } } @@ -86,11 +83,11 @@ let pages = (['slow', 'fast'] as const).map(reading => { $feed.set(undefined) if (feedId) { unbindTarget = getFeed(feedId).subscribe(value => { - if (!value.isLoading) $feed.set(value) + if (value) $feed.set(value) }) } else if (categoryId) { unbindTarget = getCategory(categoryId).subscribe(value => { - if (!value.isLoading) $category.set(value) + if (value) $category.set(value) }) } else if (!menuLoading.get()) { void nextRouteIsRedirect(() => { diff --git a/core/pages/import.ts b/core/pages/import.ts index 93fd7392..64659cea 100644 --- a/core/pages/import.ts +++ b/core/pages/import.ts @@ -1,8 +1,7 @@ -import { loadValue } from '@logux/client' import { atom } from 'nanostores' import { addCategory } from '../category.ts' -import { addCandidate, addFeed, getFeeds } from '../feed.ts' +import { addCandidate, addFeed, loadFeedByUrl } from '../feed.ts' import { addFilter } from '../filter.ts' import { createDownloadTask } from '../lib/download.ts' import { parseDocument } from '../lib/html.ts' @@ -13,17 +12,12 @@ import { createPage } from './common.ts' import { isStateExportFile, type StateExport } from './export.ts' async function readFile(file: File): Promise { - return new Promise(resolve => { - let reader = new FileReader() - reader.addEventListener('load', () => { - resolve(reader.result as string) - }) + try { + return await file.text() /* node:coverage ignore next 3 */ - reader.addEventListener('error', () => { - resolve(false) - }) - reader.readAsText(file) - }) + } catch { + return false + } } type FeedError = 'exists' | 'noFeeds' | 'unknown' | 'unloadable' @@ -54,8 +48,7 @@ export const importPage = createPage('import', () => { } async function feedExists(url: string): Promise { - let feeds = await loadValue(getFeeds({ url })) - return feeds.list.some(i => !feeds.stores.get(i.id)!.deleted) + return !!(await loadFeedByUrl(url)) } async function importOpml(doc: Document): Promise { diff --git a/core/popups/feed.ts b/core/popups/feed.ts index 7415c497..6f7e8252 100644 --- a/core/popups/feed.ts +++ b/core/popups/feed.ts @@ -1,16 +1,21 @@ -import type { FilterStore } from '@logux/client' import { atom } from 'nanostores' import { getCategories } from '../category.ts' import { getEnvironment } from '../environment.ts' import { errorToMessage, NotFoundError } from '../errors.ts' -import { addCandidate, deleteFeed, type FeedValue, getFeeds } from '../feed.ts' +import { + addCandidate, + deleteFeed, + type FeedValue, + getFeedsByUrl, + loadFeedByUrl +} from '../feed.ts' import { createDownloadTask, type DownloadTask, type TextResponse } from '../lib/download.ts' -import { waitSyncLoading } from '../lib/stores.ts' +import { waitSql } from '../lib/stores.ts' import { type FeedLoader, getLoaderForText } from '../loader/index.ts' import { commonMessages } from '../messages/index.ts' import { createPostsList } from '../posts-list.ts' @@ -38,32 +43,28 @@ function swapHttpProtocol(url: string): string { return u.toString() } -async function getFeedsFilter(url: string): Promise> { - let exactFilter = getFeeds({ url }) - let feeds = await waitSyncLoading(exactFilter) - - if (feeds.get().list.length === 0) { - let fallbackFilter = getFeeds({ url: swapHttpProtocol(url) }) - feeds = await waitSyncLoading(fallbackFilter) - if (feeds.get().list.length > 0) { - return fallbackFilter - } - } - - return exactFilter +/** + * Feed could be saved with another protocol than in the current URL. + */ +async function findFeedUrl(url: string): Promise { + if (await loadFeedByUrl(url)) return url + let swapped = swapHttpProtocol(url) + if (await loadFeedByUrl(swapped)) return swapped + return url } export const feed = definePopup('feed', async url => { let task = createDownloadTask({ cache: 'read' }) - let feedsFilter = await getFeedsFilter(url) + let feedsStore = getFeedsByUrl(await findFeedUrl(url)) + let categoriesStore = getCategories() let error: string | undefined - let [responseOrError, categoriesFilter, feeds] = await Promise.all([ + let [responseOrError, feeds] = await Promise.all([ loadFeedFromURL(task, url), - waitSyncLoading(getCategories()), - waitSyncLoading(feedsFilter) + waitSql(feedsStore), + waitSql(categoriesStore) ]) - let existing = feeds.get().list[0] + let existing = feeds[0] let response: TextResponse | undefined let candidate: false | FeedLoader | undefined let posts = createPostsList(undefined) @@ -81,26 +82,16 @@ export const feed = definePopup('feed', async url => { let $feed = atom() - let unbindFeed = (): void => {} - let unbindFeeds = feedsFilter.subscribe(value => { + let unbindFeeds = feedsStore.subscribe(value => { if (!value.isLoading) { - let find = value.list[0] - if (find) { - let $find = value.stores.get(find.id)! - unbindFeed = $find.subscribe(i => { - if (!i.isLoading) { - $feed.set(i) - } - }) - } else { - $feed.set(undefined) - } + $feed.set(value.value[0]) } }) let $categories = atom<[string, string][]>([]) - let unbindCategories = categoriesFilter.subscribe(value => { - let list = value.list.map( + let unbindCategories = categoriesStore.subscribe(value => { + if (value.isLoading) return + let list = value.value.map( category => [category.id, category.title] as [string, string] ) $categories.set([ @@ -129,7 +120,6 @@ export const feed = definePopup('feed', async url => { destroy() { task.destroy() unbindFeeds() - unbindFeed() unbindCategories() }, error, diff --git a/core/popups/post.ts b/core/popups/post.ts index 6b671e41..eefc26d4 100644 --- a/core/popups/post.ts +++ b/core/popups/post.ts @@ -1,4 +1,3 @@ -import { ensureLoadedStore, loadValue } from '@logux/client' import lz from 'lz-string' import { atom, @@ -8,11 +7,12 @@ import { } from 'nanostores' import { NotFoundError } from '../errors.ts' -import { type FeedValue, getFeeds } from '../feed.ts' +import { type FeedValue, loadFeed } from '../feed.ts' import { formatPublishedAt, i18nFormat } from '../format.ts' import { changePost, - getPosts, + getPost, + loadPost, type OriginPost, type PostValue } from '../post.ts' @@ -45,10 +45,15 @@ export const post = definePopup('post', async loader => { id = loader.slice(5) } + let unbindStored = (): void => {} if (id) { - let filter = await loadValue(getPosts()) - if (!filter.stores.has(id)) throw new NotFoundError() - $post = ensureLoadedStore(filter.stores.get(id)!) + let stored = await loadPost(id) + if (!stored) throw new NotFoundError() + let $stored = atom(stored) + unbindStored = getPost(id).subscribe(value => { + if (value) $stored.set(value) + }) + $post = $stored } else if (loader.startsWith('data:')) { let data = loader.slice(5) try { @@ -67,22 +72,20 @@ export const post = definePopup('post', async loader => { if (loader.startsWith('read:')) { read = atom(false) unbindPost = $post.subscribe(value => { - read!.set(!!value.read) + read!.set('read' in value && value.read === 1) }) unbindRead = read.listen(async value => { - if (value !== $post.get().read) { - await changePost(id!, { read: value }) + let current = $post.get() + if ('read' in current && current.read !== (value ? 1 : 0)) { + await changePost(id!, { read: value ? 1 : 0 }) } }) } let postValue = $post.get() if ('feedId' in postValue) { - let feedId = postValue.feedId - let feedFilter = await loadValue(getFeeds()) - if (feedFilter.stores.has(feedId)) { - $feed = ensureLoadedStore(feedFilter.stores.get(feedId)!) - } + let feedValue = await loadFeed(postValue.feedId) + if (feedValue) $feed = atom(feedValue) } let $publishedAt = computed([$post, i18nFormat], (value, format) => { @@ -93,6 +96,7 @@ export const post = definePopup('post', async loader => { return { destroy() { + unbindStored() unbindPost() unbindRead() }, diff --git a/core/post.ts b/core/post.ts index a0abf4c6..49b9ed04 100644 --- a/core/post.ts +++ b/core/post.ts @@ -1,27 +1,24 @@ -import { defineSyncMapActions } from '@logux/actions' -import { - changeSyncMapById, - createFilter, - createSyncMap, - deleteSyncMapById, - type Filter, - type FilterStore, - loadValue, - type SyncMapStore, - syncMapTemplate -} from '@logux/client' import { formatter } from '@nanostores/i18n' -import { nanoid } from 'nanoid' -import { atom, onMount } from 'nanostores' +import { atom, onMount, type ReadableAtom, type WritableAtom } from 'nanostores' -import { getClient } from './client.ts' import { getEnvironment } from './environment.ts' -import { getFeed } from './feed.ts' -import { loadFilters } from './filter.ts' +import { loadFeed } from './feed.ts' +import { loadFilterChecker } from './filter.ts' import { $locale } from './i18n.ts' import { sanitizeHtml, stripHTML, truncateHTML } from './lib/html.ts' -import type { OptionalId } from './lib/stores.ts' +import { firstRow } from './lib/stores.ts' import { truncateText } from './lib/text.ts' +import { + createRow, + getDatabase, + getTables, + type NewPost, + type PostChanges, + type PostValue, + select +} from './schema.ts' + +export type { NewPost, PostValue } export type OriginPost = { full?: string @@ -29,76 +26,84 @@ export type OriginPost = { media?: string originId: string publishedAt?: number - read?: boolean title?: string url?: string } +/** + * Post’s text from a feed or from the database. Database returns `null` + * for missing values, while loaders return `undefined`. + */ +export type PostContent = { + full?: null | string + intro?: null | string + originId: string + publishedAt?: null | number + title?: null | string + url?: null | string +} + export type PostMedia = { fromText?: boolean type: string url: string } -export type PostValue = { - feedId: string - id: string - publishedAt: number - reading: 'fast' | 'slow' -} & Omit +export function addPost(fields: NewPost): Promise { + return createRow(getTables().posts, fields) +} -export const Post = syncMapTemplate('posts', { - offline: true, - remote: false -}) +export function getPost(postId: string): ReadableAtom { + return firstRow(getTables().posts.select`WHERE "id" = ${postId}`) +} -export async function addPost(fields: OptionalId): Promise { - let id = fields.id ?? nanoid() - await createSyncMap(getClient(), Post, { id, ...fields }) - return id +export function loadPost(postId: string): Promise { + return select`SELECT * FROM "posts" WHERE "id" = ${postId}`.then( + rows => rows[0] + ) } -export function getPost(id: string): SyncMapStore { - return Post(id, getClient()) +export function loadPosts(): Promise { + return select`SELECT * FROM "posts" ORDER BY "publishedAt" DESC` } -export function getPosts( - filter: Filter = {} -): FilterStore { - return createFilter(getClient(), Post, filter) +export function loadPostsByFeed(feedId: string): Promise { + return select` + SELECT * FROM "posts" WHERE "feedId" = ${feedId} + ORDER BY "publishedAt" DESC + ` } export function deletePost(postId: string): Promise { - return deleteSyncMapById(getClient(), Post, postId) + return getTables().posts.delete(postId) } -export async function changePost( +export function changePost( postId: string, - changes: Partial + changes: PostChanges ): Promise { - return changeSyncMapById(getClient(), Post, postId, changes) + return getTables().posts.update(postId, changes) } export function processOriginPost( origin: OriginPost, feedId: string, reading: PostValue['reading'] -): PostValue { +): NewPost { return { ...origin, feedId, - id: nanoid(), publishedAt: origin.publishedAt ?? Date.now(), reading } } -export function getPostIntro(post: OriginPost): [string, boolean] { +export function getPostIntro(post: PostContent): [string, boolean] { if (post.intro) { let more = !!post.full && post.full !== post.intro return [post.intro, more] } else if (post.full) { - let sanitized = sanitizeHtml(post.full, post.url) + let sanitized = sanitizeHtml(post.full, post.url ?? undefined) let truncated = truncateHTML(sanitized, 300, 500) return [truncated, truncated !== sanitized] } else { @@ -106,7 +111,7 @@ export function getPostIntro(post: OriginPost): [string, boolean] { } } -export function getPostTitle(post: OriginPost): string { +export function getPostTitle(post: PostContent): string { if (post.title) { return stripHTML(post.title) } else if (post.intro) { @@ -123,15 +128,15 @@ export function getPostTitle(post: OriginPost): string { } export async function recalcPostsReading(feedId: string): Promise { - let feed = await loadValue(getFeed(feedId)) + let feed = await loadFeed(feedId) if (!feed) return let [filters, posts] = await Promise.all([ - loadFilters({ feedId }), - loadValue(getPosts({ feedId })) + loadFilterChecker(feedId), + loadPostsByFeed(feedId) ]) - for (let post of posts.list) { + for (let post of posts) { let reading = filters(post) ?? feed.reading if (reading !== 'delete') { await changePost(post.id, { reading }) @@ -141,36 +146,47 @@ export async function recalcPostsReading(feedId: string): Promise { let testPostId = 0 -export function testPost(feed: Partial = {}): PostValue { +export function testPost(post: Partial = {}): PostValue { testPostId += 1 return { feedId: 'feed-1', + full: null, id: `post-${testPostId}`, intro: `Post ${testPostId}`, + media: null, originId: `test-${testPostId}`, publishedAt: 1000, + read: 0, reading: 'fast', + title: null, + updatedAt: '{}', url: `http://example.com/${testPostId}`, - ...feed + ...post } } +function countPosts( + store: WritableAtom, + reading: PostValue['reading'] +): () => void { + let $total = getDatabase().store<{ total: number }>` + SELECT COUNT("originId") AS "total" FROM "posts" WHERE "reading" = ${reading} + ` + return $total.subscribe(value => { + store.set(value.isLoading ? undefined : value.value[0]!.total) + }) +} + export const fastPostsCount = atom(undefined) onMount(fastPostsCount, () => { - return getPosts({ reading: 'fast' }).subscribe(posts => { - fastPostsCount.set(posts.isLoading ? undefined : posts.list.length) - }) + return countPosts(fastPostsCount, 'fast') }) export const slowPostsCount = atom(undefined) onMount(slowPostsCount, () => { - return getPosts({ reading: 'slow' }).subscribe(posts => { - slowPostsCount.set(posts.isLoading ? undefined : posts.list.length) - }) + return countPosts(slowPostsCount, 'slow') }) -export const postsChangedAction = defineSyncMapActions('posts')[4] - export function stringifyMedia(media: PostMedia[]): string | undefined { if (media.length === 0) return undefined let used = new Set() @@ -182,7 +198,7 @@ export function stringifyMedia(media: PostMedia[]): string | undefined { return JSON.stringify(unique) } -export function parseMedia(value: string | undefined): PostMedia[] { +export function parseMedia(value: null | string | undefined): PostMedia[] { if (!value) return [] try { return JSON.parse(value) as PostMedia[] diff --git a/core/readers/common.ts b/core/readers/common.ts index d9c5d262..0afa150d 100644 --- a/core/readers/common.ts +++ b/core/readers/common.ts @@ -1,14 +1,8 @@ -import { - ensureLoadedStore, - type LoadedSyncMap, - loadValue, - type SyncMapStore -} from '@logux/client' import type { ReadableAtom, WritableAtom } from 'nanostores' -import { getFeeds } from '../feed.ts' -import { getPosts, type PostValue } from '../post.ts' +import type { PostValue } from '../post.ts' import type { Routes } from '../router.ts' +import { select } from '../schema.ts' export interface BaseReader { exit(): void @@ -70,30 +64,40 @@ export function createReader( } } -export async function loadPosts( - filter: PostFilter -): Promise>[]> { - let posts: PostValue[] - let stores: Map> - if ('categoryId' in filter) { - let [allPosts, feeds] = await Promise.all([ - loadValue(getPosts({ reading: filter.reading })), - loadValue(getFeeds({ categoryId: filter.categoryId })) - ]) - stores = allPosts.stores - posts = allPosts.list.filter(i => { - return feeds.stores.has(i.feedId) && !allPosts.stores.get(i.id)?.deleted - }) +function selectPosts(filter: PostFilter, read: 0 | 1): Promise { + if (filter.categoryId) { + return select` + SELECT "posts".* FROM "posts" + JOIN "feeds" ON "feeds"."id" = "posts"."feedId" + WHERE "posts"."reading" = ${filter.reading} + AND "posts"."read" = ${read} + AND "feeds"."categoryId" = ${filter.categoryId} + ORDER BY "posts"."publishedAt" DESC + ` + } else if (filter.feedId) { + return select` + SELECT * FROM "posts" + WHERE "reading" = ${filter.reading} AND "read" = ${read} + AND "feedId" = ${filter.feedId} + ORDER BY "publishedAt" DESC + ` } else { - let value = await loadValue( - getPosts({ feedId: filter.feedId, reading: filter.reading }) - ) - stores = value.stores - posts = value.list.filter(i => !value.stores.get(i.id)?.deleted) + return select` + SELECT * FROM "posts" + WHERE "reading" = ${filter.reading} AND "read" = ${read} + ORDER BY "publishedAt" DESC + ` } - return posts - .toSorted((a, b) => b.publishedAt - a.publishedAt) - .map(i => stores.get(i.id)) - .filter(i => !!i) - .map(i => ensureLoadedStore(i)) +} + +/** + * Posts to be shown in the reader. Read posts are deleted on opening + * the page, so the reader always starts from unread ones. + */ +export function loadPosts(filter: PostFilter): Promise { + return selectPosts(filter, 0) +} + +export function loadReadPosts(filter: PostFilter): Promise { + return selectPosts(filter, 1) } diff --git a/core/readers/feed.ts b/core/readers/feed.ts index 7eef3adb..29fa8d63 100644 --- a/core/readers/feed.ts +++ b/core/readers/feed.ts @@ -1,12 +1,6 @@ -import { - ensureLoadedStore, - type LoadedSyncMap, - type SyncMapStore -} from '@logux/client' import { atom } from 'nanostores' -import { type FeedValue, getFeed } from '../feed.ts' -import { waitSyncLoading } from '../lib/stores.ts' +import { type FeedValue, loadFeedsByCategory } from '../feed.ts' import { changePost, type PostValue } from '../post.ts' import { createReader, loadPosts } from './common.ts' @@ -17,10 +11,8 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { let exited = false let $loading = atom(true) - let $list = atom>[]>([]) - let $authors = atom>>>( - new Map() - ) + let $list = atom([]) + let $authors = atom>(new Map()) let $hasNext = atom(false) let $nextFrom = atom() let $prevFrom = atom() @@ -28,25 +20,13 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { let openAt = Date.now() let unbindFrom = (): void => {} async function start(): Promise { + // TODO: more optimized feed to now keep everything in memory let posts = await loadPosts(filter) if (exited) return if (filter.categoryId) { - let feedIds = new Set() - posts.forEach(post => { - feedIds.add(post.get().feedId) - }) - $authors.set( - new Map( - await Promise.all( - [...feedIds].map(async id => { - let feed = getFeed(id) - await waitSyncLoading(feed) - return [id, ensureLoadedStore(feed)] as const - }) - ) - ) - ) + let feeds = await loadFeedsByCategory(filter.categoryId) + $authors.set(new Map(feeds.map(feed => [feed.id, feed]))) // It could be switched to false during await above // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (exited) return @@ -54,18 +34,18 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { unbindFrom = params.from.subscribe(value => { let from = value ?? openAt - let fromIndex = posts.findIndex(i => i.get().publishedAt < from) + let fromIndex = posts.findIndex(i => i.publishedAt < from) if (fromIndex === -1) fromIndex = posts.length let list = posts.slice(fromIndex, fromIndex + POSTS_PER_PAGE) $hasNext.set(posts.length > fromIndex + POSTS_PER_PAGE) if (value) { let prevIndex = fromIndex - POSTS_PER_PAGE - 1 - let prevForm = posts[prevIndex]?.get().publishedAt ?? openAt + let prevForm = posts[prevIndex]?.publishedAt ?? openAt $prevFrom.set(prevForm === value ? undefined : prevForm) } else { $prevFrom.set(undefined) } - $nextFrom.set(list[list.length - 1]?.get().publishedAt) + $nextFrom.set(list[list.length - 1]?.publishedAt) $list.set(list) }) } @@ -74,9 +54,11 @@ export const feedReader = createReader('feed', (filter, params, helpers) => { }) async function readAndNext(): Promise { - let promise = Promise.all( - $list.get().map(i => changePost(i.get().id, { read: true })) - ) + let list = $list.get() + // TODO Logux DB: use one UPDATE SQL query + let promise = Promise.all(list.map(i => changePost(i.id, { read: 1 }))) + // Loaded posts are a snapshot, so we need to mark them as read too + for (let post of list) post.read = 1 if ($hasNext.get()) { params.from.set($nextFrom.get()) } else { diff --git a/core/readers/list.ts b/core/readers/list.ts index eed623e9..364db439 100644 --- a/core/readers/list.ts +++ b/core/readers/list.ts @@ -1,4 +1,3 @@ -import type { LoadedSyncMap, SyncMapStore } from '@logux/client' import { atom } from 'nanostores' import { @@ -16,11 +15,12 @@ export const listReader = createReader('list', (filter, params) => { let exited = false let $loading = atom(true) - let $list = atom>[]>([]) + let $list = atom([]) let $pages = createPagination(1, 1) let unbindFrom = (): void => {} async function start(): Promise { + // TODO Logux DB: more optimizaed way with SQL let posts = await loadPosts(filter) if (exited) return @@ -42,9 +42,10 @@ export const listReader = createReader('list', (filter, params) => { async function readPage(): Promise { let list = $list.get() - let promise = Promise.all( - list.map(i => changePost(i.get().id, { read: true })) - ) + // TODO Logux DB: use one UPDATE SQL query + let promise = Promise.all(list.map(i => changePost(i.id, { read: 1 }))) + // Loaded posts are a snapshot, so we need to mark them as read too + for (let post of list) post.read = 1 if ($pages.get().hasNext) { params.from.set($pages.get().page + 1) } diff --git a/core/refresh.ts b/core/refresh.ts index f5a40ffe..def38dfc 100644 --- a/core/refresh.ts +++ b/core/refresh.ts @@ -1,15 +1,14 @@ -import { loadValue } from '@logux/client' import { atom, computed, map } from 'nanostores' import { getEnvironment } from './environment.ts' import { changeFeed, type FeedValue, - getFeed, getFeedLatestPosts, - getFeeds + loadFeed, + loadFeeds } from './feed.ts' -import { loadFilters } from './filter.ts' +import { loadFilterChecker } from './filter.ts' import { createQueue } from './lib/queue.ts' import { increaseKey } from './lib/stores.ts' import { addPost, type OriginPost, processOriginPost } from './post.ts' @@ -78,7 +77,7 @@ async function addPosts(feed: FeedValue, posts: OriginPost[]): Promise { } if (wasAlreadyAdded(feed, first)) return - let filters = await loadFilters({ feedId: feed.id }) + let filters = await loadFilterChecker(feed.id) for (let origin of posts) { if (wasAlreadyAdded(feed, origin)) { break @@ -113,7 +112,7 @@ async function checkForNextPage( await checkForNextPage(feed, pages) }) } else { - if (!getFeed(feed.id).deleted) { + if (await loadFeed(feed.id)) { await addPosts(feed, pages.get().list) } await changeFeed(feed.id, { @@ -133,14 +132,14 @@ export async function refreshPosts(): Promise { refreshErrors.set([]) refreshStatistics.set({ ...DEFAULT_REFRESH_STATISTICS, initializing: true }) - let feeds = await loadValue(getFeeds()) + let feeds = await loadFeeds() refreshStatistics.set({ ...refreshStatistics.get(), initializing: false, - totalFeeds: feeds.list.length + totalFeeds: feeds.length }) - queue = createQueue(feeds.list) + queue = createQueue(feeds) await queue.start( 6, feed => { diff --git a/core/schema.ts b/core/schema.ts new file mode 100644 index 00000000..5f63de1a --- /dev/null +++ b/core/schema.ts @@ -0,0 +1,231 @@ +// Single place with all database tables. Every table is a CRDT LWW map +// on top of SQL database, which is filled by actions from Logux log. + +import type { CrossTabClient } from '@logux/client' +import { + bigint, + type CrdtCreateFields, + type CrdtDatabase, + type CrdtRowFields, + type CrdtTable, + type CrdtTableRow, + type CrdtTableSchema, + createCrdtDatabase, + number, + oneOf, + optional, + string +} from '@logux/client/db' +import type { Database, SqlParam } from '@nanostores/sql' + +import { busyDuring } from './busy.ts' +import { client, isOutdatedClient } from './client.ts' +import { onEnvironment } from './environment.ts' +import type { LoaderName } from './loader/index.ts' +import type { UsefulReaderName } from './readers/common.ts' + +const READERS = ['feed', 'list'] as const satisfies readonly UsefulReaderName[] + +const READINGS = ['fast', 'slow'] as const + +let categoriesSchema = { + fastReader: optional(oneOf(READERS)), + slowReader: optional(oneOf(READERS)), + title: string() +} + +let feedsSchema = { + categoryId: string({ default: 'general' }), + fastReader: optional(oneOf(READERS)), + lastOriginId: optional(string()), + lastPublishedAt: optional(bigint()), + loader: string(), + reading: oneOf(READINGS), + refreshedAt: optional(bigint()), + slowReader: optional(oneOf(READERS)), + title: string(), + url: string() +} + +let filtersSchema = { + action: oneOf(['delete', 'fast', 'slow']), + feedId: string(), + priority: number(), + query: string() +} + +let postsSchema = { + feedId: string(), + full: optional(string()), + intro: optional(string()), + media: optional(string()), + originId: string(), + publishedAt: bigint(), + // SQLite has no boolean, so we are using 0 and 1 + read: number({ default: 0 }), + reading: oneOf(READINGS), + title: optional(string()), + url: optional(string()) +} + +export type CategoryValue = CrdtTableRow +export type FeedValue = CrdtTableRow +export type FilterValue = CrdtTableRow +export type PostValue = CrdtTableRow + +/** + * Unlike table’s own type it takes `null` in optional fields. + */ +export type NewRow = { + [Key in keyof CrdtCreateFields]: undefined extends CrdtCreateFields[Key] + ? CrdtCreateFields[Key] | null + : CrdtCreateFields[Key] +} & { id?: string } + +export type NewCategory = NewRow +export type NewFeed = NewRow +export type NewFilter = NewRow +export type NewPost = NewRow + +export type CategoryChanges = Partial> +export type FeedChanges = Partial> +export type FilterChanges = Partial> +export type PostChanges = Partial> + +export interface Tables { + categories: CrdtTable + feeds: CrdtTable + filters: CrdtTable + posts: CrdtTable +} + +export function createRow( + table: CrdtTable, + fields: NewRow +): Promise { + return table.create(fields as { id?: string } & CrdtCreateFields) +} + +/** + * Remove conflict resolution data, which is local and should not be + * in the backup or in tests expectations. + */ +export function withoutMeta( + rows: Value[] +): Omit[] { + return rows.map(row => { + let copy: { updatedAt?: string } & Omit = { ...row } + delete copy.updatedAt + return copy + }) +} + +let currentCrdt: CrdtDatabase | undefined +let currentDatabase: Database | undefined +let currentTables: Tables | undefined +let ready: Promise = Promise.resolve() + +/** + * Tables of the database of the current user. + */ +export function getTables(): Tables { + /* node:coverage ignore next 3 */ + if (!currentTables) { + throw new Error('No Slow Reader database') + } + return currentTables +} + +/** + * Database of the current user for queries across few tables. + */ +export function getDatabase(): Database { + /* node:coverage ignore next 3 */ + if (!currentDatabase) { + throw new Error('No Slow Reader database') + } + return currentDatabase +} + +/** + * One-time SQL query, which waits for tables to be filled from Logux log. + */ +export function select( + sql: TemplateStringsArray, + ...params: SqlParam[] +): Promise { + return ready.then(() => getDatabase().select(sql, ...params)) +} + +/** + * Remove all rows from all tables. Logux log is not touched. + */ +export async function cleanDatabase(): Promise { + if (!currentDatabase) return + await ready + await Promise.all([ + getDatabase().exec`DELETE FROM "categories"`, + getDatabase().exec`DELETE FROM "feeds"`, + getDatabase().exec`DELETE FROM "filters"`, + getDatabase().exec`DELETE FROM "posts"` + ]) +} + +function openDatabase(logux: CrossTabClient, database: Database): void { + currentDatabase = database + let crdt = createCrdtDatabase(logux, database, { + key: 'slowreader:db', + /* node:coverage ignore next 3 */ + stop() { + isOutdatedClient.set(true) + } + }) + currentCrdt = crdt + currentTables = { + categories: crdt.table('categories', categoriesSchema), + feeds: crdt.table('feeds', feedsSchema), + filters: crdt.table('filters', filtersSchema), + posts: crdt.table('posts', postsSchema) + } + ready = new Promise(resolve => { + let unbind = crdt.status.subscribe(status => { + if (status === 'ready' || status === 'outdated') { + resolve() + setTimeout(() => { + unbind() + }) + } + }) + }) + + void busyDuring(() => ready) +} + +function closeDatabase(): void { + if (!currentDatabase) return + let closingDb = currentDatabase + currentCrdt!.destroy() + + currentCrdt = undefined + currentDatabase = undefined + currentTables = undefined + + let filling = ready + ready = Promise.resolve() + void filling.then(() => { + setTimeout(() => { + void closingDb.close() + }) + }) +} + +onEnvironment(({ databaseCreator }) => { + let unbind = client.subscribe(logux => { + closeDatabase() + if (logux) openDatabase(logux, databaseCreator()) + }) + return () => { + unbind() + closeDatabase() + } +}) diff --git a/core/test.ts b/core/test.ts index f9f741a1..c68bb7b2 100644 --- a/core/test.ts +++ b/core/test.ts @@ -1,4 +1,6 @@ import { MemoryStore } from '@logux/core' +import { openDb } from '@nanostores/sql' +import { nodeDriver } from '@nanostores/sql/node' import { delay } from 'nanodelay' import { atom } from 'nanostores' @@ -46,6 +48,9 @@ export function getTestEnvironment(): EnvironmentAndStore { delete persistentStore[key] } }, + databaseCreator() { + return openDb(nodeDriver(':memory:')) + }, errorEvents: { addEventListener() {} }, getSession() { return testSession diff --git a/core/test/busy.test.ts b/core/test/busy.test.ts index 768879b7..2d5ccef3 100644 --- a/core/test/busy.test.ts +++ b/core/test/busy.test.ts @@ -1,4 +1,4 @@ -import { equal } from 'node:assert/strict' +import { deepStrictEqual, equal } from 'node:assert/strict' import { afterEach, describe, test } from 'node:test' import { setTimeout } from 'node:timers/promises' @@ -13,13 +13,35 @@ describe('busy', () => { test('allows to manually set busy state', async () => { equal(busy.get(), false) await busyDuring(async () => { - equal(busy.get(), true) + deepStrictEqual(busy.get(), { label: undefined, progress: undefined }) await busyDuring(async () => { - equal(busy.get(), true) await setTimeout(10) }) - equal(busy.get(), true) + deepStrictEqual(busy.get(), { label: undefined, progress: undefined }) }) equal(busy.get(), false) }) + + test('shows label and progress of the latest task', async () => { + let result = await busyDuring(async setProgress => { + deepStrictEqual(busy.get(), { label: 'Loading', progress: undefined }) + setProgress(0.5) + deepStrictEqual(busy.get(), { label: 'Loading', progress: 0.5 }) + + await busyDuring(async setNested => { + deepStrictEqual(busy.get(), { label: 'Loading', progress: 0.5 }) + setNested(0.1) + deepStrictEqual(busy.get(), { label: undefined, progress: 0.1 }) + await busyDuring(async () => { + deepStrictEqual(busy.get(), { label: 'Saving', progress: undefined }) + await setTimeout(10) + }, 'Saving') + }) + + deepStrictEqual(busy.get(), { label: 'Loading', progress: 0.5 }) + return 'done' + }, 'Loading') + equal(result, 'done') + equal(busy.get(), false) + }) }) diff --git a/core/test/dom-parser.ts b/core/test/dom-parser.ts index bd2ef3d7..248cdd50 100644 --- a/core/test/dom-parser.ts +++ b/core/test/dom-parser.ts @@ -5,5 +5,4 @@ let window = new JSDOM().window global.window = window global.DOMParser = window.DOMParser global.File = window.File -global.FileReader = window.FileReader global.ErrorEvent = window.ErrorEvent diff --git a/core/test/filter.test.ts b/core/test/filter.test.ts index 9033316d..42154367 100644 --- a/core/test/filter.test.ts +++ b/core/test/filter.test.ts @@ -1,5 +1,3 @@ -import { ensureLoaded, loadValue } from '@logux/client' -import { keepMount } from 'nanostores' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -8,14 +6,15 @@ import { addFilter, addFilterForFeed, type FilterValue, - getFeed, - getFilters, isValidFilterQuery, + loadFeed, + loadFiltersByFeed, moveFilterDown, moveFilterUp, prepareFilters, sortFilters, - testFeed + testFeed, + withoutMeta } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' @@ -32,25 +31,23 @@ describe('filter', () => { let feedId1 = await addFeed(testFeed({ reading: 'fast' })) let feedId2 = await addFeed(testFeed({ reading: 'slow' })) - let filterId1 = await addFilterForFeed((await loadValue(getFeed(feedId1)))!) - let filterId2 = await addFilterForFeed((await loadValue(getFeed(feedId2)))!) + let filterId1 = await addFilterForFeed((await loadFeed(feedId1))!) + let filterId2 = await addFilterForFeed((await loadFeed(feedId2))!) - deepEqual((await loadValue(getFilters({ feedId: feedId1 }))).list, [ + deepEqual(withoutMeta(await loadFiltersByFeed(feedId1)), [ { action: 'slow', feedId: feedId1, id: filterId1, - isLoading: false, priority: 100, query: '' } ]) - deepEqual((await loadValue(getFilters({ feedId: feedId2 }))).list, [ + deepEqual(withoutMeta(await loadFiltersByFeed(feedId2)), [ { action: 'fast', feedId: feedId2, id: filterId2, - isLoading: false, priority: 100, query: '' } @@ -61,8 +58,8 @@ describe('filter', () => { let common = { action: 'fast', feedId: '10', - isLoading: false, - query: 'include(some text)' + query: 'include(some text)', + updatedAt: '{}' } as const let filter100: FilterValue = { ...common, @@ -93,14 +90,12 @@ describe('filter', () => { }) test('moves filters in sorting order', async () => { - let filters10 = getFilters({ feedId: '10' }) - keepMount(filters10) - function getSorted(): string[] { - return sortFilters(ensureLoaded(filters10.get()).list).map(i => i.id) + async function getSorted(): Promise { + return sortFilters(await loadFiltersByFeed('10')).map(i => i.id) } - function getPriorities(): Record { + async function getPriorities(): Promise> { return Object.fromEntries( - ensureLoaded(filters10.get()).list.map(i => [i.id, i.priority]) + (await loadFiltersByFeed('10')).map(i => [i.id, i.priority]) ) } @@ -113,28 +108,28 @@ describe('filter', () => { let id1 = await addFilter({ ...common }) let id2 = await addFilter({ ...common }) let id3 = await addFilter({ ...common }) - deepEqual(getSorted(), [id1, id2, id3]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 300 }) + deepEqual(await getSorted(), [id1, id2, id3]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 300 }) await moveFilterUp(id3) - deepEqual(getSorted(), [id1, id3, id2]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 150 }) + deepEqual(await getSorted(), [id1, id3, id2]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 150 }) await moveFilterUp(id3) - deepEqual(getSorted(), [id3, id1, id2]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id1, id2]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) await moveFilterUp(id3) - deepEqual(getSorted(), [id3, id1, id2]) - deepEqual(getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id1, id2]) + deepEqual(await getPriorities(), { [id1]: 100, [id2]: 200, [id3]: 0 }) await moveFilterDown(id1) - deepEqual(getSorted(), [id3, id2, id1]) - deepEqual(getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id2, id1]) + deepEqual(await getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) await moveFilterDown(id1) - deepEqual(getSorted(), [id3, id2, id1]) - deepEqual(getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) + deepEqual(await getSorted(), [id3, id2, id1]) + deepEqual(await getPriorities(), { [id1]: 300, [id2]: 200, [id3]: 0 }) }) test('validates filter query', () => { @@ -156,14 +151,16 @@ describe('filter', () => { feedId: '10', id: '2', priority: 200, - query: 'include(Special Text)' + query: 'include(Special Text)', + updatedAt: '{}' } let filterOther: FilterValue = { action: 'delete', feedId: '10', id: '3', priority: 300, - query: 'include(other text)' + query: 'include(other text)', + updatedAt: '{}' } let checker = prepareFilters([filterSpecial, filterOther]) @@ -242,14 +239,16 @@ describe('filter', () => { feedId: '10', id: '1', priority: 100, - query: 'include(a)' + query: 'include(a)', + updatedAt: '{}' } let filterNotB: FilterValue = { action: 'slow', feedId: '10', id: '2', priority: 200, - query: 'not include(b)' + query: 'not include(b)', + updatedAt: '{}' } let checker = prepareFilters([filterA, filterNotB]) @@ -295,14 +294,16 @@ describe('filter', () => { feedId: '10', id: '1', priority: 100, - query: 'broken' + query: 'broken', + updatedAt: '{}' } let filterNotB: FilterValue = { action: 'slow', feedId: '10', id: '2', priority: 200, - query: 'include(a)' + query: 'include(a)', + updatedAt: '{}' } let checker = prepareFilters([filterA, filterNotB]) diff --git a/core/test/loader.test.ts b/core/test/loader.test.ts new file mode 100644 index 00000000..7dc00cf4 --- /dev/null +++ b/core/test/loader.test.ts @@ -0,0 +1,29 @@ +import { deepStrictEqual } from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { reportLoader, setLoaderReporter } from '../index.ts' + +describe('loader', () => { + test('does nothing without reporter', () => { + reportLoader('test')() + }) + + test('reports loaders to the reporter', () => { + let calls: string[] = [] + setLoaderReporter(name => { + calls.push(`show ${name}`) + return () => { + calls.push(`hide ${name}`) + } + }) + + let hide = reportLoader('posts') + deepStrictEqual(calls, ['show posts']) + hide() + deepStrictEqual(calls, ['show posts', 'hide posts']) + + setLoaderReporter(() => { + return () => {} + }) + }) +}) diff --git a/core/test/menu.test.ts b/core/test/menu.test.ts index 4651feb9..82feed0d 100644 --- a/core/test/menu.test.ts +++ b/core/test/menu.test.ts @@ -1,4 +1,3 @@ -import { loadValue } from '@logux/client' import { LoguxError } from '@logux/core' import { cleanStores, keepMount } from 'nanostores' import { deepEqual, equal } from 'node:assert/strict' @@ -17,7 +16,9 @@ import { closeMenu, fastMenu, getClient, - getFeed, + getGeneralCategory, + loadCategory, + loadFeed, menuLoading, menuSlider, openCategory, @@ -63,7 +64,7 @@ describe('menu', () => { testFeed({ categoryId: category1, reading: 'slow' }) ) await addFeed(testFeed({ categoryId: category1, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) setBaseTestRoute({ params: {}, @@ -107,7 +108,7 @@ describe('menu', () => { equal(openedMenu.get(), undefined) await addPost(testPost({ feedId: slow, reading: 'slow' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('slow'), false) equal(menuSlider.get(), 'slow') equal(openedMenu.get(), 'slow') @@ -142,7 +143,7 @@ describe('menu', () => { }) await addFeed(testFeed({ categoryId: category2, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('fast'), false) equal(menuSlider.get(), 'fast') @@ -164,7 +165,7 @@ describe('menu', () => { testFeed({ categoryId: category1, reading: 'slow' }) ) await addFeed(testFeed({ categoryId: category1, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) setBaseTestRoute({ params: {}, @@ -207,7 +208,7 @@ describe('menu', () => { equal(openedMenu.get(), 'fast') await addPost(testPost({ feedId: slow, reading: 'slow' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('slow'), true) equal(menuSlider.get(), 'fast') equal(openedMenu.get(), 'fast') @@ -240,7 +241,7 @@ describe('menu', () => { }) await addFeed(testFeed({ categoryId: category2, reading: 'fast' })) - await setTimeout(1) + await setTimeout(10) equal(openMenu('fast'), true) equal(menuSlider.get(), 'other') @@ -262,7 +263,7 @@ describe('menu', () => { equal(menuLoading.get(), false) deepEqual(slowMenu.get(), []) - deepEqual(fastMenu.get(), [{ id: 'general', title: 'General' }]) + deepEqual(fastMenu.get(), [getGeneralCategory()]) let idB = await addCategory({ title: 'B' }) let feed1 = await addFeed(testFeed({ categoryId: idB, reading: 'slow' })) @@ -288,9 +289,9 @@ describe('menu', () => { deepEqual(slowMenu.get(), []) deepEqual(fastMenu.get(), [ - { id: idA, isLoading: false, title: 'A' }, - { id: idB, isLoading: false, title: 'B' }, - { id: 'general', title: 'General' } + await loadCategory(idA), + await loadCategory(idB), + getGeneralCategory() ]) await addPost(testPost({ feedId: feed2, reading: 'slow' })) @@ -298,29 +299,26 @@ describe('menu', () => { await addPost(testPost({ feedId: feed5, reading: 'slow' })) await addPost(testPost({ feedId: feed5, reading: 'slow' })) deepEqual(slowMenu.get(), [ + [await loadCategory(idC), [[await loadFeed(feed2), 1]]], [ - { id: idC, isLoading: false, title: 'C' }, - [[await loadValue(getFeed(feed2)), 1]] - ], - [ - { id: 'general', title: 'General' }, + getGeneralCategory(), [ - [await loadValue(getFeed(feed4)), 1], - [await loadValue(getFeed(feed5)), 2] + [await loadFeed(feed4), 1], + [await loadFeed(feed5), 2] ] ] ]) deepEqual(fastMenu.get(), [ - { id: idA, isLoading: false, title: 'A' }, - { id: idB, isLoading: false, title: 'B' }, - { id: 'general', title: 'General' } + await loadCategory(idA), + await loadCategory(idB), + getGeneralCategory() ]) await changeCategory(idA, { title: 'Z' }) deepEqual(fastMenu.get(), [ - { id: idB, isLoading: false, title: 'B' }, - { id: 'general', title: 'General' }, - { id: idA, isLoading: false, title: 'Z' } + await loadCategory(idB), + getGeneralCategory(), + await loadCategory(idA) ]) await addPost(testPost({ feedId: 'unknown', reading: 'fast' })) @@ -330,29 +328,26 @@ describe('menu', () => { ) await addPost(testPost({ feedId: orphan, reading: 'slow' })) deepEqual(slowMenu.get(), [ + [await loadCategory(idC), [[await loadFeed(feed2), 1]]], [ - { id: idC, isLoading: false, title: 'C' }, - [[await loadValue(getFeed(feed2)), 1]] - ], - [ - { id: 'general', title: 'General' }, + getGeneralCategory(), [ - [await loadValue(getFeed(feed4)), 1], - [await loadValue(getFeed(feed5)), 2] + [await loadFeed(feed4), 1], + [await loadFeed(feed5), 2] ] ] ]) deepEqual(fastMenu.get(), [ - { id: idB, isLoading: false, title: 'B' }, + await loadCategory(idB), - { id: 'general', title: 'General' }, - { id: idA, isLoading: false, title: 'Z' } + getGeneralCategory(), + await loadCategory(idA) ]) }) test('has helper to block app while menu is loading', async () => { busyUntilMenuLoader() - equal(busy.get(), true) + deepEqual(busy.get(), { label: undefined, progress: undefined }) await setTimeout(10) equal(busy.get(), false) equal(menuLoading.get(), false) diff --git a/core/test/pages/export.test.ts b/core/test/pages/export.test.ts index 4ecf5eea..2d62b9b8 100644 --- a/core/test/pages/export.test.ts +++ b/core/test/pages/export.test.ts @@ -99,16 +99,23 @@ describe('export page', () => { deepEqual(json, { categories: [ { + fastReader: null, id: category, + slowReader: null, title: 'A' } ], feeds: [ { categoryId: category, + fastReader: null, id: feed, + lastOriginId: null, + lastPublishedAt: null, loader: 'rss', reading: 'fast', + refreshedAt: null, + slowReader: null, title: '1', url: 'http://example.com/5' } @@ -125,11 +132,15 @@ describe('export page', () => { posts: [ { feedId: feed, + full: null, id: post, intro: 'Post 1', + media: null, originId: 'test-1', publishedAt: 1000, + read: 0, reading: 'fast', + title: null, url: 'http://example.com/1' } ], diff --git a/core/test/pages/feeds-by-categories.test.ts b/core/test/pages/feeds-by-categories.test.ts index 70c2900e..f3bed4c5 100644 --- a/core/test/pages/feeds-by-categories.test.ts +++ b/core/test/pages/feeds-by-categories.test.ts @@ -1,4 +1,3 @@ -import { loadValue } from '@logux/client' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -6,7 +5,9 @@ import { addCategory, addFeed, deleteCategory, - getFeed, + getGeneralCategory, + loadCategory, + loadFeed, testFeed, waitLoading } from '../../index.ts' @@ -35,10 +36,7 @@ describe('feeds by categories page', () => { let feed1 = await addFeed(testFeed({ categoryId: idA, title: '1' })) let feed2 = await addFeed(testFeed({ categoryId: idA, title: '2' })) deepEqual(page.groups.get(), [ - [ - { id: idA, isLoading: false, title: 'A' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ] + [await loadCategory(idA), [await loadFeed(feed1), await loadFeed(feed2)]] ]) let idC = await addCategory({ title: 'C' }) @@ -47,16 +45,10 @@ describe('feeds by categories page', () => { let feed4 = await addFeed(testFeed({ categoryId: 'general', title: '1' })) deepEqual(page.groups.get(), [ - [{ id: 'general', title: 'General' }, [await loadValue(getFeed(feed4))]], - [ - { id: idA, isLoading: false, title: 'A' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ], - [ - { id: idB, isLoading: false, title: 'B' }, - [await loadValue(getFeed(feed3))] - ], - [{ id: idC, isLoading: false, title: 'C' }, []] + [getGeneralCategory(), [await loadFeed(feed4)]], + [await loadCategory(idA), [await loadFeed(feed1), await loadFeed(feed2)]], + [await loadCategory(idB), [await loadFeed(feed3)]], + [await loadCategory(idC), []] ]) openPage({ @@ -78,26 +70,14 @@ describe('feeds by categories page', () => { }) await waitLoading(page.loading) deepEqual(page.groups.get(), [ - [ - { id: idA, isLoading: false, title: 'A' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ], - [ - { id: idB, isLoading: false, title: 'B' }, - [await loadValue(getFeed(feed3))] - ] + [await loadCategory(idA), [await loadFeed(feed1), await loadFeed(feed2)]], + [await loadCategory(idB), [await loadFeed(feed3)]] ]) await deleteCategory(idA) deepEqual(page.groups.get(), [ - [ - { id: 'general', title: 'General' }, - [await loadValue(getFeed(feed1)), await loadValue(getFeed(feed2))] - ], - [ - { id: idB, isLoading: false, title: 'B' }, - [await loadValue(getFeed(feed3))] - ] + [getGeneralCategory(), [await loadFeed(feed1), await loadFeed(feed2)]], + [await loadCategory(idB), [await loadFeed(feed3)]] ]) }) }) diff --git a/core/test/pages/feeds.test.ts b/core/test/pages/feeds.test.ts index 79753899..8803cb05 100644 --- a/core/test/pages/feeds.test.ts +++ b/core/test/pages/feeds.test.ts @@ -39,7 +39,7 @@ describe('feeds page', () => { }) equal(page.params.category.get(), undefined) equal(page.params.feed.get(), undefined) - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'welcome') page = openPage({ @@ -47,7 +47,7 @@ describe('feeds page', () => { route: 'fast' }) equal(page.params.category.get(), 'general') - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'welcome') let category1 = await addCategory({ title: 'A1' }) @@ -65,7 +65,7 @@ describe('feeds page', () => { }) equal(page.params.category.get(), undefined) equal(page.params.feed.get(), undefined) - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'empty') page = openPage({ @@ -74,7 +74,7 @@ describe('feeds page', () => { }) equal(page.params.category.get(), category1) equal(page.params.feed.get(), undefined) - equal(page.loading.get(), false) + await waitLoading(page.loading) equal(page.posts.get()!.name, 'empty') await addPost(testPost({ feedId: feed2, reading: 'slow' })) @@ -107,6 +107,7 @@ describe('feeds page', () => { route: 'slow' }) await waitLoading(empty.loading) + await setTimeout(10) equal(empty.posts.get()?.name, 'welcome') let category1 = await addCategory({ title: '1' }) @@ -124,6 +125,7 @@ describe('feeds page', () => { params: { feed: feed3 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'empty') await addPost(testPost({ feedId: feed1, reading: 'slow' })) @@ -132,12 +134,14 @@ describe('feeds page', () => { params: { feed: feed3 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') page = openPage({ params: { feed: feed1 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') page.changeReader('feed') @@ -148,12 +152,14 @@ describe('feeds page', () => { params: { feed: feed2 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') page = openPage({ params: { feed: feed1 }, route: 'slow' }) + await setTimeout(10) equal(page.posts.get()!.name, 'feed') page = openPage({ @@ -164,6 +170,7 @@ describe('feeds page', () => { equal(page.posts.get()!.name, 'empty') await addPost(testPost({ feedId: feed2, reading: 'fast' })) + await setTimeout(10) equal(page.posts.get()!.name, 'feed') page.changeReader('list') @@ -182,6 +189,7 @@ describe('feeds page', () => { params: { category: category1 }, route: 'fast' }) + await setTimeout(10) equal(page.posts.get()!.name, 'list') }) }) diff --git a/core/test/pages/import.test.ts b/core/test/pages/import.test.ts index aad90043..f9e0e1c0 100644 --- a/core/test/pages/import.test.ts +++ b/core/test/pages/import.test.ts @@ -1,6 +1,5 @@ import '../dom-parser.ts' -import { loadValue } from '@logux/client' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' import { setTimeout } from 'node:timers/promises' @@ -10,22 +9,23 @@ import { addFeed, addFilter, addPost, - type CategoryValue, deleteCategory, deleteFeed, deleteFilter, deletePost, - type FeedValue, - type FilterValue, - getCategories, - getFeeds, - getFilters, - getPosts, - type PostValue, + loadCategories, + loadFeeds, + loadFilters, + loadPosts, + type NewCategory, + type NewFeed, + type NewFilter, + type NewPost, preloadImages, testFeed, theme, - waitLoading + waitLoading, + withoutMeta } from '../../index.ts' import { checkAndRemoveRequestMock, @@ -44,7 +44,7 @@ describe('import page', () => { xml: 'application/xml' } - let CATEGORY = { id: 'c1', title: 'A' } satisfies CategoryValue + let CATEGORY = { id: 'c1', title: 'A' } satisfies NewCategory let FEED = { categoryId: CATEGORY.id, id: 'f1', @@ -52,14 +52,14 @@ describe('import page', () => { reading: 'slow', title: 'F1', url: 'https://example.com/feed' - } satisfies FeedValue + } satisfies NewFeed let FILTER = { action: 'fast', feedId: FEED.id, id: 'filter1', priority: 1, query: 'include(text)' - } satisfies FilterValue + } satisfies NewFilter let POST = { feedId: FEED.id, id: 'p1', @@ -67,7 +67,7 @@ describe('import page', () => { publishedAt: 1000, reading: 'fast', title: 'Post 1' - } satisfies PostValue + } satisfies NewPost function file(format: keyof typeof MIME_TYPES, content: string): File { return new File([content], `feeds.${format}`, { @@ -133,17 +133,22 @@ describe('import page', () => { equal(page.done.get(), 1) equal(theme.get(), 'light') equal(preloadImages.get(), 'never') - deepEqual((await loadValue(getCategories())).list, [ - { ...CATEGORY, isLoading: false } + deepEqual(withoutMeta(await loadCategories()), [ + { ...CATEGORY, fastReader: null, slowReader: null } ]) - deepEqual((await loadValue(getFeeds())).list, [ - { ...FEED, isLoading: false } - ]) - deepEqual((await loadValue(getFilters())).list, [ - { ...FILTER, isLoading: false } + deepEqual(withoutMeta(await loadFeeds()), [ + { + ...FEED, + fastReader: null, + lastOriginId: null, + lastPublishedAt: null, + refreshedAt: null, + slowReader: null + } ]) - deepEqual((await loadValue(getPosts())).list, [ - { ...POST, isLoading: false } + deepEqual(withoutMeta(await loadFilters()), [FILTER]) + deepEqual(withoutMeta(await loadPosts()), [ + { ...POST, full: null, intro: null, media: null, read: 0, url: null } ]) }) @@ -187,20 +192,20 @@ describe('import page', () => { ]) equal(page.done.get(), 2) - let categories = await loadValue(getCategories()) + let categories = await loadCategories() deepEqual( - categories.list.map(i => i.title), + categories.map(i => i.title), ['A'] ) deepEqual( - (await loadValue(getFeeds())).list.map(i => ({ + (await loadFeeds()).map(i => ({ categoryId: i.categoryId, loader: i.loader, title: i.title })), [ - { categoryId: 'general', loader: 'atom', title: FEED2.title }, - { categoryId: categories.list[0]?.id, loader: 'rss', title: FEED.title } + { categoryId: categories[0]?.id, loader: 'rss', title: FEED.title }, + { categoryId: 'general', loader: 'atom', title: FEED2.title } ] ) }) @@ -416,8 +421,7 @@ describe('import page', () => { deepEqual(page.feedErrors.get(), []) equal(page.done.get(), 0) - let feeds = await loadValue(getFeeds()) - equal(feeds.list.length, 2) + equal((await loadFeeds()).length, 2) }) test('imports OPML with .xml extension', async () => { @@ -449,7 +453,7 @@ describe('import page', () => { equal(page.done.get(), 1) deepEqual( - (await loadValue(getFeeds())).list.map(i => ({ + (await loadFeeds()).map(i => ({ loader: i.loader, title: i.title })), diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts index e6c57d8f..0796810b 100644 --- a/core/test/popups/feed.test.ts +++ b/core/test/popups/feed.test.ts @@ -1,6 +1,5 @@ import '../dom-parser.ts' -import { loadValue } from '@logux/client' import { cleanStores, keepMount } from 'nanostores' import { deepEqual, equal, match, notEqual } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -10,7 +9,7 @@ import { addFeed, changeFeed, closeLastPopup, - getFeed, + loadFeed, getPopupId, HTTPStatusError, openedPopups, @@ -123,9 +122,9 @@ describe('feed popup', () => { equal(addedId, feedId) equal(checkLoadedPopup(popup).feed.get()!.url, 'https://a.com/atom') equal(checkLoadedPopup(popup).feed.get()!.title, 'Atom') - equal((await loadValue(getFeed(feedId)))!.title, 'Atom') - equal((await loadValue(getFeed(feedId)))!.lastOriginId, '2') - equal((await loadValue(getFeed(feedId)))!.lastPublishedAt, 1688169600) + equal((await loadFeed(feedId))!.title, 'Atom') + equal((await loadFeed(feedId))!.lastOriginId, '2') + equal((await loadFeed(feedId))!.lastPublishedAt, 1688169600) await changeFeed(feedId, { title: 'Test Atom' }) equal(checkLoadedPopup(popup).feed.get()!.title, 'Test Atom') @@ -206,7 +205,7 @@ describe('feed popup', () => { let addedId = await checkLoadedPopup(popup).add() let feedId = checkLoadedPopup(popup).feed.get()!.id equal(addedId, feedId) - equal((await loadValue(getFeed(feedId)))!.url, 'http://a.com/atom') + equal((await loadFeed(feedId))!.url, 'http://a.com/atom') expectRequest('https://a.com/atom').andRespond( 200, diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index 4256c14e..2bbf2a0d 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -1,4 +1,3 @@ -import { ensureLoaded } from '@logux/client' import { cleanStores, keepMount } from 'nanostores' import { deepEqual, equal, match } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -8,7 +7,7 @@ import { addFeed, addPost, closeLastPopup, - getPosts, + loadPost, openedPopups, type OriginPost, type PostValue, @@ -16,7 +15,6 @@ import { testPost, waitLoading } from '../../index.ts' -import { waitSyncLoading } from '../../lib/stores.ts' import { getPostPopupParam } from '../../popups/post.ts' import { checkLoadedPopup, @@ -98,25 +96,20 @@ describe('post popup', () => { let id1 = await addPost(testPost({ feedId: feed })) let id2 = await addPost(testPost({ feedId: feed })) - let posts = getPosts() - await waitSyncLoading(posts) - let post1 = ensureLoaded(posts.get()).stores.get(id1)! - let post2 = ensureLoaded(posts.get()).stores.get(id2)! - let popup = openTestPopup('post', getPostPopupParam({ id: id1 }, true)) await waitLoading(popup.loading) - equal(ensureLoaded(post1.get()).read, undefined) - equal(ensureLoaded(post2.get()).read, undefined) + equal((await loadPost(id1))!.read, 0) + equal((await loadPost(id2))!.read, 0) checkLoadedPopup(popup).read!.set(true) await setTimeout(1) - equal(ensureLoaded(post1.get()).read, true) - equal(ensureLoaded(post2.get()).read, undefined) + equal((await loadPost(id1))!.read, 1) + equal((await loadPost(id2))!.read, 0) checkLoadedPopup(popup).read!.set(false) await setTimeout(1) - equal(ensureLoaded(post1.get()).read, false) - equal(ensureLoaded(post2.get()).read, undefined) + equal((await loadPost(id1))!.read, 0) + equal((await loadPost(id2))!.read, 0) }) test('opens candidate post', async () => { diff --git a/core/test/post.test.ts b/core/test/post.test.ts index 9d12e07d..2cf35cd9 100644 --- a/core/test/post.test.ts +++ b/core/test/post.test.ts @@ -1,12 +1,11 @@ import './dom-parser.ts' -import { loadValue } from '@logux/client' import { deepEqual, doesNotMatch, equal, match } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' import { addPost, - getPost, + loadPost, getPostIntro, getPostTitle, testPost @@ -42,7 +41,7 @@ describe('post', () => { test('has helper to load post', async () => { let id = await addPost(testPost({ title: 'Test Post' })) - let post = await loadValue(getPost(id)) + let post = await loadPost(id) equal(post?.title, 'Test Post') }) diff --git a/core/test/readers/feed.test.ts b/core/test/readers/feed.test.ts index af8ad9b4..4049a767 100644 --- a/core/test/readers/feed.test.ts +++ b/core/test/readers/feed.test.ts @@ -53,7 +53,7 @@ describe('feed reader', () => { equal(reader.hasNext.get(), false) equal(reader.authors.get().size, 0) equal(reader.list.get().length, 30) - equal(reader.list.get()[0]!.get().title, '60') + equal(reader.list.get()[0]!.title, '60') page = openPage({ params: { category: categoryId }, @@ -65,24 +65,24 @@ describe('feed reader', () => { reader = ensureReader(page.posts, 'feed') equal(reader.hasNext.get(), true) equal(reader.authors.get().size, 2) - equal(reader.authors.get().get(feed1)!.get().title, 'Test 1') - equal(reader.authors.get().get(feed2)!.get().title, 'Test 2') + equal(reader.authors.get().get(feed1)!.title, 'Test 1') + equal(reader.authors.get().get(feed2)!.title, 'Test 2') equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '60') - equal(reader.list.get()[39]!.get().title, '21') + equal(reader.list.get()[0]!.title, '60') + equal(reader.list.get()[39]!.title, '21') page.params.from.set(10) equal(page.loading.get(), false) equal(reader.list.get().length, 9) - equal(reader.list.get()[0]!.get().title, '9') + equal(reader.list.get()[0]!.title, '9') equal(reader.hasNext.get(), false) page.params.from.set(undefined) - equal(reader.list.get()[0]!.get().title, '60') + equal(reader.list.get()[0]!.title, '60') equal(reader.list.get().length, 40) let promise = reader.readAndNext() - equal(reader.list.get()[0]!.get().title, '20') + equal(reader.list.get()[0]!.title, '20') equal(reader.list.get().length, 20) equal(reader.hasNext.get(), false) await promise @@ -133,37 +133,37 @@ describe('feed reader', () => { let reader = ensureReader(page.posts, 'feed') equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '110') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '110') + equal(reader.list.get()[0]!.read, 0) await reader.readAndNext() equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '70') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '70') + equal(reader.list.get()[0]!.read, 0) page.params.from.set(reader.prevFrom.get()) equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '110') - equal(reader.list.get()[0]!.get().read, true) + equal(reader.list.get()[0]!.title, '110') + equal(reader.list.get()[0]!.read, 1) page.params.from.set(reader.nextFrom.get()) equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '70') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '70') + equal(reader.list.get()[0]!.read, 0) page.params.from.set(reader.nextFrom.get()) equal(reader.hasNext.get(), false) equal(reader.list.get().length, 30) - equal(reader.list.get()[0]!.get().title, '30') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '30') + equal(reader.list.get()[0]!.read, 0) page.params.from.set(reader.prevFrom.get()) equal(reader.hasNext.get(), true) equal(reader.list.get().length, 40) - equal(reader.list.get()[0]!.get().title, '70') - equal(reader.list.get()[0]!.get().read, undefined) + equal(reader.list.get()[0]!.title, '70') + equal(reader.list.get()[0]!.read, 0) }) }) diff --git a/core/test/readers/list.test.ts b/core/test/readers/list.test.ts index 5b086c0d..5551c3fb 100644 --- a/core/test/readers/list.test.ts +++ b/core/test/readers/list.test.ts @@ -1,4 +1,3 @@ -import { loadValue } from '@logux/client' import { keepMount } from 'nanostores' import { deepEqual, equal } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -9,8 +8,8 @@ import { addFeed, addPost, changePost, - getCategory, - getFeed, + loadCategory, + loadFeed, slowMenu, testFeed, testPost, @@ -50,10 +49,10 @@ describe('list reader', () => { deepEqual(slowMenu.get(), [ [ - await loadValue(getCategory(categoryId)), + await loadCategory(categoryId), [ - [await loadValue(getFeed(feed1)), 75], - [await loadValue(getFeed(feed2)), 75] + [await loadFeed(feed1), 75], + [await loadFeed(feed2), 75] ] ] ]) @@ -65,7 +64,7 @@ describe('list reader', () => { await waitLoading(page.loading) let reader = ensureReader(page.posts, 'list') equal(reader.list.get().length, 75) - equal(reader.list.get()[0]!.get().title, '150') + equal(reader.list.get()[0]!.title, '150') deepEqual(reader.pages.get(), { count: 1, hasNext: false, @@ -83,8 +82,8 @@ describe('list reader', () => { await waitLoading(page.postsLoading) reader = ensureReader(page.posts, 'list') equal(reader.list.get().length, 100) - equal(reader.list.get()[0]!.get().title, '150') - equal(reader.list.get()[99]!.get().title, '51') + equal(reader.list.get()[0]!.title, '150') + equal(reader.list.get()[99]!.title, '51') deepEqual(reader.pages.get(), { count: 2, hasNext: true, @@ -93,22 +92,22 @@ describe('list reader', () => { show: true }) - await changePost(reader.list.get()[0]!.get().id, { read: true }) - await changePost(reader.list.get()[5]!.get().id, { read: true }) + await changePost(reader.list.get()[0]!.id, { read: 1 }) + await changePost(reader.list.get()[5]!.id, { read: 1 }) equal(reader.list.get().length, 100) deepEqual(slowMenu.get(), [ [ - await loadValue(getCategory(categoryId)), + await loadCategory(categoryId), [ - [await loadValue(getFeed(feed1)), 74], - [await loadValue(getFeed(feed2)), 74] + [await loadFeed(feed1), 74], + [await loadFeed(feed2), 74] ] ] ]) page.params.from.set(1) equal(reader.list.get().length, 50) - equal(reader.list.get()[0]!.get().title, '50') + equal(reader.list.get()[0]!.title, '50') deepEqual(reader.pages.get(), { count: 2, hasNext: false, @@ -122,6 +121,7 @@ describe('list reader', () => { params: { feed: feed1 }, route: 'slow' }) + await setTimeout(10) page = openPage({ params: { category: categoryId, from: 1 }, @@ -132,7 +132,7 @@ describe('list reader', () => { equal(reader.list.get().length, 48) page.params.from.set(0) - equal(reader.list.get()[99]!.get().title, '49') + equal(reader.list.get()[99]!.title, '49') await reader.readPage() equal(page.params.from.get(), 1) @@ -144,12 +144,13 @@ describe('list reader', () => { pages: [0, 1], show: true }) + await setTimeout(10) deepEqual(slowMenu.get(), [ [ - await loadValue(getCategory(categoryId)), + await loadCategory(categoryId), [ - [await loadValue(getFeed(feed1)), 24], - [await loadValue(getFeed(feed2)), 24] + [await loadFeed(feed1), 24], + [await loadFeed(feed2), 24] ] ] ]) diff --git a/core/test/refresh.test.ts b/core/test/refresh.test.ts index 01bc55bd..e052609c 100644 --- a/core/test/refresh.test.ts +++ b/core/test/refresh.test.ts @@ -1,6 +1,5 @@ import './dom-parser.ts' -import { loadValue } from '@logux/client' import { restoreAll, spyOn } from 'nanospy' import { deepEqual, equal, fail, ok } from 'node:assert/strict' import { afterEach, beforeEach, describe, test } from 'node:test' @@ -11,8 +10,8 @@ import { addFilter, createPostsList, deleteFeed, - getFeed, - getPosts, + loadFeed, + loadPosts, HTTPStatusError, isRefreshing, loaders, @@ -54,8 +53,8 @@ describe('refresh', () => { async function getPostKeys( key: Key ): Promise { - let posts = await loadValue(getPosts()) - return posts.list + let posts = await loadPosts() + return posts .toSorted((a, b) => a.originId.localeCompare(b.originId)) .map(post => post[key]) } @@ -173,9 +172,9 @@ describe('refresh', () => { deepEqual(await getPostKeys('title'), ['2', '3']) deepEqual(await getPostKeys('reading'), ['slow', 'slow']) deepEqual(await getPostKeys('feedId'), [feedId1, feedId1]) - ok((await getPostKeys('publishedAt'))[0]! + 100 > Date.now()) - deepEqual((await loadValue(getFeed(feedId1)))!.lastOriginId, 'post3') - deepEqual((await loadValue(getFeed(feedId1)))!.lastPublishedAt, undefined) + ok((await getPostKeys('publishedAt'))[0]! + 1000 > Date.now()) + deepEqual((await loadFeed(feedId1))!.lastOriginId, 'post3') + deepEqual((await loadFeed(feedId1))!.lastPublishedAt, null) let atom2 = atom1.next() atom1.resolve([ @@ -200,8 +199,8 @@ describe('refresh', () => { }) deepEqual(await getPostKeys('title'), ['2', '3']) deepEqual(await getPostKeys('reading'), ['slow', 'slow']) - deepEqual((await loadValue(getFeed(feedId2)))!.lastOriginId, 'post2') - deepEqual((await loadValue(getFeed(feedId2)))!.lastPublishedAt, 5000) + deepEqual((await loadFeed(feedId2))!.lastOriginId, 'post2') + deepEqual((await loadFeed(feedId2))!.lastPublishedAt, 5000) atom2.resolve([ [ @@ -215,8 +214,8 @@ describe('refresh', () => { ]) await setTimeout(10) deepEqual(await getPostKeys('title'), ['2', '3', '6', '7', '8 slow']) - deepEqual((await loadValue(getFeed(feedId2)))!.lastOriginId, 'post9') - deepEqual((await loadValue(getFeed(feedId2)))!.lastPublishedAt, 9000) + deepEqual((await loadFeed(feedId2))!.lastOriginId, 'post9') + deepEqual((await loadFeed(feedId2))!.lastPublishedAt, 9000) equal(finished, true) deepEqual(refreshErrors.get(), []) equal(refreshStatus.get(), 'done') @@ -426,7 +425,7 @@ describe('refresh', () => { equal(refreshStatus.get(), 'done') deepEqual(await getPostKeys('title'), ['4', '5', '6']) - let feed = await loadValue(getFeed(feedId)) + let feed = await loadFeed(feedId) deepEqual(feed!.lastOriginId, 'post6') deepEqual(feed!.lastPublishedAt, 6000) }) @@ -460,7 +459,7 @@ describe('refresh', () => { equal(refreshStatus.get(), 'done') deepEqual(await getPostKeys('title'), ['2', '3', '4']) - let feed = await loadValue(getFeed(feedId)) + let feed = await loadFeed(feedId) deepEqual(feed!.lastOriginId, 'post4') deepEqual(feed!.lastPublishedAt, 4000) }) diff --git a/core/test/schema.test.ts b/core/test/schema.test.ts new file mode 100644 index 00000000..a9c5f511 --- /dev/null +++ b/core/test/schema.test.ts @@ -0,0 +1,46 @@ +import { deepEqual } from 'node:assert/strict' +import { afterEach, beforeEach, describe, test } from 'node:test' + +import { + addCategory, + addFeed, + addFilter, + addPost, + cleanDatabase, + loadCategories, + loadFeeds, + loadFilters, + loadPosts, + testFeed, + testPost +} from '../index.ts' +import { cleanClientTest, enableClientTest } from './utils.ts' + +describe('schema', () => { + beforeEach(() => { + enableClientTest() + }) + + afterEach(async () => { + await cleanClientTest() + }) + + test('cleans all tables', async () => { + let categoryId = await addCategory({ title: 'A' }) + let feedId = await addFeed(testFeed({ categoryId })) + await addFilter({ action: 'fast', feedId, query: 'include(a)' }) + await addPost(testPost({ feedId })) + + await cleanDatabase() + + deepEqual(await loadCategories(), []) + deepEqual(await loadFeeds(), []) + deepEqual(await loadFilters(), []) + deepEqual(await loadPosts(), []) + }) + + test('does nothing without database', async () => { + await cleanClientTest() + await cleanDatabase() + }) +}) diff --git a/core/test/utils.ts b/core/test/utils.ts index 8496c1cf..817a3d9d 100644 --- a/core/test/utils.ts +++ b/core/test/utils.ts @@ -6,7 +6,6 @@ import { type BasePopup, type BaseReader, type BaseRoute, - Category, client, currentPage, enableTestTime, @@ -14,9 +13,7 @@ import { type EnvironmentAndStore, fastMenu, fastPostsCount, - Feed, type FeedReader, - Filter, hasPassword, type ListReader, type Loader, @@ -27,7 +24,6 @@ import { type Page, type Popup, type PopupName, - Post, type ReaderName, setLayoutType, setupEnvironment, @@ -77,10 +73,6 @@ export function enableClientTest(env: Partial = {}): void { export async function cleanClientTest(): Promise { cleanStores( - Feed, - Filter, - Category, - Post, fastMenu, slowMenu, menuLoading, diff --git a/docs/benchmark.md b/docs/benchmark.md new file mode 100644 index 00000000..c6b81c55 --- /dev/null +++ b/docs/benchmark.md @@ -0,0 +1,143 @@ +# How to Benchmark UI? + +Benchmark fills the client with a lot of data, replays user actions in the real app, and reports loader durations and main thread freezes. + +It runs inside the real web client (lazy chunk, loaded only with `?benchmark`), not in a special page, so numbers include real bundle, real Svelte components, real IndexedDB. + +## Run Script + +Script starts the server, opens Chromium from Playwright’s cache (downloads it +on the first run), fills the database on the first run, shows the progress bar, +and prints total numbers: + +```sh +pnpm -F web build:web +pnpm -F web benchmark +``` + +Other commands: + +```sh +pnpm -F web benchmark --help # all options +DEBUG=1 pnpm -F web benchmark --clean # faster benchmark for development +``` + +Database is kept in `/tmp/slowreader-benchmark` between runs, so filling +happens only once. + +Chromium is downloaded to `~/.cache/ms-playwright/` by +`playwright-mcp install-browser chromium`, the same cache as Playwright MCP +uses. Set `CHROMIUM_PATH` to use another binary. + +Script uses Chromium, not Firefox, because only Chromium has long tasks +and memory API. + +## Run in Browser + +It is better to use Chrome because it has long tasks and memory API. + +```sh +pnpm start +``` + +Open `http://localhost:2553/?benchmark`. On the first opening benchmark +creates the database and reloads the page. + +Then in browser console: + +```js +await benchmark.run() // returns results object +benchmark.results // last results, use copy() to get JSON +benchmark.end() // return the tab to the normal app +``` + +Other commands: + +```js +await benchmark.run('read-page') // single scenario, for debugging +await benchmark.clean() // remove database and reload with new data +await benchmark.fill() // create data again +await benchmark.storage() // IndexedDB, OPFS, and localStorage sizes in bytes +``` + +For benchmark development you can use `?benchmark=debug` to make it faster. + +## Methodology + +10 categories, 100 feeds, 0–2000 posts in feed with long tail distribution (most feeds are small, few are huge), ~7000 posts total. Network is mocked. Every 5th feed returns 3 new posts on every refresh, so `refresh` scenario also measures database inserts and menu rebuilds. + +Every scenario runs 5 times. Run, which did not finish in 1 minute or did not find element to click, is counted in `failed` and does not stop other scenarios. After 2 failed runs benchmark gives up on the scenario and goes to the next one. + +```js +{ + feeds: 100, // Feeds in the database + fill: 4360, // Time of database filling in ms + posts: 7342, // Posts in the database + scenarios: { + 'read-page': { + // The biggest number of elements in the page during the scenario, + // counted on every 10th frame + domSize: 2688, + // From start to the moment when no loader is rendered and 2 frames passed + duration: 516, + // Did not finish in 1 minute or did not find element to click + failed: 0, + freezes: 7, // Frames longer than 50 ms + loaders: { posts: 120 }, // How long every loader was rendered in ms + longestFrame: 300, // The longest frame in ms + // 0 if the page did not render + // The longest task in the main thread in ms + // 0 if there were no tasks longer than 50 ms (Chromium only) + longestTask: 305, + // The biggest JS heap in MB seen on scenario’s frames. Chromium only. + memory: 112 + } + //, … other scenarios + }, + start: 12000, // Time from page opening to app without loaders in ms + storage: { // Sizes in bytes + end: { // After all scenarios + indexedDB: 13000000, // From navigator.storage.estimate() + localStorage: 2048, // Sum of all keys and values + opfs: 0, // Sum of all files in origin private file system + // All storages by navigator.storage.estimate(). Browser counts it + // lazily, so it can be smaller than the real size right after big writes + total: 13500000 + }, + start: {} // The same, but after database filling, before scenarios + }, + total: { + domSize: 2688, // Sum of scenarios + duration: 35462, // Sum + failed: 5, // Sum + freezes: 7, // Sum + loaders: 749, // Sum of all loaders of all scenarios + longestFrame: 300, // The biggest of scenarios + longestTask: 305, // The biggest + memory: 112, // The biggest + // Only the script can take 3 numbers below. They are not in + // benchmark.run() results in the browser. They are the biggest values + // of the whole session. + documents: 1004, + listeners: 40610, + // Memory of all renderer processes in MB. Renderer is killed by this + // number, not by JS heap: it also has DOM, IndexedDB, images, workers, + // and buffers outside the heap + renderer: 1209 + } +} +``` + +## Loaders + +UI loaders reports itself by `reportLoader()` while it is rendered. + +## Scenarios + +Scenarios are in `web/benchmark/scenarios.ts`. They click real elements, so they depend on class names of the real UI. + +`freeze` scenario blocks the main thread for 200 ms. It is not in the report, but you can run it to check that freeze metrics are still working: + +```sh +pnpm -F web benchmark --scenario freeze +``` diff --git a/docs/browser.md b/docs/browser.md new file mode 100644 index 00000000..f3ffc4ba --- /dev/null +++ b/docs/browser.md @@ -0,0 +1,41 @@ +# How to Debug in Browser? + +Dev Container has Firefox and Chromium with [Playwright MCP](https://github.com/microsoft/playwright-mcp) server. LLM uses them to check visual issues and to debug client database. + +## Run Application + +```sh +pnpm start +``` + +Web client will be on `http://localhost:2553`. + +Storybook is a separate process: + +```sh +pnpm -F web visual +``` + +## Open Story + +Open story without Storybook UI to keep screenshot clean: + +```text +http://localhost:2555/iframe.html?id=STORY_ID&globals=theme:light +``` + +Story IDs are in `http://localhost:2555/index.json`. Themes are `theme:light` and `theme:dark`. + +## Record Steps + +`--caps devtools` adds `browser_start_tracing`, `browser_stop_tracing`, and video tools. Record long scenario to get DOM snapshots, console, and network for every step. + +## Reset State + +Browser profile keeps cookies, `localStorage`, and IndexedDB between sessions: + +```sh +rm -rf ~/.local/share/playwright-chromium ~/.local/share/playwright-firefox +``` + +Screenshots and session logs are in `~/.local/share/playwright-output`. diff --git a/extension/README.md b/extension/README.md index 48e61117..5657adbe 100644 --- a/extension/README.md +++ b/extension/README.md @@ -6,13 +6,13 @@ _See the [full architecture guide](../README.md) first._ ## Scripts -- `cd extension && pnpm install` to install dependencies. -- `cd extension && `pnpm start` to run the plugin in development mode. -- `cd extension && pnpm build` to build the plugin for production. +- `pnpm -F extension install` to install dependencies. +- `pnpm -F extension start` to run the plugin in development mode. +- `pnpm -F extension build` to build the plugin for production. ## Quick Start -1. Run `cd extension && pnpm start` to build the extension and watch the changes. +1. Run `pnpm -F extension start` to build the extension and watch the changes. 2. Open `chrome://extensions/` → `Load unpacked` and choose `dist` folder from this repo. The extension should appear in the list of your extensions. 3. In the `.env` file of the main app, place the next line (`EXTENSION_ID` can be found in the `ID` line inside the uploaded extension block): diff --git a/extension/package.json b/extension/package.json index 66ccbf35..9bc01b29 100644 --- a/extension/package.json +++ b/extension/package.json @@ -9,10 +9,10 @@ }, "dependencies": { "@crxjs/vite-plugin": "2.7.1", - "vite": "8.1.5" + "vite": "8.2.0" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/feedly.opml b/feedly.opml new file mode 100644 index 00000000..e8a57ccd --- /dev/null +++ b/feedly.opml @@ -0,0 +1,132 @@ + + + + + Andrey subscriptions in feedly Cloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loader-tests/README.md b/loader-tests/README.md index 6d6812e1..c5a1fc46 100644 --- a/loader-tests/README.md +++ b/loader-tests/README.md @@ -12,7 +12,7 @@ Test that Slow Reader can work with all feeds from your RSS reader by using OPML 2. Once in the root you can run: ```sh - pnpm check-opml PATH_TO_YOUR_FILE.opml + pnpm -F loader-tests check-opml PATH_TO_YOUR_FILE.opml ``` ## Check Loaders by Different Blog Platforms @@ -20,8 +20,7 @@ Test that Slow Reader can work with all feeds from your RSS reader by using OPML Test that Slow Reader can work with different feeds from popular blogging platforms. ```sh -cd loader-tests/ -pnpm test +pnpm -F loader-tests test ``` ## Debug Feed Search @@ -29,8 +28,7 @@ pnpm test A small helper to run feed searching for specific feed. ```sh -cd loader-tests/ -pnpm run feed URL HOME_URL +pnpm -F loader-tests feed URL HOME_URL ``` ## Debug Posts Loading @@ -38,6 +36,5 @@ pnpm run feed URL HOME_URL A small helper to run posts loading for specific feed. ```sh -cd loader-tests/ -pnpm run url URL +pnpm -F loader-tests url URL ``` diff --git a/loader-tests/check-opml.ts b/loader-tests/check-opml.ts index 8bbe7c06..c3613c1c 100644 --- a/loader-tests/check-opml.ts +++ b/loader-tests/check-opml.ts @@ -7,14 +7,13 @@ import { import { createProxy, DEFAULT_PROXY_CONFIG } from '@slowreader/proxy' import { createServer } from 'node:http' +import { error, finish, startProgress } from '../scripts/progress.ts' import { createCLI, enableTestClient, - error, fetchAndParsePosts, - finish, - initializeProgressBar, - readText + readText, + USER_AGENT } from './utils.ts' let cli = createCLI( @@ -58,7 +57,8 @@ await cli.run(async args => { let headers = opts.headers as object | undefined let response = await fetch(nextUrl, { headers: { - Origin: 'http://localhost:8000', + 'Origin': 'http://localhost:8000', + 'User-Agent': USER_AGENT, ...headers }, ...opts @@ -83,7 +83,7 @@ await cli.run(async args => { } let unbindTotal = page.total.listen(all => { - initializeProgressBar(all) + startProgress(all) unbindTotal() }) let unbindLastAdded = page.lastAdded.listen(async url => { diff --git a/loader-tests/dom-api.ts b/loader-tests/dom-api.ts index 4be0bc84..9d7dfd22 100644 --- a/loader-tests/dom-api.ts +++ b/loader-tests/dom-api.ts @@ -4,24 +4,3 @@ let window = new JSDOM().window // @ts-expect-error JSDOM types are incomplete global.window = window global.DOMParser = window.DOMParser - -if (typeof globalThis.FileReader === 'undefined') { - // @ts-expect-error Simple polyfill - globalThis.FileReader = class { - onerror: (() => void) | null = null - onload: (() => void) | null = null - result: null | string = null - - readAsText(file: Blob): void { - file - .text() - .then(text => { - this.result = text - this.onload?.() - }) - .catch(() => { - this.onerror?.() - }) - } - } -} diff --git a/loader-tests/feeds.yml b/loader-tests/feeds.yml index 24f10f90..e53e723b 100644 --- a/loader-tests/feeds.yml +++ b/loader-tests/feeds.yml @@ -39,3 +39,6 @@ feeds: - title: JSON Feed url: https://vas3k.club/feed.json + + - title: autoSelectFamilyAttemptTimeout test + url: https://bolknote.ru/rss/ diff --git a/loader-tests/package.json b/loader-tests/package.json index 37680768..d75fe495 100644 --- a/loader-tests/package.json +++ b/loader-tests/package.json @@ -13,12 +13,12 @@ "@slowreader/proxy": "workspace:*", "@types/jsdom": "28.0.3", "@types/trusted-types": "2.0.7", - "jsdom": "30.0.0", - "nanostores": "1.4.1", + "jsdom": "30.0.1", + "nanostores": "1.4.2", "yaml": "2.9.0" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/loader-tests/test-loaders.ts b/loader-tests/test-loaders.ts index 95602639..6f739eab 100644 --- a/loader-tests/test-loaders.ts +++ b/loader-tests/test-loaders.ts @@ -1,14 +1,13 @@ import { join } from 'node:path' import yaml from 'yaml' +import { finish, startProgress } from '../scripts/progress.ts' import { completeTasks, createCLI, enableTestClient, fetchAndParsePosts, findRSSFromHome, - finish, - initializeProgressBar, type LoaderTestFeed, readText } from './utils.ts' @@ -30,7 +29,7 @@ await cli.run(async () => { enableTestClient() let feeds = await parseFeedsFromFile(FEEDS) - initializeProgressBar( + startProgress( feeds.length + feeds.filter(feed => feed.findFromHome !== false).length ) diff --git a/loader-tests/tsconfig.json b/loader-tests/tsconfig.json index 6858cda8..0b5bd10a 100644 --- a/loader-tests/tsconfig.json +++ b/loader-tests/tsconfig.json @@ -4,5 +4,5 @@ "composite": true, "types": ["node", "trusted-types"] }, - "include": ["**/*.ts"] + "include": ["../scripts/progress.ts", "**/*.ts"] } diff --git a/loader-tests/utils.ts b/loader-tests/utils.ts index 2fd47dcd..76617095 100644 --- a/loader-tests/utils.ts +++ b/loader-tests/utils.ts @@ -16,10 +16,16 @@ import { } from '@slowreader/core' import { getTestEnvironment, setBaseTestRoute } from '@slowreader/core/test' import { readFile } from 'node:fs/promises' +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net' import { isAbsolute, join } from 'node:path' -import readline from 'node:readline' -import { isatty } from 'node:tty' -import { styleText } from 'node:util' + +import { + error, + printAboveProgress, + semiSuccess, + success, + warning +} from '../scripts/progress.ts' export interface LoaderTestFeed { homeUrl?: string @@ -40,6 +46,13 @@ export function isString(attr: null | string): attr is string { return typeof attr === 'string' && attr.length > 0 } +// Node.js sends `node` and websites with bot protection block it +export const USER_AGENT = 'SlowReader/1.0 (+https://slowreader.app)' + +// Node.js gives every IP address only 500 ms to connect and gives up on all +// of them. Slow websites need more time than browsers’ default. +setDefaultAutoSelectFamilyAttemptTimeout(2000) + export function enableTestClient(route: RouteName = 'home'): void { setupEnvironment({ ...getTestEnvironment(), @@ -50,7 +63,11 @@ export function enableTestClient(route: RouteName = 'home'): void { enableTestTime() useCredentials(generateCredentials()) setBaseTestRoute({ params: {}, route }) - setRequestMethod(fetch) + setRequestMethod((url, opts = {}) => { + let headers = new Headers(opts.headers) + if (!headers.has('User-Agent')) headers.set('User-Agent', USER_AGENT) + return fetch(url, { ...opts, headers }) + }) } export function timeout( @@ -67,114 +84,6 @@ export function timeout( ]) } -interface NoFileError extends Error { - code: string - path: string -} - -function isNoFileError(e: unknown): e is NoFileError { - return e instanceof Error && `code` in e && e.code === 'ENOENT' -} - -let progress = 0 -let totalJobs = 0 - -export function initializeProgressBar(totalValue: number): void { - if (!process.env.CI) { - totalJobs = totalValue - renderProgressBar() - } -} - -function renderProgressBar(): void { - let filled = Math.floor((process.stderr.columns * progress) / totalJobs) - process.stderr.write( - '█'.repeat(filled) + '░'.repeat(process.stderr.columns - filled) + '\n' - ) - readline.moveCursor(process.stderr, 0, 0) -} - -const SIMPLE_SHELL = !isatty(1) || process.env.CI - -function updateProgressBar(): void { - if (totalJobs > 0 && progress < totalJobs && !SIMPLE_SHELL) { - progress += 1 - readline.moveCursor(process.stderr, 0, -1) - readline.clearLine(process.stderr, 0) - if (progress < totalJobs) { - renderProgressBar() - } - } -} - -export function print(msg: string): void { - if (totalJobs > 0 && progress < totalJobs && !SIMPLE_SHELL) { - readline.moveCursor(process.stderr, 0, -1) - readline.clearLine(process.stderr, 0) - process.stderr.write(`${msg}\n`) - renderProgressBar() - } else { - process.stderr.write(`${msg}\n`) - } -} - -let errors = 0 - -export function warning(text: string): void { - print(styleText('yellow', text)) - updateProgressBar() -} - -export function error(err: unknown, details?: string): void { - errors += 1 - let msg: string - if (isNoFileError(err)) { - msg = `File not found: ${err.path}` - } else if (err instanceof Error) { - msg = err.stack ?? err.message - } else { - msg = String(err) - } - print('') - print( - styleText('bold', styleText('bgRed', ' ERROR ')) + - ' ' + - styleText('bold', styleText('red', msg)) - ) - if (details) print(details) - print('') - updateProgressBar() -} - -export function finish(msg: string): void { - print('') - let postfix = '' - if (errors > 0) { - postfix = - ', ' + styleText('red', styleText('bold', `${errors} errors found`)) - } - print(styleText('gray', msg + postfix)) - process.exit(errors > 0 ? 1 : 0) -} - -export function success(msg: string, details?: string): void { - if (details) { - msg += ` ${styleText('gray', details)}` - } - print(styleText('green', styleText('bold', '✓ ') + msg)) - updateProgressBar() -} - -export function semiSuccess(msg: string, note: string): void { - print( - styleText( - 'yellow', - styleText('bold', '✓ ') + msg + ' ' + styleText('bold', note) - ) - ) - updateProgressBar() -} - export async function fetchAndParsePosts( url: string, badSource = false @@ -337,8 +246,8 @@ export function createCLI(help: string, usage?: string): CLI { args.includes('-h') || args.includes('help') ) { - print(help) - if (usage) print('Usage:\n' + usage) + printAboveProgress(help) + if (usage) printAboveProgress('Usage:\n' + usage) process.exit(0) } else { try { @@ -351,7 +260,7 @@ export function createCLI(help: string, usage?: string): CLI { }, wrongArg(message) { error(message) - if (usage) print('Usage:\n' + usage) + if (usage) printAboveProgress('Usage:\n' + usage) process.exit(1) } } diff --git a/oxlint.config.ts b/oxlint.config.ts index ed5073ec..08b24808 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -15,7 +15,11 @@ export default defineConfig({ } }, { - files: ['web/stories/*.ts', 'core/devtools.ts'], + files: [ + 'web/stories/*.ts', + 'core/devtools.ts', + 'web/benchmark/benchmark.ts' + ], rules: { 'no-console': 'off' } diff --git a/package.json b/package.json index 4f7002c7..f8525788 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "test": "FORCE_COLOR=1 pnpm run -r --include-workspace-root /^test:/", "quick": "nano-staged --unstaged --quiet --bail", "offline": "FORCE_COLOR=1 pnpm run -r --include-workspace-root '/^test:(?!online\\b)/'", - "start": "FORCE_COLOR=1 pnpm run -F server -F web start", + "start": "FORCE_COLOR=1 pnpm run --parallel -F server -F web start", "format": "pnpm run -r --include-workspace-root /^format:/", "clean": "pnpm run -r --include-workspace-root /^clean:/", "build": "pnpm run -r build", @@ -28,8 +28,9 @@ }, "devDependencies": { "@logux/eslint-config": "57.1.0", - "@logux/oxc-configs": "1.0.0", - "@types/chrome": "0.2.2", + "@logux/oxc-configs": "1.1.0", + "@playwright/mcp": "0.0.78", + "@types/chrome": "0.2.5", "@types/node": "26.1.2", "@types/ws": "8.18.1", "@typescript/native": "npm:typescript@7.0.2", @@ -43,7 +44,7 @@ "oxfmt": "0.61.0", "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", - "postcss-html": "1.8.1", + "postcss-html": "2.0.0", "remark-cli": "12.0.1", "remark-lint-check-toc": "1.0.0", "remark-lint-code-block-split-list": "1.0.0", @@ -63,8 +64,8 @@ "typescript": "6.0.3" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" }, - "packageManager": "pnpm@11.18.0" + "packageManager": "pnpm@11.20.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87ddee49..646ce165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,11 +16,14 @@ importers: specifier: 57.1.0 version: 57.1.0(@typescript-eslint/utils@8.65.0)(eslint-plugin-svelte@3.22.0)(eslint@10.8.0)(supports-color@10.2.2)(svelte-eslint-parser@1.8.0)(svelte@5.56.8)(typescript@6.0.3) '@logux/oxc-configs': - specifier: 1.0.0 - version: 1.0.0(oxlint-tsgolint@7.0.2001)(oxlint@1.76.0)(typescript@6.0.3) + specifier: 1.1.0 + version: 1.1.0(oxlint-tsgolint@7.0.2001)(oxlint@1.76.0)(typescript@6.0.3) + '@playwright/mcp': + specifier: 0.0.78 + version: 0.0.78 '@types/chrome': - specifier: 0.2.2 - version: 0.2.2 + specifier: 0.2.5 + version: 0.2.5 '@types/node': specifier: 26.1.2 version: 26.1.2 @@ -61,8 +64,8 @@ importers: specifier: 7.0.2001 version: 7.0.2001 postcss-html: - specifier: 1.8.1 - version: 1.8.1 + specifier: 2.0.0 + version: 2.0.0(postcss@8.5.25) remark-cli: specifier: 12.0.1 version: 12.0.1(supports-color@10.2.2) @@ -118,30 +121,33 @@ importers: api: dependencies: '@logux/actions': - specifier: 0.5.0 - version: 0.5.0(@logux/core@0.10.0) + specifier: github:logux/actions#next + version: git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6(@logux/core@0.10.0) devDependencies: '@logux/core': - specifier: 0.10.0 - version: 0.10.0 + specifier: github:logux/core#next + version: git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40 core: dependencies: '@logux/actions': - specifier: 0.5.0 - version: 0.5.0(@logux/core@0.10.0) + specifier: github:logux/actions#next + version: git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6(@logux/core@0.10.0) '@logux/client': - specifier: 0.23.2 - version: 0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1) + specifier: github:logux/client#next + version: git+ssh://git@github.com/logux/client.git#70c398a5cc0218a331abd331bcfa33a6c44f3829(@nanostores/sql@0.3.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1) '@logux/core': - specifier: 0.10.0 - version: 0.10.0 + specifier: github:logux/core#next + version: git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40 '@nanostores/i18n': specifier: 1.3.3 - version: 1.3.3(nanostores@1.4.1) + version: 1.3.3(nanostores@1.4.2) '@nanostores/persistent': specifier: 1.3.5 - version: 1.3.5(nanostores@1.4.1) + version: 1.3.5(nanostores@1.4.2) + '@nanostores/sql': + specifier: 0.3.1 + version: 0.3.1(@electric-sql/pglite@0.5.4)(nanostores@1.4.2)(sqlocal@0.18.0) '@slowreader/api': specifier: workspace:* version: link:../api @@ -161,15 +167,15 @@ importers: specifier: 2.0.2 version: 2.0.2 nanoid: - specifier: 6.0.0 - version: 6.0.0 + specifier: 6.0.1 + version: 6.0.1 nanostores: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 devDependencies: '@logux/server': - specifier: 0.14.0 - version: 0.14.0 + specifier: github:logux/server + version: git+ssh://git@github.com/logux/server.git#3a314f86868e9436704d0b3ea29fc8a7d877cfd3 '@slowreader/proxy': specifier: workspace:* version: link:../proxy @@ -183,8 +189,8 @@ importers: specifier: 1.0.0 version: 1.0.0 jsdom: - specifier: 30.0.0 - version: 30.0.0 + specifier: 30.0.1 + version: 30.0.1 nanospy: specifier: 2.0.2 version: 2.0.2 @@ -193,10 +199,10 @@ importers: dependencies: '@crxjs/vite-plugin': specifier: 2.7.1 - version: 2.7.1(supports-color@10.2.2)(vite@8.1.5) + version: 2.7.1(supports-color@10.2.2)(vite@8.2.0) vite: - specifier: 8.1.5 - version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + specifier: 8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) loader-tests: devDependencies: @@ -213,11 +219,11 @@ importers: specifier: 2.0.7 version: 2.0.7 jsdom: - specifier: 30.0.0 - version: 30.0.0 + specifier: 30.0.1 + version: 30.0.1 nanostores: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 yaml: specifier: 2.9.0 version: 2.9.0 @@ -234,11 +240,11 @@ importers: specifier: 0.5.4 version: 0.5.4 '@logux/actions': - specifier: 0.5.0 - version: 0.5.0(@logux/core@0.10.0) + specifier: github:logux/actions#next + version: git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6(@logux/core@0.10.0) '@logux/server': - specifier: 0.14.0 - version: 0.14.0 + specifier: github:logux/server + version: git+ssh://git@github.com/logux/server.git#3a314f86868e9436704d0b3ea29fc8a7d877cfd3 '@slowreader/api': specifier: workspace:* version: link:../api @@ -255,18 +261,18 @@ importers: specifier: 0.45.2 version: 0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9) nanoid: - specifier: 6.0.0 - version: 6.0.0 + specifier: 6.0.1 + version: 6.0.1 postgres: specifier: 3.4.9 version: 3.4.9 tsx: - specifier: 4.23.1 - version: 4.23.1 + specifier: 4.23.5 + version: 4.23.5 devDependencies: '@logux/client': - specifier: 0.23.2 - version: 0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1) + specifier: github:logux/client#next + version: git+ssh://git@github.com/logux/client.git#70c398a5cc0218a331abd331bcfa33a6c44f3829(@nanostores/sql@0.3.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1) better-node-test: specifier: 1.0.0 version: 1.0.0 @@ -292,26 +298,29 @@ importers: specifier: 5.3.0 version: 5.3.0 '@logux/client': - specifier: 0.23.2 - version: 0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1) + specifier: github:logux/client#next + version: git+ssh://git@github.com/logux/client.git#70c398a5cc0218a331abd331bcfa33a6c44f3829(@nanostores/sql@0.3.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1) '@logux/core': - specifier: 0.10.0 - version: 0.10.0 + specifier: github:logux/core#next + version: git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40 '@mdi/js': specifier: 7.4.47 version: 7.4.47 '@nanostores/i18n': specifier: 1.3.3 - version: 1.3.3(nanostores@1.4.1) + version: 1.3.3(nanostores@1.4.2) '@nanostores/media-query': specifier: 0.1.2 - version: 0.1.2(nanostores@1.4.1) + version: 0.1.2(nanostores@1.4.2) '@nanostores/persistent': specifier: 1.3.5 - version: 1.3.5(nanostores@1.4.1) + version: 1.3.5(nanostores@1.4.2) '@nanostores/router': specifier: 1.1.0 - version: 1.1.0(nanostores@1.4.1) + version: 1.1.0(nanostores@1.4.2) + '@nanostores/sql': + specifier: 0.3.1 + version: 0.3.1(@electric-sql/pglite@0.5.4)(nanostores@1.4.2)(sqlocal@0.18.0) '@slowreader/core': specifier: workspace:* version: link:../core @@ -320,7 +329,7 @@ importers: version: 10.5.5(storybook@10.5.5) '@storybook/addon-svelte-csf': specifier: 5.1.2 - version: 5.1.2(@storybook/svelte@10.5.5)(@sveltejs/vite-plugin-svelte@7.2.0)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5) + version: 5.1.2(@storybook/svelte@10.5.5)(@sveltejs/vite-plugin-svelte@7.2.0)(storybook@10.5.5)(svelte@5.56.8)(vite@8.2.0) '@storybook/addon-themes': specifier: 10.5.5 version: 10.5.5(storybook@10.5.5) @@ -329,10 +338,10 @@ importers: version: 10.5.5(storybook@10.5.5)(svelte@5.56.8) '@storybook/svelte-vite': specifier: 10.5.5 - version: 10.5.5(@sveltejs/vite-plugin-svelte@7.2.0)(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5) + version: 10.5.5(@sveltejs/vite-plugin-svelte@7.2.0)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5)(svelte@5.56.8)(vite@8.2.0) '@sveltejs/vite-plugin-svelte': specifier: 7.2.0 - version: 7.2.0(svelte@5.56.8)(vite@8.1.5) + version: 7.2.0(svelte@5.56.8)(vite@8.2.0) autoprefixer: specifier: 10.5.4 version: 10.5.4(postcss@8.5.25) @@ -343,14 +352,14 @@ importers: specifier: 1.33.0 version: 1.33.0 nanostores: - specifier: 1.4.1 - version: 1.4.1 + specifier: 1.4.2 + version: 1.4.2 postcss: specifier: 8.5.25 version: 8.5.25 postcss-load-config: specifier: 6.0.1 - version: 6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(yaml@2.9.0) + version: 6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.5)(yaml@2.9.0) postcss-mixins: specifier: 12.1.2 version: 12.1.2(postcss@8.5.25) @@ -358,8 +367,11 @@ importers: specifier: 0.3.2 version: 0.3.2(postcss@8.5.25) rollup: - specifier: 4.62.3 - version: 4.62.3 + specifier: 4.62.4 + version: 4.62.4 + sqlocal: + specifier: 0.18.0 + version: 0.18.0(drizzle-orm@0.45.2)(react@18.3.1)(vite@8.2.0) storybook: specifier: 10.5.5 version: 10.5.5(react@18.3.1) @@ -367,18 +379,18 @@ importers: specifier: 5.56.8 version: 5.56.8(@typescript-eslint/types@8.65.0) tsx: - specifier: 4.23.1 - version: 4.23.1 + specifier: 4.23.5 + version: 4.23.5 vite: - specifier: 8.1.5 - version: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + specifier: 8.2.0 + version: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) devDependencies: '@logux/stylelint-config': specifier: 1.0.1 version: 1.0.1(stylelint@17.14.1) '@size-limit/file': - specifier: 13.0.2 - version: 13.0.2(size-limit@13.0.2) + specifier: 13.0.3 + version: 13.0.3(size-limit@13.0.3) '@stylistic/stylelint-config': specifier: 5.0.0 version: 5.0.0(stylelint@17.14.1) @@ -398,8 +410,8 @@ importers: specifier: 10.8.0 version: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) postcss-html: - specifier: 1.8.1 - version: 1.8.1 + specifier: 2.0.0 + version: 2.0.0(postcss@8.5.25) postcss-nesting: specifier: 14.0.1 version: 14.0.1(postcss@8.5.25) @@ -407,8 +419,8 @@ importers: specifier: 7.1.4 version: 7.1.4 size-limit: - specifier: 13.0.2 - version: 13.0.2 + specifier: 13.0.3 + version: 13.0.3 stylelint: specifier: 17.14.1 version: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) @@ -428,8 +440,8 @@ packages: resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} engines: {node: ^22.13.0 || >=24.0.0} - '@asamuzakjp/dom-selector@8.3.0': - resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} engines: {node: ^22.13.0 || >=24.0.0} '@babel/code-frame@7.29.7': @@ -523,9 +535,6 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} - '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -535,9 +544,6 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} - '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -1121,12 +1127,21 @@ packages: peerDependencies: '@logux/core': ^0.10.0 - '@logux/client@0.23.2': - resolution: {integrity: sha512-cqDegrUH+u7WlHUa7bnq7+0UwPLlNgy+YprruO0d3uG/oO6vfkGyWUZerQt7htaKOah2Hl0vqJgvvZljNfL98w==} - engines: {node: ^20.0.0 || >=22.0.0} + '@logux/actions@git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6': + resolution: {commit: fbede590f2f311130c3db2c0a231228ca397a6f6, repo: git@github.com:logux/actions.git, type: git} + version: 0.5.0 + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + peerDependencies: + '@logux/core': ^0.10.0 + + '@logux/client@git+ssh://git@github.com/logux/client.git#70c398a5cc0218a331abd331bcfa33a6c44f3829': + resolution: {commit: 70c398a5cc0218a331abd331bcfa33a6c44f3829, repo: git@github.com:logux/client.git, type: git} + version: 0.23.2 + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} peerDependencies: '@nanostores/preact': '>=0.0.0' '@nanostores/react': '>=0.0.0' + '@nanostores/sql': '>=0.0.0' '@nanostores/vue': '>=0.0.0' nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 preact: '>=10.0.0' @@ -1138,6 +1153,8 @@ packages: optional: true '@nanostores/react': optional: true + '@nanostores/sql': + optional: true '@nanostores/vue': optional: true preact: @@ -1153,6 +1170,11 @@ packages: resolution: {integrity: sha512-VbgVkEloAe2ym5w0cjyRI16Eeyk2R0BgJa9pMYT1FcJEhFnzrB23E0ExIEhrok0iD48bFh7goledwmDLmgWaiw==} engines: {node: ^20.0.0 || >=22.0.0} + '@logux/core@git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40': + resolution: {commit: 780a5438c013d9cb7899b86e344685cd722ede40, repo: git@github.com:logux/core.git, type: git} + version: 0.10.0 + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + '@logux/eslint-config@57.1.0': resolution: {integrity: sha512-oOlYsAu5iQBzNL75QqGWVnpgle/Nz5/Gp5P6BtHRy5vBg7UXyGVqEzaG3lMHF6dgYGVb+wkLqIC0DWIH8wp/LA==} engines: {node: '>=18.0.0'} @@ -1169,8 +1191,8 @@ packages: svelte-eslint-parser: optional: true - '@logux/oxc-configs@1.0.0': - resolution: {integrity: sha512-BHn8L2r2HT/Kp508jiTX+gTC3Rij2Sua7rjLOjweNzkWkQjzAbJdXwtRYI4DeBkrvdF7PmI8A6EvcCqHtDFNEQ==} + '@logux/oxc-configs@1.1.0': + resolution: {integrity: sha512-X+yJcCwTpwnaESokhw2zjH2Wn+XAUGX3VsEToYBQzXfgdXQ4IMCyjTUURKhHO+9JoSL+vGgxtqaF7LMGtPHj5w==} engines: {node: '>=22.0.0'} peerDependencies: oxlint: ^1.57.0 @@ -1181,6 +1203,11 @@ packages: resolution: {integrity: sha512-a7KRD30U252cfIekBGktEIRuwFRT7vOPZXkEsD05DwApsPi70iRnFaHEgMde8tZu+jJUuTDN/xMgabtjzHzXaQ==} engines: {node: ^20.0.0 || >=22.0.0} + '@logux/server@git+ssh://git@github.com/logux/server.git#3a314f86868e9436704d0b3ea29fc8a7d877cfd3': + resolution: {commit: 3a314f86868e9436704d0b3ea29fc8a7d877cfd3, repo: git@github.com:logux/server.git, type: git} + version: 0.14.0 + engines: {node: ^22.5.0 || ^24.0.0 || >=26.0.0} + '@logux/stylelint-config@1.0.1': resolution: {integrity: sha512-a8vGb8ehfLtd5+ivfSgsA2MQpoM1K1aB+gmF1n6mAykweXhweWSmQzGPXYkpSyxWuo6UCZi5T1mqpVnE0oSlkw==} peerDependencies: @@ -1213,12 +1240,35 @@ packages: peerDependencies: nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 - '@napi-rs/wasm-runtime@1.2.0': - resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + '@nanostores/sql@0.3.1': + resolution: {integrity: sha512-DtbZJoxgFp0e0gYBopDzG+i61aj+q6QcCpEDMXDgfM3JgYftvVD5uGdMqBGaHPL5mfIocO3zK79zEmhg3Ez32g==} + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + peerDependencies: + '@electric-sql/pglite': '>=0.4.0' + expo-sqlite: '>=14.0.0' + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^1.0.0 + sqlocal: '>=0.12.0' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + expo-sqlite: + optional: true + sqlocal: + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^2.0.0-alpha.3 - '@emnapi/runtime': ^2.0.0-alpha.3 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1386,8 +1436,8 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} @@ -1774,101 +1824,101 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/mcp@0.0.78': + resolution: {integrity: sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==} + engines: {node: '>=18'} + hasBin: true + '@profoundlogic/hogan@3.0.4': resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==} hasBin: true - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1876,141 +1926,141 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.62.3': - resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.62.3': - resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.62.3': - resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.62.3': - resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.62.3': - resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.3': - resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.62.3': - resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.62.3': - resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.62.3': - resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.62.3': - resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.62.3': - resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.62.3': - resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.62.3': - resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.62.3': - resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.62.3': - resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.62.3': - resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.62.3': - resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.62.3': - resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.62.3': - resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.62.3': - resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.62.3': - resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.62.3': - resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.3': - resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.3': - resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.3': - resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} cpu: [x64] os: [win32] @@ -2022,11 +2072,15 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@size-limit/file@13.0.2': - resolution: {integrity: sha512-2jUyuOP1qJMjcsbDuAhHV4xoRK89uFCrz0DZ+qIRFwTsTxME4Rjb6E6ZYb7VNAy5K2aQyM/BQJOS+eT01KLRjg==} + '@size-limit/file@13.0.3': + resolution: {integrity: sha512-PWTITIXH5p9aGIf6qq2Fruihn/b9nBQyfkyoAyb6DzFJgS1Ek9MSPJYKxKFLO8jdo0aqSgBPd3sevbS6PyBiJw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} peerDependencies: - size-limit: 13.0.2 + size-limit: 13.0.3 + + '@sqlite.org/sqlite-wasm@3.51.2-build9': + resolution: {integrity: sha512-tj2Sa6X4gVZqS9SiT2xlEeChSUklSGq8Hz6zKNbHW6NRiGyrSXiFcspnrWru1wPBT76RX/VxweESKxawCxfDxg==} + engines: {node: '>=22'} '@storybook/addon-a11y@10.5.5': resolution: {integrity: sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==} @@ -2150,8 +2204,8 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/chrome@0.2.2': - resolution: {integrity: sha512-8rSMZ4cvo2xmaSyQg0sN5yRL7oiDkntLoiHxUhfwQnv1mvnkrdoZ25SlNrKWmYKaeP50WvrfWj1pmc02+U9KKw==} + '@types/chrome@0.2.5': + resolution: {integrity: sha512-DwLFgwj3D0zjxRBhQ3610RBso2g+yI1cakq/JdIGopZ9Bc5UhQluyfcLGZwivFE4t2h4b2QZYZhPV7KylExSbA==} '@types/concat-stream@2.0.3': resolution: {integrity: sha512-3qe4oQAPNwVNwK4C9c8u+VJqv9kez+2MR4qJpoPFfXtgxxif1QbFusvXzK0/Wra2VX07smostI2VMmJNSpZjuQ==} @@ -2416,6 +2470,9 @@ packages: '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + '@ungap/with-resolvers@0.1.0': + resolution: {integrity: sha512-g7f0IkJdPW2xhY7H4iE72DAsIyfuwEFc6JWc2tYFwKDMWWAF699vGjrM348cwQuOXgHpe1gWFe+Eiyjx/ewvvw==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} cpu: [arm] @@ -2676,8 +2733,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.7: - resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} engines: {node: '>=6.0.0'} hasBin: true @@ -2699,14 +2756,14 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@1.1.16: - resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.3: - resolution: {integrity: sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -2821,6 +2878,9 @@ packages: co@3.1.0: resolution: {integrity: sha512-CQsjCRiNObI8AtTsNIBDRMQ4oMR83CzEswHYahClvul7gKk+lDQiOKv+5qh7LQWf5sh6jkZNispz/QlsZxyNgA==} + coincident@1.2.3: + resolution: {integrity: sha512-Uxz3BMTWIslzeWjuQnizGWVg0j6khbvHUQ8+5BdM7WuJEm4ALXwq3wluYoB+uF68uPBz/oUOeJnYURKyfjexlA==} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -2994,8 +3054,8 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devalue@5.8.2: - resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + devalue@5.9.0: + resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -3141,8 +3201,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.397: - resolution: {integrity: sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==} + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3153,8 +3213,8 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.24.3: - resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -3371,8 +3431,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.4: - resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} @@ -3412,8 +3472,8 @@ packages: flat-cache@6.1.23: resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - flatted@3.4.3: - resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} @@ -3430,6 +3490,11 @@ packages: resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3439,6 +3504,9 @@ packages: resolution: {integrity: sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==} engines: {node: '>=14.16'} + gc-hook@0.3.1: + resolution: {integrity: sha512-E5M+O/h2o7eZzGhzRZGex6hbB3k4NWqO0eA+OzLRLXxhdbYPajZnynPwAtphnh+cRHPwsj5Z80dqZlfI4eK55A==} + get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -3447,8 +3515,8 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -3486,8 +3554,8 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.8.0: - resolution: {integrity: sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==} + globals@17.9.0: + resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} engines: {node: '>=18'} globby@16.2.2: @@ -3577,8 +3645,8 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - htmlparser2@8.0.2: - resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3734,12 +3802,12 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - jsdom@30.0.0: - resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: canvas: ^3.2.3 @@ -4118,6 +4186,10 @@ packages: resolution: {integrity: sha512-6AS5aCSXsjoxq2Jr9CdaAeT60yoYDOTp6po9ziqeOeY6vf6uTEHYSqWql6EFILrM3fEfXgkZ4KqE9L0rTm/wlA==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + nanoevents@10.0.0: + resolution: {integrity: sha512-PmIJ3BNxOzgVgnS1r/Pvyj6eZx/xUV2JCPogh0brD6smDIwjRlr1KBl9yoRX323PlfaNrBDDnfCgC9SL00OjSg==} + engines: {node: ^22.0.0 || ^24.0.0 || >=26.0.0} + nanoevents@9.1.0: resolution: {integrity: sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A==} engines: {node: ^18.0.0 || >=20.0.0} @@ -4132,8 +4204,8 @@ packages: engines: {node: ^18 || >=20} hasBin: true - nanoid@6.0.0: - resolution: {integrity: sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==} + nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} engines: {node: ^22 || ^24 || >=26} hasBin: true @@ -4144,8 +4216,8 @@ packages: resolution: {integrity: sha512-AvkslkHQavd4abp7clE0xsv4afGpBpnWUqWY23V3o4ljdyi/YIOpLiiWtrlLh1oR7kWC4GT7iBN5M2SjL5I5yw==} engines: {node: ^8.0.0 || ^10.0.0 || ^12.0.0 || ^14.0.0 || ^16.0.0 || ^18.0.0 || ^20.0.0 || ^22.0.0 || ^24.0.0 || >=26.0.0} - nanostores@1.4.1: - resolution: {integrity: sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==} + nanostores@1.4.2: + resolution: {integrity: sha512-Wxv8Roefr2nqtiRG0bnaFlpYqpIVtOEeJZHaH+4nGgOK1/7n6OHOuHCb/bhqrNQgZM8fyd0s1PqhdrJc9Ib44g==} engines: {node: ^20.0.0 || >=22.0.0} napi-postinstall@0.3.4: @@ -4163,8 +4235,8 @@ packages: nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} - node-addon-api@8.9.0: - resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} engines: {node: ^18 || ^20 || >= 21} node-gyp-build@4.8.4: @@ -4360,9 +4432,21 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss-html@1.8.1: - resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} - engines: {node: ^12 || >=14} + playwright-core@1.62.0-alpha-1783623505000: + resolution: {integrity: sha512-CPJZdsA/KGT2QQlekiV6Wt+QlQrZHVSZ6oiNtOI/bYYOIVLM8jfKGWTM4zQiyd4UN+40Cq4cA6lxmZHZbtPvJQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0-alpha-1783623505000: + resolution: {integrity: sha512-6KV9h4PP3hqu4NaGdxxcijWfYh9LJcFI/R2sP4TTC4I5cFo3oRawN0ETlW5MkE3cQEgKhhoj0KUNz4sfpCT0Tg==} + engines: {node: '>=20'} + hasBin: true + + postcss-html@2.0.0: + resolution: {integrity: sha512-f2Rvw5FCollEfVj3wfN7JdQb7n2rNIthW+epw2EByio7M6P7RH0BTj8a/ODHrUXd0cmO7ychb6YniymV93182Q==} + engines: {node: ^22.12 || >=24} + peerDependencies: + postcss: ^8.5.0 postcss-js@4.1.0: resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} @@ -4412,12 +4496,6 @@ packages: peerDependencies: postcss: ^8.4 - postcss-safe-parser@6.0.0: - resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.3.3 - postcss-safe-parser@7.0.1: resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} engines: {node: '>=18.0'} @@ -4492,6 +4570,9 @@ packages: propose@0.0.5: resolution: {integrity: sha512-Jary1vb+ap2DIwOGfyiadcK4x1Iu3pzpkDBy8tljFPmQvnc9ES3m1PMZOMiWOG50cfoAyYNtGeBzrp+Rlh4G9A==} + proxy-target@3.0.2: + resolution: {integrity: sha512-FFE1XNwXX/FNC3/P8HiKaJSy/Qk68RitG/QEcLy/bVnTAPlgTAWPZKh0pARLAnpfXQPKyalBhk009NRTgsk8vQ==} + public-ip@7.0.1: resolution: {integrity: sha512-DdNcqcIbI0wEeCBcqX+bmZpUCvrDMJHXE553zgyG1MZ8S1a/iCCxmK9iTjjql+SpHSv4cZkmRv5/zGYW93AlCw==} engines: {node: '>=18'} @@ -4542,8 +4623,8 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - recast@0.23.12: - resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} + recast@0.23.19: + resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} engines: {node: '>= 4'} redent@3.0.0: @@ -4642,8 +4723,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -4652,8 +4733,8 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - rollup@4.62.3: - resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4705,8 +4786,8 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - size-limit@13.0.2: - resolution: {integrity: sha512-bB8Hv6g7nBtmWW+mB9Y1lkyJoDOxk6pO6p7xPsK8Y6t9CyKvBWhzTRXLlhhv1H2Ru9HIyLKbCaVswXGHwVXiwg==} + size-limit@13.0.3: + resolution: {integrity: sha512-KVb2aNEU49BwTR21SVjD+2QHP9gBV/nWsTHzNB/heRwXtHyA7lLQiDZDQ1TiNh/B/TZXKAZrHYyTt+cvBUrzYw==} engines: {node: ^22.18.0 || ^24.0.0 || >=26.0.0} hasBin: true @@ -4748,6 +4829,29 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + sqlocal@0.18.0: + resolution: {integrity: sha512-AJvbSe7W52YeI3KWt3TOl6Xf4oH/72JdQ0oig78G6v6Hrk906dIVP0vT9uIx7HmJaYHU/G+AgyqCW12W2IJ1OA==} + peerDependencies: + '@angular/core': '>=17.0.0' + drizzle-orm: '*' + kysely: '*' + react: '>=18.0.0' + vite: '>=4.0.0' + vue: '>=3.0.0' + peerDependenciesMeta: + '@angular/core': + optional: true + drizzle-orm: + optional: true + kysely: + optional: true + react: + optional: true + vite: + optional: true + vue: + optional: true + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -4953,11 +5057,11 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} - tldts-core@7.4.9: - resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} - tldts@7.4.9: - resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true to-regex-range@5.0.1: @@ -4996,8 +5100,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.5: + resolution: {integrity: sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==} engines: {node: '>=18.0.0'} hasBin: true @@ -5194,13 +5298,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5368,7 +5472,7 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.5.2 - '@asamuzakjp/dom-selector@8.3.0': + '@asamuzakjp/dom-selector@8.3.2': dependencies: bidi-js: 1.0.3 css-tree: 3.2.1 @@ -5401,7 +5505,7 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@crxjs/vite-plugin@2.7.1(supports-color@10.2.2)(vite@8.1.5)': + '@crxjs/vite-plugin@2.7.1(supports-color@10.2.2)(vite@8.2.0)': dependencies: '@webcomponents/custom-elements': 1.6.0 acorn-walk: 8.3.5 @@ -5417,7 +5521,7 @@ snapshots: rollup: 2.80.0 rxjs: 7.5.7 tinyglobby: 0.2.17 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -5468,12 +5572,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.11.1': - dependencies: - '@emnapi/wasi-threads': 1.2.2 - tslib: 2.8.1 - optional: true - '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -5491,11 +5589,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 @@ -5526,7 +5619,7 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.1 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -5781,7 +5874,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -5864,16 +5957,19 @@ snapshots: dependencies: '@logux/core': 0.10.0 - '@logux/client@0.23.2(nanostores@1.4.1)(react-dom@18.3.1)(react@18.3.1)': + '@logux/actions@git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6(@logux/core@0.10.0)': dependencies: - '@logux/actions': 0.5.0(@logux/core@0.10.0) - '@logux/core': 0.10.0 - fast-json-stable-stringify: 2.1.0 - nanodelay: 2.0.2 - nanoevents: 9.1.0 - nanoid: 5.1.16 - nanostores: 1.4.1 + '@logux/core': git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40 + + '@logux/client@git+ssh://git@github.com/logux/client.git#70c398a5cc0218a331abd331bcfa33a6c44f3829(@nanostores/sql@0.3.1)(nanostores@1.4.2)(react-dom@18.3.1)(react@18.3.1)': + dependencies: + '@logux/actions': git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6(@logux/core@0.10.0) + '@logux/core': git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40 + nanoevents: 10.0.0 + nanoid: 6.0.1 + nanostores: 1.4.2 optionalDependencies: + '@nanostores/sql': 0.3.1(@electric-sql/pglite@0.5.4)(nanostores@1.4.2)(sqlocal@0.18.0) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -5881,6 +5977,10 @@ snapshots: dependencies: nanoevents: 9.1.0 + '@logux/core@git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40': + dependencies: + nanoevents: 10.0.0 + '@logux/eslint-config@57.1.0(@typescript-eslint/utils@8.65.0)(eslint-plugin-svelte@3.22.0)(eslint@10.8.0)(supports-color@10.2.2)(svelte-eslint-parser@1.8.0)(svelte@5.56.8)(typescript@6.0.3)': dependencies: '@eslint/eslintrc': 3.3.6(supports-color@10.2.2) @@ -5889,7 +5989,7 @@ snapshots: eslint-plugin-n: 17.24.0(eslint@10.8.0)(typescript@6.0.3) eslint-plugin-perfectionist: 5.10.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) eslint-plugin-prefer-let: 4.2.2 - globals: 17.8.0 + globals: 17.9.0 typescript-eslint: 8.65.0(eslint@10.8.0)(supports-color@10.2.2)(typescript@6.0.3) optionalDependencies: eslint-plugin-svelte: 3.22.0(eslint@10.8.0)(svelte@5.56.8) @@ -5901,7 +6001,7 @@ snapshots: - supports-color - typescript - '@logux/oxc-configs@1.0.0(oxlint-tsgolint@7.0.2001)(oxlint@1.76.0)(typescript@6.0.3)': + '@logux/oxc-configs@1.1.0(oxlint-tsgolint@7.0.2001)(oxlint@1.76.0)(typescript@6.0.3)': dependencies: eslint-plugin-prefer-let: 4.2.2 oxlint: 1.76.0(oxlint-tsgolint@7.0.2001) @@ -5923,6 +6023,19 @@ snapshots: - bufferutil - utf-8-validate + '@logux/server@git+ssh://git@github.com/logux/server.git#3a314f86868e9436704d0b3ea29fc8a7d877cfd3': + dependencies: + '@logux/actions': git+ssh://git@github.com/logux/actions.git#fbede590f2f311130c3db2c0a231228ca397a6f6(@logux/core@0.10.0) + '@logux/core': git+ssh://git@github.com/logux/core.git#780a5438c013d9cb7899b86e344685cd722ede40 + cookie: 2.0.1 + fastq: 1.20.1 + nanoevents: 10.0.0 + nanoid: 6.0.1 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@logux/stylelint-config@1.0.1(stylelint@17.14.1)': dependencies: stylelint: 17.14.1(supports-color@10.2.2)(typescript@6.0.3) @@ -5933,44 +6046,47 @@ snapshots: '@mdi/js@7.4.47': {} - '@nanostores/i18n@1.3.3(nanostores@1.4.1)': + '@nanostores/i18n@1.3.3(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@nanostores/media-query@0.1.2(nanostores@1.4.1)': + '@nanostores/media-query@0.1.2(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@nanostores/persistent@1.3.5(nanostores@1.4.1)': + '@nanostores/persistent@1.3.5(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@nanostores/router@1.1.0(nanostores@1.4.1)': + '@nanostores/router@1.1.0(nanostores@1.4.2)': dependencies: - nanostores: 1.4.1 + nanostores: 1.4.2 - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@nanostores/sql@0.3.1(@electric-sql/pglite@0.5.4)(nanostores@1.4.2)(sqlocal@0.18.0)': dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.3 + nanostores: 1.4.2 + optionalDependencies: + '@electric-sql/pglite': 0.5.4 + sqlocal: 0.18.0(drizzle-orm@0.45.2)(react@18.3.1)(vite@8.2.0) + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 @@ -6093,7 +6209,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.127.0': @@ -6107,7 +6223,7 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.142.0': {} '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -6161,7 +6277,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.2 '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': @@ -6307,143 +6423,143 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/mcp@0.0.78': + dependencies: + playwright: 1.62.0-alpha-1783623505000 + playwright-core: 1.62.0-alpha-1783623505000 + '@profoundlogic/hogan@3.0.4': dependencies: nopt: 1.0.10 - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.2': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.2': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.2': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.2': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.2': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.2': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': - optional: true - - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.2': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.2': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.2': optional: true '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.62.3': + '@rollup/rollup-android-arm-eabi@4.62.4': optional: true - '@rollup/rollup-android-arm64@4.62.3': + '@rollup/rollup-android-arm64@4.62.4': optional: true - '@rollup/rollup-darwin-arm64@4.62.3': + '@rollup/rollup-darwin-arm64@4.62.4': optional: true - '@rollup/rollup-darwin-x64@4.62.3': + '@rollup/rollup-darwin-x64@4.62.4': optional: true - '@rollup/rollup-freebsd-arm64@4.62.3': + '@rollup/rollup-freebsd-arm64@4.62.4': optional: true - '@rollup/rollup-freebsd-x64@4.62.3': + '@rollup/rollup-freebsd-x64@4.62.4': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.3': + '@rollup/rollup-linux-arm-musleabihf@4.62.4': optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.3': + '@rollup/rollup-linux-arm64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-arm64-musl@4.62.3': + '@rollup/rollup-linux-arm64-musl@4.62.4': optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.3': + '@rollup/rollup-linux-loong64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-loong64-musl@4.62.3': + '@rollup/rollup-linux-loong64-musl@4.62.4': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.3': + '@rollup/rollup-linux-ppc64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.3': + '@rollup/rollup-linux-ppc64-musl@4.62.4': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.3': + '@rollup/rollup-linux-riscv64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.3': + '@rollup/rollup-linux-riscv64-musl@4.62.4': optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.3': + '@rollup/rollup-linux-s390x-gnu@4.62.4': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.3': + '@rollup/rollup-linux-x64-gnu@4.62.4': optional: true - '@rollup/rollup-linux-x64-musl@4.62.3': + '@rollup/rollup-linux-x64-musl@4.62.4': optional: true - '@rollup/rollup-openbsd-x64@4.62.3': + '@rollup/rollup-openbsd-x64@4.62.4': optional: true - '@rollup/rollup-openharmony-arm64@4.62.3': + '@rollup/rollup-openharmony-arm64@4.62.4': optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.3': + '@rollup/rollup-win32-arm64-msvc@4.62.4': optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.3': + '@rollup/rollup-win32-ia32-msvc@4.62.4': optional: true - '@rollup/rollup-win32-x64-gnu@4.62.3': + '@rollup/rollup-win32-x64-gnu@4.62.4': optional: true - '@rollup/rollup-win32-x64-msvc@4.62.3': + '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true '@sindresorhus/is@5.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} - '@size-limit/file@13.0.2(size-limit@13.0.2)': + '@size-limit/file@13.0.3(size-limit@13.0.3)': dependencies: - size-limit: 13.0.2 + size-limit: 13.0.3 + + '@sqlite.org/sqlite-wasm@3.51.2-build9': {} '@storybook/addon-a11y@10.5.5(storybook@10.5.5)': dependencies: @@ -6451,11 +6567,11 @@ snapshots: axe-core: 4.12.1 storybook: 10.5.5(react@18.3.1) - '@storybook/addon-svelte-csf@5.1.2(@storybook/svelte@10.5.5)(@sveltejs/vite-plugin-svelte@7.2.0)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5)': + '@storybook/addon-svelte-csf@5.1.2(@storybook/svelte@10.5.5)(@sveltejs/vite-plugin-svelte@7.2.0)(storybook@10.5.5)(svelte@5.56.8)(vite@8.2.0)': dependencies: '@storybook/csf': 0.1.13 '@storybook/svelte': 10.5.5(storybook@10.5.5)(svelte@5.56.8) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8)(vite@8.1.5) + '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8)(vite@8.2.0) dedent: 1.7.2 es-toolkit: 1.50.0 esrap: 1.4.9 @@ -6463,7 +6579,7 @@ snapshots: storybook: 10.5.5(react@18.3.1) svelte: 5.56.8(@typescript-eslint/types@8.65.0) svelte-ast-print: 0.4.2(svelte@5.56.8) - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) zimmerframe: 1.1.4 transitivePeerDependencies: - babel-plugin-macros @@ -6473,25 +6589,25 @@ snapshots: storybook: 10.5.5(react@18.3.1) ts-dedent: 2.3.0 - '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5)': + '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5)(vite@8.2.0)': dependencies: - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5) + '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5)(vite@8.2.0) storybook: 10.5.5(react@18.3.1) ts-dedent: 2.3.0 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5)': + '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5)(vite@8.2.0)': dependencies: storybook: 10.5.5(react@18.3.1) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.1 - rollup: 4.62.3 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + rollup: 4.62.4 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) '@storybook/csf@0.1.13': dependencies: @@ -6503,17 +6619,17 @@ snapshots: dependencies: react: 18.3.1 - '@storybook/svelte-vite@10.5.5(@sveltejs/vite-plugin-svelte@7.2.0)(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(svelte@5.56.8)(vite@8.1.5)': + '@storybook/svelte-vite@10.5.5(@sveltejs/vite-plugin-svelte@7.2.0)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5)(svelte@5.56.8)(vite@8.2.0)': dependencies: - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.62.3)(storybook@10.5.5)(vite@8.1.5) + '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5)(vite@8.2.0) '@storybook/svelte': 10.5.5(storybook@10.5.5)(svelte@5.56.8) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8)(vite@8.1.5) + '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8)(vite@8.2.0) magic-string: 0.30.21 storybook: 10.5.5(react@18.3.1) svelte: 5.56.8(@typescript-eslint/types@8.65.0) svelte2tsx: 0.7.59(svelte@5.56.8)(typescript@5.9.3) typescript: 5.9.3 - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup @@ -6548,14 +6664,14 @@ snapshots: '@sveltejs/load-config@0.2.1': {} - '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8)(vite@8.1.5)': + '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8)(vite@8.2.0)': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.4 svelte: 5.56.8(@typescript-eslint/types@8.65.0) - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.1.5) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.0) '@szmarczak/http-timer@5.0.1': dependencies: @@ -6597,7 +6713,7 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/chrome@0.2.2': + '@types/chrome@0.2.5': dependencies: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 @@ -6834,6 +6950,8 @@ snapshots: '@ungap/structured-clone@1.3.3': {} + '@ungap/with-resolvers@0.1.0': {} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': optional: true @@ -6892,7 +7010,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -6967,7 +7085,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.4 + fast-uri: 3.1.5 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6994,7 +7112,7 @@ snapshots: dependencies: '@phc/format': 1.0.0 cross-env: 10.1.0 - node-addon-api: 8.9.0 + node-addon-api: 8.9.1 node-gyp-build: 4.8.4 argparse@2.0.1: {} @@ -7036,7 +7154,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.7: {} + baseline-browser-mapping@2.11.12: {} bcp-47-match@2.0.3: {} @@ -7050,16 +7168,16 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@1.1.16: + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.3: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7069,9 +7187,9 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.7 + baseline-browser-mapping: 2.11.12 caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.397 + electron-to-chromium: 1.5.399 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -7165,6 +7283,18 @@ snapshots: co@3.1.0: {} + coincident@1.2.3: + dependencies: + '@ungap/structured-clone': 1.3.3 + '@ungap/with-resolvers': 0.1.0 + gc-hook: 0.3.1 + proxy-target: 3.0.2 + optionalDependencies: + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -7204,7 +7334,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -7313,7 +7443,7 @@ snapshots: detect-libc@2.1.2: {} - devalue@5.8.2: {} + devalue@5.9.0: {} devlop@1.1.0: dependencies: @@ -7369,7 +7499,7 @@ snapshots: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.1 + tsx: 4.23.5 drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9): optionalDependencies: @@ -7378,7 +7508,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.397: {} + electron-to-chromium@1.5.399: {} emoji-regex@10.6.0: {} @@ -7386,7 +7516,7 @@ snapshots: emoji-regex@9.2.2: {} - enhanced-resolve@5.24.3: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -7510,7 +7640,7 @@ snapshots: eslint-import-context@0.1.9(unrs-resolver@1.12.2): dependencies: - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.1 stable-hash-x: 0.2.0 optionalDependencies: unrs-resolver: 1.12.2 @@ -7542,10 +7672,10 @@ snapshots: eslint-plugin-n@17.24.0(eslint@10.8.0)(typescript@6.0.3): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0) - enhanced-resolve: 5.24.3 + enhanced-resolve: 5.24.5 eslint: 10.8.0(jiti@2.7.0)(supports-color@10.2.2) eslint-plugin-es-x: 7.8.0(eslint@10.8.0) - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.1 globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 @@ -7701,7 +7831,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.4: {} + fast-uri@3.1.5: {} fastest-levenshtein@1.0.16: {} @@ -7732,16 +7862,16 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.3 + flatted: 3.4.4 keyv: 4.5.4 flat-cache@6.1.23: dependencies: cacheable: 2.5.0 - flatted: 3.4.3 + flatted: 3.4.4 hookified: 1.15.1 - flatted@3.4.3: {} + flatted@3.4.4: {} foreground-child@3.3.1: dependencies: @@ -7758,16 +7888,21 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true function-timeout@0.1.1: {} + gc-hook@0.3.1: {} + get-east-asian-width@1.6.0: {} get-stream@6.0.1: {} - get-tsconfig@4.14.0: + get-tsconfig@4.14.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -7806,7 +7941,7 @@ snapshots: globals@16.5.0: {} - globals@17.8.0: {} + globals@17.9.0: {} globby@16.2.2: dependencies: @@ -7944,7 +8079,7 @@ snapshots: html-url-attributes@3.0.1: {} - htmlparser2@8.0.2: + htmlparser2@9.1.0: dependencies: domelementtype: 2.3.0 domhandler: 5.0.3 @@ -8066,14 +8201,14 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 - jsdom@30.0.0: + jsdom@30.0.1: dependencies: '@asamuzakjp/css-color': 6.0.5 - '@asamuzakjp/dom-selector': 8.3.0 + '@asamuzakjp/dom-selector': 8.3.2 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) '@exodus/bytes': 1.15.1 @@ -8498,15 +8633,15 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 1.1.18 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.3 + brace-expansion: 2.1.4 minimist@1.2.8: {} @@ -8524,7 +8659,7 @@ snapshots: dompurify: 3.4.12 highlight.js: 11.11.1 marked: 18.0.7 - nanostores: 1.4.1 + nanostores: 1.4.2 yaml: 2.9.0 transitivePeerDependencies: - '@logux/core' @@ -8535,13 +8670,15 @@ snapshots: nanodelay@2.0.2: {} + nanoevents@10.0.0: {} + nanoevents@9.1.0: {} nanoid@3.3.16: {} nanoid@5.1.16: {} - nanoid@6.0.0: {} + nanoid@6.0.1: {} nanospinner@1.2.2: dependencies: @@ -8549,7 +8686,7 @@ snapshots: nanospy@2.0.2: {} - nanostores@1.4.1: {} + nanostores@1.4.2: {} napi-postinstall@0.3.4: {} @@ -8561,7 +8698,7 @@ snapshots: dependencies: '@types/nlcst': 2.0.3 - node-addon-api@8.9.0: {} + node-addon-api@8.9.1: {} node-gyp-build@4.8.4: {} @@ -8835,12 +8972,20 @@ snapshots: picomatch@4.0.5: {} - postcss-html@1.8.1: + playwright-core@1.62.0-alpha-1783623505000: {} + + playwright@1.62.0-alpha-1783623505000: + dependencies: + playwright-core: 1.62.0-alpha-1783623505000 + optionalDependencies: + fsevents: 2.3.2 + + postcss-html@2.0.0(postcss@8.5.25): dependencies: - htmlparser2: 8.0.2 + htmlparser2: 9.1.0 js-tokens: 9.0.1 postcss: 8.5.25 - postcss-safe-parser: 6.0.0(postcss@8.5.25) + postcss-safe-parser: 7.0.1(postcss@8.5.25) postcss-js@4.1.0(postcss@8.5.25): dependencies: @@ -8854,13 +8999,13 @@ snapshots: optionalDependencies: postcss: 8.5.25 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.1)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.5)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 postcss: 8.5.25 - tsx: 4.23.1 + tsx: 4.23.5 yaml: 2.9.0 postcss-mixins@12.1.2(postcss@8.5.25): @@ -8878,10 +9023,6 @@ snapshots: postcss: 8.5.25 postcss-selector-parser: 7.1.4 - postcss-safe-parser@6.0.0(postcss@8.5.25): - dependencies: - postcss: 8.5.25 - postcss-safe-parser@7.0.1(postcss@8.5.25): dependencies: postcss: 8.5.25 @@ -8940,6 +9081,8 @@ snapshots: dependencies: levenshtein-edit-distance: 1.0.0 + proxy-target@3.0.2: {} + public-ip@7.0.1: dependencies: dns-socket: 4.2.2 @@ -8988,7 +9131,7 @@ snapshots: readdirp@4.1.2: {} - recast@0.23.12: + recast@0.23.19: dependencies: ast-types: 0.16.1 esprima: 4.0.1 @@ -9176,60 +9319,60 @@ snapshots: reusify@1.1.0: {} - rolldown@1.1.5: + rolldown@1.2.2: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 rollup@2.80.0: optionalDependencies: fsevents: 2.3.3 - rollup@4.62.3: + rollup@4.62.4: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.3 - '@rollup/rollup-android-arm64': 4.62.3 - '@rollup/rollup-darwin-arm64': 4.62.3 - '@rollup/rollup-darwin-x64': 4.62.3 - '@rollup/rollup-freebsd-arm64': 4.62.3 - '@rollup/rollup-freebsd-x64': 4.62.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 - '@rollup/rollup-linux-arm-musleabihf': 4.62.3 - '@rollup/rollup-linux-arm64-gnu': 4.62.3 - '@rollup/rollup-linux-arm64-musl': 4.62.3 - '@rollup/rollup-linux-loong64-gnu': 4.62.3 - '@rollup/rollup-linux-loong64-musl': 4.62.3 - '@rollup/rollup-linux-ppc64-gnu': 4.62.3 - '@rollup/rollup-linux-ppc64-musl': 4.62.3 - '@rollup/rollup-linux-riscv64-gnu': 4.62.3 - '@rollup/rollup-linux-riscv64-musl': 4.62.3 - '@rollup/rollup-linux-s390x-gnu': 4.62.3 - '@rollup/rollup-linux-x64-gnu': 4.62.3 - '@rollup/rollup-linux-x64-musl': 4.62.3 - '@rollup/rollup-openbsd-x64': 4.62.3 - '@rollup/rollup-openharmony-arm64': 4.62.3 - '@rollup/rollup-win32-arm64-msvc': 4.62.3 - '@rollup/rollup-win32-ia32-msvc': 4.62.3 - '@rollup/rollup-win32-x64-gnu': 4.62.3 - '@rollup/rollup-win32-x64-msvc': 4.62.3 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 run-applescript@7.1.0: {} @@ -9271,12 +9414,11 @@ snapshots: signal-exit@4.1.0: {} - size-limit@13.0.2: + size-limit@13.0.3: dependencies: bytes-iec: 3.1.1 lilconfig: 3.1.3 nanospinner: 1.2.2 - picocolors: 1.1.1 slash@5.1.0: {} @@ -9313,6 +9455,18 @@ snapshots: spdx-license-ids@3.0.23: {} + sqlocal@0.18.0(drizzle-orm@0.45.2)(react@18.3.1)(vite@8.2.0): + dependencies: + '@sqlite.org/sqlite-wasm': 3.51.2-build9 + coincident: 1.2.3 + optionalDependencies: + drizzle-orm: 0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9) + react: 18.3.1 + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + stable-hash-x@0.2.0: {} storybook@10.5.5(react@18.3.1): @@ -9330,7 +9484,7 @@ snapshots: open: 10.2.0 oxc-parser: 0.127.0 oxc-resolver: 11.24.2 - recast: 0.23.12 + recast: 0.23.19 semver: 7.8.5 use-sync-external-store: 1.6.0(react@18.3.1) ws: 8.21.1 @@ -9525,7 +9679,7 @@ snapshots: aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.8.2 + devalue: 5.9.0 esm-env: 1.2.2 esrap: 2.3.0(@typescript-eslint/types@8.65.0) is-reference: 3.0.3 @@ -9580,11 +9734,11 @@ snapshots: tinyspy@4.0.4: {} - tldts-core@7.4.9: {} + tldts-core@7.4.10: {} - tldts@7.4.9: + tldts@7.4.10: dependencies: - tldts-core: 7.4.9 + tldts-core: 7.4.10 to-regex-range@5.0.1: dependencies: @@ -9592,7 +9746,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.9 + tldts: 7.4.10 tr46@6.0.0: dependencies: @@ -9615,7 +9769,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.5: dependencies: esbuild: 0.28.1 optionalDependencies: @@ -9944,12 +10098,12 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0): + vite@8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.25 - rolldown: 1.1.5 + rolldown: 1.2.2 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.1.2 @@ -9957,12 +10111,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 sugarss: 5.0.1(postcss@8.5.25) - tsx: 4.23.1 + tsx: 4.23.5 yaml: 2.9.0 - vitefu@1.1.3(vite@8.1.5): + vitefu@1.1.3(vite@8.2.0): optionalDependencies: - vite: 8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.2.0(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sugarss@5.0.1)(tsx@4.23.5)(yaml@2.9.0) w3c-xmlserializer@5.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa24a98c..d04170b2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,7 @@ allowBuilds: esbuild: true unrs-resolver: false -blockExoticSubdeps: true +blockExoticSubdeps: false forceLegacyDeploy: true diff --git a/proxy/Dockerfile b/proxy/Dockerfile index 639b11f3..99744312 100644 --- a/proxy/Dockerfile +++ b/proxy/Dockerfile @@ -4,8 +4,8 @@ LABEL org.opencontainers.image.source=https://github.com/hplush/slowreader LABEL org.opencontainers.image.description="Proxy server of Slow Reader" LABEL org.opencontainers.image.licenses=AGPL-3.0-or-later -ENV NODE_VERSION=26.5.1 \ - NODE_CHECKSUM=sha256:cc7b3484ade63bd203a9d304f21ec37a3b622b988d7bdecf1dc4d68fc44a91b7 +ENV NODE_VERSION=26.6.0 \ + NODE_CHECKSUM=sha256:e53a8c8ca2ee7135de38e63859bd4f43002ae2d222f5b3a98ac1945db9f544bb ADD --checksum=$NODE_CHECKSUM https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /node.tar.xz RUN tar -xf "node.tar.xz" --strip-components=1 -C /usr/local/ \ diff --git a/proxy/README.md b/proxy/README.md index e04e4df1..9666d188 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -10,10 +10,10 @@ _See the [full architecture guide](../README.md) first._ ## Scripts -- `cd proxy && pnpm test`: run all proxy tests. -- `cd proxy && pnpm start`: run proxy server. -- `cd proxy && pnpm build`: prepare deploy files with production dependencies only. -- `cd proxy && pnpm production`: start production build of the proxy server. +- `pnpm -F proxy test`: run all proxy tests. +- `pnpm -F proxy start`: run proxy server. +- `pnpm -F proxy build`: prepare deploy files with production dependencies only. +- `pnpm -F proxy production`: start production build of the proxy server. ## Abuse Protection diff --git a/proxy/index.ts b/proxy/index.ts index a66da0e0..38c72a50 100644 --- a/proxy/index.ts +++ b/proxy/index.ts @@ -40,6 +40,16 @@ export const DEFAULT_PROXY_CONFIG: Omit = { requestTimeout: 10000 } +const REDIRECTS = new Set([301, 302, 303, 307, 308]) + +const MAX_REDIRECTS = 10 + +function resolveLocation(location: string, from: string): string { + let host = new URL(from).host + return new URL(location.replace(/^(https?:)\/\/(?=\/)/i, `$1//${host}`), from) + .href +} + function allowCors(req: IncomingMessage, res: ServerResponse): void { if (req.headers.origin) { res.setHeader('Access-Control-Allow-Headers', '*') @@ -57,10 +67,59 @@ function allowCors(req: IncomingMessage, res: ServerResponse): void { } } +function checkDestination( + target: string, + allowUnsafeDestinations?: boolean +): URL { + let parsed = new URL(target) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new BadRequestError('Only HTTP or HTTPS are supported') + } + if (!allowUnsafeDestinations) { + if ( + !parsed.hostname.includes('.') || + parsed.hostname.includes('.localhost') || + /\.(local|internal)$/.test(parsed.hostname) || + parsed.hostname === 'localhost.' || + isIP(parsed.hostname.replace(/^\[|\]$/g, '')) !== 0 + ) { + throw new BadRequestError('IP addresses or local domains are not allowed') + } + } + return parsed +} + +async function loadTarget( + method: string, + url: string, + headers: Record, + requestTimeout: number +): Promise { + try { + return await fetch(url, { + headers: { ...headers, host: new URL(url).host }, + method: method, + redirect: 'manual', + signal: AbortSignal.timeout(requestTimeout) + }) + } catch (e) { + /* node:coverage disable */ + if (e instanceof TypeError) { + throw new BadRequestError(e.message, 400, { cause: e }) + } else if (e instanceof Error && e.name === 'TimeoutError') { + throw new BadRequestError('Timeout', 400, { cause: e }) + } else { + throw e + } + /* node:coverage enable */ + } +} + export function createProxy( config: ProxyConfig ): (req: IncomingMessage, res: ServerResponse) => void { let allowsFrom = new RegExp(config.allowsFrom) + return async (req, res) => { let sent = false @@ -90,12 +149,7 @@ export function createProxy( try { let url = decodeURIComponent(req.url!.slice(1).replace(/^proxy\//, '')) - let parsedUrl = new URL(url) - - // Only HTTP or HTTPS protocols are allowed - if (!url.startsWith('http://') && !url.startsWith('https://')) { - throw new BadRequestError('Only HTTP or HTTPS are supported') - } + let parsedUrl = checkDestination(url, config.allowUnsafeDestinations) // We do not typically need non-GET to load RSS if (req.method !== 'GET') { @@ -113,20 +167,6 @@ export function createProxy( ) } - if (!config.allowUnsafeDestinations) { - if ( - !parsedUrl.hostname.includes('.') || - parsedUrl.hostname.includes('.localhost') || - /\.(local|internal)$/.test(parsedUrl.hostname) || - parsedUrl.hostname === 'localhost.' || - isIP(parsedUrl.hostname.replace(/^\[|\]$/g, '')) !== 0 - ) { - throw new BadRequestError( - 'IP addresses or local domains are not allowed' - ) - } - } - let debug = req.headers['x-slowreader-debug'] let clientIp = firstValue(req.headers['x-real-ip']) ?? @@ -160,23 +200,34 @@ export function createProxy( 'X-Forwarded-For': clientIp } - let targetResponse: Response - try { - targetResponse = await fetch(url, { - headers: requestHeaders, - method: req.method, - signal: AbortSignal.timeout(config.requestTimeout) - }) - } catch (e) { - /* node:coverage disable */ - if (e instanceof TypeError) { - throw new BadRequestError(e.message, 400, { cause: e }) - } else if (e instanceof Error && e.name === 'TimeoutError') { - throw new BadRequestError('Timeout', 400, { cause: e }) - } else { - throw e + let targetUrl = url + let targetResponse = await loadTarget( + req.method, + targetUrl, + requestHeaders, + config.requestTimeout + ) + let redirects = 0 + while ( + REDIRECTS.has(targetResponse.status) && + targetResponse.headers.has('location') + ) { + if (redirects === MAX_REDIRECTS) { + throw new BadRequestError('Too many redirects') } - /* node:coverage enable */ + redirects += 1 + void targetResponse.body?.cancel() + targetUrl = resolveLocation( + targetResponse.headers.get('location')!, + targetUrl + ) + checkDestination(targetUrl, config.allowUnsafeDestinations) + targetResponse = await loadTarget( + req.method, + targetUrl, + requestHeaders, + config.requestTimeout + ) } if ( @@ -219,7 +270,7 @@ export function createProxy( } if (debug) { responseHeaders['x-slowreader-request'] = - `${req.method} ${url}\\n` + formatHeaders(requestHeaders) + `${req.method} ${targetUrl}\\n` + formatHeaders(requestHeaders) responseHeaders['x-slowreader-response'] = `${targetResponse.status}\\n` + formatHeaders(Object.fromEntries(targetResponse.headers.entries())) @@ -229,7 +280,7 @@ export function createProxy( if (targetResponse.body) { let nodeStream = Readable.fromWeb( - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + // oxlint-disable-next-line typescript/no-unnecessary-type-assertion targetResponse.body as WebReadableStream ) await pipeline(nodeStream, res, { diff --git a/proxy/package.json b/proxy/package.json index fe666ace..1d08c3de 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -18,7 +18,7 @@ "better-node-test": "1.0.0" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/proxy/server.ts b/proxy/server.ts index 2aeb0a18..4b2bac83 100755 --- a/proxy/server.ts +++ b/proxy/server.ts @@ -1,8 +1,13 @@ import { createServer } from 'node:http' +import { setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net' import { styleText } from 'node:util' import { createProxy, DEFAULT_PROXY_CONFIG } from './index.ts' +// Node.js gives every IP address only 500 ms to connect and gives up on all +// of them. Slow websites need more time than browsers’ default. +setDefaultAutoSelectFamilyAttemptTimeout(2000) + if (!process.env.PROXY_ORIGIN) { process.stderr.write( styleText('red', 'Set PROXY_ORIGIN environment variable\n') diff --git a/proxy/test/index.test.ts b/proxy/test/index.test.ts index d6d742a5..1427e78e 100644 --- a/proxy/test/index.test.ts +++ b/proxy/test/index.test.ts @@ -31,7 +31,16 @@ describe('proxy', () => { await setTimeout(parseInt(queryParams.sleep)) } - if (queryParams.big === 'file') { + if (queryParams.redirectTo) { + res.writeHead(302, { Location: queryParams.redirectTo }) + res.end() + } else if (queryParams.emptyHost) { + res.writeHead(302, { Location: 'http:///?redirected=1' }) + res.end() + } else if (queryParams.loop) { + res.writeHead(302, { Location: '/?loop=1' }) + res.end() + } else if (queryParams.big === 'file') { res.writeHead(200, { 'Content-Length': '2000', 'Content-Type': 'text/text' @@ -147,6 +156,35 @@ describe('proxy', () => { await expectBadRequest(response2, 'Invalid URL') }) + test('follows redirects', async () => { + let response = await request( + `${targetUrl}?redirectTo=${encodeURIComponent(`${targetUrl}/final?redirected=1`)}` + ) + let parsedResponse = (await response.json()) as EchoResponse + equal(response.status, 200) + equal(parsedResponse.request.requestPath, '/final') + equal(parsedResponse.request.queryParams.redirected, '1') + }) + + test('fixes redirects with empty host', async () => { + let response = await request(`${targetUrl}?emptyHost=1`) + let parsedResponse = (await response.json()) as EchoResponse + equal(response.status, 200) + equal(parsedResponse.request.queryParams.redirected, '1') + }) + + test('stops redirect loops', async () => { + let response = await request(`${targetUrl}?loop=1`) + await expectBadRequest(response, 'Too many redirects') + }) + + test('checks redirect destination', async () => { + let response = await request( + `${targetUrl}?redirectTo=${encodeURIComponent('ftp://example.com/feed')}` + ) + await expectBadRequest(response, 'Only HTTP or HTTPS are supported') + }) + test('can use only HTTP or HTTPS protocols', async () => { let response = await fetch( `${proxyUrl}/${targetUrl.replace('http', 'ftp')}`, diff --git a/scripts/progress.ts b/scripts/progress.ts new file mode 100644 index 00000000..7f467e08 --- /dev/null +++ b/scripts/progress.ts @@ -0,0 +1,135 @@ +// Progress bar for long scripts. It is rendered in stderr to keep stdout +// for the script’s result. + +import readline from 'node:readline' +import { isatty } from 'node:tty' +import { styleText } from 'node:util' + +const COLORS = styleText('bgGreen', ' ', { stream: process.stderr }) !== ' ' + +let total = 0 +let current = 0 +let enabled = false + +function render(): void { + let width = process.stderr.columns + let filled = Math.floor((width * current) / total) + let rest = width - filled + let bar: string + if (COLORS) { + bar = + styleText('bgGreen', ' '.repeat(filled), { stream: process.stderr }) + + styleText('bgWhite', ' '.repeat(rest), { stream: process.stderr }) + } else { + bar = '█'.repeat(filled) + '▒'.repeat(rest) + } + process.stderr.write(`${bar}\n`) + readline.moveCursor(process.stderr, 0, 0) +} + +function rendered(): boolean { + return enabled && total > 0 && current < total +} + +export function startProgress(steps: number): void { + enabled = isatty(2) && !process.env.CI + if (!enabled) return + total = steps + current = 0 + render() +} + +export function nextProgress(): void { + if (!rendered()) return + current += 1 + readline.moveCursor(process.stderr, 0, -1) + readline.clearLine(process.stderr, 0) + if (current < total) render() +} + +export function printAboveProgress( + message: string, + mode: 'error' | 'status' | 'text' | 'warning' = 'text' +): void { + let text = message + if (mode === 'error') { + text = styleText('red', message, { stream: process.stderr }) + } else if (mode === 'warning') { + text = styleText('yellow', message, { stream: process.stderr }) + } else if (mode === 'status') { + text = styleText('gray', message, { stream: process.stderr }) + } + if (rendered()) { + readline.moveCursor(process.stderr, 0, -1) + readline.clearLine(process.stderr, 0) + process.stderr.write(`${text}\n`) + render() + } else { + process.stderr.write(`${text}\n`) + } +} + +interface NoFileError extends Error { + code: string + path: string +} + +function isNoFileError(e: unknown): e is NoFileError { + return e instanceof Error && `code` in e && e.code === 'ENOENT' +} + +let errors = 0 + +export function warning(text: string): void { + printAboveProgress(text, 'warning') + nextProgress() +} + +export function error(err: unknown, details?: string): void { + errors += 1 + let msg: string + if (isNoFileError(err)) { + msg = `File not found: ${err.path}` + } else if (err instanceof Error) { + msg = err.stack ?? err.message + } else { + msg = String(err) + } + printAboveProgress('') + printAboveProgress( + styleText('bold', styleText('bgRed', ' ERROR ')) + + ' ' + + styleText('bold', styleText('red', msg)) + ) + if (details) printAboveProgress(details) + printAboveProgress('') + nextProgress() +} + +export function success(msg: string, details?: string): void { + let text = msg + if (details) { + text += ` ${styleText('gray', details)}` + } + printAboveProgress(styleText('green', styleText('bold', '✓ ') + text)) + nextProgress() +} + +export function semiSuccess(msg: string, note: string): void { + printAboveProgress( + styleText('bold', '✓ ') + msg + ' ' + styleText('bold', note), + 'warning' + ) + nextProgress() +} + +export function finish(msg: string): void { + printAboveProgress('') + let postfix = '' + if (errors > 0) { + postfix = + ', ' + styleText('red', styleText('bold', `${errors} errors found`)) + } + printAboveProgress(msg + postfix, 'status') + process.exit(errors > 0 ? 1 : 0) +} diff --git a/server/Dockerfile b/server/Dockerfile index 5658aaa1..22d6be44 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -4,8 +4,8 @@ LABEL org.opencontainers.image.source=https://github.com/hplush/slowreader LABEL org.opencontainers.image.description="Slow Reader" LABEL org.opencontainers.image.licenses=AGPL-3.0-or-later -ENV NODE_VERSION=26.5.1 \ - NODE_CHECKSUM=sha256:cc7b3484ade63bd203a9d304f21ec37a3b622b988d7bdecf1dc4d68fc44a91b7 +ENV NODE_VERSION=26.6.0 \ + NODE_CHECKSUM=sha256:e53a8c8ca2ee7135de38e63859bd4f43002ae2d222f5b3a98ac1945db9f544bb ADD --checksum=$NODE_CHECKSUM https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz /node.tar.xz RUN tar -xf "node.tar.xz" --strip-components=1 -C /usr/local/ \ diff --git a/server/README.md b/server/README.md index 54ca7746..9641c0ee 100644 --- a/server/README.md +++ b/server/README.md @@ -17,11 +17,11 @@ and uses end-to-end encryption not to know what users read and like. ## Scripts -- `cd server && pnpm start`: start server in development mode. -- `cd server && pnpm migration`: generate migration based on DB schema changes. -- `cd server && pnpm database`: see database content. -- `cd server && pnpm build`: prepare deploy files with production dependencies only. -- `cd server && pnpm production`: start production build of the server. +- `pnpm -F server start`: start server in development mode. +- `pnpm -F server migration`: generate migration based on DB schema changes. +- `pnpm -F server database`: see database content. +- `pnpm -F server build`: prepare deploy files with production dependencies only. +- `pnpm -F server production`: start production build of the server. ## Environment Variables @@ -63,13 +63,13 @@ To use SQL with TypeScript we are using [Drizzle](https://orm.drizzle.team/docs/ To change database schema: 1. Change [`./db/schema.ts`](./db/schema.ts). -2. Run `cd server && pnpm migration` to generate new migration. +2. Run `pnpm -F server migration` to generate new migration. 3. Restart server. It will apply all new migrations automatically. You can see local database content by running: ```sh -cd server && pnpm database +pnpm -F server database ``` ## Deploy diff --git a/server/modules/sync.ts b/server/modules/sync.ts index e722531b..4cb305bb 100644 --- a/server/modules/sync.ts +++ b/server/modules/sync.ts @@ -15,10 +15,10 @@ export default (server: BaseServer): void => { async process(ctx, action, meta) { await db.insert(actions).values({ added: await server.log.store.getLastAdded(), - compressed: action.z, - encrypted: Buffer.from(action.d, 'base64'), + compressed: action.compressed, + encrypted: Buffer.from(action.d), id: meta.id, - iv: Buffer.from(action.iv, 'base64'), + iv: Buffer.from(action.iv), subprotocol: meta.subprotocol ?? SUBPROTOCOL, time: meta.time - EPOCH, userId: ctx.userId @@ -55,9 +55,9 @@ export default (server: BaseServer): void => { return list.map(column => { return [ zero({ - d: Buffer.from(column.encrypted).toString('base64'), - iv: Buffer.from(column.iv).toString('base64'), - z: column.compressed + compressed: column.compressed, + d: column.encrypted, + iv: column.iv }), { added: column.added, diff --git a/server/package.json b/server/package.json index 712c6fbd..162c6442 100644 --- a/server/package.json +++ b/server/package.json @@ -21,24 +21,24 @@ }, "dependencies": { "@electric-sql/pglite": "0.5.4", - "@logux/actions": "0.5.0", - "@logux/server": "0.14.0", + "@logux/actions": "github:logux/actions#next", + "@logux/server": "github:logux/server", "@slowreader/api": "workspace:*", "@slowreader/proxy": "workspace:*", "argon2": "0.45.1", "cookie": "2.0.1", "drizzle-orm": "0.45.2", - "nanoid": "6.0.0", + "nanoid": "6.0.1", "postgres": "3.4.9", - "tsx": "4.23.1" + "tsx": "4.23.5" }, "devDependencies": { - "@logux/client": "0.23.2", + "@logux/client": "github:logux/client#next", "better-node-test": "1.0.0", "drizzle-kit": "0.31.10" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/server/test/assets.test.ts b/server/test/assets.test.ts index 8e20fb25..d6b167ed 100644 --- a/server/test/assets.test.ts +++ b/server/test/assets.test.ts @@ -76,8 +76,10 @@ describe('server assets', () => { let index1 = await server.fetch('/') checkHeaders(index1, { 'content-security-policy': - "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'; require-trusted-types-for 'script'; trusted-types dompurify slowreader-rich svelte-trusted-html slowreader-parse", + "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'wasm-unsafe-eval' 'self'; require-trusted-types-for 'script'; trusted-types default dompurify slowreader-rich svelte-trusted-html slowreader-parse", 'content-type': 'text/html', + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', 'x-content-type-options': 'nosniff' @@ -87,8 +89,10 @@ describe('server assets', () => { let html = await server.fetch('/404.html') checkHeaders(html, { 'content-security-policy': - "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'; require-trusted-types-for 'script'; trusted-types dompurify slowreader-rich svelte-trusted-html slowreader-parse", + "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'wasm-unsafe-eval' 'self'; require-trusted-types-for 'script'; trusted-types default dompurify slowreader-rich svelte-trusted-html slowreader-parse", 'content-type': 'text/html', + 'cross-origin-embedder-policy': 'credentialless', + 'cross-origin-opener-policy': 'same-origin', 'strict-transport-security': 'max-age=31536000; includeSubDomains; preload', 'x-content-type-options': 'nosniff' diff --git a/server/test/users.test.ts b/server/test/users.test.ts index f9c8f5d7..9560b4ee 100644 --- a/server/test/users.test.ts +++ b/server/test/users.test.ts @@ -47,8 +47,12 @@ describe('server users', () => { cookie: { session: userB.session } }) - await clientA1.process(zero({ d: 'a', iv: 'a', z: false })) - await clientB.process(zero({ d: 'b', iv: 'b', z: false })) + await clientA1.process( + zero({ compressed: false, d: Buffer.from('a'), iv: Buffer.from('a') }) + ) + await clientB.process( + zero({ compressed: false, d: Buffer.from('b'), iv: Buffer.from('b') }) + ) await clientA1.process(deleteUser({})) await setTimeout(100) diff --git a/web/.browserslistrc b/web/.browserslistrc index 29e1d35c..4baa6e0a 100644 --- a/web/.browserslistrc +++ b/web/.browserslistrc @@ -1,6 +1,6 @@ # Browsers that we support. See actual browsers: -# https://browsersl.ist/#q=baseline+widely+available+on+2026-07-28+with+downstream +# https://browsersl.ist/#q=baseline+widely+available+on+2026-08-04+with+downstream # # Don’t forget to update links on changes. -baseline widely available on 2026-07-28 with downstream +baseline widely available on 2026-08-04 with downstream diff --git a/web/.size-limit.json b/web/.size-limit.json index a8b9537a..5a434019 100644 --- a/web/.size-limit.json +++ b/web/.size-limit.json @@ -8,6 +8,7 @@ "!dist/{404,500}.html", "!dist/icon-*.{png,svg}", "!dist/favicon.ico", + "!dist/assets/benchmark-*.js", "!dist/assets/devtools-*.js", "!dist/assets/*.woff", "!dist/assets/*.map", @@ -16,12 +17,12 @@ "!dist/assets/playpen-sans-*.woff2", "!dist/assets/*-{italic,math,latin-ext}-*.woff2" ], - "limit": "190 KB" + "limit": "720 KB" }, { "name": "Core scripts to execute", "path": "dist/assets/index-*.js", "brotli": false, - "limit": "315 KB" + "limit": "355 KB" } ] diff --git a/web/README.md b/web/README.md index de524356..43f8cff1 100644 --- a/web/README.md +++ b/web/README.md @@ -21,6 +21,7 @@ We use **[Svelte](https://joyofcode.xyz/learn-svelte)** as the UI framework and - [`index.ts`](./main/index.ts): JS entry point. - [`environment.ts`](./main/environment.ts): how client core should work with browser environment. - [`browser.ts`](./main/browser.ts): connect core stores to global browser settings like `document.title`. +- [`benchmark/`](./benchmark/): UI performance benchmark, which is loaded only by `?benchmark` in URL. See [benchmark guide](../docs/benchmark.md). - [`pages/`](./pages/): Svelte components for pages. - [`ui/`](./ui/): shared components between different pages. Some people call it “UI kit”. - [`public/`](./public/): static files like favicon and manifests. @@ -45,12 +46,13 @@ We use **[Svelte](https://joyofcode.xyz/learn-svelte)** as the UI framework and ## Scripts -- `cd web && pnpm test`: run all web client tests. -- `cd web && pnpm visual`: run visual test server. -- `cd web && pnpm chromatic`: publish visual tests and generate diffs. -- `cd web && pnpm production`: start web client production build locally. -- `cd web && pnpm build`: build production files in `web/dist/`. -- `cd web && pnpm size`: check the JS bundle size of the production build. +- `pnpm -F web test`: run all web client tests. +- `pnpm -F web visual`: run visual test server. +- `pnpm -F web chromatic`: publish visual tests and generate diffs. +- `pnpm -F web production`: start web client production build locally. +- `pnpm -F web build`: build production files in `web/dist/`. +- `pnpm -F web size`: check the JS bundle size of the production build. +- `pnpm -F web benchmark`: run [UI performance benchmark](../docs/benchmark.md). ## Design System diff --git a/web/benchmark/benchmark.ts b/web/benchmark/benchmark.ts new file mode 100644 index 00000000..790021b7 --- /dev/null +++ b/web/benchmark/benchmark.ts @@ -0,0 +1,213 @@ +import { signOut } from '@slowreader/core' +import { + BENCHMARK_GIVE_UP, + benchmarkStatistics, + fillBenchmarkData, + type FillStatistics, + getBenchmarkRuns, + mockBenchmarkRequests, + setBenchmarkDebug, + signInBenchmark +} from '@slowreader/core/benchmark' + +import { + measure, + type Measurement, + median, + START_TIMEOUT, + waitIdle +} from './measure.ts' +import { createScenarios } from './scenarios.ts' +import { measureStorage, type StorageSize } from './storage.ts' + +interface ScenarioResults { + domSize: number + duration: number + failed: number + freezes: number + loaders: Record + longestFrame: number + longestTask: number + memory: number +} + +interface BenchmarkTotal { + domSize: number + duration: number + failed: number + freezes: number + loaders: number + longestFrame: number + longestTask: number + memory: number +} + +interface BenchmarkResults { + feeds: number + fill: number + posts: number + scenarios: Record + start: number + storage: { end: StorageSize; start: StorageSize } + total: BenchmarkTotal +} + +function total(scenarios: ScenarioResults[]): BenchmarkTotal { + function sum(cb: (scenario: ScenarioResults) => number): number { + return scenarios.reduce((all, i) => all + cb(i), 0) + } + function max(cb: (scenario: ScenarioResults) => number): number { + return Math.max(0, ...scenarios.map(cb)) + } + return { + domSize: sum(i => i.domSize), + duration: sum(i => i.duration), + failed: sum(i => i.failed), + freezes: sum(i => i.freezes), + loaders: sum(i => Object.values(i.loaders).reduce((all, j) => all + j, 0)), + longestFrame: max(i => i.longestFrame), + longestTask: max(i => i.longestTask), + memory: max(i => i.memory) + } +} + +async function prepare(): Promise { + mockBenchmarkRequests() + await signInBenchmark() + try { + await waitIdle(START_TIMEOUT) + } catch { + throw new Error( + `App did not load database in ${START_TIMEOUT / 1000} seconds` + ) + } +} + +function merge(runs: Measurement[], failed: number): ScenarioResults { + let loaders: Record = {} + for (let measurement of runs) { + for (let loader of measurement.loaders) { + loaders[loader.name] = Math.max(loaders[loader.name] ?? 0, loader.median) + } + } + return { + domSize: median(runs.map(i => i.domSize)), + duration: median(runs.map(i => i.duration)), + failed, + freezes: median(runs.map(i => i.freezes)), + loaders, + longestFrame: Math.max(0, ...runs.map(i => i.longestFrame)), + longestTask: Math.max(0, ...runs.map(i => i.longestTask)), + memory: Math.max(0, ...runs.map(i => i.memory)) + } +} + +async function fill(): Promise { + mockBenchmarkRequests() + await signInBenchmark() + let statistics = await fillBenchmarkData() + return statistics +} + +function clean(): Promise { + return signOut() +} + +function end(): void { + sessionStorage.removeItem('benchmark') + location.reload() +} + +async function waitStart(): Promise { + try { + await waitIdle(START_TIMEOUT) + return Math.round(performance.now()) + } catch { + return 0 + } +} + +setBenchmarkDebug(sessionStorage.getItem('benchmark') === 'debug') + +let startTime = waitStart() + +async function fillOnFirstOpen(): Promise { + if (!benchmarkStatistics.get()) { + await fill() + location.reload() + } +} + +async function run(only?: string): Promise { + let statistics = benchmarkStatistics.get() + if (!statistics) { + throw new Error('Run await benchmark.fill() and reload the page') + } + await prepare() + let storageStart = await measureStorage() + + let scenarios = createScenarios( + statistics.biggestCategory, + statistics.slowFeeds + ).filter(i => (only ? i.name === only : i.name !== 'freeze')) + + let list: Record = {} + for (let scenario of scenarios) { + let runs: Measurement[] = [] + let failed = 0 + for (let i = 0; i < getBenchmarkRuns() && failed < BENCHMARK_GIVE_UP; i++) { + try { + await scenario.prepare?.() + runs.push(await measure(() => scenario.run())) + } catch (e) { + failed += 1 + let message = e instanceof Error ? e.message : String(e) + console.error(`${scenario.name} failed: ${message}`) + } + } + let merged = merge(runs, failed) + list[scenario.name] = merged + console.log(`${scenario.name}: ${merged.duration} ms, ${failed} failed`) + } + + window.benchmark.results = { + feeds: statistics.feeds, + fill: Math.round(statistics.duration), + posts: statistics.posts, + scenarios: list, + start: await startTime, + storage: { end: await measureStorage(), start: storageStart }, + total: total(Object.values(list)) + } + return window.benchmark.results +} + +declare global { + interface Window { + benchmark: { + clean(): Promise + data: FillStatistics | undefined + end(): void + fill(): Promise + results: BenchmarkResults | undefined + run(only?: string): Promise + scenarios: string[] + storage(): Promise + } + } +} + +window.benchmark = { + clean, + data: benchmarkStatistics.get(), + end, + fill, + results: undefined, + run, + scenarios: createScenarios('', ['', '']) + .map(i => i.name) + .filter(i => i !== 'freeze'), + storage: measureStorage +} + +void fillOnFirstOpen() diff --git a/web/benchmark/measure.ts b/web/benchmark/measure.ts new file mode 100644 index 00000000..6bca47d3 --- /dev/null +++ b/web/benchmark/measure.ts @@ -0,0 +1,189 @@ +import { + getRenderedLoaders, + type LoaderSpan, + recordLoaders +} from '@slowreader/core/benchmark' + +export interface LoaderReport { + count: number + max: number + median: number + name: string +} + +export interface Measurement { + domSize: number + duration: number + freezes: number + loaders: LoaderReport[] + longestFrame: number + longestTask: number + memory: number +} + +const FREEZE = 50 + +const IDLE_TIMEOUT = 60000 + +export const START_TIMEOUT = 600000 + +const CALM = 30 + +// Counting all elements is slow, so we do it only on every 10th frame +const DOM_SAMPLE = 10 + +export function nextFrame(): Promise { + return new Promise(resolve => { + requestAnimationFrame(resolve) + }) +} + +export function median(list: number[]): number { + if (list.length === 0) return 0 + let sorted = list.toSorted((a, b) => a - b) + return Math.round(sorted[Math.floor(sorted.length / 2)]!) +} + +export function percentile(list: number[], share: number): number { + if (list.length === 0) return 0 + let sorted = list.toSorted((a, b) => a - b) + return Math.round( + sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * share))]! + ) +} + +interface FramesReport { + domSize: number + freezes: number + longestFrame: number + memory: number +} + +function domSize(): number { + return document.querySelectorAll('*').length +} + +function trackFrames(): () => FramesReport { + let freezes = 0 + let longest = 0 + let memory = 0 + let nodes = 0 + let counter = 0 + let prev = performance.now() + let stopped = false + + function frame(time: number): void { + if (stopped) return + let gap = time - prev + prev = time + if (gap > longest) longest = gap + if (gap > FREEZE) freezes += 1 + let used = performance.memory?.usedJSHeapSize ?? 0 + if (used > memory) memory = used + counter += 1 + if (counter % DOM_SAMPLE === 0) nodes = Math.max(nodes, domSize()) + requestAnimationFrame(frame) + } + requestAnimationFrame(frame) + + return () => { + stopped = true + return { + domSize: Math.max(nodes, domSize()), + freezes, + longestFrame: Math.round(longest), + memory: Math.round(memory / 1024 / 1024) + } + } +} + +function trackLongTasks(): () => number { + let longest = 0 + let observer: PerformanceObserver | undefined + try { + observer = new PerformanceObserver(list => { + for (let entry of list.getEntries()) { + if (entry.duration > longest) longest = entry.duration + } + }) + observer.observe({ type: 'longtask' }) + } catch { + observer = undefined + } + return () => { + observer?.disconnect() + return Math.round(longest) + } +} + +function reportLoaders(spans: LoaderSpan[]): LoaderReport[] { + let byName = new Map() + for (let span of spans) { + let durations = byName.get(span.name) ?? [] + durations.push((span.end ?? performance.now()) - span.start) + byName.set(span.name, durations) + } + return [...byName.entries()].map(([name, durations]) => ({ + count: durations.length, + max: Math.round(Math.max(...durations)), + median: median(durations), + name + })) +} + +export function isBusy(): boolean { + return getRenderedLoaders().length > 0 || !!document.getElementById('loader') +} + +export async function waitIdle(timeout = IDLE_TIMEOUT): Promise { + let finish = performance.now() + timeout + let ended = performance.now() + let calm = 0 + while (calm < CALM) { + await nextFrame() + if (!isBusy()) { + if (calm === 0) ended = performance.now() + calm += 1 + } else { + calm = 0 + } + if (performance.now() > finish) { + let names = getRenderedLoaders().map(i => i.name) + if (document.getElementById('loader')) names.push('app') + throw new Error( + `Loaders are still rendered after ${timeout} ms: ${names.join(', ')}` + ) + } + } + return ended +} + +export async function measure( + action: () => Promise | void +): Promise { + let stopLoaders = recordLoaders() + let stopFrames = trackFrames() + let stopTasks = trackLongTasks() + let started = performance.now() + let ended: number + let frames: FramesReport + let loaders: LoaderSpan[] + let longestTask: number + try { + await action() + ended = await waitIdle() + } finally { + loaders = stopLoaders() + frames = stopFrames() + longestTask = stopTasks() + } + return { + domSize: frames.domSize, + duration: Math.round(ended - started), + freezes: frames.freezes, + loaders: reportLoaders(loaders), + longestFrame: frames.longestFrame, + longestTask, + memory: frames.memory + } +} diff --git a/web/benchmark/scenarios.ts b/web/benchmark/scenarios.ts new file mode 100644 index 00000000..871f00e0 --- /dev/null +++ b/web/benchmark/scenarios.ts @@ -0,0 +1,201 @@ +import { closeAllPopups } from '@slowreader/core' + +import { getURL, pathRouter } from '../stores/url-router.ts' +import { nextFrame, waitIdle } from './measure.ts' + +export interface Scenario { + name: string + prepare?: () => Promise + run: () => Promise +} + +const ADDED = 'slowreader:benchmark-added' + +function nextFeedUrl(): string { + let count = Number(localStorage.getItem(ADDED) ?? 0) + 1 + localStorage.setItem(ADDED, String(count)) + return `https://example.com/new-feed-${count}.xml` +} + +const WAIT_TIMEOUT = 30000 + +const FREEZE_TEST = 200 + +async function waitFor( + selector: string +): Promise { + let finish = performance.now() + WAIT_TIMEOUT + let element = document.querySelector(selector) + while (!element) { + if (performance.now() > finish) { + throw new Error(`No ${selector} on the page`) + } + await nextFrame() + element = document.querySelector(selector) + } + return element +} + +async function click(selector: string, index = 0): Promise { + await waitFor(selector) + let elements = document.querySelectorAll(selector) + let element = elements[index] + if (!element) throw new Error(`No ${selector} number ${index} on the page`) + element.click() +} + +async function open(url: string): Promise { + closeAllPopups() + pathRouter.open(url) + await waitIdle() +} + +async function typeIn(selector: string, text: string): Promise { + let input = await waitFor(selector) + input.value = text + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +function slowPage(feed: string, from?: number): string { + return getURL({ params: { feed, from }, route: 'slow' }) +} + +function fastPage(category: string): string { + return getURL({ params: { category, from: undefined }, route: 'fast' }) +} + +export function createScenarios( + category: string, + slowFeeds: string[] +): Scenario[] { + let feed = slowFeeds[0]! + let disposable = slowFeeds[1] ?? feed + return [ + { + name: 'open-slow-feed', + async prepare() { + await open(getURL('about')) + }, + async run() { + await open(slowPage(feed)) + } + }, + { + name: 'open-fast-category', + async prepare() { + await open(getURL('about')) + }, + async run() { + await open(fastPage(category)) + } + }, + { + name: 'paginate', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + await click('.pagination_button .button') + } + }, + { + name: 'switch-reader', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + await click('.radio_input[value="feed"]') + await waitIdle() + await click('.radio_input[value="list"]') + } + }, + { + name: 'open-post', + async prepare() { + closeAllPopups() + await open(slowPage(feed, 0)) + }, + async run() { + await click('.links_item') + await waitFor('.popup') + } + }, + { + name: 'menu-navigation', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + let items = document.querySelectorAll('.navbar-item').length + for (let i = 0; i < Math.min(5, items); i++) { + await click('.navbar-item', i) + await waitIdle() + } + } + }, + { + name: 'feeds-by-categories', + async prepare() { + await open(getURL('about')) + }, + async run() { + await open(getURL('feedsByCategories')) + } + }, + { + name: 'read-page', + async prepare() { + await open(slowPage(disposable, 0)) + if (document.querySelector('.radio_input[value="feed"]')) { + await click('.radio_input[value="feed"]') + await waitIdle() + } + }, + async run() { + await click('.button.is-attention') + } + }, + { + name: 'add-feed', + async prepare() { + closeAllPopups() + await open( + getURL({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + ) + }, + async run() { + let url = nextFeedUrl() + await typeIn('input', url) + await click( + `#add-results .links_item[href*="${encodeURIComponent(url)}"]` + ) + await click('.popup .button.is-main') + } + }, + { + name: 'refresh', + async prepare() { + await open(slowPage(feed, 0)) + }, + async run() { + await click('.navbar-button') + } + }, + { + name: 'freeze', + async prepare() { + await open(getURL('about')) + }, + run() { + let finish = Date.now() + FREEZE_TEST + while (Date.now() < finish) { + document.body.getBoundingClientRect() + } + return Promise.resolve() + } + } + ] +} diff --git a/web/benchmark/storage.ts b/web/benchmark/storage.ts new file mode 100644 index 00000000..b3140402 --- /dev/null +++ b/web/benchmark/storage.ts @@ -0,0 +1,43 @@ +export interface StorageSize { + indexedDB: number + localStorage: number + opfs: number + total: number +} + +async function opfsSize(dir: FileSystemDirectoryHandle): Promise { + let size = 0 + for await (let handle of dir.values()) { + if (handle.kind === 'file') { + size += (await handle.getFile()).size + } else { + size += await opfsSize(handle) + } + } + return size +} + +function localStorageSize(): number { + let size = 0 + for (let i = 0; i < localStorage.length; i++) { + let key = localStorage.key(i)! + size += (key.length + (localStorage.getItem(key) ?? '').length) * 2 + } + return size +} + +export async function measureStorage(): Promise { + let estimate = await navigator.storage.estimate() + let opfs = 0 + try { + opfs = await opfsSize(await navigator.storage.getDirectory()) + } catch { + // OPFS is not supported + } + return { + indexedDB: estimate.usageDetails?.indexedDB ?? 0, + localStorage: localStorageSize(), + opfs, + total: estimate.usage ?? 0 + } +} diff --git a/web/global.d.ts b/web/global.d.ts index 54827e3a..b0de2991 100644 --- a/web/global.d.ts +++ b/web/global.d.ts @@ -20,6 +20,22 @@ interface Navigator { | undefined } +interface Performance { + memory: + | { + usedJSHeapSize: number + } + | undefined +} + +interface StorageEstimate { + usageDetails: + | { + indexedDB?: number + } + | undefined +} + declare module '*.avif' declare module '*.png' diff --git a/web/main/browser.ts b/web/main/browser.ts index 624f2871..9d994392 100644 --- a/web/main/browser.ts +++ b/web/main/browser.ts @@ -100,6 +100,14 @@ window.addEventListener('load', () => { void import('./devtools.ts').then(() => {}) }) +if (location.search.includes('benchmark')) { + let debug = location.search.includes('benchmark=debug') + sessionStorage.setItem('benchmark', debug ? 'debug' : '1') +} +if (sessionStorage.getItem('benchmark')) { + void import('../benchmark/benchmark.ts').then(() => {}) +} + startKeyUX(window, [ pressKeyUX('is-pseudo-active'), focusGroupKeyUX(), diff --git a/web/main/environment.ts b/web/main/environment.ts index c3092568..d34ad2a2 100644 --- a/web/main/environment.ts +++ b/web/main/environment.ts @@ -2,6 +2,8 @@ import { IndexedStore } from '@logux/client' import { windowPersistentEvents } from '@nanostores/persistent' +import { openDb } from '@nanostores/sql' +import { sqlocalDriver } from '@nanostores/sql/sqlocal' import { type NetworkType, type NetworkTypeDetector, @@ -68,6 +70,9 @@ setupEnvironment({ cleanStorage() { localStorage.clear() }, + databaseCreator() { + return openDb(sqlocalDriver('slowreader.sqlite')) + }, errorEvents: window, getSession() { // Browser will use session from http-only cookie diff --git a/web/main/index.ts b/web/main/index.ts index 06677135..bc95fa52 100644 --- a/web/main/index.ts +++ b/web/main/index.ts @@ -1,3 +1,4 @@ +import './trusted-types.ts' import './environment.ts' import './browser.ts' import './updater.ts' diff --git a/web/main/trusted-types.ts b/web/main/trusted-types.ts new file mode 100644 index 00000000..29737c4e --- /dev/null +++ b/web/main/trusted-types.ts @@ -0,0 +1,13 @@ +// SQLocal creates Web Worker for SQLite database by itself, so we can’t wrap +// worker’s URL into own Trusted Types policy. Default policy allows scripts +// only from our own origin. +if (window.trustedTypes && !window.trustedTypes.defaultPolicy) { + window.trustedTypes.createPolicy('default', { + createScriptURL(url) { + if (new URL(url, location.href).origin !== location.origin) { + throw new Error(`Blocked script from ${url}`) + } + return url + } + }) +} diff --git a/web/nginx.conf b/web/nginx.conf index 688684af..94c97a77 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -37,7 +37,10 @@ http { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header X-Content-Type-Options "nosniff"; - add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'self'; require-trusted-types-for 'script'; trusted-types dompurify slowreader-rich svelte-trusted-html slowreader-parse"; + # SQLite in OPFS needs cross-origin isolation to save the database + add_header Cross-Origin-Opener-Policy "same-origin"; + add_header Cross-Origin-Embedder-Policy "credentialless"; + add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'none'; form-action 'none'; base-uri 'none'; style-src 'sha256-SmAM1DSNiCCdAEabBHfOLWn8GuDZmajUjuFmodxWN5E=' 'sha256-MQAdkik2UpIegx87oj4jDzvVmtZQHZ8UHLkaMPDUYns=' 'sha256-6V2udMXGcrAVUt4WPmtKduau7GKHBV09b7CIdEvxvK4=' 'self'; script-src 'sha256-iliif2S6Fr8mQazzDJs2huHUeow98/TYx+Staat/56E=' 'wasm-unsafe-eval' 'self'; require-trusted-types-for 'script'; trusted-types default dompurify slowreader-rich svelte-trusted-html slowreader-parse"; location ~* __ROUTES__ { try_files /index.html =404; diff --git a/web/package.json b/web/package.json index 49d40720..b856549a 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "test": "pnpm run /^test:/", "build": "pnpm run /^build:/ && mv ./storybook-static ./dist/ui", "start": "vite --host 0.0.0.0 --port 2553", + "benchmark": "node ./scripts/benchmark.ts", "visual": "storybook dev -p 2555 --no-open --host 0.0.0.0", "chromatic": "node --env-file-if-exists=../.env node_modules/chromatic/dist/bin.cjs --exit-zero-on-changes --skip-update-check --exit-once-uploaded", "production": "node --run build && ./scripts/run-image.sh", @@ -27,13 +28,14 @@ "@fontsource-variable/noto-serif": "5.3.0", "@fontsource/inika": "5.3.0", "@fontsource/playpen-sans": "5.3.0", - "@logux/client": "0.23.2", - "@logux/core": "0.10.0", + "@logux/client": "github:logux/client#next", + "@logux/core": "github:logux/core#next", "@mdi/js": "7.4.47", "@nanostores/i18n": "1.3.3", "@nanostores/media-query": "0.1.2", "@nanostores/persistent": "1.3.5", "@nanostores/router": "1.1.0", + "@nanostores/sql": "0.3.1", "@slowreader/core": "workspace:*", "@storybook/addon-a11y": "10.5.5", "@storybook/addon-svelte-csf": "5.1.2", @@ -44,36 +46,37 @@ "autoprefixer": "10.5.4", "keyux": "0.11.6", "lightningcss": "1.33.0", - "nanostores": "1.4.1", + "nanostores": "1.4.2", "postcss": "8.5.25", "postcss-load-config": "6.0.1", "postcss-mixins": "12.1.2", "postcss-smooth-shadow": "0.3.2", - "rollup": "4.62.3", + "rollup": "4.62.4", + "sqlocal": "0.18.0", "storybook": "10.5.5", "svelte": "5.56.8", - "tsx": "4.23.1", - "vite": "8.1.5" + "tsx": "4.23.5", + "vite": "8.2.0" }, "devDependencies": { "@logux/stylelint-config": "1.0.1", - "@size-limit/file": "13.0.2", + "@size-limit/file": "13.0.3", "@stylistic/stylelint-config": "5.0.0", "@stylistic/stylelint-plugin": "5.2.1", "@types/trusted-types": "2.0.7", "browserslist": "4.28.7", "chromatic": "18.1.0", "eslint": "10.8.0", - "postcss-html": "1.8.1", + "postcss-html": "2.0.0", "postcss-nesting": "14.0.1", "postcss-selector-parser": "7.1.4", - "size-limit": "13.0.2", + "size-limit": "13.0.3", "stylelint": "17.14.1", "stylelint-use-logical": "2.1.3", "svelte-check": "4.7.4" }, "engines": { - "node": "^26.5.0", + "node": "^26.6.0", "pnpm": "^11.0.0" } } diff --git a/web/pages/add.svelte b/web/pages/add.svelte index 4cbdccb7..6cfd16f2 100644 --- a/web/pages/add.svelte +++ b/web/pages/add.svelte @@ -48,7 +48,7 @@ {:else if $searching} - + {:else if $error} {$t[$error]} {:else if $noResults} diff --git a/web/pages/busy.svelte b/web/pages/busy.svelte index 1b1374c7..fe8b6d28 100644 --- a/web/pages/busy.svelte +++ b/web/pages/busy.svelte @@ -1,8 +1,16 @@ - - - + + + diff --git a/web/pages/feeds/index.svelte b/web/pages/feeds/index.svelte index e0f26b71..dd00cbc0 100644 --- a/web/pages/feeds/index.svelte +++ b/web/pages/feeds/index.svelte @@ -50,7 +50,7 @@ {#if $postsLoading || !$posts} - + {:else if $posts.name === 'empty'} diff --git a/web/pages/import.svelte b/web/pages/import.svelte index e7333616..b6e34545 100644 --- a/web/pages/import.svelte +++ b/web/pages/import.svelte @@ -43,7 +43,7 @@ {#if $importing} - + {:else} {#if $posts.isLoading} - + {:else} {#if $posts.error} diff --git a/web/popups/loading.svelte b/web/popups/loading.svelte index 65d3aeff..4fd42746 100644 --- a/web/popups/loading.svelte +++ b/web/popups/loading.svelte @@ -10,6 +10,6 @@ - + diff --git a/web/popups/refresh.svelte b/web/popups/refresh.svelte index 4534cff0..2b80f38a 100644 --- a/web/popups/refresh.svelte +++ b/web/popups/refresh.svelte @@ -61,6 +61,7 @@ diff --git a/web/scripts/benchmark.ts b/web/scripts/benchmark.ts new file mode 100644 index 00000000..c66e4071 --- /dev/null +++ b/web/scripts/benchmark.ts @@ -0,0 +1,561 @@ +// Script to run UI benchmark in Chromium and print results. +// See docs/benchmark.md + +import { spawn, spawnSync } from 'node:child_process' +import { globSync, readdirSync, readFileSync, rmSync, writeSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + error, + finish, + printAboveProgress, + semiSuccess, + startProgress, + success, + warning +} from '../../scripts/progress.ts' + +const ROOT = join(import.meta.dirname, '..') + +const PROFILE = join(tmpdir(), 'slowreader-benchmark') + +const PORT = 2557 + +const FILL_ATTEMPTS = 900 + +const PEAK_INTERVAL = 500 + +const CLOSE_TIMEOUT = 5000 + +interface FillStatistics { + debug: boolean + duration: number + feeds: number + posts: number +} + +interface StorageSize { + indexedDB: number + localStorage: number + opfs: number + total: number +} + +interface Peaks { + documents: number + listeners: number + renderer: number +} + +interface BenchmarkResults { + feeds: number + fill: number + posts: number + scenarios: Record> + start: number + storage: { end: StorageSize; start: StorageSize } + total: Record +} + +const FLAGS = ['--clean', '--dev', '--help', '--json', '-h'] + +const OPTIONS = ['--scenario', '--url'] + +let args = process.argv.slice(2) + +function option(name: string): string | undefined { + let index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] ?? '' +} + +let debug = process.env.DEBUG === '1' +let dev = args.includes('--dev') +let clean = args.includes('--clean') +let json = args.includes('--json') +let scenario = option('--scenario') +let url = option('--url') + +const HELP = `Run UI benchmark in Chromium and print the report. + +Usage: + pnpm -F web benchmark [options] + +Options: + --clean Remove browser profile with database before the run + --scenario NAME Run only this scenario + --json Print full report as JSON instead of total numbers + --dev Use Vite dev server instead of production build + --url URL Use already running server instead of starting new one + --help Show this help + +Environment: + DEBUG=1 Small database and single run of every scenario + CHROMIUM_PATH Path to Chromium binary instead of downloaded one` + +if (args.includes('--help') || args.includes('-h')) { + process.stdout.write(`${HELP}\n`) + process.exit(0) +} + +function print(message: string): void { + writeSync(1, `${message}\n`) +} + +function status(message: string): void { + if (json) return + printAboveProgress(message, 'status') +} + +function fail(message: string): never { + error(message) + finish('Benchmark failed') + process.exit(1) +} + +for (let i = 0; i < args.length; i++) { + let arg = args[i]! + if (OPTIONS.includes(arg)) { + let value = args[i + 1] + if (!value || value.startsWith('-')) fail(`${arg} needs a value`) + i += 1 + } else if (!FLAGS.includes(arg)) { + fail(`Unknown argument ${arg}\n\n${HELP}`) + } +} + +function installedChromium(): string | undefined { + return globSync( + join(homedir(), '.cache/ms-playwright/chromium-*/chrome-linux*/chrome') + ) + .toSorted() + .pop() +} + +function findChromium(): string { + if (process.env.CHROMIUM_PATH) return process.env.CHROMIUM_PATH + let installed = installedChromium() + if (installed) return installed + status('Downloading Chromium…') + let install = spawnSync( + join(ROOT, '../node_modules/.bin/playwright-mcp'), + ['install-browser', 'chromium', '--no-shell'], + { stdio: json ? 'ignore' : 'inherit' } + ) + if (install.status !== 0) fail('Chromium install failed. Set CHROMIUM_PATH') + let binary = installedChromium() + if (!binary) fail('No Chromium after install. Set CHROMIUM_PATH') + return binary +} + +async function waitServer(address: string): Promise { + for (let i = 0; i < 300; i++) { + try { + await fetch(address) + return + } catch { + await new Promise(resolve => setTimeout(resolve, 200)) + } + } + fail(`Server ${address} did not start`) +} + +async function startServer(): Promise<[string, () => void]> { + if (url) return [url, () => {}] + let address = `http://localhost:${PORT}` + try { + await fetch(address) + fail(`Port ${PORT} is busy. Stop that server or use --url`) + } catch (e) { + if (e instanceof Error && e.message.includes('busy')) throw e + } + + let serverArgs = ['--port', String(PORT), '--strictPort'] + if (!dev) { + status('Building app…') + let build = spawnSync(join(ROOT, 'node_modules/.bin/vite'), ['build'], { + cwd: ROOT, + stdio: 'ignore' + }) + if (build.status !== 0) fail('Vite build failed') + serverArgs.unshift('preview') + } + let child = spawn(join(ROOT, 'node_modules/.bin/vite'), serverArgs, { + cwd: ROOT, + stdio: 'ignore' + }) + + await waitServer(address) + return [ + address, + () => { + child.kill() + } + ] +} + +class Chromium { + private closed = false + + private handlers = new Map< + number, + { reject: (error: Error) => void; resolve: (result: unknown) => void } + >() + + private last = 0 + + private session = '' + + private socket: WebSocket + + private constructor(socket: WebSocket) { + this.socket = socket + socket.addEventListener('message', event => { + if (this.closed) return + let message = JSON.parse(String(event.data)) as { + error?: { message: string } + id?: number + method?: string + params?: Record + result?: unknown + } + if (message.id) { + let handler = this.handlers.get(message.id) + this.handlers.delete(message.id) + if (message.error) { + handler?.reject(new Error(`Chromium: ${message.error.message}`)) + } else { + handler?.resolve(message.result) + } + } else if (message.method === 'Page.javascriptDialogOpening') { + void this.send('Page.handleJavaScriptDialog', { accept: true }) + } else if (message.method === 'Runtime.consoleAPICalled') { + let params = message.params as { + args: { description?: string; value?: string }[] + type: string + } + let text = params.args + .map(i => i.value ?? i.description ?? '') + .join(' ') + let scenarioLine = text.match(/^(\S+): (\d+ ms), (\d+) failed$/) + if (params.type === 'error') { + error(text) + } else if (json) { + // Only errors in JSON mode + } else if (scenarioLine) { + let [, name, time, failed] = scenarioLine + if (failed === '0') { + success(name!, time) + } else if (time === '0 ms') { + warning(`${name} failed in every run`) + } else { + semiSuccess(name!, `${failed} failed`) + } + } else { + status(text) + } + } + }) + } + + static async start(binary: string): Promise { + let child = spawn(binary, [ + '--headless=new', + '--remote-debugging-port=0', + `--user-data-dir=${PROFILE}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-gpu', + '--disable-dev-shm-usage', + '--hide-scrollbars', + '--window-size=1280,900', + '--enable-precise-memory-info' + ]) + let endpoint = await new Promise(resolve => { + child.stderr.on('data', (chunk: Buffer) => { + let match = chunk.toString().match(/ws:\/\/\S+/) + if (match) resolve(match[0]) + }) + }) + let socket = new WebSocket(endpoint) + await new Promise(resolve => { + socket.addEventListener('open', resolve, { once: true }) + }) + let chromium = new Chromium(socket) + chromium.kill = () => { + child.kill() + } + chromium.exit = () => { + if (child.exitCode !== null) return Promise.resolve() + return new Promise(resolve => { + child.once('exit', () => { + resolve() + }) + }) + } + return chromium + } + + exit: () => Promise = () => Promise.resolve() + + kill: () => void = () => {} + + close(): void { + this.closed = true + this.socket.close() + this.kill() + } + + async closeSaving(): Promise { + this.session = '' + function timeout(): Promise { + return new Promise(resolve => { + setTimeout(resolve, CLOSE_TIMEOUT) + }) + } + await Promise.race([this.send('Browser.close', {}), timeout()]) + await Promise.race([this.exit(), timeout()]) + this.close() + } + + async evaluate(expression: string): Promise { + let result = (await this.send('Runtime.evaluate', { + awaitPromise: true, + expression, + returnByValue: true + })) as { + exceptionDetails?: { exception?: { description?: string } } + result: { value: Value } + } + if (result.exceptionDetails) { + let details = result.exceptionDetails.exception?.description ?? '' + throw new Error(`Page: ${details.split('\n')[0]}`) + } + return result.result.value + } + + async metrics(): Promise> { + let result = (await this.send('Performance.getMetrics', {})) as { + metrics: { name: string; value: number }[] + } + let values: Record = {} + for (let metric of result.metrics) values[metric.name] = metric.value + return values + } + + async open(address: string): Promise { + let target = (await this.send('Target.createTarget', { + url: 'about:blank' + })) as { targetId: string } + let attached = (await this.send('Target.attachToTarget', { + flatten: true, + targetId: target.targetId + })) as { sessionId: string } + this.session = attached.sessionId + await this.send('Page.enable', {}) + await this.send('Runtime.enable', {}) + await this.send('Performance.enable', {}) + await this.send('Page.navigate', { url: address }) + await this.waitApi() + } + + async reload(): Promise { + await this.send('Page.reload', {}) + await this.waitApi() + } + + async waitFilled(): Promise { + for (let i = 0; i < FILL_ATTEMPTS; i++) { + await new Promise(resolve => setTimeout(resolve, 2000)) + try { + if (await this.evaluate('!!window.benchmark?.data')) return + } catch { + continue + } + } + fail('Database was not filled') + } + + private send(method: string, params: object): Promise { + this.last += 1 + let id = this.last + let message: Record = { id, method, params } + if (this.session) message.sessionId = this.session + this.socket.send(JSON.stringify(message)) + return new Promise((resolve, reject) => { + this.handlers.set(id, { reject, resolve }) + }) + } + + private async waitApi(): Promise { + for (let i = 0; i < 300; i++) { + try { + if (await this.evaluate('!!window.benchmark')) return + } catch { + // Page is still navigating + } + await new Promise(resolve => setTimeout(resolve, 200)) + } + fail('Benchmark API did not load') + } +} + +// performance.memory shows only JS heap of the main isolate. Renderer is +// killed by the whole process memory: DOM, IndexedDB, images, workers. +function rendererMemory(): number { + let total = 0 + let processes: string[] + try { + processes = readdirSync('/proc') + } catch { + return 0 + } + for (let pid of processes) { + try { + let cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8') + if (!cmdline.includes(PROFILE)) continue + if (!cmdline.includes('--type=renderer')) continue + let used = readFileSync(`/proc/${pid}/status`, 'utf8').match( + /VmRSS:\s+(\d+) kB/ + ) + if (used) total += Number(used[1]) * 1024 + } catch { + // Process is already dead or is not a process directory + } + } + return total +} + +function trackPeaks(browser: Chromium): () => Promise { + let peaks: Peaks = { documents: 0, listeners: 0, renderer: 0 } + + async function sample(): Promise { + try { + let metrics = await browser.metrics() + peaks.documents = Math.max(peaks.documents, metrics.Documents ?? 0) + peaks.listeners = Math.max(peaks.listeners, metrics.JSEventListeners ?? 0) + } catch { + // Page is navigating or browser was closed + } + peaks.renderer = Math.max(peaks.renderer, rendererMemory()) + } + + let sampling = Promise.resolve() + let busy = false + let timer = setInterval(() => { + if (busy) return + busy = true + sampling = sample().finally(() => { + busy = false + }) + }, PEAK_INTERVAL) + + return async () => { + clearInterval(timer) + await sampling + return peaks + } +} + +function size(bytes: number): string { + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${Math.round(bytes / 1024 / 1024)} MB` +} + +let [address, stopServer] = await startServer() +if (clean) rmSync(PROFILE, { force: true, recursive: true }) +let binary = findChromium() +let chromium = await Chromium.start(binary) +let stopPeaks: (() => Promise) | undefined + +try { + if (debug) status('Debug mode: small database and single run of scenarios') + let page = `${address}/?benchmark${debug ? '=debug' : ''}` + await chromium.open(page) + + if (!(await chromium.evaluate('!!window.benchmark.data'))) { + status('Filling database, it will take a while…') + await chromium.waitFilled() + status('Restarting browser to forget memory used by filling') + await chromium.closeSaving() + chromium = await Chromium.start(binary) + await chromium.open(page) + } + stopPeaks = trackPeaks(chromium) + + let saved = await chromium.evaluate('window.benchmark.storage()') + if (saved.opfs === 0) { + throw new Error( + 'Database was not saved to OPFS. Server should send ' + + 'Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers' + ) + } + + let data = await chromium.evaluate('window.benchmark.data') + status( + `Data: ${data.feeds} feeds, ${data.posts} posts, ` + + `filled in ${Math.round(data.duration)} ms` + ) + if (data.debug !== debug) { + warning( + `Database was filled in ${data.debug ? 'debug' : 'normal'} mode. ` + + 'Use --clean to fill it again' + ) + } + + let names = await chromium.evaluate('window.benchmark.scenarios') + if (scenario && scenario !== 'freeze' && !names.includes(scenario)) { + throw new Error(`Unknown scenario ${scenario}`) + } + if (!json) { + startProgress(scenario ? 1 : names.length) + } + let results = await chromium.evaluate( + scenario + ? `window.benchmark.run(${JSON.stringify(scenario)})` + : 'window.benchmark.run()' + ) + let peaks = await stopPeaks() + stopPeaks = undefined + results.total.documents = peaks.documents + results.total.listeners = peaks.listeners + results.total.renderer = Math.round(peaks.renderer / 1024 / 1024) + + if (json) { + print(JSON.stringify(results, null, 2)) + } else { + let storage = results.storage.end + let growth = storage.total - results.storage.start.total + print('\n') + print(`Duration: ${results.total.duration} ms (sum)`) + print(`Loaders: ${results.total.loaders} ms (sum)`) + print(`Freezes: ${results.total.freezes} (sum)`) + print(`Longest frame: ${results.total.longestFrame} ms (max)`) + print(`Longest task: ${results.total.longestTask} ms (max)`) + print(`DOM size: ${results.total.domSize} nodes (sum)`) + print(`Documents: ${results.total.documents} (max)`) + print(`Listeners: ${results.total.listeners} (max)`) + print(`JS heap: ${results.total.memory} MB (max)`) + print(`Renderer memory: ${results.total.renderer} MB (max)`) + print( + `Storage: ${size(storage.total)} ` + + `(IndexedDB ${size(storage.indexedDB)}, ` + + `OPFS ${size(storage.opfs)}, ` + + `localStorage ${size(storage.localStorage)})` + ) + print(`Storage growth: ${size(growth)} during scenarios`) + print(`Failed: ${results.total.failed} (sum)`) + } +} catch (e) { + void stopPeaks?.() + await chromium.closeSaving() + stopServer() + fail(e instanceof Error ? e.message : String(e)) +} finally { + void stopPeaks?.() + await chromium.closeSaving() + stopServer() +} + +if (!json) finish('Benchmark finished') diff --git a/web/stories/environment.ts b/web/stories/environment.ts index 10e29a46..1d55fbf2 100644 --- a/web/stories/environment.ts +++ b/web/stories/environment.ts @@ -1,3 +1,5 @@ +import { openDb } from '@nanostores/sql' +import { sqlocalDriver } from '@nanostores/sql/sqlocal' import { type BaseRoute, type NetworkTypeDetector, @@ -46,6 +48,7 @@ effect([mobileMedia, tabletMedia], (mobile, tablet) => { setupEnvironment({ ...getTestEnvironment(), baseRouter, + databaseCreator: () => openDb(sqlocalDriver(':memory:')), errorEvents: window, getSession() { return undefined diff --git a/web/stories/pages/busy.stories.svelte b/web/stories/pages/busy.stories.svelte index f494a2b3..ca8fc52c 100644 --- a/web/stories/pages/busy.stories.svelte +++ b/web/stories/pages/busy.stories.svelte @@ -1,7 +1,9 @@ - + + { + busy.set({}) + }} + > + + + + + + { + busy.set({ label: 'Creating database' }) + }} + > + + + + + + { + busy.set({ + label: 'Creating benchmark data', + progress: 0.72 + }) + }} + > + + + + + + { + busy.set({ + label: + 'Creating benchmark data with a very long description, ' + + 'which should be wrapped to a few lines on any screen', + progress: 0.72 + }) + }} + > + + + diff --git a/web/stories/pages/feeds.stories.svelte b/web/stories/pages/feeds.stories.svelte index 0da24a3f..a72c9081 100644 --- a/web/stories/pages/feeds.stories.svelte +++ b/web/stories/pages/feeds.stories.svelte @@ -141,8 +141,8 @@ feeds={[{ id: 'feed', reading: 'slow' }]} oninit={() => { setTimeout(() => { - changePost('post-2', { read: true }) - changePost('post-3', { read: true }) + changePost('post-2', { read: 1 }) + changePost('post-3', { read: 1 }) }, 1) }} posts={POSTS} @@ -184,8 +184,8 @@ feeds={[{ id: 'feed', reading: 'fast' }]} oninit={() => { setTimeout(() => { - changePost('post-2', { read: true }) - changePost('post-3', { read: true }) + changePost('post-2', { read: 1 }) + changePost('post-3', { read: 1 }) }, 1) }} posts={POSTS.map(i => ({ ...i, reading: 'fast' }))} diff --git a/web/stories/scene.svelte b/web/stories/scene.svelte index 576ce2c0..1e44efc1 100644 --- a/web/stories/scene.svelte +++ b/web/stories/scene.svelte @@ -4,20 +4,17 @@ addFeed, addPost, type BaseRoute, - Category, - type CategoryValue, + cleanDatabase, client, closedCategories, currentPage, DEFAULT_REFRESH_STATISTICS, - Feed, type FeedValue, - Filter, hasPassword, needWelcome, + type NewCategory, pages, type ParamlessRouteName, - Post, type PostValue, refreshErrors, refreshStatistics, @@ -32,7 +29,6 @@ useReducedMotion } from '@slowreader/core' import { addHashToBaseRoute, testCredentials } from '@slowreader/core/test' - import { cleanStores } from 'nanostores' import { onDestroy, type Snippet } from 'svelte' import { systemReducedMotion } from '../stores/media-queries.ts' @@ -52,7 +48,7 @@ route, user = true }: { - categories?: CategoryValue[] + categories?: NewCategory[] children: Snippet feeds?: Partial[] oninit?: () => void @@ -62,9 +58,32 @@ user?: boolean } = $props() - function cleanLogux(): void { + function cleanLogux(): Promise { client.get()?.clean() - cleanStores(Feed, Filter, Category, Post) + return cleanDatabase() + } + + async function fillScene(): Promise { + await cleanLogux() + for (let category of categories ?? []) { + await addCategory(category) + } + for (let feed of feeds ?? []) { + await addFeed(testFeed(feed)) + } + for (let post of posts ?? []) { + await addPost(testPost(post)) + } + + oninit() + + if (typeof route === 'string') { + baseRouter.set({ hash: '', params: {}, route }) + } else { + baseRouter.set( + addHashToBaseRoute(route) ?? { hash: '', params: {}, route: 'slow' } + ) + } } let unbindSyncStatus = syncStatus.listen(() => {}) @@ -77,7 +96,6 @@ } else if (client.get()) { signOut() } - cleanLogux() prepareResponses(responses) stopRefreshing() refreshStatus.set('start') @@ -111,25 +129,7 @@ htmlObserver.disconnect() }) - for (let category of categories ?? []) { - addCategory(category) - } - for (let feed of feeds ?? []) { - addFeed(testFeed(feed)) - } - for (let post of posts ?? []) { - addPost(testPost(post)) - } - - oninit() - - if (typeof route === 'string') { - baseRouter.set({ hash: '', params: {}, route }) - } else { - baseRouter.set( - addHashToBaseRoute(route) ?? { hash: '', params: {}, route: 'slow' } - ) - } + fillScene() }) onDestroy(() => { diff --git a/web/tsconfig.json b/web/tsconfig.json index 54440205..c64e9775 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -3,9 +3,15 @@ "compilerOptions": { "moduleResolution": "bundler", "module": "ESNext", - "lib": ["ES2024", "DOM", "DOM.Iterable"], + "lib": ["ES2024", "DOM", "DOM.AsyncIterable", "DOM.Iterable"], "types": ["trusted-types"] }, - "include": ["**/*.ts", "**/*.cts", "**/*.svelte", ".storybook/*.ts"], + "include": [ + "../scripts/progress.ts", + "**/*.ts", + "**/*.cts", + "**/*.svelte", + ".storybook/*.ts" + ], "exclude": ["dist", "storybook-static"] } diff --git a/web/ui/button.svelte b/web/ui/button.svelte index ba700c2e..966c2562 100644 --- a/web/ui/button.svelte +++ b/web/ui/button.svelte @@ -84,6 +84,7 @@ {#if loader} diff --git a/web/ui/captioned.svelte b/web/ui/captioned.svelte new file mode 100644 index 00000000..8d9fb223 --- /dev/null +++ b/web/ui/captioned.svelte @@ -0,0 +1,51 @@ + + +
+
+ {@render children()} +
+ {#if caption} +

+ {caption} +

+ {/if} +
+ + diff --git a/web/ui/feed/index.svelte b/web/ui/feed/index.svelte index fc2e9b36..4ef4d55b 100644 --- a/web/ui/feed/index.svelte +++ b/web/ui/feed/index.svelte @@ -1,5 +1,4 @@
    - {#each posts as post (post.get().id)} - + {#each posts as post (post.id)} + {/each}
diff --git a/web/ui/feed/post.svelte b/web/ui/feed/post.svelte index 1447ab6d..ba371421 100644 --- a/web/ui/feed/post.svelte +++ b/web/ui/feed/post.svelte @@ -1,5 +1,4 @@
  • -
    +
    {#if author} {/if} - {#if $post.title} + {#if post.title}

    - +

    {/if} - - {#each parseMedia($post.media) as media, index (`${media.url}${index}`)} + + {#each parseMedia(post.media) as media, index (`${media.url}${index}`)} {#if !media.fromText && media.type.startsWith('image')} {/if} {/each}