From 72b0d2102af3c46ae1436705c216b9836a441ce6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 10:03:41 +0200 Subject: [PATCH] fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory - A queued write keeps its edits as steps: an edit continuing the last one joins it, one made behind a travelling write or on another tab's change stays apart, and a conflict replays them in order. Saved writes and early edits store the steps too. - The instance probe treats any refusal but 404 (a proxy's 429 or 403) as unreachable, so a browser keeps its remembered server. - Cache reads that browser storage refuses fall back to memory, so a re-read and later edits still work. Co-Authored-By: Claude Opus 5.5 --- .../server-instance-probe.spec.ts | 4 +- .../server-storage/server-instance-probe.ts | 15 ++-- docs/architecture/pwa-self-hosted.md | 24 ++++--- .../server-storage/server-unsent-writes.ts | 57 +++++++-------- .../server-storage/server-user-data-edit.ts | 31 ++++----- .../server-user-data-entries.ts | 41 ++++++++--- .../server-storage/server-user-data-queue.ts | 69 ++++++++++--------- .../server-user-data-storage.ts | 42 ++++++----- .../server-user-data-sync.spec.ts | 59 ++++++++++++++++ 9 files changed, 213 insertions(+), 129 deletions(-) diff --git a/apps/web/src/app/server-storage/server-instance-probe.spec.ts b/apps/web/src/app/server-storage/server-instance-probe.spec.ts index 65eed0607..682857f9a 100644 --- a/apps/web/src/app/server-storage/server-instance-probe.spec.ts +++ b/apps/web/src/app/server-storage/server-instance-probe.spec.ts @@ -74,10 +74,12 @@ describe('probeServerInstance', () => { } }); - it('reports an unreachable server for failures and 5xx', async () => { + it('reports an unreachable server for failures, 5xx and refusals', async () => { for (const fetchFn of [ jest.fn().mockRejectedValue(new TypeError('Failed to fetch')), answer('Bad Gateway', 502), + answer('Too Many Requests', 429), + answer('Forbidden', 403), // Headers arrived, then the body was cut off. jest.fn().mockResolvedValue({ ok: true, diff --git a/apps/web/src/app/server-storage/server-instance-probe.ts b/apps/web/src/app/server-storage/server-instance-probe.ts index e78338c3d..f95ba8f1c 100644 --- a/apps/web/src/app/server-storage/server-instance-probe.ts +++ b/apps/web/src/app/server-storage/server-instance-probe.ts @@ -6,9 +6,10 @@ const PROBE_TIMEOUT_MS = 2_500; /** * - `skipped`: no runtime backend URL, or Electron. - * - `server` / `none`: the backend answered. - * - `unreachable`: no answer, a timeout or a 5xx (nginx answers 502 while - * the backend restarts), so the result says nothing about storage. + * - `server` / `none`: the backend answered (a 404 is an older backend). + * - `unreachable`: no answer, a timeout, a 5xx (nginx answers 502 while + * the backend restarts) or another refusal such as a proxy's 429, so the + * result says nothing about storage. */ export type ServerInstanceProbe = | { readonly kind: 'skipped' | 'none' | 'unreachable' } @@ -52,13 +53,13 @@ export async function probeServerInstance( } catch { return { kind: 'unreachable' }; } - if (response.status >= 500) { - return { kind: 'unreachable' }; - } - if (!response.ok) { + if (response.status === 404) { // A backend from before server storage has no /v1 routes. return { kind: 'none' }; } + if (!response.ok) { + return { kind: 'unreachable' }; + } try { const body: unknown = await response.json(); return isServerInstance(body) && body.storage === 'server' diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index f7b128ea4..43e13cee6 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -179,8 +179,8 @@ deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__` `storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and merges `provideServerStorage()` into the app config, and remembers the instance under `iptvnator-server:instance`. When the probe gets no answer -(network error, timeout, or a 5xx such as nginx's 502 while the backend -restarts), a browser that remembers this backend still starts in server +(network error, timeout, a 5xx such as nginx's 502 while the backend +restarts, or a refusal other than 404, such as a proxy's 429), a browser that remembers this backend still starts in server mode, offline, and reloads once the server answers; it never falls back to an empty browser library. Until that reload it sends no writes (`holdWrites`): the address may lead to another database by then, which @@ -220,11 +220,15 @@ settings and guards read them in every build. as offline. Each write is sent on the tag of the entry it was made against (`UserDataWriteQueue`); after a `412` the edit (before → after) is replayed on the server's current value, so another device's change to the - same playlist's entry is kept. An edit queued behind a travelling write - keeps its own before → after and carries the travelling one as `prior` - (also in the saved writes): if that write was lost, a conflict replays - both in order, and if it landed, adding and then removing an item still - removes it. An edit made on another tab's unsent change, and an entry a + same playlist's entry is kept. A queued write keeps its edits as steps + (before → after, also in the saved writes): an edit continuing the last + one joins it, one made behind a travelling write or on another tab's + change is kept apart, and a conflict replays them in order. So a lost + write is not dropped, and adding and then removing an item still removes + it when the addition landed. When two tabs edit one entry before either + write lands, a removal of the other tab's unsent item holds if that tab's + write lands first, as it does when a tab is left (its writes go out on + hide); otherwise the item can come back. An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has (`UNKNOWN_TAG`), so they conflict and are replayed too. Page-hide writes report their new tag or conflict like any other, and one behind a travelling write is @@ -238,9 +242,9 @@ settings and guards read them in every build. registry in `localStorage` (`iptvnator-server:unconfirmed:`, one record per page load, renewed every 30 s while it has unsent writes, stale after ten minutes) lets a re-read in one tab keep the cache values - another tab is still sending. A value too big for - `localStorage` stays readable from memory, so callers and the diff - baseline agree. A re-read + another tab is still sending. A value too big for `localStorage`, or any + value when the browser refuses storage access, stays readable from + memory, so callers and the diff baseline agree. A re-read updates the cache; views already open show another device's changes on their next load (no live push). Start-up waits up to 3 s for the first read. diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.ts b/libs/services/src/lib/server-storage/server-unsent-writes.ts index 2195c75cf..7506b09e6 100644 --- a/libs/services/src/lib/server-storage/server-unsent-writes.ts +++ b/libs/services/src/lib/server-storage/server-unsent-writes.ts @@ -63,17 +63,18 @@ export function claimCache(local: UserDataStorage, instanceId: string): void { } } +/** Saved edits: JSON drops an undefined side, which reads back as such. */ +type Steps = readonly { readonly before: unknown; readonly after: unknown }[]; + export interface UnsentWrite { readonly namespace: ServerUserStateNamespace; readonly playlistId: string; /** `undefined` deletes the entry. */ readonly value: unknown; - /** The entry the edit was made against, to replay it on a conflict. */ - readonly before?: unknown; /** The server version it is based on (see `PendingEntry.tag`). */ readonly tag?: string | null; - /** A write sent before it, maybe saved (see `PendingEntry.prior`). */ - readonly prior?: { readonly before: unknown; readonly after: unknown }; + /** The edits since that version (see `PendingEntry.steps`). */ + readonly steps: Steps; } interface StoredWrite { @@ -81,12 +82,24 @@ interface StoredWrite { readonly playlistId: string; readonly value?: unknown; readonly remove?: true; - readonly before?: unknown; readonly tag?: string | null; - /** JSON drops an undefined side: absent reads back as undefined. */ - readonly prior?: { readonly before?: unknown; readonly after?: unknown }; + readonly steps?: unknown; } +function readSteps(stored: unknown): Steps { + return Array.isArray(stored) + ? stored + .filter((step) => step && typeof step === 'object') + .map((step: { before?: unknown; after?: unknown }) => ({ + before: step.before, + after: step.after, + })) + : []; +} + +const saveSteps = (steps: Steps) => + steps.map(({ before, after }) => ({ before, after })); + /** * Records `page` as the tab's current page load and returns the one * before it: a reload, whose saved writes this page takes over. @@ -160,15 +173,8 @@ export function readUnsentWrites( namespace: write.namespace, playlistId: write.playlistId, value: write.remove ? undefined : write.value, - before: write.before, tag: write.tag, - prior: - write.prior && typeof write.prior === 'object' - ? { - before: write.prior.before, - after: write.prior.after, - } - : undefined, + steps: readSteps(write.steps), }); } } @@ -187,15 +193,12 @@ export function saveUnsentWrites( return; } const stored: StoredWrite[] = writes.map( - ({ namespace, playlistId, value, before, tag, prior }) => ({ + ({ namespace, playlistId, value, tag, steps }) => ({ namespace, playlistId, ...(value === undefined ? { remove: true } : { value }), - ...(before === undefined ? {} : { before }), ...(tag === undefined ? {} : { tag }), - ...(prior === undefined - ? {} - : { prior: { before: prior.before, after: prior.after } }), + steps: saveSteps(steps), }) ); local.setItem(writesKey(instanceId, page), JSON.stringify(stored)); @@ -222,8 +225,7 @@ export function newestWrites( export interface SavedDeferredEdit { readonly namespace: ServerUserStateNamespace; readonly playlistId: string; - readonly before: unknown; - readonly after: unknown; + readonly steps: Steps; } export function readDeferredEdits( @@ -250,12 +252,10 @@ export function readDeferredEdits( isServerUserStateNamespace(namespace) && typeof playlistId === 'string' ) { - // JSON has no undefined: absent sides are flagged. edits.push({ namespace, playlistId, - before: edit['noBefore'] ? undefined : edit['before'], - after: edit['noAfter'] ? undefined : edit['after'], + steps: readSteps(edit['steps']), }); } } @@ -279,12 +279,7 @@ export function saveDeferredEdits( edits.map((edit) => ({ namespace: edit.namespace, playlistId: edit.playlistId, - ...(edit.before === undefined - ? { noBefore: true } - : { before: edit.before }), - ...(edit.after === undefined - ? { noAfter: true } - : { after: edit.after }), + steps: saveSteps(edit.steps), })) ) ); diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.ts b/libs/services/src/lib/server-storage/server-user-data-edit.ts index 2f0c52ab3..67527889f 100644 --- a/libs/services/src/lib/server-storage/server-user-data-edit.ts +++ b/libs/services/src/lib/server-storage/server-user-data-edit.ts @@ -1,12 +1,12 @@ import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; import { itemKey } from './server-migration-merge'; +import type { EditStep } from './server-user-data-entries'; -/** An edit made before the first read, applied once the server's value is known. */ +/** Edits made before the first read, applied once the server's value is known. */ export interface DeferredEdit { readonly namespace: ServerUserStateNamespace; readonly playlistId: string; - readonly before: unknown; - readonly after: unknown; + readonly steps: readonly EditStep[]; } /** @@ -29,11 +29,7 @@ export function refreshedNamespace( } } for (const edit of deferred) { - const merged = applyEdit( - server[edit.playlistId], - edit.before, - edit.after - ); + const merged = replaySteps(server[edit.playlistId], edit.steps); if (merged === undefined) { delete value[edit.playlistId]; } else { @@ -115,17 +111,14 @@ function isListOrMissing(value: unknown): value is unknown[] | undefined { return value === undefined || Array.isArray(value); } -/** A saved write's edits, its `prior` first, applied to `server`. */ -export function replayWrite( +/** Edits replayed on `server` in order. */ +export function replaySteps( server: unknown, - write: { - readonly before?: unknown; - readonly value: unknown; - readonly prior?: { readonly before: unknown; readonly after: unknown }; - } + steps: readonly { readonly before: unknown; readonly after: unknown }[] ): unknown { - const base = write.prior - ? applyEdit(server, write.prior.before, write.prior.after) - : server; - return applyEdit(base, write.before, write.value); + let value = server; + for (const step of steps) { + value = applyEdit(value, step.before, step.after); + } + return value; } diff --git a/libs/services/src/lib/server-storage/server-user-data-entries.ts b/libs/services/src/lib/server-storage/server-user-data-entries.ts index cc088c40d..66008853d 100644 --- a/libs/services/src/lib/server-storage/server-user-data-entries.ts +++ b/libs/services/src/lib/server-storage/server-user-data-entries.ts @@ -8,26 +8,47 @@ export interface PendingEntry { readonly value: unknown; /** The edit this value came from; a retry never replaces a newer one. */ readonly edit: number; - /** The entry as the edit found it, to replay the edit on a conflict. */ - readonly before: unknown; /** - * The server version the edit is based on: a tag, `null` for an entry + * The server version `value` is based on: a tag, `null` for an entry * seen absent, `undefined` when unknown (an unconditional write). */ readonly tag?: string | null; /** - * The edit of the write this one was queued behind, which may or may - * not have reached the server: a conflict replays it first. Kept apart - * from this edit, so adding and then removing an item is not one no-op. + * The edits since that version, oldest first: a conflict replays them + * on the server's value in order. Kept apart where they cannot be one + * before → after (another tab's change in between, or a write that may + * or may not have landed), so adding and then removing an item is + * never a no-op. */ - readonly prior?: PriorEdit; + readonly steps: readonly EditStep[]; } -export interface PriorEdit { +/** One edit as it was made: the entry before and after it. */ +export interface EditStep { readonly before: unknown; readonly after: unknown; - /** The write it was sent with; `0` after a reload. */ - readonly edit: number; + /** The travelling write that carries it, until that one is answered. */ + readonly sent?: number; +} + +/** + * `steps` plus the edit `before` → `after`: joined with the last step when + * it continues it, kept apart otherwise. + */ +export function addStep( + steps: readonly EditStep[], + before: unknown, + after: unknown +): EditStep[] { + const last = steps[steps.length - 1]; + if ( + last && + last.sent === undefined && + JSON.stringify(last.after) === JSON.stringify(before) + ) { + return [...steps.slice(0, -1), { before: last.before, after }]; + } + return [...steps, { before, after }]; } /** diff --git a/libs/services/src/lib/server-storage/server-user-data-queue.ts b/libs/services/src/lib/server-storage/server-user-data-queue.ts index f31731bdf..21120fd4c 100644 --- a/libs/services/src/lib/server-storage/server-user-data-queue.ts +++ b/libs/services/src/lib/server-storage/server-user-data-queue.ts @@ -7,12 +7,13 @@ import { ServerApiError, type ServerApiClient } from './server-api-client'; import { entryKey, entryPath, + addStep, + type EditStep, type PendingEntry, preconditionHeaders, - type PriorEdit, UNKNOWN_TAG, } from './server-user-data-entries'; -import { replayWrite } from './server-user-data-edit'; +import { replaySteps } from './server-user-data-edit'; import { newestWrites } from './server-unsent-writes'; const FLUSH_DELAY_MS = 500; @@ -118,10 +119,9 @@ export class UserDataWriteQueue { /** * Queues an edit (`before` → `value`). A newer edit of an entry still - * queued joins it; one behind a travelling write keeps its own edit, - * carries the write's as `prior` and is based on the write's tag. A - * `foreign` edit was made on another tab's unsent change: its write - * must conflict and be replayed. + * queued joins it; one behind a travelling write keeps that write's + * steps (marked as sent) and tag. A `foreign` edit was made on another + * tab's unsent change: its write must conflict and be replayed. */ queue( namespace: ServerUserStateNamespace, @@ -130,7 +130,7 @@ export class UserDataWriteQueue { before: unknown, base: { readonly tag?: string | null; - readonly prior?: Omit; + readonly steps?: readonly EditStep[]; readonly foreign?: boolean; } = {} ): void { @@ -140,32 +140,37 @@ export class UserDataWriteQueue { const edit = ++this.editCount; let entry: PendingEntry; if (queued) { - entry = { ...queued, value, edit }; + entry = { + ...queued, + value, + edit, + steps: addStep(queued.steps, before, value), + }; } else if (travelling) { + const sent = travelling.steps.map((step) => ({ + ...step, + sent: travelling.edit, + })); entry = { namespace, playlistId, value, edit, - before, tag: travelling.tag, - prior: { - before: travelling.prior - ? travelling.prior.before - : travelling.before, - after: travelling.value, - edit: travelling.edit, - }, + steps: [...sent, { before, after: value }], }; } else { - const tag = - base.tag !== undefined - ? base.tag - : this.tagFor(namespace, playlistId); - entry = { namespace, playlistId, value, edit, before, tag }; - if (base.prior) { - entry = { ...entry, prior: { ...base.prior, edit: 0 } }; - } + entry = { + namespace, + playlistId, + value, + edit, + tag: + base.tag !== undefined + ? base.tag + : this.tagFor(namespace, playlistId), + steps: base.steps ?? [{ before, after: value }], + }; } if (base.foreign) { entry = { ...entry, tag: UNKNOWN_TAG }; @@ -367,7 +372,7 @@ export class UserDataWriteQueue { )); this.retireInFlight(entry); if (outcome.status === 'saved') { - // Without a tag a newer edit keeps this one as `prior`. + // Without a tag a newer edit keeps this one's steps. if (outcome.tag !== undefined) { this.confirmed(entry, outcome.tag); } @@ -422,11 +427,11 @@ export class UserDataWriteQueue { this.setTag(entry.namespace, entry.playlistId, tag); const key = entryKey(entry); const next = this.pending.get(key); - if (next?.prior?.edit === entry.edit) { + if (next?.steps.some((step) => step.sent === entry.edit)) { // A foreign edit stays unknown: its baseline is not this value. this.pending.set(key, { ...next, - prior: undefined, + steps: next.steps.filter((step) => step.sent !== entry.edit), tag: next.tag === UNKNOWN_TAG ? UNKNOWN_TAG : tag, }); } @@ -434,9 +439,8 @@ export class UserDataWriteQueue { /** * Another device changed the entry and `entry` did not land: replay its - * edits on the server's current value (with the newer queued edit that - * carries it as `prior`, if any), show the result and send it on that - * version. + * steps on the server's current value (with the newer queued edit that + * carries them, if any), show the result and send it on that version. */ private replayOnConflict( entry: PendingEntry, @@ -450,7 +454,7 @@ export class UserDataWriteQueue { if (!latest) { return; } - const merged = replayWrite(current.value, latest); + const merged = replaySteps(current.value, latest.steps); this.hooks.show(entry.namespace, entry.playlistId, merged); if ( latest === entry && @@ -461,9 +465,8 @@ export class UserDataWriteQueue { this.pending.set(key, { ...latest, value: merged, - before: current.value, tag: current.tag, - prior: undefined, + steps: [{ before: current.value, after: merged }], }); this.scheduleFlush(); } diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index 86d8860c6..9b9376bd0 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -7,6 +7,7 @@ import { import type { UserDataStorage } from '../user-data-storage'; import type { ServerApiClient } from './server-api-client'; import { + addStep, parseMap, splitEditKey, UNKNOWN_TAG, @@ -14,7 +15,7 @@ import { import { type DeferredEdit, refreshedNamespace, - replayWrite, + replaySteps, } from './server-user-data-edit'; import { UserDataWriteQueue } from './server-user-data-queue'; import { @@ -50,9 +51,9 @@ export class ServerUserDataStorage implements UserDataStorage { private readonly writes: UserDataWriteQueue; private unsentSaved = false; /** - * Edits made before the first successful read, as before → after: the - * cache they were made against may be empty or old, so they are - * applied to the server's value once it is known (`applyEdit`). + * Edits made before the first successful read: the cache they were + * made against may be empty or old, so they are replayed on the + * server's value once it is known (`applyEdit`). */ private readonly deferred = new Map(); private hydrated = false; @@ -102,8 +103,8 @@ export class ServerUserDataStorage implements UserDataStorage { write.namespace, write.playlistId, write.value, - write.before, - { tag: write.tag, prior: write.prior } + undefined, + { tag: write.tag, steps: write.steps } ); } for (const edit of readDeferredEdits( @@ -131,15 +132,13 @@ export class ServerUserDataStorage implements UserDataStorage { this.tabId )) { const edits = [ - ...readDeferredEdits(this.local, this.instanceId, page).map( - (edit) => ({ ...edit, value: edit.after }) - ), + ...readDeferredEdits(this.local, this.instanceId, page), ...readUnsentWrites(this.local, this.instanceId, page), ]; for (const edit of edits) { const before = this.baseline(edit.namespace); const after = { ...before }; - const value = replayWrite(before[edit.playlistId], edit); + const value = replaySteps(before[edit.playlistId], edit.steps); if (value === undefined) { delete after[edit.playlistId]; } else { @@ -323,11 +322,17 @@ export class ServerUserDataStorage implements UserDataStorage { this.adoptClosedTabs(); } + /** Memory first; browser storage that refuses access reads as empty. */ private readCache(namespace: ServerUserStateNamespace): string | null { - return ( - this.overflow.get(namespace) ?? - this.local.getItem(SERVER_CACHE_PREFIX + namespace) - ); + const kept = this.overflow.get(namespace); + if (kept !== undefined) { + return kept; + } + try { + return this.local.getItem(SERVER_CACHE_PREFIX + namespace); + } catch { + return null; + } } /** `null` empties the entry. Browser storage first, memory if full. */ @@ -398,13 +403,14 @@ export class ServerUserDataStorage implements UserDataStorage { return; } const key = `${namespace}|${playlistId}`; - const earlier = this.deferred.get(key); this.deferred.set(key, { namespace, playlistId, - // An earlier edit's baseline stays, even when it was empty. - before: earlier ? earlier.before : previous[playlistId], - after: value, + steps: addStep( + this.deferred.get(key)?.steps ?? [], + previous[playlistId], + value + ), }); this.saveDeferred(); this.publishUnconfirmed(); diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts index cc61185b8..d598f233e 100644 --- a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -534,4 +534,63 @@ describe('ServerUserDataStorage with other devices', () => { ) ).toEqual([]); }); + + it("keeps a removal of another tab's unsent item", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + tabA.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...favorites(tabB).p1, 'Y'] }) + ); + // Tab A removes the Y it sees from tab B before sending anything; + // tab B's write lands first (leaving a tab sends its writes). + tabA.setItem( + 'xtream-favorites', + JSON.stringify({ + p1: favorites(tabA).p1.filter((id: string) => id !== 'Y'), + }) + ); + await tabB.flush(); + await tabB.flush(); + await tabA.flush(); + await tabA.flush(); + + expect(tabA.hasUnsentWrites() || tabB.hasUnsentWrites()).toBe(false); + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X']); + }); + + it('reads and writes through memory when browser storage refuses', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const denied = () => { + throw new DOMException('denied', 'SecurityError'); + }; + const phone = new ServerUserDataStorage( + server.api as never, + { getItem: denied, setItem: denied, removeItem: denied }, + jest.fn() + ); + + await phone.hydrate(); + expect(favorites(phone)).toEqual({ p1: ['S'] }); + phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] })); + await phone.flush(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); });