@alxia/react-router 0.40.0 → 0.42.0

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.
Files changed (49) hide show
  1. package/README.md +91 -2
  2. package/dist/chunks/index-1f16k1sj.js +186 -0
  3. package/dist/chunks/index-1f16k1sj.js.map +14 -0
  4. package/dist/chunks/index-atxcmy0f.js +47 -0
  5. package/dist/chunks/index-atxcmy0f.js.map +11 -0
  6. package/dist/chunks/index-gchzf3mc.js +30 -0
  7. package/dist/chunks/index-gchzf3mc.js.map +10 -0
  8. package/dist/chunks/{index-2swe47m6.js → index-hn6htqc2.js} +3 -46
  9. package/dist/chunks/{index-2swe47m6.js.map → index-hn6htqc2.js.map} +4 -6
  10. package/dist/chunks/index-vr82typq.js +23 -0
  11. package/dist/chunks/index-vr82typq.js.map +11 -0
  12. package/dist/chunks/index-zyg8amjc.js +23 -0
  13. package/dist/chunks/index-zyg8amjc.js.map +10 -0
  14. package/dist/errors/index.js +15 -30
  15. package/dist/errors/index.js.map +3 -4
  16. package/dist/fetcher/index.d.ts +5 -2
  17. package/dist/fetcher/index.d.ts.map +1 -1
  18. package/dist/fetcher/index.js +51 -26
  19. package/dist/fetcher/index.js.map +6 -5
  20. package/dist/fetcher/use-visible-interval.d.ts +24 -0
  21. package/dist/fetcher/use-visible-interval.d.ts.map +1 -0
  22. package/dist/fetcher/use-visible-revalidation.d.ts +10 -0
  23. package/dist/fetcher/use-visible-revalidation.d.ts.map +1 -0
  24. package/dist/oauth/answer.d.ts +24 -0
  25. package/dist/oauth/answer.d.ts.map +1 -0
  26. package/dist/oauth/consent.d.ts +88 -0
  27. package/dist/oauth/consent.d.ts.map +1 -0
  28. package/dist/oauth/index.d.ts +12 -0
  29. package/dist/oauth/index.d.ts.map +1 -0
  30. package/dist/oauth/index.js +106 -0
  31. package/dist/oauth/index.js.map +11 -0
  32. package/dist/oauth/model.d.ts +13 -0
  33. package/dist/oauth/model.d.ts.map +1 -0
  34. package/dist/oauth/react/index.d.ts +7 -0
  35. package/dist/oauth/react/index.d.ts.map +1 -0
  36. package/dist/oauth/react/index.js +87 -0
  37. package/dist/oauth/react/index.js.map +10 -0
  38. package/dist/oauth/react/use-consent.d.ts +47 -0
  39. package/dist/oauth/react/use-consent.d.ts.map +1 -0
  40. package/dist/session/index.js +30 -39
  41. package/dist/session/index.js.map +3 -5
  42. package/dist/session/react/index.js +31 -204
  43. package/dist/session/react/index.js.map +3 -8
  44. package/docs/README.md +3 -2
  45. package/docs/guide/fetcher.md +45 -3
  46. package/docs/guide/oauth.md +132 -0
  47. package/docs/roadmap.md +23 -1
  48. package/docs/troubleshooting.md +39 -0
  49. package/package.json +14 -2
