Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion packages/local-edge/docs/loader-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ unsubscribe?.()
| `openNetwork()` | Navigate to `networkUrl()` |
| `paths` | Read the derived scope, worker, descriptor, and control paths |
| `debug.*` | Read, subscribe to, and change diagnostics state without navigation |
| `debug.setUrlWriter(writer)` | Register how `__fwa_debug` is removed from the URL after a diagnostics change; `null` restores the default |

The browser fallback renders reset as a same-origin form navigation. The kernel accepts an exact same-origin `Origin`; for navigation POSTs where iOS omits `Origin`, it accepts browser-controlled `Sec-Fetch-Site: same-origin`, or an exact same-origin `Referer` only when stronger provenance headers are absent. Explicit cross-site fetch metadata, foreign or opaque origins, and provenance-free requests remain forbidden. Programmatic reset continues to require the `X-FWA-Control: reset` header in addition to same-origin request provenance.

Expand All @@ -66,6 +67,21 @@ An `updated` result never announces from the response payload: every successful

The package client entry exports the same facade through `getFwaLocalEdge()` without adding framework state or lifecycle ownership.

## URL ownership

The loader does not rewrite the application URL at startup, so `__fwa_debug=1` and `__fwa_debug=0` stay visible to the page until diagnostics change. The one exception is `__fwa_debug=reset`: the loader consumes it during startup, before the application has run. A writer queued with `['debug.setUrlWriter', writer]` before the loader executes is applied first and performs that removal; otherwise it is a direct `history.replaceState`. A writer registered through the facade after startup only applies to later changes. When diagnostics are later enabled or disabled through `debug.setEnabled` or the diagnostics panel, the loader removes `__fwa_debug` from the current URL without adding a history entry.

By default that removal calls `history.replaceState` directly. An application whose router or navigation runtime is the only history writer registers its own writer instead:

```ts
getFwaLocalEdge()?.debug.setUrlWriter((url) => {
// `url` is the same-origin path, search, and hash without `__fwa_debug`.
router.replace(url)
})
```

The writer receives the target as `pathname + search + hash` and must replace the current entry synchronously. Its return value is ignored; if it throws or returns a rejected promise, the diagnostics state and stored preference still change and only the URL keeps the stale parameter. Passing `null` restores the direct write; any other non-function value is ignored.

## Command queue

