From 01e181431eb6e197a3e379039ec7b936b3acac23 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 13:34:54 +0000 Subject: [PATCH] Works offline: detect it from the service worker, tag listings, filter by it The inspector now finds the app's service worker (the URL the page registers, the manifest's serviceworker.src, a small linked registration script, then /sw.js, /service-worker.js, /serviceworker.js, /sw.min.js), fetches it with safeFetch (same origin, 1 MB, 5 s) and classifies it: offline when it is Workbox or has a fetch handler that uses the Cache API. A new recommended check "Works offline" says why when it is not. - migration 0011: apps.features text[] (GIN), offline_reason, features_checked_at - set on submit, refresh, saasrow import and release-detect's daily re-read - daemon job feature-backfill: 6 never-checked listings every 2 minutes - ?feature=offline on /api/v1/apps and /apps; a Works offline chip on browse (keeps q, category, sort, publisher), a link on home, a tag on cards and the details page; shape() carries features, offline, offline_reason - CLI 0.6.0: search/browse --offline; MCP 0.6.0: search_apps feature/offline - sw.js VERSION v5 Co-Authored-By: Claude Opus 5.5 --- apps/web/public/store.css | 6 + apps/web/public/sw.js | 2 +- apps/web/src/api.js | 9 +- apps/web/src/app.js | 18 +-- apps/web/src/catalog.js | 23 +++- apps/web/src/daemon.js | 37 ++++++ apps/web/src/imports.js | 3 +- apps/web/src/inspect.js | 123 ++++++++++++++++++- apps/web/src/pages.js | 38 ++++-- apps/web/src/releases.js | 8 +- bun.lock | 4 +- packages/cli/README.md | 1 + packages/cli/package.json | 2 +- packages/cli/src/cli.js | 10 +- packages/cli/src/client.js | 2 +- packages/db/migrations/0011_app_features.sql | 9 ++ packages/mcp/README.md | 2 +- packages/mcp/package.json | 2 +- packages/mcp/src/core.js | 7 +- test/api.test.js | 55 ++++++++- test/offline.test.js | 91 ++++++++++++++ 21 files changed, 411 insertions(+), 41 deletions(-) create mode 100644 packages/db/migrations/0011_app_features.sql create mode 100644 test/offline.test.js diff --git a/apps/web/public/store.css b/apps/web/public/store.css index d73a051..7e492c5 100644 --- a/apps/web/public/store.css +++ b/apps/web/public/store.css @@ -158,6 +158,12 @@ section.block { padding: 36px 0; } .card .by { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .card .sum { font-size: 13.5px; color: var(--ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .card .meta { margin-top: auto; padding-top: 6px; display: flex; gap: 10px; font: 11.5px var(--mono); color: var(--muted); } +.tag { + display: inline-flex; align-items: center; gap: 4px; align-self: flex-start; padding: 2px 8px; border-radius: 999px; + font: 600 11px var(--mono); border: 1px solid var(--line); color: var(--ink-2); background: var(--paper-2); white-space: nowrap; +} +.tag.offline::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #3f8f5a; } +.card .tag { margin-top: 4px; } .icon { border-radius: 22%; background: var(--paper-2); object-fit: cover; border: 1px solid var(--line); } .icon.ph { display: grid; place-items: center; font: 400 24px var(--serif); color: var(--paper); background: var(--ink-2); } .feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; } diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 8cc9fec..94a394a 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,7 +1,7 @@ // pwamart service worker. Static assets: cache first, refreshed in the background. // Pages and the API: network first, so a listing is never stale; the last copy of a // page is the offline fallback. Bump VERSION when an asset changes shape. -const VERSION = 'pwamart-v4'; +const VERSION = 'pwamart-v5'; // Push: "Get notified" releases (handlers from @profullstack/notifications). importScripts('/assets/push-sw.js'); diff --git a/apps/web/src/api.js b/apps/web/src/api.js index 7d7b911..afa8917 100644 --- a/apps/web/src/api.js +++ b/apps/web/src/api.js @@ -8,9 +8,9 @@ import { applyPurchase, billingState, currentPlan, quote, setRenew } from './bil import { checkClaim, shapeClaim, startClaim } from './imports.js'; import { follow as followFn } from './releases.js'; import { vapidKeysFromEnv } from '@profullstack/notifications/server'; -import { PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, getApp, getPublisher, listApps, listPublishers, recordInstall, reviewsFor, shape } from './catalog.js'; +import { FEATURES, PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, featureKey, getApp, getPublisher, listApps, listPublishers, recordInstall, reviewsFor, shape } from './catalog.js'; import { CATEGORIES, CATEGORY_NAMES, PLANS, config } from './config.js'; -import { InspectError, inspect, verifyOrigin } from './inspect.js'; +import { InspectError, featureColumns, inspect, verifyOrigin } from './inspect.js'; import { sharedHost } from './shared-hosts.js'; import { send, sendLoginLink, sendOrgInvite } from './mail.js'; import * as newsletter from './newsletter.js'; @@ -257,12 +257,16 @@ api.get('/plans', (c) => api.get('/apps', async (c) => { const q = c.req.query(); + const raw = q.feature || (q.offline === '1' || q.offline === 'true' ? 'offline' : null); + const feature = featureKey(raw); + if (raw && !feature) return c.json({ error: `unknown feature; one of: ${Object.keys(FEATURES).join(', ')}` }, 400); return c.json( await listApps({ q: q.q, category: q.category, publisher: q.publisher, featured: q.featured === '1' || q.featured === 'true', + feature, sort: q.sort, limit: q.limit, offset: q.offset, @@ -483,6 +487,7 @@ function fromReport(r) { background_color: r.app.backgroundColor, display: r.app.display, check_report: sql.json({ installable: r.installable, score: r.score, checks: r.checks }), + ...featureColumns(r), }; } diff --git a/apps/web/src/app.js b/apps/web/src/app.js index b0d3602..e4e5cae 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -8,7 +8,7 @@ import * as auth from './auth.js'; import { getCookie } from 'hono/cookie'; import { mountOAuth } from './oauth.js'; import { mountCoinPaySignin } from './coinpay-signin.js'; -import { PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, getApp, getPublisher, listApps, listPublishers, reviewsFor, shape } from './catalog.js'; +import { PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, featureCounts, featureKey, getApp, getPublisher, listApps, listPublishers, reviewsFor, shape } from './catalog.js'; import { CATEGORIES, config } from './config.js'; import { handleRpc } from '@profullstack/pwamart-mcp/core'; import { buildProfile } from './mobileconfig.js'; @@ -80,14 +80,15 @@ const html = (c, body, status = 200) => c.html(body, status, { 'cache-control': /* ---------------------------------------------------------------- pages -- */ app.get('/', async (c) => { - const [featured, top, fresh, counts, s] = await Promise.all([ + const [featured, top, fresh, counts, features, s] = await Promise.all([ listApps({ featured: true, limit: 4 }), listApps({ sort: 'top', limit: 12 }), listApps({ sort: 'new', limit: 8 }), categoryCounts(), + featureCounts(), stats(), ]); - return html(c, homePage({ featured: featured.apps, top: top.apps, fresh: fresh.apps, counts, stats: s })); + return html(c, homePage({ featured: featured.apps, top: top.apps, fresh: fresh.apps, counts, features, stats: s })); }); app.get('/apps', async (c) => { @@ -97,12 +98,14 @@ app.get('/apps', async (c) => { const offset = Math.max(0, Number(c.req.query('offset')) || 0); const limit = 24; const publisher = c.req.query('publisher') ? await getPublisher(c.req.query('publisher')) : null; - const [list, counts, s] = await Promise.all([ - listApps({ q, category, publisher: publisher?.slug, sort: sort ?? 'top', limit, offset }), + const feature = featureKey(c.req.query('feature')) ?? (c.req.query('offline') === '1' ? 'offline' : null); + const [list, counts, features, s] = await Promise.all([ + listApps({ q, category, publisher: publisher?.slug, feature, sort: sort ?? 'top', limit, offset }), categoryCounts(), + featureCounts(), stats(), ]); - return html(c, browsePage({ q, category, sort, list, counts, offset, limit, stats: s, publisher })); + return html(c, browsePage({ q, category, sort, list, counts, offset, limit, stats: s, publisher, feature, features })); }); app.get('/publishers', async (c) => { @@ -492,6 +495,7 @@ app.get('/sitemap.xml', async (c) => { u('/publishers'), u('/newsletter'), ...CATEGORIES.map(([k]) => u(`/apps?category=${k}`)), + u('/apps?feature=offline'), ...rows.map((r) => u(`/apps/${r.slug}`, r.updated_at)), ...pubs.map((p) => u(`/publishers/${p.slug}`)), ].join(''); @@ -511,7 +515,7 @@ Unlimited $199/year (unlimited publishers and apps, shared orgs, teams, projects ## API (${config.siteUrl}/api/v1) Reads are public. Writes take \`Authorization: Bearer pm_live_...\` (create one in the console under API keys). -- GET /apps?q=&category=&publisher=&sort=top|new|rating|name&limit=&offset= +- GET /apps?q=&category=&publisher=&feature=offline&sort=top|new|rating|name&limit=&offset= feature=offline: a service worker that answers from a cache - GET /apps/:slug details, publisher, reviews - POST /apps/:slug/installs {method} count an install - GET /categories, GET /plans, GET /publishers?q=&filter=all|claimed|imported|verified&sort=apps|name|new, GET /publishers/:slug diff --git a/apps/web/src/catalog.js b/apps/web/src/catalog.js index 543b578..de572d2 100644 --- a/apps/web/src/catalog.js +++ b/apps/web/src/catalog.js @@ -9,7 +9,7 @@ import { CATEGORY_NAMES } from './config.js'; const PUBLIC_FIELDS = (sql) => sql` a.id, a.slug, a.name, a.summary, a.description, a.url, a.origin, a.start_url, a.manifest_id, a.manifest_url, a.icon_url, a.screenshots, a.theme_color, a.background_color, a.display, - a.category, a.tags, a.status, a.featured, a.installs, a.rating_count, + a.category, a.tags, a.features, a.offline_reason, a.status, a.featured, a.installs, a.rating_count, case when a.rating_count > 0 then round(a.rating_sum::numeric / a.rating_count, 1) else null end as rating, a.verified_at is not null as verified, a.check_report, a.checked_at, a.published_at, a.updated_at, p.slug as publisher_slug, p.name as publisher_name, p.avatar_url as publisher_avatar, p.verified as publisher_verified, p.claimable as publisher_claimable, p.claim_domain as publisher_claim_domain`; @@ -35,6 +35,9 @@ export function shape(row) { category: row.category, category_name: CATEGORY_NAMES[row.category] ?? row.category, tags: row.tags ?? [], + features: row.features ?? [], + offline: (row.features ?? []).includes('offline'), + offline_reason: row.offline_reason ?? null, status: row.status, featured: row.featured, installs: row.installs, @@ -63,8 +66,23 @@ const SORTS = { name: (sql) => sql`lower(a.name)`, }; +/** What the inspector can detect about an app, filterable everywhere as `feature=`. */ +export const FEATURES = { offline: 'Works offline' }; +export const featureKey = (v) => (v && Object.hasOwn(FEATURES, String(v)) ? String(v) : null); + +/** Published apps per feature: { offline: 42 }. */ +export async function featureCounts() { + const sql = db(); + const out = {}; + for (const k of Object.keys(FEATURES)) { + const [r] = await sql`select count(*)::int as n from apps where status = 'published' and features @> array[${k}]::text[]`; + out[k] = r.n; + } + return out; +} + /** Published apps, searchable. `q` is websearch syntax, so raw input never errors. */ -export async function listApps({ q, category, publisher, featured, sort = 'top', limit = 24, offset = 0 } = {}) { +export async function listApps({ q, category, publisher, featured, feature, sort = 'top', limit = 24, offset = 0 } = {}) { const sql = db(); const lim = Math.min(Math.max(Number(limit) || 24, 1), 100); const off = Math.max(Number(offset) || 0, 0); @@ -79,6 +97,7 @@ export async function listApps({ q, category, publisher, featured, sort = 'top', ${category ? sql`and a.category = ${category}` : sql``} ${publisher ? sql`and p.slug = ${publisher}` : sql``} ${featured ? sql`and a.featured` : sql``} + ${feature ? sql`and a.features @> array[${String(feature)}]::text[]` : sql``} ${ query ? sql`and (to_tsvector('english', coalesce(a.name, '') || ' ' || coalesce(a.summary, '') || ' ' || coalesce(a.description, '')) diff --git a/apps/web/src/daemon.js b/apps/web/src/daemon.js index 21bf5f8..0f612ba 100644 --- a/apps/web/src/daemon.js +++ b/apps/web/src/daemon.js @@ -163,6 +163,43 @@ addJob('release-notify', 60_000, async () => { await sendDeliveries(); }); +/* ------------------------------------------------------ feature tags -- */ + +/** + * Listings never inspected for features (they predate the offline check) are + * re-inspected a few per turn, one site at a time, so the backlog clears in an hour + * or two without hammering anyone. A site that is down is marked checked anyway; + * release-detect's daily re-read tags it when it is back. + */ +export async function runFeatureBackfill({ batch = 6, inspectFn, sql = db() } = {}) { + const run = inspectFn ?? (await import('./inspect.js')).inspect; + const { featureColumns } = await import('./inspect.js'); + const due = await sql` + select id, url, origin from apps + where status in ('published', 'unlisted') and features_checked_at is null + order by published_at nulls last limit ${batch}`; + let offline = 0; + for (const a of due) { + try { + const r = await run(a.url); + if (r.origin !== a.origin) throw new Error(`now lands on ${r.origin}`); + const f = featureColumns(r); + if (f.features.includes('offline')) offline++; + await sql`update apps set features = ${f.features}::text[], offline_reason = ${f.offline_reason}, features_checked_at = now(), + check_report = ${sql.json({ installable: r.installable, score: r.score, checks: r.checks })} + where id = ${a.id}`; + } catch (err) { + await sql`update apps set features_checked_at = now(), offline_reason = ${`not checked: ${String(err?.message ?? err)}`.slice(0, 200)} where id = ${a.id}`; + } + } + return { checked: due.length, offline }; +} + +addJob('feature-backfill', 2 * 60_000, async () => { + const r = await runFeatureBackfill(); + if (r.checked) console.log(`[daemon] feature-backfill ${JSON.stringify(r)}`); +}); + /* ------------------------------------------------- featured + newsletter -- */ // A paid feature comes off the home page when its week is up (staff picks never do). diff --git a/apps/web/src/imports.js b/apps/web/src/imports.js index 74d0b77..a0922c0 100644 --- a/apps/web/src/imports.js +++ b/apps/web/src/imports.js @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto'; import { Resolver } from 'node:dns/promises'; import { db, orgs } from '@pwamart/db'; import { config } from './config.js'; -import { inspect, verifyOrigin } from './inspect.js'; +import { featureColumns, inspect, verifyOrigin } from './inspect.js'; import { sharedHost } from './shared-hosts.js'; import { sendClaimVerified } from './mail.js'; @@ -184,6 +184,7 @@ export async function importProduct(product, { source = 'saasrow', inspectFn = i status: 'published', verify_token: randomBytes(12).toString('hex'), check_report: sqlj.json({ installable: report.installable, score: report.score, checks: report.checks }), + ...featureColumns(report), checked_at: new Date(), published_at: new Date(), source, diff --git a/apps/web/src/inspect.js b/apps/web/src/inspect.js index 405ce26..ffeba00 100644 --- a/apps/web/src/inspect.js +++ b/apps/web/src/inspect.js @@ -51,14 +51,14 @@ async function assertPublic(url) { } /** GET with every hop checked. Returns { url, status, type, text }. */ -export async function safeFetch(url, { accept = '*/*', maxBytes = MAX_BYTES } = {}) { +export async function safeFetch(url, { accept = '*/*', maxBytes = MAX_BYTES, timeoutMs = TIMEOUT_MS } = {}) { let current = url; for (let hop = 0; hop < 5; hop++) { await assertPublic(current); const res = await fetch(current, { redirect: 'manual', headers: { 'user-agent': UA, accept }, - signal: AbortSignal.timeout(TIMEOUT_MS), + signal: AbortSignal.timeout(timeoutMs), }); if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { current = new URL(res.headers.get('location'), current).href; @@ -127,6 +127,110 @@ export function readHead(html, base) { return out; } +/* ---------------------------------------------------------- offline -- */ + +/** + * The service worker URLs a page registers: `navigator.serviceWorker.register('/sw.js')`, + * minified (`register("./sw.js",{scope:"/"})`) or `register(new URL('./sw.js', import.meta.url))`. + * Only same-origin URLs: a browser refuses any other, so we never fetch one. + */ +export function serviceWorkerUrls(text, base) { + const out = []; + const origin = new URL(base).origin; + const re = /serviceWorker\s*\.\s*register\s*\(\s*(?:new\s+URL\s*\(\s*)?(['"`])([^'"`\s]{1,300})\1/g; + for (const m of String(text ?? '').matchAll(re)) { + if (m[2].includes('${')) continue; + try { + const u = new URL(decode(m[2]), base); + if (u.origin === origin && !out.includes(u.href)) out.push(u.href); + } catch {} + } + return out; +} + +/** Where service workers usually live when the page registers one from a bundle we do not read. */ +export const SW_FALLBACKS = ['/sw.js', '/service-worker.js', '/serviceworker.js', '/sw.min.js']; + +const FETCH_HANDLER = /addEventListener\s*\(\s*(['"`])fetch\1|\bonfetch\s*=/; + +/** + * Does this service worker script make the app work offline? It has to answer + * requests (a fetch handler) and keep responses (the Cache API), or be Workbox, + * which does both. A worker that only shows push notifications, or a fetch handler + * that passes every request to the network, does not. + */ +export function classifyServiceWorker(src) { + const s = String(src ?? ''); + if (!s.trim()) return { offline: false, reason: 'no service worker' }; + if (/\bworkbox\b|workbox-sw|precacheAndRoute|registerRoute|__WB_MANIFEST/i.test(s)) return { offline: true, reason: 'workbox' }; + if (!FETCH_HANDLER.test(s)) return { offline: false, reason: 'no fetch handler' }; + const cache = /\bcaches\s*\.\s*(open|match|keys|has)\s*\(/.test(s) || /\.addAll\s*\(/.test(s) || /cacheStorage/i.test(s); + if (!cache) return { offline: false, reason: 'fetch handler without a cache' }; + return { offline: true, reason: 'fetch+cache' }; +} + +/** A real script, not an SPA's catch-all index.html or a JSON 404 served with 200. */ +const looksLikeScript = (r) => + r && r.status < 400 && !/html|json|xml|image\//i.test(r.type) && !/^\s* 0; + +/** + * Find the app's service worker and classify it. Candidates in order: what the page + * HTML registers, the manifest's `serviceworker.src`, what one small same-origin + * registration script registers (vite-plugin-pwa's registerSW.js), then the usual + * paths. A few short fetches at most; never throws. + */ +export async function detectOffline({ html, pageUrl, manifest = null, manifestUrl = null, fetchFn = safeFetch, maxFetches = 4 }) { + const origin = new URL(pageUrl).origin; + const candidates = serviceWorkerUrls(html, pageUrl); + const add = (u, base = origin) => { + try { + const h = new URL(u, base).href; + if (new URL(h).origin === origin && !candidates.includes(h)) candidates.push(h); + } catch {} + }; + if (typeof manifest?.serviceworker?.src === 'string') add(manifest.serviceworker.src, manifestUrl ?? pageUrl); + let fetches = 0; + const get = async (u) => { + if (fetches >= maxFetches) return null; + fetches++; + try { + return await fetchFn(u, { accept: 'text/javascript,application/javascript,*/*;q=0.5', maxBytes: 1024 * 1024, timeoutMs: 5_000 }); + } catch { + return null; + } + }; + if (!candidates.length) { + const script = [...String(html ?? '').matchAll(/]*\ssrc\s*=\s*["']([^"']+)["'][^>]*>/gi)] + .map((m) => decode(m[1])) + .find((s) => /register|(^|[/._-])sw([._-]|$)|service-?worker|pwa/i.test(s)); + let u = null; + try { + u = script ? new URL(script, pageUrl) : null; + } catch {} + if (u?.origin === origin) { + const r = await get(u.href); + if (looksLikeScript(r)) { + if (FETCH_HANDLER.test(r.text) && !/serviceWorker\s*\.\s*register/.test(r.text)) return { ...classifyServiceWorker(r.text), sw: r.url }; + for (const v of serviceWorkerUrls(r.text, r.url)) add(v); + } + } + } + const registered = candidates.length > 0 || /serviceWorker\s*\.\s*register\s*\(/.test(String(html ?? '')); + for (const f of SW_FALLBACKS) add(f); + // The first worker that works offline wins; a worker that does not (a stale /sw.js + // left behind by a rename) does not stop the search while fetches remain. + let first = null; + for (const u of candidates) { + if (fetches >= maxFetches) break; + const r = await get(u); + if (!looksLikeScript(r) || new URL(r.url).origin !== origin) continue; + const c = { ...classifyServiceWorker(r.text), sw: r.url }; + if (c.offline) return c; + first ??= c; + } + return first ?? { offline: false, reason: registered ? 'service worker not found' : 'no service worker', sw: null }; +} + /** The largest square-ish icon at or above `min`, preferring png/svg/webp. */ export function pickIcon(icons = [], min = 192) { const scored = icons @@ -213,8 +317,14 @@ export async function inspect(rawUrl) { add('display', ['standalone', 'fullscreen', 'minimal-ui', 'window-controls-overlay', 'tabbed'].includes(display), 'Opens in its own window', 'Set "display": "standalone".'); add('start_url', startUrl && new URL(startUrl).origin === finalUrl.origin, 'Start URL on the same origin', 'start_url must stay on the app\'s own origin.'); - add('sw', head.registersServiceWorker, 'Registers a service worker', + const offline = await detectOffline({ html: page.text, pageUrl: finalUrl.href, manifest, manifestUrl }); + add('sw', head.registersServiceWorker || Boolean(offline.sw), 'Registers a service worker', 'Not required by Chrome since 2023, but it is what makes an app work offline.', 'recommended'); + add('offline', offline.offline, 'Works offline', + offline.offline + ? `Its service worker answers requests from a cache (${offline.reason}).` + : `Not detected (${offline.reason}). Cache the app shell in the service worker and answer fetch events from that cache; Workbox's precacheAndRoute does both.`, + 'recommended'); add('maskable', icons.some((i) => String(i.purpose ?? '').includes('maskable')), 'Has a maskable icon', 'Android crops icons to a shape; a maskable icon keeps the art inside the safe zone.', 'recommended'); add('screenshots', Array.isArray(mf.screenshots) && mf.screenshots.length > 0, 'Has screenshots', @@ -231,6 +341,8 @@ export async function inspect(rawUrl) { checks, manifestUrl: manifest ? manifestUrl : null, manifest, + offline, + features: offline.offline ? ['offline'] : [], app: { name: listingName(mf.name || head.title || finalUrl.hostname, mf.short_name), fullName: mf.name || null, @@ -252,6 +364,11 @@ export async function inspect(rawUrl) { }; } +/** Inspection -> the apps columns the feature filters read. */ +export function featureColumns(r) { + return { features: r.features ?? [], offline_reason: r.offline?.reason ?? null, features_checked_at: new Date() }; +} + /* ------------------------------------------------------- domain ownership -- */ /** The token an app's manifest carries: `"pwamart": { "verification": "" }`. */ diff --git a/apps/web/src/pages.js b/apps/web/src/pages.js index 5637892..478927a 100644 --- a/apps/web/src/pages.js +++ b/apps/web/src/pages.js @@ -266,27 +266,35 @@ export function card(app) {
${e(app.publisher.name)} ${app.publisher.verified ? CHECK : ''}
${e(app.summary ?? '')}
${stars(app.rating)}${fmt(app.installs)} installs${e(app.category_name)}
+ ${app.offline ? OFFLINE_TAG : ''} `; } +/** The small "Works offline" tag on cards and the details page. */ +export const OFFLINE_TAG = 'Works offline'; + export function grid(apps, emptyText = 'No apps here yet.') { if (!apps.length) return `
${e(emptyText)} Submit one.
`; return `
${apps.map(card).join('')}
`; } -function categoryChips(counts, active) { +function categoryChips(counts, active, keep = {}) { + const href = (category) => { + const p = new URLSearchParams({ ...(category ? { category } : {}), ...keep }); + return p.size ? `/apps?${p}` : '/apps'; + }; return `
- All + All ${CATEGORIES.filter(([slug]) => counts[slug] || slug === active) - .map(([slug, name]) => `${e(name)} ${counts[slug] ?? 0}`) + .map(([slug, name]) => `${e(name)} ${counts[slug] ?? 0}`) .join('')}
`; } /* --------------------------------------------------------------- home -- */ -export function homePage({ featured, top, fresh, counts, stats }) { +export function homePage({ featured, top, fresh, counts, stats, features = {} }) { const tints = ['#e5eadb', '#f3e2d6', '#e1e6ee', '#efe6cf']; const body = `
@@ -327,7 +335,8 @@ export function homePage({ featured, top, fresh, counts, stats }) { : '' } -

Categories

All apps →
${categoryChips(counts, null)}
+

Categories

All apps →
${categoryChips(counts, null)} +

Top apps

See all →
${grid(top)}
${adSlot('home')}

New this week

See all →
${grid(fresh)}
@@ -348,25 +357,30 @@ export function homePage({ featured, top, fresh, counts, stats }) { /* ------------------------------------------------------------- browse -- */ -export function browsePage({ q, category, sort, list, counts, offset, limit, stats, publisher = null }) { - const title = publisher ? `Apps by ${publisher.name}` : q ? `“${q}”` : category ? CATEGORY_NAMES[category] ?? 'Apps' : 'All web apps'; - const qs = (o) => { +export function browsePage({ q, category, sort, list, counts, offset, limit, stats, publisher = null, feature = null, features = {} }) { + const base = publisher ? `Apps by ${publisher.name}` : q ? `“${q}”` : category ? CATEGORY_NAMES[category] ?? 'Apps' : 'All web apps'; + const title = feature === 'offline' ? (q || category || publisher ? `${base} · works offline` : 'Web apps that work offline') : base; + const qs = (o, { feat = feature } = {}) => { const p = new URLSearchParams(); if (q) p.set('q', q); if (category) p.set('category', category); if (publisher) p.set('publisher', publisher.slug); if (sort) p.set('sort', sort); + if (feat) p.set('feature', feat); if (o) p.set('offset', o); return `/apps?${p}`; }; + const keep = { ...(publisher ? { publisher: publisher.slug } : {}), ...(sort ? { sort } : {}), ...(feature ? { feature } : {}) }; + const offlineChip = ``; const sorts = [['top', 'Top'], ['new', 'Newest'], ['rating', 'Top rated'], ['name', 'A–Z']]; const body = `
${e(list.total)} result${list.total === 1 ? '' : 's'}

${e(title)}

${publisher ? `` : ''} - ${categoryChips(counts, category)} - ${q ? '' : `
${sorts.map(([k, n]) => `${n}`).join('')}
`} + ${categoryChips(counts, category, keep)} + ${offlineChip} + ${q ? '' : `
${sorts.map(([k, n]) => `${n}`).join('')}
`}
${grid(list.apps, q ? 'Nothing matches that search.' : 'No apps in this category yet.')}
${list.apps.length ? adSlot('browse') : ''} @@ -375,7 +389,7 @@ export function browsePage({ q, category, sort, list, counts, offset, limit, sta ${offset + limit < list.total ? `Next →` : ''}
`; - return layout({ title, path: '/apps', body, stats, noindex: Boolean(q) }); + return layout({ title, path: feature ? `/apps?feature=${feature}` : '/apps', body, stats, noindex: Boolean(q) }); } /* ------------------------------------------------------------ details -- */ @@ -426,6 +440,7 @@ export function appPage({ app, publisher, reviews, related, releases = [], stats
${e(app.category_name)}${app.featured ? ' · ★ Featured' : ''}

${e(app.name)}

by ${e(app.publisher.name)} ${app.publisher.verified ? CHECK : ''} · ${e(host)}
+ ${app.offline ? `` : ''} ${app.summary ? `

${e(app.summary)}

` : ''} ${app.publisher.claimable ? claimNote(app.publisher) : ''}
@@ -491,6 +506,7 @@ export function appPage({ app, publisher, reviews, related, releases = [], stats
Website
${e(host)}
Category
${e(app.category_name)}
Opens as
${e(app.display ?? 'browser')}
+
Offline
${app.offline ? 'Works offline' : app.offline_reason && !app.offline_reason.startsWith('not checked') ? `No (${e(app.offline_reason)})` : '–'}
Price
Free to install
${app.updated_at ? `
Checked
${e(new Date(app.checked_at ?? app.updated_at).toISOString().slice(0, 10))}
` : ''}
Manifest
${app.manifest_url ? `view` : '–'}
diff --git a/apps/web/src/releases.js b/apps/web/src/releases.js index 53a27b8..5f586a6 100644 --- a/apps/web/src/releases.js +++ b/apps/web/src/releases.js @@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'node:crypto'; import { sendPush, vapidKeysFromEnv } from '@profullstack/notifications/server'; import { db } from '@pwamart/db'; import { config } from './config.js'; -import { inspect } from './inspect.js'; +import { featureColumns, inspect } from './inspect.js'; import { send } from './mail.js'; /** @@ -88,7 +88,11 @@ export async function runReleaseDetect({ batch = 20, inspectFn = inspect, sql = }); releases++; } - await sql`update apps set manifest_hash = ${fp.hash}, manifest_fields = ${sql.json(fp.fields)}, release_checked_at = now() where id = ${a.id}`; + // The same read keeps the feature tags (works offline) current. + const f = report.offline ? featureColumns(report) : null; + await sql`update apps set manifest_hash = ${fp.hash}, manifest_fields = ${sql.json(fp.fields)}, release_checked_at = now() + ${f ? sql`, features = ${f.features}::text[], offline_reason = ${f.offline_reason}, features_checked_at = now()` : sql``} + where id = ${a.id}`; } return { checked: due.length, releases }; } diff --git a/bun.lock b/bun.lock index d3e3177..943fc11 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ }, "packages/cli": { "name": "@profullstack/pwamart", - "version": "0.5.0", + "version": "0.6.0", "bin": { "pwamart": "./bin/pwamart.mjs", }, @@ -44,7 +44,7 @@ }, "packages/mcp": { "name": "@profullstack/pwamart-mcp", - "version": "0.5.0", + "version": "0.6.0", "bin": { "pwamart-mcp": "./bin/pwamart-mcp.mjs", }, diff --git a/packages/cli/README.md b/packages/cli/README.md index 00adc99..02b20fa 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -6,6 +6,7 @@ curl -fsSL https://pwamart.com/install.sh | sh # or: npm i -g @profullstack/pwamart pwamart search notes +pwamart browse --offline # only apps whose service worker works offline pwamart info pwamart install # opens it as an app window, writes a launcher entry (Linux) pwamart tui # the whole store in your terminal, on hqtui diff --git a/packages/cli/package.json b/packages/cli/package.json index 48f23d7..8d84791 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/pwamart", - "version": "0.5.0", + "version": "0.6.0", "description": "pwamart from the terminal: search the web-app store, install PWAs as app windows, publish your own, and browse it all in an hqtui TUI.", "type": "module", "license": "MIT", diff --git a/packages/cli/src/cli.js b/packages/cli/src/cli.js index 5d3eeda..5e68473 100644 --- a/packages/cli/src/cli.js +++ b/packages/cli/src/cli.js @@ -12,7 +12,8 @@ import { installApp } from './install.js'; const HELP = `pwamart: find and install web apps (PWAs) on any device. Browse - pwamart search [query] [--category C] [--sort top|new|rating] [--json] + pwamart search [query] [--category C] [--sort top|new|rating] [--offline] [--json] + pwamart browse [--category C] [--offline] the same list; --offline: only apps that work offline pwamart info [--json] pwamart install [--no-launch] open it as an app window + add a launcher entry pwamart uninstall remove the launcher entry @@ -79,7 +80,7 @@ export function formatList(r) { if (!r.apps.length) return 'No apps found.'; const w = Math.min(28, Math.max(...r.apps.map((a) => a.slug.length))); const lines = r.apps.map( - (a) => `${a.slug.padEnd(w)} ${a.name.slice(0, 30).padEnd(30)} ${stars(a.rating).padEnd(6)} ${String(a.installs).padStart(6)} ↓ ${a.publisher.name}`, + (a) => `${a.slug.padEnd(w)} ${a.name.slice(0, 30).padEnd(30)} ${stars(a.rating).padEnd(6)} ${String(a.installs).padStart(6)} ↓ ${a.offline ? 'offline ' : ''}${a.publisher.name}`, ); return `${lines.join('\n')}\n\n${r.total} app${r.total === 1 ? '' : 's'}${r.total > r.apps.length ? ` (showing ${r.apps.length})` : ''} · pwamart install `; } @@ -95,6 +96,7 @@ export function formatApp({ app, publisher }, server = "https://pwamart.com") { `URL ${app.start_url}`, `Page ${server}/apps/${app.slug}`, `Score ${app.score ?? '–'}% ${req.map((c) => `${c.ok ? '✓' : '✕'} ${c.label}`).join(' ')}`, + `Offline ${app.offline ? 'yes' : app.offline_reason ? `no (${app.offline_reason})` : '–'}`, `iOS ${server}/apps/${app.slug}/install.mobileconfig`, `Install pwamart install ${app.slug}`, ].join('\n'); @@ -127,8 +129,10 @@ export async function main(argv = process.argv.slice(2)) { return h.ok ? 0 : 1; } case 'search': + case 'browse': case 'ls': { - const r = await api.search(auth, { q: rest.join(' ') || undefined, category: flags.category, sort: flags.sort, limit: flags.limit ?? 20 }); + const feature = flags.offline === true || flags.offline === 'true' ? 'offline' : typeof flags.feature === 'string' ? flags.feature : undefined; + const r = await api.search(auth, { q: rest.join(' ') || undefined, category: flags.category, sort: flags.sort, feature, limit: flags.limit ?? 20 }); out(r, formatList(r)); return 0; } diff --git a/packages/cli/src/client.js b/packages/cli/src/client.js index 39a7d17..720094d 100644 --- a/packages/cli/src/client.js +++ b/packages/cli/src/client.js @@ -77,7 +77,7 @@ const post = (body) => ({ method: 'POST', body: JSON.stringify(body ?? {}) }); const qs = (o) => new URLSearchParams(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString(); export const health = (auth) => call(auth, '/health'); -export const search = (auth, { q, category, sort, limit = 20, offset } = {}) => call(auth, `/apps?${qs({ q, category, sort, limit, offset })}`); +export const search = (auth, { q, category, sort, feature, limit = 20, offset } = {}) => call(auth, `/apps?${qs({ q, category, sort, feature, limit, offset })}`); export const getApp = (auth, slug) => call(auth, `/apps/${encodeURIComponent(slug)}`); export const categories = (auth) => call(auth, '/categories'); export const countInstall = (auth, slug, method) => call(auth, `/apps/${encodeURIComponent(slug)}/installs`, post({ method })); diff --git a/packages/db/migrations/0011_app_features.sql b/packages/db/migrations/0011_app_features.sql new file mode 100644 index 0000000..9a7932d --- /dev/null +++ b/packages/db/migrations/0011_app_features.sql @@ -0,0 +1,9 @@ +-- What an app can do, as detected by the inspector, so the store can filter by it. +-- Generic on purpose: 'offline' first (a service worker that answers from a cache), +-- later features are more strings in the same array. +alter table apps + add column features text[] not null default '{}', + add column offline_reason text, -- workbox | fetch+cache | no service worker | no fetch handler | ... + add column features_checked_at timestamptz; -- null: never inspected for features (the daemon backfills these) + +create index apps_features_gin on apps using gin (features); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e73a1d8..f37bf0c 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -10,7 +10,7 @@ Or use the hosted endpoint with no install: `https://pwamart.com/mcp` (streamabl | Tool | Needs a key | | --- | --- | -| `search_apps` | no | +| `search_apps` (`feature: "offline"` or `offline: true` for apps that work offline) | no | | `get_app` | no | | `list_categories` | no | | `install_app`: steps and command for ios, android, desktop, linux, macos, windows, tron | no | diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 6a24084..e521ca8 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/pwamart-mcp", - "version": "0.5.0", + "version": "0.6.0", "description": "MCP server for pwamart.com: search the web-app store, get install steps for any platform, and submit and publish your PWAs from any MCP client.", "type": "module", "license": "MIT", diff --git a/packages/mcp/src/core.js b/packages/mcp/src/core.js index 4774ed4..8bae377 100644 --- a/packages/mcp/src/core.js +++ b/packages/mcp/src/core.js @@ -20,6 +20,8 @@ export const TOOLS = [ query: { type: 'string', description: 'free text, e.g. "markdown notes"' }, category: { type: 'string', description: 'category slug from list_categories' }, sort: { type: 'string', enum: ['top', 'new', 'rating', 'name'] }, + feature: { type: 'string', enum: ['offline'], description: 'only apps with this detected feature; offline = its service worker answers from a cache' }, + offline: { type: 'boolean', description: 'shorthand for feature: "offline"' }, limit: { type: 'integer', minimum: 1, maximum: 50 }, }, additionalProperties: false, @@ -144,10 +146,11 @@ export async function callTool(name, args, call, siteUrl) { const json = (method, body) => ({ method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) }); switch (name) { case 'search_apps': { - const r = await call(`/apps?${q({ q: args.query, category: args.category, sort: args.sort, limit: args.limit ?? 10 })}`); + const feature = args.feature ?? (args.offline ? 'offline' : undefined); + const r = await call(`/apps?${q({ q: args.query, category: args.category, sort: args.sort, feature, limit: args.limit ?? 10 })}`); return text({ total: r.total, - apps: r.apps.map((a) => ({ slug: a.slug, name: a.name, summary: a.summary, publisher: a.publisher.name, category: a.category, rating: a.rating, installs: a.installs, url: a.start_url, page: `${siteUrl}/apps/${a.slug}` })), + apps: r.apps.map((a) => ({ slug: a.slug, name: a.name, summary: a.summary, publisher: a.publisher.name, category: a.category, features: a.features ?? [], rating: a.rating, installs: a.installs, url: a.start_url, page: `${siteUrl}/apps/${a.slug}` })), }); } case 'get_app': { diff --git a/test/api.test.js b/test/api.test.js index 63a5552..32883dc 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -18,9 +18,16 @@ function fixturePage(name, { manifest = true } = {}) { ${manifest ? `` : ''} - ${name}`; + ${name}`; } +// notes works offline (a fetch handler answering from caches); todo's worker only passes requests through. +const FIXTURE_SW = { + notes: `self.addEventListener('install', (e) => e.waitUntil(caches.open('v1').then((c) => c.addAll(['/notes/'])))); +self.addEventListener('fetch', (e) => e.respondWith(caches.match(e.request).then((r) => r || fetch(e.request))));`, + todo: `self.addEventListener('fetch', (e) => e.respondWith(fetch(e.request)));`, +}; + beforeAll(async () => { if (!HAS_DB) return; process.env.SITE_URL = 'http://localhost:3999'; @@ -64,6 +71,10 @@ beforeAll(async () => { { src: `/${name}/i512.png`, sizes: '512x512', type: 'image/png' }, ], }); + if (file === 'sw.js') + return FIXTURE_SW[name] + ? new Response(FIXTURE_SW[name], { headers: { 'content-type': 'text/javascript' } }) + : new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } }); if (file?.endsWith('.png')) return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0]), { headers: { 'content-type': 'image/png' } }); fixtureApps.set(name, true); return new Response(fixturePage(name), { headers: { 'content-type': 'text/html' } }); @@ -229,6 +240,48 @@ d('store end to end', () => { expect(cats.body.categories.find((c) => c.slug === 'utilities').apps).toBe(1); }); + test('works offline: detected on submit, filterable by ?feature=offline, shown on the pages', async () => { + const notes = await anon('GET', '/apps/notes'); + expect(notes.body.app.features).toEqual(['offline']); + expect(notes.body.app.offline).toBe(true); + expect(notes.body.app.offline_reason).toBe('fetch+cache'); + expect(notes.body.app.checks.find((c) => c.id === 'offline').ok).toBe(true); + const todo = await anon('GET', '/apps/todo'); + expect(todo.body.app.features).toEqual([]); + expect(todo.body.app.offline_reason).toBe('fetch handler without a cache'); + expect(todo.body.app.checks.find((c) => c.id === 'offline').ok).toBe(false); + + const off = await anon('GET', '/apps?feature=offline'); + expect(off.body.apps.map((a) => a.slug)).toEqual(['notes']); + expect(off.body.total).toBe(1); + expect((await anon('GET', '/apps?offline=1')).body.apps.map((a) => a.slug)).toEqual(['notes']); + expect((await anon('GET', '/apps?feature=offline&category=utilities')).body.total).toBe(0); + expect((await anon('GET', '/apps?feature=teleport')).status).toBe(400); + + const browse = await (await app.request('/apps?feature=offline&sort=new')).text(); + expect(browse).toContain('data-feature="offline"'); + expect(browse).toContain('Web apps that work offline'); + expect(browse).toContain('href="/apps/notes"'); + expect(browse).not.toContain('href="/apps/todo"'); + expect(browse).toContain('class="chip on" data-feature="offline" href="/apps?sort=new"'); // the toggle turns it off and keeps sort + const all = await (await app.request('/apps?category=productivity&q=notes')).text(); + expect(all).toContain('data-feature="offline" href="/apps?q=notes&category=productivity&feature=offline"'); + expect(await (await app.request('/')).text()).toContain('href="/apps?feature=offline"'); + expect(await (await app.request('/apps/notes')).text()).toContain('Works offline'); + expect(await (await app.request('/apps/todo')).text()).not.toContain('class="tag offline"'); + }); + + test('the backfill tags listings never checked for features, a few at a time', async () => { + const sql = db(); + await sql`update apps set features = '{}', offline_reason = null, features_checked_at = null where slug in ('notes', 'todo')`; + expect((await anon('GET', '/apps?feature=offline')).body.total).toBe(0); + const { runFeatureBackfill } = await import('../apps/web/src/daemon.js'); + const r = await runFeatureBackfill({ batch: 10 }); + expect(r.offline).toBe(1); + expect((await anon('GET', '/apps?feature=offline')).body.apps.map((a) => a.slug)).toEqual(['notes']); + expect((await runFeatureBackfill()).checked).toBe(0); + }); + test('another account cannot touch the app', async () => { const bob = req((await keyFor('bob@example.test')).key); expect((await bob('PATCH', '/apps/notes', { name: 'pwned' })).status).toBe(404); diff --git a/test/offline.test.js b/test/offline.test.js new file mode 100644 index 0000000..c232c41 --- /dev/null +++ b/test/offline.test.js @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test'; +import { classifyServiceWorker, detectOffline, serviceWorkerUrls } from '../apps/web/src/inspect.js'; + +describe('offline detection', () => { + const base = 'https://a.example/app/'; + + test('serviceWorkerUrls reads register() calls, same origin only', () => { + const html = ` + + + + `; + expect(serviceWorkerUrls(html, base)).toEqual(['https://a.example/sw.js', 'https://a.example/app/worker.js', 'https://a.example/pwa/sw.js']); + expect(serviceWorkerUrls('

no worker

', base)).toEqual([]); + }); + + test('Workbox is offline', () => { + expect( + classifyServiceWorker( + "importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js'); workbox.routing.registerRoute(/x/, new workbox.strategies.CacheFirst())", + ), + ).toEqual({ offline: true, reason: 'workbox' }); + expect(classifyServiceWorker('precacheAndRoute(self.__WB_MANIFEST)').offline).toBe(true); + }); + + test('a fetch handler that answers from caches is offline', () => { + const sw = `self.addEventListener('install', e => e.waitUntil(caches.open('v1').then(c => c.addAll(['/'])))); + self.addEventListener("fetch", e => e.respondWith(caches.match(e.request).then(r => r || fetch(e.request))));`; + expect(classifyServiceWorker(sw)).toEqual({ offline: true, reason: 'fetch+cache' }); + expect(classifyServiceWorker('self.onfetch = (e) => e.respondWith(caches.match(e.request))').offline).toBe(true); + }); + + test('a pass-through fetch handler, a push-only worker and no worker are not', () => { + expect(classifyServiceWorker("self.addEventListener('fetch', (e) => e.respondWith(fetch(e.request)))")).toEqual({ + offline: false, + reason: 'fetch handler without a cache', + }); + expect(classifyServiceWorker("self.addEventListener('push', (e) => self.registration.showNotification('hi'))")).toEqual({ + offline: false, + reason: 'no fetch handler', + }); + expect(classifyServiceWorker('')).toEqual({ offline: false, reason: 'no service worker' }); + }); + + const fake = (files, seen = []) => async (url) => { + seen.push(new URL(url).pathname); + const f = files[new URL(url).pathname]; + if (!f) return { url, status: 404, type: 'text/html', text: 'not found' }; + return { url, status: 200, type: f.type ?? 'text/javascript', text: f.text }; + }; + + test('detectOffline fetches the registered worker, once', async () => { + const seen = []; + const r = await detectOffline({ + html: "", + pageUrl: base, + fetchFn: fake({ '/app/worker.js': { text: 'precacheAndRoute(self.__WB_MANIFEST)' } }, seen), + }); + expect(r).toEqual({ offline: true, reason: 'workbox', sw: 'https://a.example/app/worker.js' }); + expect(seen).toEqual(['/app/worker.js']); + }); + + test('detectOffline falls back to the usual paths; an SPA index.html there is not a worker', async () => { + const found = await detectOffline({ + html: '

a bundle registers it

', + pageUrl: base, + fetchFn: fake({ '/service-worker.js': { text: "addEventListener('fetch',e=>e.respondWith(caches.match(e.request)))" } }), + }); + expect(found.offline).toBe(true); + expect(found.sw).toBe('https://a.example/service-worker.js'); + const spa = await detectOffline({ html: '

x

', pageUrl: base, fetchFn: fake({ '/sw.js': { type: 'text/html', text: '
' } }) }); + expect(spa).toEqual({ offline: false, reason: 'no service worker', sw: null }); + }); + + test('detectOffline reads a linked registration script and the manifest serviceworker', async () => { + const viaScript = await detectOffline({ + html: '', + pageUrl: base, + fetchFn: fake({ '/registerSW.js': { text: "navigator.serviceWorker.register('/my-sw.js',{scope:'/'})" }, '/my-sw.js': { text: 'workbox' } }), + }); + expect(viaScript).toEqual({ offline: true, reason: 'workbox', sw: 'https://a.example/my-sw.js' }); + const viaManifest = await detectOffline({ + html: '', + pageUrl: base, + manifest: { serviceworker: { src: 'w.js' } }, + manifestUrl: 'https://a.example/m/manifest.json', + fetchFn: fake({ '/m/w.js': { text: "self.addEventListener('fetch', e => {})" } }), + }); + expect(viaManifest).toEqual({ offline: false, reason: 'fetch handler without a cache', sw: 'https://a.example/m/w.js' }); + }); +});