@@ -1,17 +1,12 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/session/react/step-up/answers.ts", "../src/session/react/step-up/dialog-props.ts", "../src/session/react/step-up/phase.ts", "../src/session/react/step-up/use-step-up.ts", "../src/session/react/step-up/use-step-up-replay.ts", "../src/session/react/use-code-field.ts", "../src/session/react/use-link-param.ts", "../src/session/react/use-sign-out.ts"],
3
+ "sources": ["../src/session/react/use-code-field.ts", "../src/session/react/use-link-param.ts", "../src/session/react/use-sign-out.ts"],
4
4
  "sourcesContent": [
5
- "import type {\n\tStepUpConfirmAnswer,\n\tStepUpRequestAnswer,\n} from '../../model/step-up-answers';\n\nconst intentOf = (data: unknown) =>\n\ttypeof data === 'object' && data !== null\n\t\t? (data as { intent?: unknown }).intent\n\t\t: undefined;\n\n/**\n * The step-up route answers both intents from one action, so a fetcher's data\n * is their union: this is the request's answer, or `undefined` for any other.\n */\nexport function requestAnswerOf(\n\tdata: unknown,\n): StepUpRequestAnswer | undefined {\n\treturn intentOf(data) === 'request-step-up'\n\t\t? (data as StepUpRequestAnswer)\n\t\t: undefined;\n}\n\n/** The confirm's answer out of the route's union, or `undefined` for any other. */\nexport function confirmAnswerOf(\n\tdata: unknown,\n): StepUpConfirmAnswer | undefined {\n\treturn intentOf(data) === 'confirm-step-up'\n\t\t? (data as StepUpConfirmAnswer)\n\t\t: undefined;\n}\n",
6
- "import type { StepUpVia } from '../../model/second-factor';\nimport type { StepUpProps } from '../../model/step-up-props';\nimport type { StepUpDialogPhase } from './phase';\nimport type { useStepUp } from './use-step-up';\n\n/** What a step-up dialog is given: the phase to show and the handlers. */\nexport interface StepUpDialogProps {\n\topen: boolean;\n\tphase: StepUpDialogPhase;\n\t/** How the code is confirmed, in every phase once a challenge was granted (`undefined` before). */\n\tvia: StepUpVia | undefined;\n\t/** The words of the code form (`stepUpProps`). */\n\tprops: StepUpProps;\n\t/** The last refusal worded by the server. */\n\tmessage: string | undefined;\n\tbusy: boolean;\n\t/** The code was accepted and the action is being sent again. */\n\tconfirming: boolean;\n\tonCancel(): void;\n\t/** \"Retry\" after a refused request or an over challenge. */\n\tonRetry(): void;\n\tonSubmit(code: string): void;\n\t/** \"Resend code\", for a mailed code only. */\n\tonResend: (() => void) | undefined;\n}\n\n/** The dialog's props out of a step-up: plain, so any host can build them. */\nexport function dialogPropsOf(\n\tstepUp: ReturnType<typeof useStepUp>,\n\topen: boolean,\n\tcancel: () => void,\n): StepUpDialogProps {\n\treturn {\n\t\topen,\n\t\tphase: stepUp.phase,\n\t\tvia: stepUp.via,\n\t\tprops: stepUp.props,\n\t\tmessage: stepUp.message,\n\t\tbusy: stepUp.busy,\n\t\tconfirming: stepUp.phase === 'confirmed',\n\t\tonCancel: cancel,\n\t\tonRetry: () => stepUp.start(),\n\t\tonSubmit: (code) => stepUp.submit(code),\n\t\tonResend: stepUp.via === 'email' ? () => stepUp.start() : undefined,\n\t};\n}\n",
7
- "import type { StepUpState } from '../../model/step-up';\n\n/** Where a step-up is, as a dialog shows it. */\nexport type StepUpDialogPhase =\n\t| 'asking'\n\t| 'failed'\n\t| 'code'\n\t| 'expired'\n\t| 'confirmed';\n\n/** The phase a reducer state is in: what the dialog shows. */\nexport function stepUpDialogPhase(state: StepUpState): StepUpDialogPhase {\n\tif (state.phase === 'done') return 'confirmed';\n\tif (state.challenge) return 'code';\n\tif (state.error === 'expired') return 'expired';\n\treturn state.error ? 'failed' : 'asking';\n}\n\n/** Whether an answer is a refusal asking for a step-up (`stepUp` set, not ok). */\nexport function asksStepUp(answer: unknown): boolean {\n\tif (typeof answer !== 'object' || answer === null) return false;\n\tconst a = answer as { ok?: unknown; stepUp?: unknown };\n\treturn !a.ok && Boolean(a.stepUp);\n}\n",
8
- "import { useEffect, useReducer, useRef } from 'react';\nimport type { StepUpVia } from '../../model/second-factor';\nimport { INITIAL_STEP_UP, stepUpReducer } from '../../model/step-up';\nimport type {\n\tStepUpConfirmAnswer,\n\tStepUpRequestAnswer,\n} from '../../model/step-up-answers';\nimport { stepUpProps } from '../../model/step-up-props';\nimport { stepUpDialogPhase } from './phase';\n\n/**\n * One step-up intent as the host posts it: its own `post` (so the fields stay\n * typed by the host's action), the settled answer it got since it mounted\n * (`undefined` before), and whether it is in flight. `reset` forgets the\n * answer (a fetcher's `reset`).\n */\nexport interface StepUpChannel<Fields, Answer> {\n\tpost(fields: Fields): void;\n\treadonly answer: Answer | undefined;\n\treadonly busy: boolean;\n\treset?(): void;\n}\n\nexport interface StepUpOptions {\n\t/** Posts `{ intent: 'request-step-up' }`, to the route that serves `stepUpSteps().route`. */\n\trequest: StepUpChannel<Record<string, never>, StepUpRequestAnswer>;\n\t/** Posts `{ intent: 'confirm-step-up', challenge, code, via }`. */\n\tconfirm: StepUpChannel<\n\t\t{ challenge: string; code: string; via: StepUpVia | undefined },\n\t\tStepUpConfirmAnswer\n\t>;\n\t/** Runs once the API accepted the code. */\n\tonConfirmed(): void;\n}\n\n/**\n * The step-up as the page lives it, on `stepUpReducer`: `start` asks for a\n * challenge, `submit` posts the code, and once it is accepted `onConfirmed`\n * runs. The challenge lives in the reducer's state and the channels' answers\n * only; `release` forgets everything, and an answer that lands after it (a\n * request or a code in flight when the host closed) is ignored.\n */\nexport function useStepUp({ request, confirm, onConfirmed }: StepUpOptions) {\n\tconst [state, dispatch] = useReducer(stepUpReducer, INITIAL_STEP_UP);\n\tconst confirmedRun = useRef(onConfirmed);\n\tconfirmedRun.current = onConfirmed;\n\t// True from `start` to `release`: only then are answers this step-up's.\n\tconst live = useRef(false);\n\n\t// Each settled answer once, even one worded as the last (a new object).\n\tconst handledRequest = useRef(request.answer);\n\tconst handledConfirm = useRef(confirm.answer);\n\tuseEffect(() => {\n\t\tconst answer = request.answer;\n\t\tif (!answer || handledRequest.current === answer) return;\n\t\thandledRequest.current = answer;\n\t\tif (!live.current) return;\n\t\tdispatch({ type: 'requested', answer });\n\t}, [request.answer]);\n\tuseEffect(() => {\n\t\tconst answer = confirm.answer;\n\t\tif (!answer || handledConfirm.current === answer) return;\n\t\thandledConfirm.current = answer;\n\t\tif (!live.current) return;\n\t\tdispatch({ type: 'confirmed', answer });\n\t\tif (answer.ok) confirmedRun.current();\n\t}, [confirm.answer]);\n\n\treturn {\n\t\tphase: stepUpDialogPhase(state),\n\t\t/** How the challenge held (or the last one) is confirmed. */\n\t\tvia: state.via,\n\t\t/** What a dialog words its texts from (`stepUpProps`). */\n\t\tprops: stepUpProps(state),\n\t\t/** The last refusal worded by the server: the fallback of the dialog's own words. */\n\t\tmessage: state.message,\n\t\t/** A request or a code is in flight. */\n\t\tbusy: request.busy || confirm.busy,\n\t\t/** Asks for a challenge: the first, or \"Resend code\" / \"New code\". */\n\t\tstart() {\n\t\t\tlive.current = true;\n\t\t\tdispatch({ type: 'start' });\n\t\t\trequest.post({});\n\t\t},\n\t\t/** Posts the code for the challenge held. */\n\t\tsubmit(code: string) {\n\t\t\tif (!state.challenge) return;\n\t\t\tdispatch({ type: 'submit' });\n\t\t\tconfirm.post({ challenge: state.challenge, code, via: state.via });\n\t\t},\n\t\t/** Forgets the challenge, the answers and the errors. */\n\t\trelease() {\n\t\t\tlive.current = false;\n\t\t\trequest.reset?.();\n\t\t\tconfirm.reset?.();\n\t\t\thandledRequest.current = request.answer;\n\t\t\thandledConfirm.current = confirm.answer;\n\t\t\tdispatch({ type: 'release' });\n\t\t},\n\t};\n}\n",
9
- "import { useEffect, useRef, useState } from 'react';\nimport { dialogPropsOf } from './dialog-props';\nimport { asksStepUp } from './phase';\nimport { type StepUpOptions, useStepUp } from './use-step-up';\n\nexport interface StepUpReplayOptions<Action>\n\textends Pick<StepUpOptions, 'request' | 'confirm'> {\n\t/**\n\t * The settled answer of the sensitive action. A refusal carrying `stepUp`\n\t * opens the step-up; the first answer after the replay closes it.\n\t */\n\tanswer: unknown;\n\t/** Sends the refused action again, once the session is confirmed. */\n\treplay(): void;\n\t/** The action that was refused, for `spendsCode`. */\n\taction?: Action | undefined;\n\t/**\n\t * Whether the action carries a one-time code that a step-up by the\n\t * second factor just spent. Such an action is not replayed: the dialog\n\t * closes and `confirmedNotice` is true (\"confirmed, type a new code\").\n\t * The notice clears when `action` becomes another value (a new action was\n\t * posted), at once and not at its answer, so pass the action as you post\n\t * it, a new object for each post.\n\t */\n\tspendsCode?(action: Action): boolean;\n}\n\n/**\n * The step-up a sensitive action asks for: an answer carrying `stepUp` opens\n * it; once the session is confirmed the same action is sent again by itself\n * (`replay`), except an action that `spendsCode` after a step-up by the\n * second factor, whose code was just spent: the user types the next one\n * (`confirmedNotice`). The dialog stays, saying so, until the replayed\n * action is answered; if that is refused for a step-up again, it asks again.\n * If `replay` posts nothing, no answer ends that wait: call `cancel()`.\n * Generic over the host's `post`s, so each intent keeps its typed fields.\n *\n * @example\n * const { open, dialogProps, cancel, confirmedNotice } = useStepUpReplay({\n * \tanswer: fetcher.data,\n * \treplay: () => post(lastAction.current),\n * \trequest: { post: () => stepUpPost({ intent: 'request-step-up' }), answer, busy },\n * \tconfirm: { post: (f) => stepUpPost({ intent: 'confirm-step-up', ...f }), answer, busy },\n * });\n */\nexport function useStepUpReplay<Action = never>({\n\tanswer,\n\treplay,\n\taction,\n\tspendsCode,\n\trequest,\n\tconfirm,\n}: StepUpReplayOptions<Action>) {\n\tconst [open, setOpen] = useState(false);\n\tconst [confirmedNotice, setConfirmedNotice] = useState(false);\n\tconst isOpen = useRef(false);\n\tconst replaying = useRef(false);\n\tconst latest = useRef({ replay, action, spendsCode });\n\tlatest.current = { replay, action, spendsCode };\n\n\tfunction show(value: boolean) {\n\t\tisOpen.current = value;\n\t\tsetOpen(value);\n\t}\n\n\t// The notice belongs to the action it was shown for: another action\n\t// posted clears it at once, not at that action's answer.\n\tconst noticeFor = useRef(action);\n\tuseEffect(() => {\n\t\tif (noticeFor.current === action) return;\n\t\tnoticeFor.current = action;\n\t\tsetConfirmedNotice(false);\n\t}, [action]);\n\n\tconst stepUp = useStepUp({\n\t\trequest,\n\t\tconfirm,\n\t\tonConfirmed() {\n\t\t\t// A code accepted after the host cancelled replays nothing.\n\t\t\tif (!isOpen.current) return;\n\t\t\tconst { replay, action, spendsCode } = latest.current;\n\t\t\tconst spent =\n\t\t\t\tstepUpRef.current.via === 'secondFactor' &&\n\t\t\t\taction !== undefined &&\n\t\t\t\tspendsCode?.(action) === true;\n\t\t\tif (spent) {\n\t\t\t\tstepUpRef.current.release();\n\t\t\t\tshow(false);\n\t\t\t\tsetConfirmedNotice(true);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// The dialog stays, saying so, until the action is answered.\n\t\t\treplaying.current = true;\n\t\t\treplay();\n\t\t},\n\t});\n\tconst stepUpRef = useRef(stepUp);\n\tstepUpRef.current = stepUp;\n\n\t// Each answer once: a refusal asking for a step-up opens it; the answer\n\t// of the replayed action closes it (a new step-up opens it again).\n\tconst handled = useRef(answer);\n\tuseEffect(() => {\n\t\tif (!answer || handled.current === answer) return;\n\t\thandled.current = answer;\n\t\tif (asksStepUp(answer)) {\n\t\t\treplaying.current = false;\n\t\t\tsetConfirmedNotice(false);\n\t\t\tshow(true);\n\t\t\tstepUpRef.current.start();\n\t\t\treturn;\n\t\t}\n\t\tsetConfirmedNotice(false);\n\t\tif (replaying.current) {\n\t\t\treplaying.current = false;\n\t\t\tshow(false);\n\t\t\tstepUpRef.current.release();\n\t\t}\n\t}, [answer]);\n\n\tfunction cancel() {\n\t\treplaying.current = false;\n\t\tstepUp.release();\n\t\tshow(false);\n\t\tsetConfirmedNotice(false);\n\t}\n\n\treturn {\n\t\topen,\n\t\t/** True after a step-up by the second factor spent the action's own code. */\n\t\tconfirmedNotice,\n\t\tdialogProps: dialogPropsOf(stepUp, open, cancel),\n\t\t/** Closes the dialog without sending the action again. */\n\t\tcancel,\n\t\tstepUp,\n\t};\n}\n",
10
5
  "import {\n\ttype RefObject,\n\tuseCallback,\n\tuseEffect,\n\tuseRef,\n\tuseState,\n} from 'react';\n\n/** The part of a Standard Schema (`~standard`) this hook reads; Zod, Valibot and ArkType satisfy it. */\nexport interface CodeSchema {\n\treadonly '~standard': {\n\t\tvalidate(value: unknown): unknown;\n\t};\n}\n\n/**\n * Calls `handler` once for each new `refusal` (a new object each time,\n * `undefined` when there is none). A refusal already there when the\n * component mounted is not new: it is the page's own, seen again.\n * Library-agnostic: pair it with any form library's `reset` and `focus`.\n */\nexport function useOnRefusal(\n\trefusal: object | undefined,\n\thandler: () => void,\n): void {\n\tconst handled = useRef(refusal);\n\tconst latest = useRef(handler);\n\tlatest.current = handler;\n\tuseEffect(() => {\n\t\tif (!refusal || handled.current === refusal) return;\n\t\thandled.current = refusal;\n\t\tlatest.current();\n\t}, [refusal]);\n}\n\nexport interface CodeField {\n\t/** The code typed so far. */\n\treadonly code: string;\n\treadonly setCode: (code: string) => void;\n\t/** On the input, so a refusal can focus it. */\n\treadonly ref: RefObject<HTMLInputElement | null>;\n\t/** The first issue's message of the last `check`, cleared by typing. */\n\treadonly error: string | undefined;\n\t/**\n\t * Validates the code against the schema: the parsed value, or\n\t * `undefined` and `error` set. Synchronous schemas only.\n\t */\n\treadonly check: () => unknown;\n}\n\n/**\n * A one-code field (an authenticator app's code, a recovery code) with no\n * form library: it holds the code, checks it against `schema`, and on each\n * new `refusal` clears the refused code and focuses the input for the next\n * try. With react-hook-form, use `useOnRefusal` and its `setValue` and\n * `setFocus` instead.\n */\nexport function useCodeField(\n\tschema: CodeSchema,\n\trefusal: object | undefined,\n): CodeField {\n\tconst [code, setCodeState] = useState('');\n\tconst [error, setError] = useState<string | undefined>();\n\tconst ref = useRef<HTMLInputElement | null>(null);\n\tconst setCode = useCallback((next: string) => {\n\t\tsetCodeState(next);\n\t\tsetError(undefined);\n\t}, []);\n\tuseOnRefusal(refusal, () => {\n\t\tsetCode('');\n\t\tref.current?.focus();\n\t});\n\tconst check = () => {\n\t\tconst result = schema['~standard'].validate(code);\n\t\tif (result instanceof Promise) {\n\t\t\tthrow new TypeError('useCodeField takes a synchronous schema');\n\t\t}\n\t\tconst { issues, value } = result as {\n\t\t\tissues?: ReadonlyArray<{ message: string }>;\n\t\t\tvalue?: unknown;\n\t\t};\n\t\tif (issues?.length) {\n\t\t\tsetError(issues[0]?.message);\n\t\t\treturn undefined;\n\t\t}\n\t\tsetError(undefined);\n\t\treturn value;\n\t};\n\treturn { code, setCode, ref, error, check };\n}\n",
11
6
  "import { useEffect, useState } from 'react';\nimport { useLocation, useSearchParams } from 'react-router';\n\nexport interface LinkParamOptions {\n\t/**\n\t * Remove the parameter from the address bar once read\n\t * (`history.replaceState`), so a token is not left in the history, a\n\t * bookmark or a shared screen. Default `false`.\n\t */\n\treadonly drop?: boolean;\n\t/**\n\t * Follow the URL instead of reading it once. A parameter that a redirect\n\t * or a link adds to the SAME mounted route (`?state=linked`) is seen, and\n\t * each new location is read again. Without `drop` the value is the\n\t * parameter of the current location. With `drop` the value is kept after\n\t * the address bar loses the parameter, until a location carries a new\n\t * one: a navigation that has none does not clear it. Default `false`.\n\t */\n\treadonly live?: boolean;\n}\n\n/**\n * The query parameter a mail's link carries (`token`, `code`, `state`),\n * read once: the value of the first render is kept for the page's life, so\n * dropping it from the address bar never loses it. With `live: true` it\n * follows the URL instead (see `LinkParamOptions`). `null` when absent or\n * empty. Safe to render on the server, where nothing is dropped.\n */\nexport function useLinkParam(\n\tname: string,\n\t{ drop = false, live = false }: LinkParamOptions = {},\n): string | null {\n\tconst [params] = useSearchParams();\n\tconst { key } = useLocation();\n\tconst followed = live ? key : null;\n\tconst current = params.get(name) || null;\n\tconst [kept, setKept] = useState(current);\n\tconst [seen, setSeen] = useState(current);\n\tif (live && current !== seen) {\n\t\tsetSeen(current);\n\t\tif (!drop || current !== null) setKept(current);\n\t}\n\tuseEffect(() => {\n\t\tif (!drop) return;\n\t\tconst url = new URL(window.location.href);\n\t\tif (!url.searchParams.has(name)) return;\n\t\turl.searchParams.delete(name);\n\t\twindow.history.replaceState(\n\t\t\twindow.history.state,\n\t\t\t'',\n\t\t\t`${url.pathname}${url.search}${url.hash}`,\n\t\t);\n\t}, [drop, name, followed]);\n\tif (live && !drop) return current;\n\treturn kept;\n}\n",
12
7
  "import { useCallback, useState } from 'react';\nimport { useNavigation, useSubmit } from 'react-router';\n\n/** Where `signInFlow`'s sign-out action is mounted unless the app says otherwise. */\nexport const SIGN_OUT_PATH = '/sign-out';\n\nexport interface SignOutOptions {\n\t/** The sign-out route (default `/sign-out`). */\n\treadonly action?: string;\n\t/**\n\t * Runs before the post: clear the worker's caches, a local store. A\n\t * rejection never keeps the user signed in: the sign-out goes on.\n\t */\n\treadonly before?: () => Promise<unknown> | unknown;\n}\n\n/**\n * Runs `before`, then `submit`. A `before` that throws or rejects is\n * ignored, so a failed cleanup cannot keep a session alive.\n */\nexport async function signOutAfter(\n\tbefore: (() => Promise<unknown> | unknown) | undefined,\n\tsubmit: () => Promise<void> | void,\n): Promise<void> {\n\ttry {\n\t\tawait before?.();\n\t} catch {\n\t\t// Signing out matters more than the cleanup.\n\t}\n\tawait submit();\n}\n\n/**\n * The one way a page signs out, wherever the control is: posts `{}` as JSON\n * to the sign-out route (same-origin and JSON only, as `signInFlow`'s action\n * requires). `busy` from the call until the navigation it starts has ended.\n */\nexport function useSignOut(options: SignOutOptions = {}): {\n\tsignOut: () => void;\n\tbusy: boolean;\n} {\n\tconst { action = SIGN_OUT_PATH, before } = options;\n\tconst submit = useSubmit();\n\tconst [preparing, setPreparing] = useState(false);\n\tconst submitting = useNavigation().formAction === action;\n\tconst signOut = useCallback(() => {\n\t\tsetPreparing(true);\n\t\tvoid signOutAfter(before, () =>\n\t\t\tsubmit({}, { method: 'post', action, encType: 'application/json' }),\n\t\t).finally(() => setPreparing(false));\n\t}, [submit, action, before]);\n\treturn { signOut, busy: preparing || submitting };\n}\n"
13
8
  ],