Commands may be queued before the loader executes:
Expand All @@ -82,7 +98,7 @@ Commands may be queued before the loader executes:
</script>
```

Supported Local Edge commands mirror the facade methods: `localEdge.getState`, `localEdge.subscribe`, `localEdge.revalidate`, `localEdge.setUpdateCheck`, `localEdge.applyUpdate`, `localEdge.reset`, and `localEdge.openNetwork`. Diagnostics commands remain under `debug.*`.
Supported Local Edge commands mirror the facade methods: `localEdge.getState`, `localEdge.subscribe`, `localEdge.revalidate`, `localEdge.setUpdateCheck`, `localEdge.applyUpdate`, `localEdge.reset`, and `localEdge.openNetwork`. Diagnostics commands remain under `debug.*`: `debug.getState`, `debug.subscribe`, `debug.setEnabled`, and `debug.setUrlWriter`.

## Update state

Expand Down
1 change: 1 addition & 0 deletions packages/local-edge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"src/release.ts",
"src/revalidation-observation.ts",
"src/route-policy.ts",
"src/loader/command-dispatch.ts",
"src/loader/debug-installation.ts",
"src/loader/debug-panel.ts",
"src/loader/debug-position.ts",
Expand Down
13 changes: 13 additions & 0 deletions packages/local-edge/src/loader/client-public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,22 @@ export interface FwaDebugState {

export type FwaDebugStateListener = (state: FwaDebugState) => void

/**
* Replaces the current document URL with `url` (path, search and hash) without
* adding a history entry. Hosts whose router owns history register one so the
* loader never writes history behind the router's back.
*/
export type FwaUrlWriter = (url: string) => void

export interface FwaDebugApi {
getState(): FwaDebugState
subscribe(listener: FwaDebugStateListener): () => void
setEnabled(enabled: boolean): void
/**
* Register how the loader removes `__fwa_debug` after a diagnostics change.
* `null` restores the default direct `history.replaceState`.
*/
setUrlWriter(writer: FwaUrlWriter | null): void
}

export interface FwaLocalEdgeApi {
Expand All @@ -82,6 +94,7 @@ export type FwaQueuedCommand =
| readonly ['debug.getState', (state: FwaDebugState) => void]
| readonly ['debug.subscribe', FwaDebugStateListener]
| readonly ['debug.setEnabled', boolean]
| readonly ['debug.setUrlWriter', FwaUrlWriter | null]

export interface FwaGlobal {
q: FwaQueuedCommand[]
Expand Down
47 changes: 47 additions & 0 deletions packages/local-edge/src/loader/command-dispatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import { applyQueuedUrlWriters, dispatchCommand } from './command-dispatch.ts'

function facade() {
return {
debug: { setUrlWriter: vi.fn(), setEnabled: vi.fn() },
} as never as Parameters<typeof dispatchCommand>[0] & {
debug: { setUrlWriter: ReturnType<typeof vi.fn>; setEnabled: ReturnType<typeof vi.fn> }
}
}

describe('queued debug.setUrlWriter', () => {
it('registers a function and clears with null', () => {
const api = facade()
const writer = () => undefined
dispatchCommand(api, ['debug.setUrlWriter', writer])
dispatchCommand(api, ['debug.setUrlWriter', null])
expect(api.debug.setUrlWriter.mock.calls).toEqual([[writer], [null]])
})

it('ignores any other argument', () => {
const api = facade()
dispatchCommand(api, ['debug.setUrlWriter', undefined])
dispatchCommand(api, ['debug.setUrlWriter', 'replace'])
dispatchCommand(api, ['debug.setUrlWriter'])
expect(api.debug.setUrlWriter).not.toHaveBeenCalled()
})
})

describe('applyQueuedUrlWriters', () => {
it('applies queued writers in order before startup and ignores everything else', () => {
const setUrlWriter = vi.fn()
const first = () => undefined
const second = () => undefined
applyQueuedUrlWriters(
[
['debug.setEnabled', true],
['debug.setUrlWriter', first],
['debug.setUrlWriter', 'replace'],
'not-a-command',
['debug.setUrlWriter', second],
],
{ setUrlWriter } as never,
)
expect(setUrlWriter.mock.calls).toEqual([[first], [second]])
})
})
90 changes: 90 additions & 0 deletions packages/local-edge/src/loader/command-dispatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { isValidUpdateCheckIntervalMinutes } from '../config-contract.ts'
import type { FwaDebugApi, FwaLocalEdgeApi } from './loader-contract.ts'

// Executes one queued `window.__fwa.q` command against the loader facade.
// Malformed commands and arguments are ignored.
export function dispatchCommand(localEdgeApi: FwaLocalEdgeApi, command: unknown) {
if (!Array.isArray(command)) {
return
}

const [name, argument] = command
switch (name) {
case 'localEdge.getState':
if (typeof argument === 'function') {
argument(localEdgeApi.getState())
}
break
case 'localEdge.subscribe':
if (typeof argument === 'function') {
localEdgeApi.subscribe(argument)
}
break
case 'localEdge.revalidate':
void localEdgeApi.revalidate().catch(() => undefined)
break
case 'localEdge.setUpdateCheck':
if (isUpdateCheckCommandConfig(argument)) {
localEdgeApi.setUpdateCheck(argument)
}
break
case 'localEdge.applyUpdate':
localEdgeApi.applyUpdate()
break
case 'localEdge.reset':
void localEdgeApi.reset().catch(() => undefined)
break
case 'localEdge.openNetwork':
localEdgeApi.openNetwork()
break
case 'debug.getState':
if (typeof argument === 'function') {
argument(localEdgeApi.debug.getState())
}
break
case 'debug.subscribe':
if (typeof argument === 'function') {
localEdgeApi.debug.subscribe(argument)
}
break
case 'debug.setEnabled':
if (typeof argument === 'boolean') {
localEdgeApi.debug.setEnabled(argument)
}
break
case 'debug.setUrlWriter':
if (typeof argument === 'function' || argument === null) {
localEdgeApi.debug.setUrlWriter(argument)
}
break
}
}

function isUpdateCheckCommandConfig(
value: unknown,
): value is { enabled?: boolean; intervalMinutes?: number } {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const { enabled, intervalMinutes } = value as Record<string, unknown>
return (
(enabled === undefined || typeof enabled === 'boolean') &&
(intervalMinutes === undefined ||
isValidUpdateCheckIntervalMinutes(intervalMinutes))
)
}

/**
* Apply URL writers that the host queued before the loader ran, so the
* startup `__fwa_debug=reset` removal already goes through the host. The
* commands stay in the queue; replaying them later is idempotent.
*/
export function applyQueuedUrlWriters(queue: readonly unknown[], debug: FwaDebugApi) {
for (const command of queue) {
if (!Array.isArray(command) || command[0] !== 'debug.setUrlWriter') continue
const writer = command[1]
if (typeof writer === 'function' || writer === null) {
debug.setUrlWriter(writer)
}
}
}
158 changes: 158 additions & 0 deletions packages/local-edge/src/loader/debug-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('./debug-panel.ts', () => ({
installFwaDebugPanel: () => () => undefined,
}))

const { createFwaDebugRuntime } = await import('./debug-runtime.ts')

function installWindow(href: string) {
const location = new URL(href)
const replaceState = vi.fn((_state: unknown, _unused: string, url: string) => {
const next = new URL(url, location.href)
location.pathname = next.pathname
location.search = next.search
location.hash = next.hash
})
const storage = new Map<string, string>()
vi.stubGlobal('window', {
location,
history: { state: { owned: true }, replaceState },
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => void storage.set(key, value),
removeItem: (key: string) => void storage.delete(key),
},
})
return { location, replaceState, storage }
}

function runtime() {
return createFwaDebugRuntime(() => ({}) as never)
}

describe('debug seed removal', () => {
beforeEach(() => {
vi.unstubAllGlobals()
})
afterEach(() => {
vi.unstubAllGlobals()
})

it('writes history directly when the host registers no URL writer', () => {
const { replaceState } = installWindow(
'https://app.test/settings?view=all&__fwa_debug=0#developer',
)
const debug = runtime()

debug.setEnabled(true)

expect(replaceState).toHaveBeenCalledOnce()
expect(replaceState).toHaveBeenCalledWith(
{ owned: true },
'',
'/settings?view=all#developer',
)
expect(debug.getState()).toEqual({ enabled: true })
})

it('hands the cleaned URL to the host writer and never writes history itself', () => {
const { replaceState } = installWindow(
'https://app.test/settings?view=all&__fwa_debug=0#developer',
)
const writer = vi.fn()
const debug = runtime()
debug.setUrlWriter(writer)

debug.setEnabled(true)

expect(writer).toHaveBeenCalledWith('/settings?view=all#developer')
expect(replaceState).not.toHaveBeenCalled()
expect(debug.getState()).toEqual({ enabled: true })
})

it('keeps the diagnostics state when the host writer throws', () => {
installWindow('https://app.test/?__fwa_debug=1')
const debug = runtime()
debug.setUrlWriter(() => {
throw new Error('router refused')
})

expect(() => debug.setEnabled(false)).not.toThrow()
expect(debug.getState()).toEqual({ enabled: false })
})

it('calls no writer when the URL carries no debug seed', () => {
const { replaceState } = installWindow('https://app.test/library/')
const writer = vi.fn()
const debug = runtime()
debug.setUrlWriter(writer)

debug.setEnabled(true)

expect(writer).not.toHaveBeenCalled()
expect(replaceState).not.toHaveBeenCalled()
})

it('restores the direct write when the writer is cleared', () => {
const { replaceState } = installWindow('https://app.test/?__fwa_debug=1')
const writer = vi.fn()
const debug = runtime()
debug.setUrlWriter(writer)
debug.setUrlWriter(null)

debug.setEnabled(false)

expect(writer).not.toHaveBeenCalled()
expect(replaceState).toHaveBeenCalledWith({ owned: true }, '', '/')
})
})

describe('debug URL writer registration', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('keeps the registered writer when given anything but a function or null', () => {
installWindow('https://app.test/?__fwa_debug=1')
const writer = vi.fn()
const debug = runtime()
debug.setUrlWriter(writer)
debug.setUrlWriter(undefined as never)
debug.setUrlWriter('replace' as never)

debug.setEnabled(false)

expect(writer).toHaveBeenCalledWith('/')
})

it('absorbs a rejected promise from an async writer', async () => {
installWindow('https://app.test/?__fwa_debug=1')
const debug = runtime()
debug.setUrlWriter(() => Promise.reject(new Error('router refused')) as never)

debug.setEnabled(false)
await new Promise((resolve) => setTimeout(resolve, 0))

expect(debug.getState()).toEqual({ enabled: false })
})

it('consumes a reset seed at startup with a direct write, before any writer exists', () => {
const { replaceState } = installWindow('https://app.test/library/?__fwa_debug=reset#x')
const debug = runtime()

debug.start()

expect(replaceState).toHaveBeenCalledWith({ owned: true }, '', '/library/#x')
expect(debug.getState()).toEqual({ enabled: true })
})

it('leaves enable and disable seeds in the URL at startup', () => {
for (const seed of ['1', '0']) {
const { replaceState } = installWindow(`https://app.test/?__fwa_debug=${seed}`)
runtime().start()
expect(replaceState).not.toHaveBeenCalled()
vi.unstubAllGlobals()
}
})
})
Loading
Loading