@linxin666/dsh-remote-web-ui 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/lib/client.js +125 -6
- package/lib/client.js.map +1 -1
- package/lib/index.js +73 -5
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +142 -8
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +46 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +43 -0
- package/lib/types/remote-api.d.ts +13 -1
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +14 -1
- package/package.json +19 -19
- package/src/client/mobile-adapt.ts +144 -5
- package/src/index.ts +46 -12
- package/src/pairing.ts +44 -1
- package/src/remote-api.ts +14 -1
|
@@ -54,6 +54,17 @@ const ADAPT_CSS_ID = 'dsh-remote-web-ui/mobile-adapt.css'
|
|
|
54
54
|
const ACTIVE_CLASS = 'dsh-remote-portrait'
|
|
55
55
|
/** Body class while the collapsed rail is hidden behind the whale. */
|
|
56
56
|
const RAIL_HIDDEN_CLASS = 'dsh-remote-rail-hidden'
|
|
57
|
+
/**
|
|
58
|
+
* The official sidebar column root. Also the scope of the row sweep: every row
|
|
59
|
+
* the drag suppression covers is a descendant of it, so the document-wide scan
|
|
60
|
+
* it replaces only ever returned these.
|
|
61
|
+
*/
|
|
62
|
+
const SIDEBAR_SELECTOR = '[class*="_sidebarCol"]'
|
|
63
|
+
/** The official draggable session/project rows (see disableRowDrag). */
|
|
64
|
+
const ROW_SELECTOR = '[class*="_sessionRow"], [class*="_projectRow"]'
|
|
65
|
+
/** The injected stylesheet, addressed through the same cached lookup. */
|
|
66
|
+
const STYLE_SELECTOR = `style[data-plugin-css="${ADAPT_CSS_ID}"]`
|
|
67
|
+
|
|
57
68
|
/** Whale button id. */
|
|
58
69
|
const WHALE_ID = 'dshRemoteWhale'
|
|
59
70
|
/** Compact picker: synthesized model button id. */
|
|
@@ -346,6 +357,8 @@ export function startMobileAdapt(): void {
|
|
|
346
357
|
let whaleObserver: MutationObserver | null = null
|
|
347
358
|
/** Header subtree observer: marks the geometry measurement dirty on re-render. */
|
|
348
359
|
let headerObserver: MutationObserver | null = null
|
|
360
|
+
/** Document child-list observer: invalidates the cached-absent selector set. */
|
|
361
|
+
let domObserver: MutationObserver | null = null
|
|
349
362
|
let observedHeader: Element | null = null
|
|
350
363
|
/** Whether the seated-actions geometry needs re-measuring (see alignActionsText). */
|
|
351
364
|
let headerGeometryDirty = true
|
|
@@ -368,17 +381,28 @@ export function startMobileAdapt(): void {
|
|
|
368
381
|
* rail compaction) while the body class stays.
|
|
369
382
|
*/
|
|
370
383
|
function ensureAdaptStyle(): void {
|
|
371
|
-
if (nodeOf(
|
|
384
|
+
if (nodeOf(STYLE_SELECTOR) !== null) return
|
|
372
385
|
const tag = document.createElement('style')
|
|
373
386
|
tag.dataset.plugin = 'remote-web-ui'
|
|
374
387
|
tag.dataset.pluginCss = ADAPT_CSS_ID
|
|
375
388
|
tag.textContent = ADAPT_CSS.join('')
|
|
376
389
|
document.head.appendChild(tag)
|
|
390
|
+
// Seed the lookup cache: the tag is created here, outside nodeOf, so the
|
|
391
|
+
// negative cache could otherwise report it missing for one more tick and
|
|
392
|
+
// stack a duplicate stylesheet.
|
|
393
|
+
nodeCache.set(STYLE_SELECTOR, tag)
|
|
394
|
+
nodeMissCache.delete(STYLE_SELECTOR)
|
|
377
395
|
}
|
|
378
396
|
|
|
379
397
|
function apply(): void {
|
|
380
398
|
if (active) return
|
|
381
399
|
active = true
|
|
400
|
+
// First statement of the activation, before any lookup: nothing observed the
|
|
401
|
+
// document while the layer was reverted (the observer is created below), so
|
|
402
|
+
// no "absent" verdict recorded earlier may be served now. Clearing here
|
|
403
|
+
// rather than beside the observer also covers the lookups this path runs
|
|
404
|
+
// before that point.
|
|
405
|
+
forgetAbsentSelectors()
|
|
382
406
|
document.body.classList.add(ACTIVE_CLASS)
|
|
383
407
|
// A details panel opened before the viewport rotated into portrait (or
|
|
384
408
|
// restored across reloads) would sit behind the display:none above;
|
|
@@ -397,6 +421,7 @@ export function startMobileAdapt(): void {
|
|
|
397
421
|
meta.setAttribute('content', `${savedViewportContent}, viewport-fit=cover`)
|
|
398
422
|
}
|
|
399
423
|
ensureWhale()
|
|
424
|
+
ensureDomObserver()
|
|
400
425
|
syncWhale()
|
|
401
426
|
// React builds nodes with attributes before inserting them, so
|
|
402
427
|
// attribute observers miss the initial collapsed state — a light
|
|
@@ -429,6 +454,45 @@ export function startMobileAdapt(): void {
|
|
|
429
454
|
whaleObserver.disconnect()
|
|
430
455
|
whaleObserver = null
|
|
431
456
|
}
|
|
457
|
+
if (domObserver !== null) {
|
|
458
|
+
domObserver.disconnect()
|
|
459
|
+
domObserver = null
|
|
460
|
+
}
|
|
461
|
+
// Nothing observes the document from here on; a verdict recorded now could
|
|
462
|
+
// not be trusted after the next apply().
|
|
463
|
+
forgetAbsentSelectors()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Observe the document for the two kinds of change that can make a cached-absent
|
|
468
|
+
* target discoverable again (see nodeOf): node insertion/removal, and the
|
|
469
|
+
* class / compat-stamp attributes the cached selectors match on.
|
|
470
|
+
*
|
|
471
|
+
* Body class writes are excluded by target: the layer toggles three body
|
|
472
|
+
* classes every tick, and counting those would invalidate the negative cache
|
|
473
|
+
* on every tick and restore the very scan this cache removes. They cannot
|
|
474
|
+
* create a target either — every cached selector matches an element other
|
|
475
|
+
* than <body>.
|
|
476
|
+
*
|
|
477
|
+
* The record queue is drained on a microtask, so this stays off the layout
|
|
478
|
+
* path; a chat turn's insertions are a handful per second, not per element.
|
|
479
|
+
*/
|
|
480
|
+
function ensureDomObserver(): void {
|
|
481
|
+
if (domObserver !== null || typeof MutationObserver === 'undefined' || !document.body) return
|
|
482
|
+
domObserver = new MutationObserver((records) => {
|
|
483
|
+
for (const record of records) {
|
|
484
|
+
if (record.type === 'childList' || record.target !== document.body) {
|
|
485
|
+
noteDomChange()
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
domObserver.observe(document.body, {
|
|
491
|
+
childList: true,
|
|
492
|
+
subtree: true,
|
|
493
|
+
attributes: true,
|
|
494
|
+
attributeFilter: ['class', 'data-dsh-frame'],
|
|
495
|
+
})
|
|
432
496
|
}
|
|
433
497
|
|
|
434
498
|
/** Start/stop the 600ms sync tick; a no-op when already in the asked state. */
|
|
@@ -702,7 +766,7 @@ export function startMobileAdapt(): void {
|
|
|
702
766
|
const dragOverridden = new Map<Element, string | null>()
|
|
703
767
|
function disableRowDrag(): void {
|
|
704
768
|
if (!active) return
|
|
705
|
-
const rows =
|
|
769
|
+
const rows = sidebarRows()
|
|
706
770
|
for (const row of rows) {
|
|
707
771
|
if (row.getAttribute('draggable') !== 'false') {
|
|
708
772
|
if (!dragOverridden.has(row)) dragOverridden.set(row, row.getAttribute('draggable'))
|
|
@@ -731,15 +795,90 @@ export function startMobileAdapt(): void {
|
|
|
731
795
|
* node on a major re-render, which the isConnected guard detects.
|
|
732
796
|
*/
|
|
733
797
|
const nodeCache = new Map<string, Element>()
|
|
798
|
+
/**
|
|
799
|
+
* Selectors known to be absent as of {@link domGeneration}. A miss is the
|
|
800
|
+
* common case, not the exception: the layer holds no overlay of its own, and
|
|
801
|
+
* several official surfaces it looks for (tabs row, tools row, sidebar rows)
|
|
802
|
+
* legitimately do not exist at once. Re-walking a conversation-sized document
|
|
803
|
+
* for the same absent selector on every 600ms tick was waste that almost
|
|
804
|
+
* always resolved to null: five document-wide lookups per tick on the measured
|
|
805
|
+
* fixture, zero after this cache. That is a lookup count — the wall-clock
|
|
806
|
+
* saving is real while the mounted content is quiet and shrinks to the
|
|
807
|
+
* row-scope saving when a turn streams (see the Agent Note for the harness and
|
|
808
|
+
* the before/after numbers).
|
|
809
|
+
*
|
|
810
|
+
* A miss is trusted only while the observed content has not changed, so a
|
|
811
|
+
* surface that appears is still found on the very next tick — the same
|
|
812
|
+
* discovery latency the unconditional probe had. The cache is never aged by a
|
|
813
|
+
* clock: it is cleared by {@link noteDomChange} while the observer is live and
|
|
814
|
+
* by {@link forgetAbsentSelectors} across the windows where it is not (apply
|
|
815
|
+
* and revert). One blind spot is deliberate and load-bearing: the observer
|
|
816
|
+
* watches <body>, so a head-resident target is not covered by that signal —
|
|
817
|
+
* the one such target is seeded into the cache where it is created (see
|
|
818
|
+
* ensureAdaptStyle).
|
|
819
|
+
*/
|
|
820
|
+
const nodeMissCache = new Set<string>()
|
|
821
|
+
/**
|
|
822
|
+
* Bumped whenever a cached target may have appeared or disappeared. A surface
|
|
823
|
+
* qualifies on a class token or on the aggregate compat stamp
|
|
824
|
+
* (`data-dsh-frame`), so both insertions/removals AND those two attributes
|
|
825
|
+
* can change the answer. Nothing the layer writes per tick lands here: its own
|
|
826
|
+
* class writes are all on <body>, and its row/transform/label writes touch
|
|
827
|
+
* other attributes entirely (see ensureDomObserver).
|
|
828
|
+
*/
|
|
829
|
+
let domGeneration = 0
|
|
830
|
+
/** The generation the negative cache was recorded against. */
|
|
831
|
+
let missGeneration = -1
|
|
832
|
+
|
|
833
|
+
function noteDomChange(): void {
|
|
834
|
+
domGeneration += 1
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Drop every cached "absent" verdict. Called on apply and revert: while the
|
|
839
|
+
* observer is disconnected the layer is blind to insertions, so a verdict
|
|
840
|
+
* recorded before the gap could otherwise outlive the change that falsified
|
|
841
|
+
* it and be served without a probe. The positive cache needs no equivalent —
|
|
842
|
+
* its `isConnected` guard already re-resolves a replaced node.
|
|
843
|
+
*/
|
|
844
|
+
function forgetAbsentSelectors(): void {
|
|
845
|
+
nodeMissCache.clear()
|
|
846
|
+
domGeneration += 1
|
|
847
|
+
}
|
|
848
|
+
|
|
734
849
|
function nodeOf(selector: string): Element | null {
|
|
735
850
|
const cached = nodeCache.get(selector)
|
|
736
|
-
if (cached !== undefined
|
|
851
|
+
if (cached !== undefined) {
|
|
852
|
+
// A node React replaced is no longer connected; drop it and fall through
|
|
853
|
+
// so the lookup below can find its replacement.
|
|
854
|
+
if (cached.isConnected) return cached
|
|
855
|
+
nodeCache.delete(selector)
|
|
856
|
+
}
|
|
857
|
+
if (missGeneration !== domGeneration) {
|
|
858
|
+
nodeMissCache.clear()
|
|
859
|
+
missGeneration = domGeneration
|
|
860
|
+
}
|
|
861
|
+
if (nodeMissCache.has(selector)) return null
|
|
737
862
|
const found = document.querySelector(selector)
|
|
738
|
-
if (found === null)
|
|
739
|
-
|
|
863
|
+
if (found === null) {
|
|
864
|
+
nodeMissCache.add(selector)
|
|
865
|
+
return null
|
|
866
|
+
}
|
|
867
|
+
nodeCache.set(selector, found)
|
|
740
868
|
return found
|
|
741
869
|
}
|
|
742
870
|
|
|
871
|
+
/**
|
|
872
|
+
* The session/project rows the drag suppression covers, scoped to the cached
|
|
873
|
+
* sidebar root. Each row carries the sidebar column class as an ancestor, so
|
|
874
|
+
* the document-wide scan this replaces only ever returned rows from here.
|
|
875
|
+
*/
|
|
876
|
+
function sidebarRows(): Element[] {
|
|
877
|
+
const sidebar = nodeOf(SIDEBAR_SELECTOR)
|
|
878
|
+
if (sidebar === null) return []
|
|
879
|
+
return Array.from(sidebar.querySelectorAll(ROW_SELECTOR))
|
|
880
|
+
}
|
|
881
|
+
|
|
743
882
|
/** The official application frame, through the same cached lookup. */
|
|
744
883
|
function frameEl(): HTMLElement | null {
|
|
745
884
|
const frame = nodeOf(APP_FRAME_SELECTOR)
|
package/src/index.ts
CHANGED
|
@@ -736,18 +736,22 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
|
|
|
736
736
|
console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
|
|
737
737
|
}
|
|
738
738
|
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
739
|
+
// LAN bind toggle: only write the managed patch block once the user has
|
|
740
|
+
// flipped it (undefined = untouched, never write). Desired host/port come
|
|
741
|
+
// from the CLI flags when given (flags win), else from the toggle and
|
|
742
|
+
// the currently bound port. The block takes effect on the next start, so
|
|
743
|
+
// the re-assert at every boot keeps it in sync with both the toggle and
|
|
744
|
+
// the flags.
|
|
745
|
+
//
|
|
746
|
+
// This work must leave the caller's async context first: a settings save
|
|
747
|
+
// runs inside the Host's hmr.runExclusive, and cordis.patch.yml is exactly
|
|
748
|
+
// the file the HMR config watcher refreshes from. A write issued on that
|
|
749
|
+
// same context makes the watcher's refresh re-enter runExclusive, which
|
|
750
|
+
// rejects with "HMR transactions cannot be nested" and fails the user's
|
|
751
|
+
// save (issue #1751). setImmediate starts a fresh AsyncLocalStorage store,
|
|
752
|
+
// so the assertion runs outside the transaction; it is idempotent, so the
|
|
753
|
+
// coalesced follow-up sync() re-reading the same block writes nothing.
|
|
754
|
+
const applyLanBindWork = (value: ResolvedConfig): void => {
|
|
751
755
|
if (value.lanBind !== undefined) {
|
|
752
756
|
const startup = ctx.get('webStartup') as StartupFacts | undefined
|
|
753
757
|
const desiredHost = desiredBindHost(value.lanBind === true, startup?.host)
|
|
@@ -785,6 +789,36 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
|
|
|
785
789
|
}
|
|
786
790
|
}
|
|
787
791
|
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// The LAN bind assertion never blocks the caller: one pending work item is
|
|
795
|
+
// enough, and it always re-reads the config through `resolve()` at run time
|
|
796
|
+
// so a toggle flipped twice before the tick lands on the last value.
|
|
797
|
+
let lanBindWorkPending = false
|
|
798
|
+
const scheduleLanBindWork = (): void => {
|
|
799
|
+
if (lanBindWorkPending) return
|
|
800
|
+
lanBindWorkPending = true
|
|
801
|
+
setImmediate(() => {
|
|
802
|
+
lanBindWorkPending = false
|
|
803
|
+
try {
|
|
804
|
+
applyLanBindWork(resolve())
|
|
805
|
+
} catch (error) {
|
|
806
|
+
// A deferred assertion must never surface as an unhandled rejection:
|
|
807
|
+
// the settings save that triggered it already answered, and the card
|
|
808
|
+
// reads the live block state back on its own poll.
|
|
809
|
+
console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
810
|
+
}
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
|
|
814
|
+
|
|
815
|
+
// Apply the effective configuration to the running surfaces once per
|
|
816
|
+
// activation: the service tunables, the LAN-bind block/firewall, the tunnel
|
|
817
|
+
// plan, the pairing routes, the presence sweep, and the posture probe.
|
|
818
|
+
const sync = (): void => {
|
|
819
|
+
const value = resolve()
|
|
820
|
+
service.config = pairingConfigOf(value)
|
|
821
|
+
scheduleLanBindWork()
|
|
788
822
|
// The plugin-managed tunnels own the public base while one runs: the URL
|
|
789
823
|
// lands in the service through the tunnel's phase listener (the minted
|
|
790
824
|
// quick URL, or the named tunnel's fixed public hostname). The manual
|
package/src/pairing.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { randomBytes } from 'node:crypto'
|
|
25
25
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
26
26
|
import { dirname } from 'node:path'
|
|
27
|
-
import type { PostureSnapshot } from './posture.ts'
|
|
27
|
+
import type { PostureHost, PostureSnapshot } from './posture.ts'
|
|
28
28
|
|
|
29
29
|
/** The observable pairing phases the panel renders. */
|
|
30
30
|
export type PairingPhase =
|
|
@@ -663,6 +663,7 @@ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
|
|
|
663
663
|
&& a.publicUrl === b.publicUrl
|
|
664
664
|
&& tunnelEqual(a.tunnel, b.tunnel)
|
|
665
665
|
&& relayEqual(a.relay, b.relay)
|
|
666
|
+
&& postureEqual(a.posture, b.posture)
|
|
666
667
|
&& a.tokenId === b.tokenId
|
|
667
668
|
&& a.tokenExpiresAt === b.tokenExpiresAt
|
|
668
669
|
&& a.deviceCount === b.deviceCount
|
|
@@ -695,6 +696,48 @@ function relayEqual(a: RelayStatus | undefined, b: RelayStatus | undefined): boo
|
|
|
695
696
|
&& a.state === b.state && a.url === b.url && a.error === b.error)
|
|
696
697
|
}
|
|
697
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Posture equality. A probe round can change this frame while every other field
|
|
701
|
+
* stays put — the common case, since the probe re-runs on a settings change and
|
|
702
|
+
* on a tunnel reaching running, and a quick-tunnel restart churns the probed
|
|
703
|
+
* host set. Leaving the frame out of the comparison suppressed those emits, so
|
|
704
|
+
* the loopback SSE stream and the desktop panel could sit on a stale "exposed"
|
|
705
|
+
* (or stale "clean") verdict for as long as the phase and roster stayed put.
|
|
706
|
+
*/
|
|
707
|
+
function postureEqual(a: PostureSnapshot | undefined, b: PostureSnapshot | undefined): boolean {
|
|
708
|
+
if (a === b) return true
|
|
709
|
+
// A frame that is not an object at all (a hand-built context can pass
|
|
710
|
+
// anything) counts as changed instead of throwing out of the setter.
|
|
711
|
+
if (!isPostureFrame(a) || !isPostureFrame(b)) return false
|
|
712
|
+
return a.checkedAt === b.checkedAt && hostsEqual(a.hosts, b.hosts)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Whether a value has the shape a posture frame is compared on. */
|
|
716
|
+
function isPostureFrame(value: unknown): value is PostureSnapshot {
|
|
717
|
+
return typeof value === 'object' && value !== null
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Per-host posture equality (a round's verdicts, in probe order). Tolerates a
|
|
722
|
+
* frame that is not the expected shape: this runs inside the emit path, outside
|
|
723
|
+
* the listener error containment, so a malformed snapshot handed in by another
|
|
724
|
+
* host-fiber caller must degrade to "changed" instead of throwing out of the
|
|
725
|
+
* setter that produced it.
|
|
726
|
+
*/
|
|
727
|
+
function hostsEqual(a: readonly PostureHost[] | undefined, b: readonly PostureHost[] | undefined): boolean {
|
|
728
|
+
if (a === b) return true
|
|
729
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
730
|
+
return a.length === b.length && a.every((host, index) => {
|
|
731
|
+
const other = b[index]
|
|
732
|
+
return isPostureHost(host) && isPostureHost(other) && host.host === other.host && host.exposed === other.exposed
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Whether a value has the shape one probed host is compared on. */
|
|
737
|
+
function isPostureHost(value: unknown): value is PostureHost {
|
|
738
|
+
return typeof value === 'object' && value !== null
|
|
739
|
+
}
|
|
740
|
+
|
|
698
741
|
/** Element-wise string list equality (interface order is meaningful). */
|
|
699
742
|
function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
700
743
|
return a.length === b.length && a.every((value, index) => value === b[index])
|
package/src/remote-api.ts
CHANGED
|
@@ -182,7 +182,19 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
|
-
* Map one outer upgrade URL onto the loopback path
|
|
185
|
+
* Map one outer upgrade URL onto the loopback path, dropping the cookieless
|
|
186
|
+
* device credential on the way in.
|
|
187
|
+
*
|
|
188
|
+
* The outer handshake carries the device id as `?device=<id>` because WebSocket
|
|
189
|
+
* handshakes cannot carry headers from the Web API. That id is a live session
|
|
190
|
+
* credential — it opens the full host API through this same channel — and the
|
|
191
|
+
* inner leg is a request the rest of the loopback origin sees (on a WebSocket
|
|
192
|
+
* leg, a raw handshake request line written to a loopback socket), so forwarding
|
|
193
|
+
* it handed a working credential to every other observer of the local port for
|
|
194
|
+
* no benefit: the gate above has already authenticated the caller, and no
|
|
195
|
+
* upstream route reads the parameter (the gateway mux matches on path alone).
|
|
196
|
+
* The rest of the query rides through unchanged, so a caller's own parameters
|
|
197
|
+
* still reach the upstream.
|
|
186
198
|
*/
|
|
187
199
|
export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: string): string {
|
|
188
200
|
if (reqUrl === undefined || reqUrl === '') return fallbackPath
|
|
@@ -194,6 +206,7 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
|
|
|
194
206
|
}
|
|
195
207
|
const inner = innerPathOf(url.pathname)
|
|
196
208
|
if (inner === undefined) return fallbackPath
|
|
209
|
+
url.searchParams.delete(REMOTE_DEVICE_QUERY)
|
|
197
210
|
return `${inner}${url.search}`
|
|
198
211
|
}
|
|
199
212
|
|