14
- "mappings": "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",
15
- "debugId": "CBDBAC635F0C4CB164756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "116C793B397FA8DD64756E2164756E21",
16
11
  "names": []
17
12
  }
package/docs/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The [package README](../README.md) is the short version. This folder is
4
4
  the long one: the setup, the Vite plugin and its default server, the build for Bun,
5
5
  customising the server, the typed context, the client's files, making the app
6
- installable and usable offline, wording an API's refusals, the BFF session, a GraphQL subscription relayed to the browser (`/subscription`), and what to do with each message.
6
+ installable and usable offline, wording an API's refusals, the BFF session, a GraphQL subscription relayed to the browser (`/subscription`), the consent screen of an OAuth server (`/oauth`), and what to do with each message.
7
7
 
8
8
  | Page | Read it when |
9
9
  | --- | --- |
@@ -21,8 +21,9 @@ installable and usable offline, wording an API's refusals, the BFF session, a Gr
21
21
  | [An API's refusals](guide/errors.md) | wording a GraphQL API's refusals for the pages: recognising an `ApiError` or a REST API's `problemError`, the request id, the page a request is for, the catalogue store, the form checks in the API's words, the provider, and the loaders, the error page and the actions that answer with it, the JSON actions with their CSRF check and fetcher hook, calling the API from a loader (`apiCallFailure`), and keeping the catalogue fresh (`@alxia/react-router/errors`) |
22
22
  | [A BFF session](guide/session.md) | redirecting after sign-in to a page of the site and nowhere else, the sign-in pages' paths, the session's cookies, a signed session and a sign-in throttle (`signedSession`, `createSignInThrottle`), the sign-in flow (where an answer lands, a refused code, the viewer, sign-out; `@alxia/react-router/session`), the pages a mail's link opens (a magic link, a password reset: `authLinks`), the page's hooks (sign-out, a mail link's parameter, a one-code field; `@alxia/react-router/session/react`), the step-up route's signed-out handling (`onSignedOut`), and the step-up's state and the props it gives the UI (`@alxia/react-router/session/model`) |
23
23
  | [Links through React Router](guide/link.md) | `RouterLink`, React Router's `Link` as the link component of a UI library that hands `href` and spreads anchor props; `@alxia/react-router/link` |
24
- | [A fetcher's fresh answer](guide/fetcher.md) | `useFetcherAnswer`, `useFetcherPromise` (one submission's answer as a Promise), a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useDebouncedFetcher`, a resource route loaded for what is typed (pause, minimum length, stale answer kept, retry); `@alxia/react-router/fetcher` |
24
+ | [A fetcher's fresh answer](guide/fetcher.md) | `useFetcherAnswer`, `useFetcherPromise` (one submission's answer as a Promise), a handler called once per new answer of a keyed fetcher, not for the data it held at mount; `useDebouncedFetcher`, a resource route loaded for what is typed (pause, minimum length, stale answer kept, retry); `useVisibleInterval` and `useVisibleRevalidation`, a callback or a revalidation on an interval while the page is visible; `@alxia/react-router/fetcher` |
25
25
  | [Lists whose state is the URL](guide/lists.md) | a search and filters in the address bar that drop the cursor (`useSearchChange`), a skeleton while the same page reloads (`useSameRouteLoading`), and a load-more button that appends pages by cursor and keeps the rows when one fails (`usePagedRows`); `@alxia/react-router/lists` |
26
+ | [The consent screen of an OAuth server](guide/oauth.md) | letting a viewer allow or deny an OAuth client: `oauthConsent` (the route's loader and action over the janus-oauth GraphQL, passed in as functions), `useConsent` (the decision, the step-up replay, the callback) and the pure helpers; `@alxia/react-router/oauth` |
26
27
  | [Subscriptions for a BFF](guide/subscriptions.md) | showing a GraphQL subscription in the browser without giving it the API's token or a way to write a document: a resource route with a map of operation names to the server's documents, the same-site check, the bearer from the session, extra upstream headers, a heartbeat, abort propagation, size limits and a 429 passed through with `retryAfter` (`@alxia/react-router/subscription`) |
27
28
  | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a page streams, hydrates or reads its context unlike you expected |
28
29
  | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
@@ -6,9 +6,11 @@ component, so its data from an earlier visit is still there when the component
6
6
  mounts again, and a plain `useEffect` on `fetcher.data` reacts to it as if it
7
7
  were new. `@alxia/react-router/fetcher` holds the hook that tells them apart
8
8
  (`useFetcherAnswer`, which reads the fetcher structurally) and the one that
9
- loads a resource route for what the user types (`useDebouncedFetcher`, below).
10
- `react` is its only import, plus `react-router` for `useDebouncedFetcher`, and
11
- both render on the server.
9
+ loads a resource route for what the user types (`useDebouncedFetcher`, below),
10
+ and the ones that read data again while the page is visible
11
+ (`useVisibleInterval`, `useVisibleRevalidation`, below). `react` is its only
12
+ import, plus `react-router` for the hooks that use a fetcher or the
13
+ revalidator, and all render on the server.
12
14
 
13
15
  ```tsx
14
16
  import { useJsonFetcher } from '@alxia/react-router/errors';
@@ -140,3 +142,43 @@ export function Suggestions({ typed }: { typed: string }) {
140
142
  fetcher.
141
143
  - `enabled: false` asks nothing and forgets the previous answer (a closed
142
144
  dialog); `key` shares the fetcher with another component.
145
+
146
+ ## Fresh data while the page is visible: `useVisibleInterval(callback, ms, options?)`
147
+
148
+ A screen with no real-time channel reads its data again on a timer. The timer
149
+ should run only while the user can see the page, and a page that was hidden
150
+ for a while should not show old data when it comes back.
151
+
152
+ ```tsx
153
+ import { useFetcher } from 'react-router';
154
+ import {
155
+ useVisibleInterval,
156
+ useVisibleRevalidation,
157
+ } from '@alxia/react-router/fetcher';
158
+
159
+ export function Board({ menuOpen }: { menuOpen: boolean }) {
160
+ // the route's loaders, every minute, never while a menu is open
161
+ useVisibleRevalidation(60_000, { enabled: !menuOpen });
162
+
163
+ // any callback, here a fetcher's load, every five minutes
164
+ const counts = useFetcher<Counts>();
165
+ useVisibleInterval(() => void counts.load('/counts'), 300_000);
166
+ return <Tiles counts={counts.data} />;
167
+ }
168
+ ```
169
+
170
+ - The callback runs every `ms` while `document.visibilityState` is
171
+ `'visible'`. A tick that fires while the page is hidden is skipped.
172
+ - When a hidden page becomes visible and at least `ms` has passed since the
173
+ last run, the callback runs at once (`runOnVisible`, default `true`; pass
174
+ `false` to wait for the next tick). A page that comes back sooner waits for
175
+ the tick.
176
+ - `enabled: false` stops the timer and removes the listener; `true` again
177
+ starts a fresh interval.
178
+ - The callback is read from a ref: a new function each render does not
179
+ restart the timer, and the latest one runs. It decides whether to skip a
180
+ read already in flight (`fetcher.state`); `useVisibleRevalidation` does so
181
+ for `useRevalidator`.
182
+ - The timer and the listener are cleared on unmount. On the server nothing
183
+ starts, since effects do not run there.
184
+ - Changing `ms` restarts the interval. `ms` must be positive.
@@ -0,0 +1,132 @@
1
+ # The consent screen of an OAuth server
2
+
3
+ An app that is the OAuth server of other apps (`@nxgt/janus-oauth`) asks its
4
+ viewer, on one screen, whether a client may act for them. The API serves the
5
+ three calls (`oauthRequest`, `approveOAuthRequest`, `denyOAuthRequest` of
6
+ `@nxgt/janus-oauth/graphql`); the decision logic around them is the same in
7
+ every app, so `@alxia/react-router` holds it. The screen itself (the card, the
8
+ words, the scopes' labels) stays yours.
9
+
10
+ Two subpaths, so a page never loads the server side:
11
+
12
+ | subpath | holds | imports |
13
+ | --- | --- | --- |
14
+ | `@alxia/react-router/oauth` | `oauthConsent`, `consentShouldRevalidate`, `requestIdOf`, `followableRedirect`, the `ConsentAnswer` type | `react-router` (its only package) |
15
+ | `@alxia/react-router/oauth/react` | `useConsent` | `react`, `react-router` |
16
+
17
+ ## The route's server side
18
+
19
+ `oauthConsent` takes your GraphQL calls and returns the route's `loader` and
20
+ `action`. It does not import a GraphQL client: you pass the three operations
21
+ as functions, typed by your own generated documents.
22
+
23
+ ```ts
24
+ // app/routes/oauth/consent.tsx
25
+ import { alxiaOf } from '@alxia/react-router';
26
+ import { consentShouldRevalidate, oauthConsent } from '@alxia/react-router/oauth';
27
+ import type { Route } from './+types/consent';
28
+
29
+ const consent = oauthConsent({
30
+ json, // your jsonActions()
31
+ errors, // your createApiErrors(...)
32
+ // a guest is redirected to sign in and brought back to this address
33
+ viewer: (request) => requireViewer(request),
34
+ caller: (request) => sessionCaller(request),
35
+ find: async (id, caller) => (await execute(OAuthRequestQuery, { id }, caller)).oauthRequest,
36
+ approve: async ({ id, scopes }, caller) =>
37
+ (await execute(ApproveOAuthRequestMutation, { id, scopes }, caller)).approveOAuthRequest,
38
+ deny: async ({ id }, caller) =>
39
+ (await execute(DenyOAuthRequestMutation, { id }, caller)).denyOAuthRequest,
40
+ });
41
+
42
+ export const loader = ({ request }: Route.LoaderArgs) => consent.loader(request);
43
+ export const action = ({ context, request }: Route.ActionArgs) =>
44
+ consent.action(request, alxiaOf(context).language);
45
+ export const shouldRevalidate = consentShouldRevalidate;
46
+ ```
47
+
48
+ What each half does:
49
+
50
+ - **`loader(request)`** runs `viewer` first (a thrown redirect sends a guest to
51
+ sign in and back, request id included), reads the request id from
52
+ `?request_id=` (else `?request=`), asks `find`, and answers
53
+ `{ pending, viewer }` with `Cache-Control: no-store` and the headers `viewer`
54
+ gave (a refreshed cookie). No id, a `null` answer, or an API refusal that
55
+ `notFound` accepts (a 404 by default) answers `pending: null` with a 404
56
+ status: render your expired state. Any other failure is thrown.
57
+ - **`action(request, language)`** takes the JSON `{ intent: 'approve' | 'deny',
58
+ id, scopes? }` the page posts (`json.requireJsonPost` and `readJson` run
59
+ first: another site is a 403, a bad body a 400), calls `approve` or `deny`,
60
+ and answers a `ConsentAnswer`: `{ ok: true, intent, id, redirectTo }` with the
61
+ callback address (only `http:` and `https:` are followed, any other is
62
+ refused), or `{ ok: false, stepUp, expired, error }`. A session not confirmed
63
+ lately (the API's `STEP_UP_REQUIRED`) is `stepUp: true` with no message; a
64
+ request the API no longer knows is `expired: true`; any other refusal is the
65
+ worded `error` with its HTTP status.
66
+ - The action does not call `viewer`: the session reaches the API through `caller`, and a refreshed cookie is set by the loader's answer only.
67
+ - **`consentShouldRevalidate`** keeps the loader from running after a decision:
68
+ the decision spends the request, so a reload would answer "expired" while the
69
+ browser is on its way to the callback.
70
+
71
+ Options that differ from the defaults: `notFound(error)` (which refusals mean
72
+ "unknown request", a 404 by default) and `stepUpRequired` (the API's code,
73
+ `STEP_UP_REQUIRED` by default).
74
+
75
+ ## The page's hook
76
+
77
+ ```tsx
78
+ import { useConsent } from '@alxia/react-router/oauth/react';
79
+
80
+ export default function Consent({ loaderData }: Route.ComponentProps) {
81
+ const { pending: request } = loaderData;
82
+ if (!request) return <Expired />;
83
+ const { approve, deny, pending, expired, error, dialogProps } = useConsent(request.id, {
84
+ request: stepUpRequestChannel, // the step-up channels of useStepUpReplay
85
+ confirm: stepUpConfirmChannel,
86
+ });
87
+ return (
88
+ <>
89
+ <Card request={request} pending={pending} error={error} onAllow={approve} onDeny={deny} />
90
+ <StepUpDialog {...dialogProps} />
91
+ </>
92
+ );
93
+ }
94
+ ```
95
+
96
+ `useConsent(requestId, { request, confirm, go?, action? })` posts the decision
97
+ as JSON to the page's action (or `action`), and returns:
98
+
99
+ - `approve()` and `deny()`: ignored while a decision is in flight or the
100
+ browser is leaving.
101
+ - `pending`: the decision in flight (`'approve'` or `'deny'`, `true` while a
102
+ step-up is open before one was chosen), otherwise `false`. After a decision
103
+ lands it stays set until the page is left, since the browser follows the
104
+ callback.
105
+ - `expired`: the API no longer knows the request.
106
+ - `error`: any other refusal's message; cleared by the next decision.
107
+ - `dialogProps`: the step-up dialog's props. `request` and `confirm` are the
108
+ two channels `useStepUpReplay` takes (see the
109
+ [session guide](session.md#the-pages-hooks)); a refused decision opens the
110
+ dialog and is posted again by itself once the session is confirmed. A
111
+ decision carries no one-time code, so nothing is spent. Cancelling the dialog
112
+ frees the card.
113
+
114
+ `go(url)` is a hard navigation by default (`window.location.assign`): the
115
+ callback is another site's, so a client-side navigation would not leave. Pass
116
+ your own to watch it in a test.
117
+
118
+ ## The pure helpers
119
+
120
+ - `requestIdOf(url: URL)`: the request an authorization link names, trimmed;
121
+ `undefined` when empty or absent.
122
+ - `followableRedirect(value)`: the normalised address when it is `http:` or
123
+ `https:` (a loopback callback is `http:`), else `undefined`. Use it for any
124
+ redirect an API hands you.
125
+
126
+ ## Testing
127
+
128
+ `oauthConsent` is plain functions over `Request`: call `loader` and `action`
129
+ with a `Request` and fake `find`, `approve` and `deny`. Post a body with
130
+ `content-type: application/json` and an `Origin` of the app. For the hook,
131
+ mount a memory router whose route `action` answers `ConsentAnswer`s, as the
132
+ package's own spec does.
package/docs/roadmap.md CHANGED
@@ -35,6 +35,14 @@ number on it. Every release, with each change it made, is in
35
35
  over on a new first page, keep the rows when a page fails). See the
36
36
  [lists guide](guide/lists.md).
37
37
 
38
+ - **`@alxia/react-router/oauth`: the consent screen of an OAuth server.**
39
+ `oauthConsent` gives a route's `loader` and `action` over the three
40
+ janus-oauth GraphQL calls (passed in as functions): the sign-in round trip,
41
+ `no-store`, the expired state, the step-up refusal and a callback address
42
+ that is only followed when it is http(s). `useConsent`
43
+ (`/oauth/react`) posts the decision, replays it after the shared step-up
44
+ and follows the callback. See the [OAuth guide](guide/oauth.md).
45
+
38
46
  ## Next
39
47
 
40
48
  Nothing planned.
@@ -86,7 +94,15 @@ Nothing planned.
86
94
 
87
95
  ## Shipped
88
96
 
89
- ### Unreleased
97
+ ### 0.41.0
98
+
99
+ - **`useVisibleInterval` and `useVisibleRevalidation` on `/fetcher`.** A
100
+ callback, or a revalidation of the route's loaders, runs every N ms only
101
+ while the page is visible, and at once when a hidden page comes back after
102
+ more than N ms. See [Fresh data while the page is
103
+ visible](guide/fetcher.md#fresh-data-while-the-page-is-visible-usevisibleintervalcallback-ms-options).
104
+
105
+ ### 0.40.0
90
106
 
91
107
  - **A delay-only global bucket for `createSignInThrottle`.** With
92
108
  `globalMode: 'delay'`, `wait` answers per key only and `globalDelay()` gives
@@ -95,6 +111,8 @@ Nothing planned.
95
111
  real user away. Refusing stays the default. See [A delay-only global
96
112
  bucket](guide/session.md#a-delay-only-global-bucket).
97
113
 
114
+ ### 0.39.0
115
+
98
116
  - **`createSignInThrottle` on `/session`.** A failed-attempt throttle for a
99
117
  password sign-in: `wait`, `fail` and `clear` per key within a window, a
100
118
  bounded number of keys, a global bucket that delays rather than locks, and
@@ -102,11 +120,15 @@ Nothing planned.
102
120
  past a short queue. Per process. See [A sign-in
103
121
  throttle](guide/session.md#a-sign-in-throttle).
104
122
 
123
+ ### 0.38.0
124
+
105
125
  - **`useFetcherPromise` on `/fetcher`.** `run(submit)` returns a Promise
106
126
  settled by the fetcher's next new answer, rejected with an `Error` when your
107
127
  `refusal(answer)` returns a message. See [One submission's answer as a
108
128
  Promise](guide/fetcher.md#one-submissions-answer-as-a-promise-usefetcherpromisefetcher-options).
109
129
 
130
+ ### 0.35.0
131
+
110
132
  - **A health probe in `createServer`.** `health: '/health'` or `{ path,
111
133
  check, timeout }` declares `@alxia/core`'s liveness probe, in text, before
112
134
  every middleware. See [A health
@@ -3105,3 +3105,42 @@ action, a guard that returned early) produces no answer, so nothing ends it.
3105
3105
 
3106
3106
  **Fix:** make `replay` post the refused action, or call `cancel()` when it has
3107
3107
  nothing to send.
3108
+
3109
+ ### The consent page shows "expired" right after a decision
3110
+
3111
+ **When:** a viewer allows or denies a client and, before the browser reaches the client's callback, the page replaces itself with the expired state.
3112
+
3113
+ **Why:** the decision spends the request, so the route's loader reloaded after the action's submission and the API no longer knew the request.
3114
+
3115
+ **Fix:** export `consentShouldRevalidate` from `@alxia/react-router/oauth` as the route's `shouldRevalidate`:
3116
+
3117
+ ```ts
3118
+ import { consentShouldRevalidate } from '@alxia/react-router/oauth';
3119
+
3120
+ export const shouldRevalidate = consentShouldRevalidate;
3121
+ ```
3122
+
3123
+ ### A decision answers `ok: false` with an empty `error`
3124
+
3125
+ **When:** `useConsent` leaves `error` unset and the card does nothing after a click.
3126
+
3127
+ **Why:** an empty `error` goes with `stepUp: true` (the session needs confirming: the dialog opens, and the decision is posted again once confirmed) or `expired: true` (the API no longer knows the request). If neither dialog nor expired state shows, the page did not pass `dialogProps` to its step-up dialog or did not render `expired`.
3128
+
3129
+ **Fix:** render `<StepUpDialog {...dialogProps} />` and the `expired` state beside the card.
3130
+
3131
+ ### `oauthConsent` treats a failing `find` as an expired request
3132
+
3133
+ **When:** every failure of `find` renders the expired page, or a real "unknown request" is thrown to the error page.
3134
+
3135
+ **Why:** only an API refusal that `notFound` accepts (a 404 by default) means "unknown request"; anything else is thrown.
3136
+
3137
+ **Fix:** pass `notFound: (error) => error.code === '<your API's code>'` when your API words it with another status.
3138
+
3139
+ ### The decision fails with "The API answered a redirect that is not an address"
3140
+
3141
+ **When:** approving or denying throws instead of sending the browser to the client's callback.
3142
+
3143
+ **Why:** `oauthConsent` follows a callback only when it is an `http:` or `https:` address (a `javascript:` or `data:` one would run in your page). The `redirectTo` your `approve` or `deny` returned was something else, or not a string.
3144
+
3145
+ **Fix:** return the API's `approveOAuthRequest` / `denyOAuthRequest` object as is (`{ redirectTo }`), and check the client's registered redirect URI is an http(s) address.
3146
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alxia/react-router",
3
- "version": "0.40.0",
4
- "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`)",
3
+ "version": "0.42.0",
4
+ "description": "alxia as the server of a React Router framework app: server rendering behind the app's hooks, loaders reading its typed context, the client's assets served; the code with no UI an installable app shares (`/pwa`), its hooks (`/pwa/react`), `noCache` for the worker's script (`/pwa/server`), the service worker on Workbox (`/pwa/worker`) and its build (`/pwa/build`), an API's refusals worded for the page (`/errors`), the BFF session: cookies, sign-in, step-up and mail links (`/session`), its page hooks (`/session/react`) and what it shares with the page (`/session/model`), a GraphQL subscription relayed to the browser over SSE (`/subscription`), the page's CSP nonce and inline boot scripts (`/document`) and a streaming `entry.server` (`/entry`), the consent screen of an OAuth server: a route's loader and action (`/oauth`) and the decision hook (`/oauth/react`)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.js",
@@ -82,6 +82,16 @@
82
82
  "import": "./dist/session/model/index.js",
83
83
  "default": "./dist/session/model/index.js"
84
84
  },
85
+ "./oauth": {
86
+ "types": "./dist/oauth/index.d.ts",
87
+ "import": "./dist/oauth/index.js",
88
+ "default": "./dist/oauth/index.js"
89
+ },
90
+ "./oauth/react": {
91
+ "types": "./dist/oauth/react/index.d.ts",
92
+ "import": "./dist/oauth/react/index.js",
93
+ "default": "./dist/oauth/react/index.js"
94
+ },
85
95
  "./lists": {
86
96
  "types": "./dist/lists/index.d.ts",
87
97
  "import": "./dist/lists/index.js",
@@ -131,6 +141,8 @@
131
141
  "src/session/react/index.ts",
132
142
  "src/subscription/index.ts",
133
143
  "src/lists/index.ts",
144
+ "src/oauth/index.ts",
145
+ "src/oauth/react/index.ts",
134
146
  "src/link/index.ts",
135
147
  "src/fetcher/index.ts",
136
148
  "src/document/index.ts",