@owlmeans/web-captcha 0.0.0-stage → 0.1.19-rc.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 (47) hide show
  1. package/README.md +77 -2
  2. package/agent-meta/manifest.json +16 -0
  3. package/agent-meta/skills/web-captcha/SKILL.md +80 -0
  4. package/build/consts.d.ts +16 -0
  5. package/build/consts.d.ts.map +1 -0
  6. package/build/consts.js +16 -0
  7. package/build/consts.js.map +1 -0
  8. package/build/errors.d.ts +7 -0
  9. package/build/errors.d.ts.map +1 -0
  10. package/build/errors.js +10 -0
  11. package/build/errors.js.map +1 -0
  12. package/build/index.d.ts +5 -0
  13. package/build/index.d.ts.map +1 -0
  14. package/build/index.js +4 -0
  15. package/build/index.js.map +1 -0
  16. package/build/loader.d.ts +9 -0
  17. package/build/loader.d.ts.map +1 -0
  18. package/build/loader.js +105 -0
  19. package/build/loader.js.map +1 -0
  20. package/build/react/types.d.ts +22 -0
  21. package/build/react/types.d.ts.map +1 -0
  22. package/build/react/types.js +2 -0
  23. package/build/react/types.js.map +1 -0
  24. package/build/react/use-captcha.d.ts +8 -0
  25. package/build/react/use-captcha.d.ts.map +1 -0
  26. package/build/react/use-captcha.js +56 -0
  27. package/build/react/use-captcha.js.map +1 -0
  28. package/build/react.d.ts +3 -0
  29. package/build/react.d.ts.map +1 -0
  30. package/build/react.js +2 -0
  31. package/build/react.js.map +1 -0
  32. package/build/types.d.ts +51 -0
  33. package/build/types.d.ts.map +1 -0
  34. package/build/types.js +2 -0
  35. package/build/types.js.map +1 -0
  36. package/package.json +48 -4
  37. package/src/consts.ts +20 -0
  38. package/src/errors.ts +12 -0
  39. package/src/index.ts +4 -0
  40. package/src/loader.ts +124 -0
  41. package/src/react/types.ts +23 -0
  42. package/src/react/use-captcha.ts +63 -0
  43. package/src/react.ts +2 -0
  44. package/src/types.ts +52 -0
  45. package/tests/context.ts +73 -0
  46. package/tests/loader.spec.ts +112 -0
  47. package/tsconfig.json +16 -0
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # @owlmeans/web-captcha
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The browser half of OwlMeans captcha: a loader for Google's invisible, score-based reCAPTCHA v3 and
4
+ a React hook over it. It has no `@owlmeans/*` dependencies, so a size-sensitive bundle (an embedded
5
+ widget) can carry it alone. The server half — verifying the token — is `@owlmeans/captcha`.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bun add @owlmeans/web-captcha@^0.1.19-rc.0
11
+ ```
12
+
13
+ ## Concepts
14
+
15
+ - **Lazy** — nothing touches the page at import. The script `api.js?render=<site key>` is injected
16
+ on the first `prepare` or `token` call, so the module is safe under server rendering and in tests;
17
+ without a page a call fails with `CaptchaLoadError('unavailable')`.
18
+ - **One script per site key** — concurrent and later calls share one load per loader; use the shared
19
+ `captchaLoader` so every form of a page shares it too. A failed load is forgotten, so the next call
20
+ injects the script again.
21
+ - **Badge** — by default the loader adds one document style hiding Google's badge. A page that hides
22
+ it must show Google's notice ("This site is protected by reCAPTCHA and the Google Privacy Policy
23
+ and Terms of Service apply.") next to the form.
24
+ - **Failures** — every failure is a `CaptchaLoadError` whose `reason` is `load` (the script did not
25
+ load), `timeout` (not ready within `loadTimeout`, 15 s), `unavailable` (no page, or the script
26
+ came without its API) or `execute` (no token within `executeTimeout`, 20 s).
27
+
28
+ ## Usage
29
+
30
+ ```typescript
31
+ import { captchaLoader } from '@owlmeans/web-captcha'
32
+
33
+ void captchaLoader.prepare(siteKey) // e.g. when the form opens
34
+ const token = await captchaLoader.token(siteKey, 'email_pin') // per submit; send it to the server
35
+ ```
36
+
37
+ ```tsx
38
+ import { useCaptcha } from '@owlmeans/web-captcha/react'
39
+
40
+ const { ready, error, execute } = useCaptcha({ siteKey: options.captcha?.siteKey ?? null, action: 'email_pin' })
41
+
42
+ const submit = async () => {
43
+ const captcha = await execute() // null while the captcha is off
44
+ await api.sendPin({ email, ...(captcha != null ? { captcha } : {}) })
45
+ }
46
+ ```
47
+
48
+ ## API
49
+
50
+ | Export | Description |
51
+ |---|---|
52
+ | `createCaptchaLoader(options?)` | A `CaptchaLoader`: `prepare(siteKey)`, `token(siteKey, action)` |
53
+ | `captchaLoader` | The shared ready instance |
54
+ | `CaptchaLoadError` | `Error` with `reason: 'load' \| 'timeout' \| 'execute' \| 'unavailable'` |
55
+ | `RECAPTCHA_SCRIPT_URL`, `CAPTCHA_LOAD_TIMEOUT`, `CAPTCHA_EXECUTE_TIMEOUT` | Google's script, `15_000`, `20_000` |
56
+ | `CAPTCHA_SCRIPT_ATTRIBUTE`, `CAPTCHA_STYLE_ATTRIBUTE`, `CAPTCHA_BADGE_CSS` | What the loader marks and injects |
57
+ | `CaptchaLoader`, `CaptchaLoaderOptions`, `CaptchaLoadErrorReason`, `Grecaptcha`, `CaptchaWindow`, `CaptchaDocument` | Types |
58
+ | `useCaptcha({ siteKey, action, loader? })` (`./react`) | `{ ready, error, execute }`; `siteKey` `null` = off: `ready` true, `execute()` resolves `null` |
59
+ | `UseCaptchaOptions`, `UseCaptchaResult` (`./react`) | The hook's types |
60
+
61
+ `CaptchaLoaderOptions`: `hideBadge` (default `true`), `loadTimeout`, `executeTimeout`, `scriptUrl`,
62
+ and `document` / `window` to load into another page (or a test's stand-in) instead of the globals.
63
+
64
+ <!-- owlmeans:agent-guidance:start -->
65
+ ## Agent guidance
66
+
67
+ This package ships embedded agent skills under `agent-meta/`. After installing your
68
+ `@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
69
+ your project's skill store (`.agents/skills/`):
70
+
71
+ ```sh
72
+ npx @owlmeans/agent-skills@^0.1.19-rc.0
73
+ ```
74
+
75
+ The embedded files are version-matched to this package release. Do not edit them
76
+ directly — they are regenerated on each publish. To contribute guidance edits,
77
+ open a PR against the source monorepo.
78
+ <!-- owlmeans:agent-guidance:end -->
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "package": "@owlmeans/web-captcha",
4
+ "version": "0.1.19-rc.0",
5
+ "generatedAt": "2026-11-01T17:08:08.485Z",
6
+ "canonicalRepo": "https://github.com/owlmeans/common",
7
+ "entries": [
8
+ {
9
+ "kind": "skill",
10
+ "name": "web-captcha",
11
+ "category": "package-specific",
12
+ "file": "skills/web-captcha/SKILL.md",
13
+ "canonicalPath": ".agents/skills/web-captcha/SKILL.md"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: web-captcha
3
+ description: How to use @owlmeans/web-captcha — the browser half of OwlMeans captcha, with no @owlmeans dependencies. The lazy reCAPTCHA v3 loader (createCaptchaLoader, the shared captchaLoader — one script per site key, the badge hidden, a failed load forgotten), CaptchaLoadError and its four reasons, and the useCaptcha React hook (./react) that answers null while the captcha is off. Auto-invoked when a form or an embedded widget needs a reCAPTCHA v3 token, when importing captchaLoader or useCaptcha, or when phrasing a captcha load failure.
4
+ user-invocable: false
5
+ ---
6
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
7
+
8
+ # @owlmeans/web-captcha
9
+
10
+ **Layer:** Web (L0 — no `@owlmeans/*` dependencies; React an optional peer, used only by `./react`)
11
+ **Install:** `"@owlmeans/web-captcha": "^0.1.19-rc.0"` in `dependencies`
12
+ **Server half:** `@owlmeans/captcha` (`makeCaptchaCheck` verifies what this produces)
13
+
14
+ Zero framework dependencies is deliberate: a size-sensitive bundle — an embedded IIFE widget that
15
+ must not pull ajv in through an `@owlmeans` package index — can carry it alone.
16
+
17
+ ## Key Exports
18
+
19
+ | Export | Description |
20
+ |---|---|
21
+ | `createCaptchaLoader(options?)` | A `CaptchaLoader`: `prepare(siteKey)`, `token(siteKey, action)` |
22
+ | `captchaLoader` | The shared ready instance — use it so every form of a page shares one script |
23
+ | `CaptchaLoadError` | `Error` with `reason: 'load' \| 'timeout' \| 'execute' \| 'unavailable'` |
24
+ | `RECAPTCHA_SCRIPT_URL`, `CAPTCHA_LOAD_TIMEOUT`, `CAPTCHA_EXECUTE_TIMEOUT` | Google's `api.js`, `15_000`, `20_000` |
25
+ | `CAPTCHA_SCRIPT_ATTRIBUTE`, `CAPTCHA_STYLE_ATTRIBUTE`, `CAPTCHA_BADGE_CSS` | `data-owlmeans-captcha`, `data-owlmeans-captcha-style`, `.grecaptcha-badge{visibility:hidden}` |
26
+ | `useCaptcha({ siteKey, action, loader? })` (`./react`) | `{ ready, error, execute }` |
27
+ | `CaptchaLoaderOptions` | `hideBadge` (default `true`), `loadTimeout`, `executeTimeout`, `scriptUrl`, `document` / `window` (load into another page or a test stand-in) |
28
+
29
+ ## Behaviour
30
+
31
+ - **Lazy.** Nothing touches the page at import: `api.js?render=<site key>` is injected on the first
32
+ `prepare` or `token`. Safe under server rendering and in tests; without a page a call fails with
33
+ `CaptchaLoadError('unavailable')`.
34
+ - **One script per site key per loader**; concurrent calls share the load. A failed load is
35
+ forgotten, so the next call injects the script again.
36
+ - **Badge.** The loader adds one document rule hiding Google's badge. A page that hides it must show
37
+ Google's notice next to the form ("This site is protected by reCAPTCHA and the Google Privacy
38
+ Policy and Terms of Service apply.", both linked).
39
+ - **Failures** — `load` (script did not load), `timeout` (not ready within 15 s), `unavailable` (no
40
+ page, or the script came without its API), `execute` (no token within 20 s). A caller maps the
41
+ reason to its own sentence: `load`/`timeout`/`unavailable` read as "blocked" (an extension or a
42
+ network blocks Google), `execute` as "verification failed".
43
+
44
+ ## Usage
45
+
46
+ ```typescript
47
+ import { captchaLoader } from '@owlmeans/web-captcha'
48
+
49
+ void captchaLoader.prepare(siteKey) // when the form opens
50
+ const token = await captchaLoader.token(siteKey, 'inquiry') // per submit — never reuse a token
51
+ ```
52
+
53
+ ```tsx
54
+ import { useCaptcha } from '@owlmeans/web-captcha/react'
55
+
56
+ const { ready, error, execute } = useCaptcha({ siteKey: options.captcha?.siteKey ?? null, action: 'email_pin' })
57
+
58
+ const submit = async () => {
59
+ const captcha = await execute() // null while the captcha is off; throws CaptchaLoadError
60
+ await api.sendPin({ email, ...(captcha != null ? { captcha } : {}) })
61
+ }
62
+ ```
63
+
64
+ ## Rules
65
+
66
+ - `siteKey` `null` or `''` means OFF: `ready` is true at once and `execute()` resolves `null` — send
67
+ no captcha field at all rather than an empty one.
68
+ - Fetch a fresh token for every protected request: a token is verifiable once, within two minutes,
69
+ and is bound to the action it was executed for — the server's strict check compares it exactly.
70
+ - Load the script on the user's first interaction with the form (`prepare` on open), not with the
71
+ page: Google's script is a third-party request the cookie notice may not yet cover.
72
+
73
+ ## External docs
74
+
75
+ - https://developers.google.com/recaptcha/docs/v3 — `api.js?render=<site key>`, `grecaptcha.ready` then `execute(siteKey, { action })` per protected action
76
+
77
+ ## Related
78
+
79
+ `captcha` (the server half) · `web-auth-email` (the e-mail card's send-PIN and password sign-in) ·
80
+ `common-inquiry` / `web-inquiry` (the inquiry widget a CRM serves loads reCAPTCHA through this package)
@@ -0,0 +1,16 @@
1
+ /** Google's reCAPTCHA v3 script; `?render=<site key>` selects the invisible, score-based widget. */
2
+ export declare const RECAPTCHA_SCRIPT_URL = "https://www.google.com/recaptcha/api.js";
3
+ /** Marks the script the loader injected; its value is the site key. */
4
+ export declare const CAPTCHA_SCRIPT_ATTRIBUTE = "data-owlmeans-captcha";
5
+ /** Marks the document style that hides Google's badge. */
6
+ export declare const CAPTCHA_STYLE_ATTRIBUTE = "data-owlmeans-captcha-style";
7
+ /**
8
+ * The one document-level rule the loader adds unless told not to: Google's badge hidden. A page
9
+ * that hides it must show Google's notice ("This site is protected by reCAPTCHA…") itself.
10
+ */
11
+ export declare const CAPTCHA_BADGE_CSS = ".grecaptcha-badge{visibility:hidden}";
12
+ /** How long the script may take to become ready, in milliseconds. */
13
+ export declare const CAPTCHA_LOAD_TIMEOUT = 15000;
14
+ /** How long one `execute` may take, in milliseconds. */
15
+ export declare const CAPTCHA_EXECUTE_TIMEOUT = 20000;
16
+ //# sourceMappingURL=consts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,4CAA4C,CAAA;AAE7E,uEAAuE;AACvE,eAAO,MAAM,wBAAwB,0BAA0B,CAAA;AAE/D,0DAA0D;AAC1D,eAAO,MAAM,uBAAuB,gCAAgC,CAAA;AAEpE;;;GAGG;AACH,eAAO,MAAM,iBAAiB,yCAAyC,CAAA;AAEvE,qEAAqE;AACrE,eAAO,MAAM,oBAAoB,QAAS,CAAA;AAE1C,wDAAwD;AACxD,eAAO,MAAM,uBAAuB,QAAS,CAAA"}
@@ -0,0 +1,16 @@
1
+ /** Google's reCAPTCHA v3 script; `?render=<site key>` selects the invisible, score-based widget. */
2
+ export const RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js';
3
+ /** Marks the script the loader injected; its value is the site key. */
4
+ export const CAPTCHA_SCRIPT_ATTRIBUTE = 'data-owlmeans-captcha';
5
+ /** Marks the document style that hides Google's badge. */
6
+ export const CAPTCHA_STYLE_ATTRIBUTE = 'data-owlmeans-captcha-style';
7
+ /**
8
+ * The one document-level rule the loader adds unless told not to: Google's badge hidden. A page
9
+ * that hides it must show Google's notice ("This site is protected by reCAPTCHA…") itself.
10
+ */
11
+ export const CAPTCHA_BADGE_CSS = '.grecaptcha-badge{visibility:hidden}';
12
+ /** How long the script may take to become ready, in milliseconds. */
13
+ export const CAPTCHA_LOAD_TIMEOUT = 15_000;
14
+ /** How long one `execute` may take, in milliseconds. */
15
+ export const CAPTCHA_EXECUTE_TIMEOUT = 20_000;
16
+ //# sourceMappingURL=consts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,oGAAoG;AACpG,MAAM,CAAC,MAAM,oBAAoB,GAAG,yCAAyC,CAAA;AAE7E,uEAAuE;AACvE,MAAM,CAAC,MAAM,wBAAwB,GAAG,uBAAuB,CAAA;AAE/D,0DAA0D;AAC1D,MAAM,CAAC,MAAM,uBAAuB,GAAG,6BAA6B,CAAA;AAEpE;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,sCAAsC,CAAA;AAEvE,qEAAqE;AACrE,MAAM,CAAC,MAAM,oBAAoB,GAAG,MAAM,CAAA;AAE1C,wDAAwD;AACxD,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAA"}
@@ -0,0 +1,7 @@
1
+ import type { CaptchaLoadErrorReason } from './types.js';
2
+ /** The captcha could not give a token; `reason` says which step failed. */
3
+ export declare class CaptchaLoadError extends Error {
4
+ readonly reason: CaptchaLoadErrorReason;
5
+ constructor(reason: CaptchaLoadErrorReason, message?: string, options?: ErrorOptions);
6
+ }
7
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAA;AAExD,2EAA2E;AAC3E,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,MAAM,EAAE,sBAAsB,CAAA;IAE9C,YAAY,MAAM,EAAE,sBAAsB,EAAE,OAAO,GAAE,MAA4B,EAAE,OAAO,CAAC,EAAE,YAAY,EAIxG;CACF"}
@@ -0,0 +1,10 @@
1
+ /** The captcha could not give a token; `reason` says which step failed. */
2
+ export class CaptchaLoadError extends Error {
3
+ reason;
4
+ constructor(reason, message = `captcha:${reason}`, options) {
5
+ super(message, options);
6
+ this.name = 'CaptchaLoadError';
7
+ this.reason = reason;
8
+ }
9
+ }
10
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA,2EAA2E;AAC3E,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IACzB,MAAM,CAAwB;IAE9C,YAAY,MAA8B,EAAE,OAAO,GAAW,WAAW,MAAM,EAAE,EAAE,OAAsB;QACvG,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QACvB,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAA;QAC9B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF"}
@@ -0,0 +1,5 @@
1
+ export type * from './types.js';
2
+ export * from './consts.js';
3
+ export * from './errors.js';
4
+ export * from './loader.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './consts.js';
2
+ export * from './errors.js';
3
+ export * from './loader.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
@@ -0,0 +1,9 @@
1
+ import type { CaptchaLoader, CaptchaLoaderOptions } from './types.js';
2
+ /**
3
+ * Google's reCAPTCHA v3 loader. Nothing touches the page until `prepare` or `token` is called, so
4
+ * the module is safe to import during server rendering and in tests.
5
+ */
6
+ export declare const createCaptchaLoader: (options?: CaptchaLoaderOptions) => CaptchaLoader;
7
+ /** The shared loader of a page: one script per site key across every caller. */
8
+ export declare const captchaLoader: CaptchaLoader;
9
+ //# sourceMappingURL=loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader.d.ts","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAC+B,aAAa,EAAE,oBAAoB,EAC7E,MAAM,YAAY,CAAA;AAEnB;;;GAGG;AACH,eAAO,MAAM,mBAAmB,aAAa,oBAAoB,KAAQ,aA2GxE,CAAA;AAED,gFAAgF;AAChF,eAAO,MAAM,aAAa,eAAwB,CAAA"}
@@ -0,0 +1,105 @@
1
+ import { CAPTCHA_BADGE_CSS, CAPTCHA_EXECUTE_TIMEOUT, CAPTCHA_LOAD_TIMEOUT, CAPTCHA_SCRIPT_ATTRIBUTE, CAPTCHA_STYLE_ATTRIBUTE, RECAPTCHA_SCRIPT_URL, } from './consts.js';
2
+ import { CaptchaLoadError } from './errors.js';
3
+ /**
4
+ * Google's reCAPTCHA v3 loader. Nothing touches the page until `prepare` or `token` is called, so
5
+ * the module is safe to import during server rendering and in tests.
6
+ */
7
+ export const createCaptchaLoader = (options = {}) => {
8
+ const hideBadge = options.hideBadge ?? true;
9
+ const loadTimeout = options.loadTimeout ?? CAPTCHA_LOAD_TIMEOUT;
10
+ const executeTimeout = options.executeTimeout ?? CAPTCHA_EXECUTE_TIMEOUT;
11
+ const scriptUrl = options.scriptUrl ?? RECAPTCHA_SCRIPT_URL;
12
+ const loads = new Map();
13
+ const documentOf = () => options.document ?? (typeof document !== 'undefined' ? document : null);
14
+ const windowOf = () => options.window ?? (typeof window !== 'undefined' ? window : null);
15
+ const addBadgeStyle = (page) => {
16
+ if (page.querySelector(`style[${CAPTCHA_STYLE_ATTRIBUTE}]`) != null) {
17
+ return;
18
+ }
19
+ const style = page.createElement('style');
20
+ style.setAttribute(CAPTCHA_STYLE_ATTRIBUTE, '');
21
+ style.textContent = CAPTCHA_BADGE_CSS;
22
+ page.head.appendChild(style);
23
+ };
24
+ const inject = (siteKey) => new Promise((resolve, reject) => {
25
+ const page = documentOf();
26
+ const host = windowOf();
27
+ if (page == null || host == null) {
28
+ reject(new CaptchaLoadError('unavailable', 'reCAPTCHA needs a page to load into'));
29
+ return;
30
+ }
31
+ const script = page.createElement('script');
32
+ let settled = false;
33
+ let timer;
34
+ const fail = (reason, message) => {
35
+ if (!settled) {
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ script.remove();
39
+ reject(new CaptchaLoadError(reason, message));
40
+ }
41
+ };
42
+ timer = setTimeout(() => { fail('timeout', 'reCAPTCHA did not become ready in time'); }, loadTimeout);
43
+ script.addEventListener('error', () => { fail('load', 'reCAPTCHA failed to load'); });
44
+ script.addEventListener('load', () => {
45
+ const grecaptcha = host.grecaptcha;
46
+ if (grecaptcha == null || typeof grecaptcha.ready !== 'function') {
47
+ fail('unavailable', 'reCAPTCHA loaded without its API');
48
+ return;
49
+ }
50
+ grecaptcha.ready(() => {
51
+ if (!settled) {
52
+ settled = true;
53
+ clearTimeout(timer);
54
+ resolve(host.grecaptcha ?? grecaptcha);
55
+ }
56
+ });
57
+ });
58
+ script.src = `${scriptUrl}${scriptUrl.includes('?') ? '&' : '?'}render=${encodeURIComponent(siteKey)}`;
59
+ script.async = true;
60
+ script.setAttribute(CAPTCHA_SCRIPT_ATTRIBUTE, siteKey);
61
+ if (hideBadge) {
62
+ addBadgeStyle(page);
63
+ }
64
+ page.head.appendChild(script);
65
+ });
66
+ const readyOf = (siteKey) => {
67
+ const known = loads.get(siteKey);
68
+ if (known != null) {
69
+ return known;
70
+ }
71
+ const loading = inject(siteKey);
72
+ loads.set(siteKey, loading);
73
+ loading.catch(() => { loads.delete(siteKey); });
74
+ return loading;
75
+ };
76
+ const prepare = async (siteKey) => {
77
+ await readyOf(siteKey);
78
+ };
79
+ const token = async (siteKey, action) => {
80
+ const grecaptcha = await readyOf(siteKey);
81
+ let timer;
82
+ try {
83
+ const value = await Promise.race([
84
+ Promise.resolve(grecaptcha.execute(siteKey, { action })),
85
+ new Promise((_, reject) => {
86
+ timer = setTimeout(() => { reject(new Error('reCAPTCHA execute timed out')); }, executeTimeout);
87
+ }),
88
+ ]);
89
+ if (typeof value !== 'string' || value === '') {
90
+ throw new Error('reCAPTCHA gave no token');
91
+ }
92
+ return value;
93
+ }
94
+ catch (error) {
95
+ throw new CaptchaLoadError('execute', `reCAPTCHA refused: ${String(error)}`, { cause: error });
96
+ }
97
+ finally {
98
+ clearTimeout(timer);
99
+ }
100
+ };
101
+ return { prepare, token };
102
+ };
103
+ /** The shared loader of a page: one script per site key across every caller. */
104
+ export const captchaLoader = createCaptchaLoader();
105
+ //# sourceMappingURL=loader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader.js","sourceRoot":"","sources":["../src/loader.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,iBAAiB,EAAE,uBAAuB,EAAE,oBAAoB,EAAE,wBAAwB,EAAE,uBAAuB,EACnH,oBAAoB,GACrB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAK9C;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,OAAO,GAAyB,EAAE,EAAiB,EAAE;IACvF,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,IAAI,CAAA;IAC3C,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,oBAAoB,CAAA;IAC/D,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,IAAI,uBAAuB,CAAA;IACxE,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,oBAAoB,CAAA;IAC3D,MAAM,KAAK,GAAG,IAAI,GAAG,EAA+B,CAAA;IAEpD,MAAM,UAAU,GAAG,GAA2B,EAAE,CAC9C,OAAO,CAAC,QAAQ,IAAI,CAAC,OAAO,QAAQ,KAAK,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAEzE,MAAM,QAAQ,GAAG,GAAyB,EAAE,CAC1C,OAAO,CAAC,MAAM,IAAI,CAAC,OAAO,MAAM,KAAK,WAAW,CAAC,CAAC,CAAC,MAAkC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAE/F,MAAM,aAAa,GAAG,CAAC,IAAqB,EAAQ,EAAE;QACpD,IAAI,IAAI,CAAC,aAAa,CAAC,SAAS,uBAAuB,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC;YACpE,OAAM;QACR,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,CAAA;QACzC,KAAK,CAAC,YAAY,CAAC,uBAAuB,EAAE,EAAE,CAAC,CAAA;QAC/C,KAAK,CAAC,WAAW,GAAG,iBAAiB,CAAA;QACrC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAA;IAC9B,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,CAAC,OAAe,EAAuB,EAAE,CAAC,IAAI,OAAO,CAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACnG,MAAM,IAAI,GAAG,UAAU,EAAE,CAAA;QACzB,MAAM,IAAI,GAAG,QAAQ,EAAE,CAAA;QACvB,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACjC,MAAM,CAAC,IAAI,gBAAgB,CAAC,aAAa,EAAE,qCAAqC,CAAC,CAAC,CAAA;YAClF,OAAM;QACR,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;QAC3C,IAAI,OAAO,GAAG,KAAK,CAAA;QACnB,IAAI,KAAgD,CAAA;QACpD,MAAM,IAAI,GAAG,CAAC,MAA8B,EAAE,OAAe,EAAQ,EAAE;YACrE,IAAI,CAAC,OAAO,EAAE,CAAC;gBACb,OAAO,GAAG,IAAI,CAAA;gBACd,YAAY,CAAC,KAAK,CAAC,CAAA;gBACnB,MAAM,CAAC,MAAM,EAAE,CAAA;gBACf,MAAM,CAAC,IAAI,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;YAC/C,CAAC;QACH,CAAC,CAAA;QACD,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,wCAAwC,CAAC,CAAA,CAAC,CAAC,EAAE,WAAW,CAAC,CAAA;QAEpG,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,0BAA0B,CAAC,CAAA,CAAC,CAAC,CAAC,CAAA;QACpF,MAAM,CAAC,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE;YACnC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAA;YAClC,IAAI,UAAU,IAAI,IAAI,IAAI,OAAO,UAAU,CAAC,KAAK,KAAK,UAAU,EAAE,CAAC;gBACjE,IAAI,CAAC,aAAa,EAAE,kCAAkC,CAAC,CAAA;gBACvD,OAAM;YACR,CAAC;YACD,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE;gBACpB,IAAI,CAAC,OAAO,EAAE,CAAC;oBACb,OAAO,GAAG,IAAI,CAAA;oBACd,YAAY,CAAC,KAAK,CAAC,CAAA;oBACnB,OAAO,CAAC,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC,CAAA;gBACxC,CAAC;YACH,CAAC,CAAC,CAAA;QACJ,CAAC,CAAC,CAAA;QAEF,MAAM,CAAC,GAAG,GAAG,GAAG,SAAS,GAAG,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,UAAU,kBAAkB,CAAC,OAAO,CAAC,EAAE,CAAA;QACtG,MAAM,CAAC,KAAK,GAAG,IAAI,CAAA;QACnB,MAAM,CAAC,YAAY,CAAC,wBAAwB,EAAE,OAAO,CAAC,CAAA;QACtD,IAAI,SAAS,EAAE,CAAC;YACd,aAAa,CAAC,IAAI,CAAC,CAAA;QACrB,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAA;IAC/B,CAAC,CAAC,CAAA;IAEF,MAAM,OAAO,GAAG,CAAC,OAAe,EAAuB,EAAE;QACvD,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;QAChC,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;YAClB,OAAO,KAAK,CAAA;QACd,CAAC;QACD,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,CAAA;QAC/B,KAAK,CAAC,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QAC3B,OAAO,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA,CAAC,CAAC,CAAC,CAAA;QAE9C,OAAO,OAAO,CAAA;IAChB,CAAC,CAAA;IAED,MAAM,OAAO,GAA6B,KAAK,EAAC,OAAO,EAAC,EAAE;QACxD,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IACxB,CAAC,CAAA;IAED,MAAM,KAAK,GAA2B,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE;QAC9D,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;QACzC,IAAI,KAAgD,CAAA;QACpD,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC;gBAC/B,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;gBACxD,IAAI,OAAO,CAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE;oBAC/B,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC,CAAA,CAAC,CAAC,EAAE,cAAc,CAAC,CAAA;gBAChG,CAAC,CAAC;aACH,CAAC,CAAA;YACF,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;gBAC9C,MAAM,IAAI,KAAK,CAAC,yBAAyB,CAAC,CAAA;YAC5C,CAAC;YACD,OAAO,KAAK,CAAA;QACd,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,MAAM,IAAI,gBAAgB,CAAC,SAAS,EAAE,sBAAsB,MAAM,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAA;QAChG,CAAC;gBAAS,CAAC;YACT,YAAY,CAAC,KAAK,CAAC,CAAA;QACrB,CAAC;IACH,CAAC,CAAA;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAA;AAC3B,CAAC,CAAA;AAED,gFAAgF;AAChF,MAAM,CAAC,MAAM,aAAa,GAAG,mBAAmB,EAAE,CAAA"}
@@ -0,0 +1,22 @@
1
+ import type { CaptchaLoader } from '../types.js';
2
+ export interface UseCaptchaOptions {
3
+ /** The site key the server advertised; `null` (or `''`) = captcha off. */
4
+ siteKey: string | null;
5
+ /** The exact action the server checks this form's token for. */
6
+ action: string;
7
+ /** Default: the shared `captchaLoader`. */
8
+ loader?: CaptchaLoader;
9
+ }
10
+ export interface UseCaptchaResult {
11
+ /** The script is ready for the site key — or the captcha is off. */
12
+ ready: boolean;
13
+ /** The last load or execute failure, cleared by the next success. */
14
+ error: Error | null;
15
+ /**
16
+ * A fresh token for the action, or `null` while the captcha is off.
17
+ *
18
+ * @throws {CaptchaLoadError} when the script cannot load or Google gives no token.
19
+ */
20
+ execute: () => Promise<string | null>;
21
+ }
22
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/react/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAEhD,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAA;IACd,2CAA2C;IAC3C,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,KAAK,EAAE,OAAO,CAAA;IACd,qEAAqE;IACrE,KAAK,EAAE,KAAK,GAAG,IAAI,CAAA;IACnB;;;;OAIG;IACH,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACtC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/react/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,8 @@
1
+ import type { UseCaptchaOptions, UseCaptchaResult } from './types.js';
2
+ /**
3
+ * reCAPTCHA v3 for one form: starts loading the script as soon as a site key is known and gives a
4
+ * fresh token per submit. With no site key the captcha is off — `ready` is true and `execute`
5
+ * resolves `null`, so the form sends no captcha at all.
6
+ */
7
+ export declare const useCaptcha: ({ siteKey, action, loader }: UseCaptchaOptions) => UseCaptchaResult;
8
+ //# sourceMappingURL=use-captcha.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-captcha.d.ts","sourceRoot":"","sources":["../../src/react/use-captcha.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAKrE;;;;GAIG;AACH,eAAO,MAAM,UAAU,gCAAiD,iBAAiB,KAAG,gBAgD3F,CAAA"}
@@ -0,0 +1,56 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { CaptchaLoadError } from '../errors.js';
3
+ import { captchaLoader } from '../loader.js';
4
+ const errorOf = (failure, reason) => failure instanceof Error ? failure : new CaptchaLoadError(reason, String(failure));
5
+ /**
6
+ * reCAPTCHA v3 for one form: starts loading the script as soon as a site key is known and gives a
7
+ * fresh token per submit. With no site key the captcha is off — `ready` is true and `execute`
8
+ * resolves `null`, so the form sends no captcha at all.
9
+ */
10
+ export const useCaptcha = ({ siteKey, action, loader = captchaLoader }) => {
11
+ const key = siteKey != null && siteKey !== '' ? siteKey : null;
12
+ const [ready, setReady] = useState(key == null);
13
+ const [error, setError] = useState(null);
14
+ const mounted = useRef(true);
15
+ useEffect(() => {
16
+ mounted.current = true;
17
+ return () => { mounted.current = false; };
18
+ }, []);
19
+ useEffect(() => {
20
+ setError(null);
21
+ if (key == null) {
22
+ setReady(true);
23
+ return;
24
+ }
25
+ let current = true;
26
+ setReady(false);
27
+ loader.prepare(key).then(() => { if (current) {
28
+ setReady(true);
29
+ } }, (failure) => { if (current) {
30
+ setError(errorOf(failure, 'load'));
31
+ } });
32
+ return () => { current = false; };
33
+ }, [key, loader]);
34
+ const execute = useCallback(async () => {
35
+ if (key == null) {
36
+ return null;
37
+ }
38
+ try {
39
+ const token = await loader.token(key, action);
40
+ if (mounted.current) {
41
+ setReady(true);
42
+ setError(null);
43
+ }
44
+ return token;
45
+ }
46
+ catch (failure) {
47
+ const reported = errorOf(failure, 'execute');
48
+ if (mounted.current) {
49
+ setError(reported);
50
+ }
51
+ throw reported;
52
+ }
53
+ }, [key, action, loader]);
54
+ return { ready, error, execute };
55
+ };
56
+ //# sourceMappingURL=use-captcha.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-captcha.js","sourceRoot":"","sources":["../../src/react/use-captcha.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export type * from './react/types.js';
2
+ export * from './react/use-captcha.js';
3
+ //# sourceMappingURL=react.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAAA,mBAAmB,kBAAkB,CAAA;AACrC,cAAc,wBAAwB,CAAA"}
package/build/react.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from './react/use-captcha.js';
2
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AACA,cAAc,wBAAwB,CAAA"}
@@ -0,0 +1,51 @@
1
+ /** The part of Google's `grecaptcha` (v3, `render=<site key>`) the loader uses. */
2
+ export interface Grecaptcha {
3
+ ready: (callback: () => void) => void;
4
+ execute: (siteKey: string, options: {
5
+ action: string;
6
+ }) => PromiseLike<string>;
7
+ }
8
+ /** The window as the loader reads it: only Google's global. */
9
+ export interface CaptchaWindow {
10
+ grecaptcha?: Grecaptcha;
11
+ }
12
+ /** The document as the loader uses it: one script and one style appended to its head. */
13
+ export interface CaptchaDocument extends Pick<Document, 'createElement' | 'querySelector' | 'head'> {
14
+ }
15
+ /**
16
+ * Why a captcha could not give a token: the script failed to `load`, did not become ready in time
17
+ * (`timeout`), there is no page or no API to use (`unavailable`), or Google gave no token (`execute`).
18
+ */
19
+ export type CaptchaLoadErrorReason = 'load' | 'timeout' | 'execute' | 'unavailable';
20
+ export interface CaptchaLoaderOptions {
21
+ /** Add the document style that hides Google's badge. Default `true`. */
22
+ hideBadge?: boolean;
23
+ /** Milliseconds the script may take to become ready. Default `CAPTCHA_LOAD_TIMEOUT`. */
24
+ loadTimeout?: number;
25
+ /** Milliseconds one `execute` may take. Default `CAPTCHA_EXECUTE_TIMEOUT`. */
26
+ executeTimeout?: number;
27
+ /** The script address `?render=<site key>` is added to. Default `RECAPTCHA_SCRIPT_URL`. */
28
+ scriptUrl?: string;
29
+ /** The page to load into. Default: the global `document`, read at call time. */
30
+ document?: CaptchaDocument;
31
+ /** Where Google's global appears. Default: the global `window`, read at call time. */
32
+ window?: CaptchaWindow;
33
+ }
34
+ /** Google's reCAPTCHA v3, loaded on first use — never at import. */
35
+ export interface CaptchaLoader {
36
+ /**
37
+ * Inject `api.js?render=<siteKey>` once per key (and, unless disabled, the document style that
38
+ * hides Google's badge); resolves once `grecaptcha` is ready. A failure is forgotten, so the next
39
+ * call injects again.
40
+ *
41
+ * @throws {CaptchaLoadError} `load`, `timeout` or `unavailable`.
42
+ */
43
+ prepare: (siteKey: string) => Promise<void>;
44
+ /**
45
+ * A fresh token for `action`, loading the script first when needed.
46
+ *
47
+ * @throws {CaptchaLoadError} what `prepare` throws, or `execute` when Google gives no token in time.
48
+ */
49
+ token: (siteKey: string, action: string) => Promise<string>;
50
+ }
51
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,IAAI,CAAA;IACrC,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,WAAW,CAAC,MAAM,CAAC,CAAA;CAC/E;AAED,+DAA+D;AAC/D,MAAM,WAAW,aAAa;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAA;CACxB;AAED,yFAAyF;AACzF,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,eAAe,GAAG,MAAM,CAAC;CAAG;AAEtG;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAA;AAEnF,MAAM,WAAW,oBAAoB;IACnC,wEAAwE;IACxE,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,wFAAwF;IACxF,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,sFAAsF;IACtF,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB;AAED,oEAAoE;AACpE,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,OAAO,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3C;;;;OAIG;IACH,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;CAC5D"}
package/build/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@owlmeans/web-captcha",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.19-rc.0",
4
+ "license": "MIT",
5
+ "description": "Browser half of OwlMeans captcha — the invisible score-based reCAPTCHA v3 loader and a React hook; no OwlMeans dependencies.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc -b",
9
+ "dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
10
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
11
+ "test": "bun test ./tests"
12
+ },
13
+ "main": "build/index.js",
14
+ "module": "build/index.js",
15
+ "types": "build/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./build/index.js",
19
+ "require": "./build/index.js",
20
+ "default": "./build/index.js",
21
+ "module": "./build/index.js",
22
+ "types": "./build/index.d.ts"
23
+ },
24
+ "./react": {
25
+ "import": "./build/react.js",
26
+ "require": "./build/react.js",
27
+ "default": "./build/react.js",
28
+ "module": "./build/react.js",
29
+ "types": "./build/react.d.ts"
30
+ }
31
+ },
32
+ "peerDependencies": {
33
+ "react": "*"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "react": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@owlmeans/dep-config": "^0.1.19-rc.0",
42
+ "@types/bun": "^1.4.2",
43
+ "@types/react": "^19.3.0",
44
+ "nodemon": "^3.1.14",
45
+ "typescript": "^7.0.2"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Google's reCAPTCHA v3 script; `?render=<site key>` selects the invisible, score-based widget. */
2
+ export const RECAPTCHA_SCRIPT_URL = 'https://www.google.com/recaptcha/api.js'
3
+
4
+ /** Marks the script the loader injected; its value is the site key. */
5
+ export const CAPTCHA_SCRIPT_ATTRIBUTE = 'data-owlmeans-captcha'
6
+
7
+ /** Marks the document style that hides Google's badge. */
8
+ export const CAPTCHA_STYLE_ATTRIBUTE = 'data-owlmeans-captcha-style'
9
+
10
+ /**
11
+ * The one document-level rule the loader adds unless told not to: Google's badge hidden. A page
12
+ * that hides it must show Google's notice ("This site is protected by reCAPTCHA…") itself.
13
+ */
14
+ export const CAPTCHA_BADGE_CSS = '.grecaptcha-badge{visibility:hidden}'
15
+
16
+ /** How long the script may take to become ready, in milliseconds. */
17
+ export const CAPTCHA_LOAD_TIMEOUT = 15_000
18
+
19
+ /** How long one `execute` may take, in milliseconds. */
20
+ export const CAPTCHA_EXECUTE_TIMEOUT = 20_000
package/src/errors.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { CaptchaLoadErrorReason } from './types.js'
2
+
3
+ /** The captcha could not give a token; `reason` says which step failed. */
4
+ export class CaptchaLoadError extends Error {
5
+ public readonly reason: CaptchaLoadErrorReason
6
+
7
+ constructor(reason: CaptchaLoadErrorReason, message: string = `captcha:${reason}`, options?: ErrorOptions) {
8
+ super(message, options)
9
+ this.name = 'CaptchaLoadError'
10
+ this.reason = reason
11
+ }
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type * from './types.js'
2
+ export * from './consts.js'
3
+ export * from './errors.js'
4
+ export * from './loader.js'
package/src/loader.ts ADDED
@@ -0,0 +1,124 @@
1
+ import {
2
+ CAPTCHA_BADGE_CSS, CAPTCHA_EXECUTE_TIMEOUT, CAPTCHA_LOAD_TIMEOUT, CAPTCHA_SCRIPT_ATTRIBUTE, CAPTCHA_STYLE_ATTRIBUTE,
3
+ RECAPTCHA_SCRIPT_URL,
4
+ } from './consts.js'
5
+ import { CaptchaLoadError } from './errors.js'
6
+ import type {
7
+ CaptchaDocument, CaptchaLoadErrorReason, CaptchaLoader, CaptchaLoaderOptions, CaptchaWindow, Grecaptcha,
8
+ } from './types.js'
9
+
10
+ /**
11
+ * Google's reCAPTCHA v3 loader. Nothing touches the page until `prepare` or `token` is called, so
12
+ * the module is safe to import during server rendering and in tests.
13
+ */
14
+ export const createCaptchaLoader = (options: CaptchaLoaderOptions = {}): CaptchaLoader => {
15
+ const hideBadge = options.hideBadge ?? true
16
+ const loadTimeout = options.loadTimeout ?? CAPTCHA_LOAD_TIMEOUT
17
+ const executeTimeout = options.executeTimeout ?? CAPTCHA_EXECUTE_TIMEOUT
18
+ const scriptUrl = options.scriptUrl ?? RECAPTCHA_SCRIPT_URL
19
+ const loads = new Map<string, Promise<Grecaptcha>>()
20
+
21
+ const documentOf = (): CaptchaDocument | null =>
22
+ options.document ?? (typeof document !== 'undefined' ? document : null)
23
+
24
+ const windowOf = (): CaptchaWindow | null =>
25
+ options.window ?? (typeof window !== 'undefined' ? window as unknown as CaptchaWindow : null)
26
+
27
+ const addBadgeStyle = (page: CaptchaDocument): void => {
28
+ if (page.querySelector(`style[${CAPTCHA_STYLE_ATTRIBUTE}]`) != null) {
29
+ return
30
+ }
31
+ const style = page.createElement('style')
32
+ style.setAttribute(CAPTCHA_STYLE_ATTRIBUTE, '')
33
+ style.textContent = CAPTCHA_BADGE_CSS
34
+ page.head.appendChild(style)
35
+ }
36
+
37
+ const inject = (siteKey: string): Promise<Grecaptcha> => new Promise<Grecaptcha>((resolve, reject) => {
38
+ const page = documentOf()
39
+ const host = windowOf()
40
+ if (page == null || host == null) {
41
+ reject(new CaptchaLoadError('unavailable', 'reCAPTCHA needs a page to load into'))
42
+ return
43
+ }
44
+
45
+ const script = page.createElement('script')
46
+ let settled = false
47
+ let timer: ReturnType<typeof setTimeout> | undefined
48
+ const fail = (reason: CaptchaLoadErrorReason, message: string): void => {
49
+ if (!settled) {
50
+ settled = true
51
+ clearTimeout(timer)
52
+ script.remove()
53
+ reject(new CaptchaLoadError(reason, message))
54
+ }
55
+ }
56
+ timer = setTimeout(() => { fail('timeout', 'reCAPTCHA did not become ready in time') }, loadTimeout)
57
+
58
+ script.addEventListener('error', () => { fail('load', 'reCAPTCHA failed to load') })
59
+ script.addEventListener('load', () => {
60
+ const grecaptcha = host.grecaptcha
61
+ if (grecaptcha == null || typeof grecaptcha.ready !== 'function') {
62
+ fail('unavailable', 'reCAPTCHA loaded without its API')
63
+ return
64
+ }
65
+ grecaptcha.ready(() => {
66
+ if (!settled) {
67
+ settled = true
68
+ clearTimeout(timer)
69
+ resolve(host.grecaptcha ?? grecaptcha)
70
+ }
71
+ })
72
+ })
73
+
74
+ script.src = `${scriptUrl}${scriptUrl.includes('?') ? '&' : '?'}render=${encodeURIComponent(siteKey)}`
75
+ script.async = true
76
+ script.setAttribute(CAPTCHA_SCRIPT_ATTRIBUTE, siteKey)
77
+ if (hideBadge) {
78
+ addBadgeStyle(page)
79
+ }
80
+ page.head.appendChild(script)
81
+ })
82
+
83
+ const readyOf = (siteKey: string): Promise<Grecaptcha> => {
84
+ const known = loads.get(siteKey)
85
+ if (known != null) {
86
+ return known
87
+ }
88
+ const loading = inject(siteKey)
89
+ loads.set(siteKey, loading)
90
+ loading.catch(() => { loads.delete(siteKey) })
91
+
92
+ return loading
93
+ }
94
+
95
+ const prepare: CaptchaLoader['prepare'] = async siteKey => {
96
+ await readyOf(siteKey)
97
+ }
98
+
99
+ const token: CaptchaLoader['token'] = async (siteKey, action) => {
100
+ const grecaptcha = await readyOf(siteKey)
101
+ let timer: ReturnType<typeof setTimeout> | undefined
102
+ try {
103
+ const value = await Promise.race([
104
+ Promise.resolve(grecaptcha.execute(siteKey, { action })),
105
+ new Promise<never>((_, reject) => {
106
+ timer = setTimeout(() => { reject(new Error('reCAPTCHA execute timed out')) }, executeTimeout)
107
+ }),
108
+ ])
109
+ if (typeof value !== 'string' || value === '') {
110
+ throw new Error('reCAPTCHA gave no token')
111
+ }
112
+ return value
113
+ } catch (error) {
114
+ throw new CaptchaLoadError('execute', `reCAPTCHA refused: ${String(error)}`, { cause: error })
115
+ } finally {
116
+ clearTimeout(timer)
117
+ }
118
+ }
119
+
120
+ return { prepare, token }
121
+ }
122
+
123
+ /** The shared loader of a page: one script per site key across every caller. */
124
+ export const captchaLoader = createCaptchaLoader()
@@ -0,0 +1,23 @@
1
+ import type { CaptchaLoader } from '../types.js'
2
+
3
+ export interface UseCaptchaOptions {
4
+ /** The site key the server advertised; `null` (or `''`) = captcha off. */
5
+ siteKey: string | null
6
+ /** The exact action the server checks this form's token for. */
7
+ action: string
8
+ /** Default: the shared `captchaLoader`. */
9
+ loader?: CaptchaLoader
10
+ }
11
+
12
+ export interface UseCaptchaResult {
13
+ /** The script is ready for the site key — or the captcha is off. */
14
+ ready: boolean
15
+ /** The last load or execute failure, cleared by the next success. */
16
+ error: Error | null
17
+ /**
18
+ * A fresh token for the action, or `null` while the captcha is off.
19
+ *
20
+ * @throws {CaptchaLoadError} when the script cannot load or Google gives no token.
21
+ */
22
+ execute: () => Promise<string | null>
23
+ }
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { CaptchaLoadError } from '../errors.js'
3
+ import { captchaLoader } from '../loader.js'
4
+ import type { CaptchaLoadErrorReason } from '../types.js'
5
+ import type { UseCaptchaOptions, UseCaptchaResult } from './types.js'
6
+
7
+ const errorOf = (failure: unknown, reason: CaptchaLoadErrorReason): Error =>
8
+ failure instanceof Error ? failure : new CaptchaLoadError(reason, String(failure))
9
+
10
+ /**
11
+ * reCAPTCHA v3 for one form: starts loading the script as soon as a site key is known and gives a
12
+ * fresh token per submit. With no site key the captcha is off — `ready` is true and `execute`
13
+ * resolves `null`, so the form sends no captcha at all.
14
+ */
15
+ export const useCaptcha = ({ siteKey, action, loader = captchaLoader }: UseCaptchaOptions): UseCaptchaResult => {
16
+ const key = siteKey != null && siteKey !== '' ? siteKey : null
17
+ const [ready, setReady] = useState<boolean>(key == null)
18
+ const [error, setError] = useState<Error | null>(null)
19
+ const mounted = useRef(true)
20
+
21
+ useEffect(() => {
22
+ mounted.current = true
23
+ return () => { mounted.current = false }
24
+ }, [])
25
+
26
+ useEffect(() => {
27
+ setError(null)
28
+ if (key == null) {
29
+ setReady(true)
30
+ return
31
+ }
32
+ let current = true
33
+ setReady(false)
34
+ loader.prepare(key).then(
35
+ () => { if (current) { setReady(true) } },
36
+ (failure: unknown) => { if (current) { setError(errorOf(failure, 'load')) } }
37
+ )
38
+
39
+ return () => { current = false }
40
+ }, [key, loader])
41
+
42
+ const execute = useCallback(async (): Promise<string | null> => {
43
+ if (key == null) {
44
+ return null
45
+ }
46
+ try {
47
+ const token = await loader.token(key, action)
48
+ if (mounted.current) {
49
+ setReady(true)
50
+ setError(null)
51
+ }
52
+ return token
53
+ } catch (failure) {
54
+ const reported = errorOf(failure, 'execute')
55
+ if (mounted.current) {
56
+ setError(reported)
57
+ }
58
+ throw reported
59
+ }
60
+ }, [key, action, loader])
61
+
62
+ return { ready, error, execute }
63
+ }
package/src/react.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type * from './react/types.js'
2
+ export * from './react/use-captcha.js'
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ /** The part of Google's `grecaptcha` (v3, `render=<site key>`) the loader uses. */
2
+ export interface Grecaptcha {
3
+ ready: (callback: () => void) => void
4
+ execute: (siteKey: string, options: { action: string }) => PromiseLike<string>
5
+ }
6
+
7
+ /** The window as the loader reads it: only Google's global. */
8
+ export interface CaptchaWindow {
9
+ grecaptcha?: Grecaptcha
10
+ }
11
+
12
+ /** The document as the loader uses it: one script and one style appended to its head. */
13
+ export interface CaptchaDocument extends Pick<Document, 'createElement' | 'querySelector' | 'head'> {}
14
+
15
+ /**
16
+ * Why a captcha could not give a token: the script failed to `load`, did not become ready in time
17
+ * (`timeout`), there is no page or no API to use (`unavailable`), or Google gave no token (`execute`).
18
+ */
19
+ export type CaptchaLoadErrorReason = 'load' | 'timeout' | 'execute' | 'unavailable'
20
+
21
+ export interface CaptchaLoaderOptions {
22
+ /** Add the document style that hides Google's badge. Default `true`. */
23
+ hideBadge?: boolean
24
+ /** Milliseconds the script may take to become ready. Default `CAPTCHA_LOAD_TIMEOUT`. */
25
+ loadTimeout?: number
26
+ /** Milliseconds one `execute` may take. Default `CAPTCHA_EXECUTE_TIMEOUT`. */
27
+ executeTimeout?: number
28
+ /** The script address `?render=<site key>` is added to. Default `RECAPTCHA_SCRIPT_URL`. */
29
+ scriptUrl?: string
30
+ /** The page to load into. Default: the global `document`, read at call time. */
31
+ document?: CaptchaDocument
32
+ /** Where Google's global appears. Default: the global `window`, read at call time. */
33
+ window?: CaptchaWindow
34
+ }
35
+
36
+ /** Google's reCAPTCHA v3, loaded on first use — never at import. */
37
+ export interface CaptchaLoader {
38
+ /**
39
+ * Inject `api.js?render=<siteKey>` once per key (and, unless disabled, the document style that
40
+ * hides Google's badge); resolves once `grecaptcha` is ready. A failure is forgotten, so the next
41
+ * call injects again.
42
+ *
43
+ * @throws {CaptchaLoadError} `load`, `timeout` or `unavailable`.
44
+ */
45
+ prepare: (siteKey: string) => Promise<void>
46
+ /**
47
+ * A fresh token for `action`, loading the script first when needed.
48
+ *
49
+ * @throws {CaptchaLoadError} what `prepare` throws, or `execute` when Google gives no token in time.
50
+ */
51
+ token: (siteKey: string, action: string) => Promise<string>
52
+ }
@@ -0,0 +1,73 @@
1
+ import type { CaptchaDocument, CaptchaWindow, Grecaptcha } from '../src/types.js'
2
+
3
+ /** An element as the loader uses it: attributes, listeners, `src`/`async`/`textContent`, `remove`. */
4
+ export interface PageElement {
5
+ tag: string
6
+ attributes: Record<string, string>
7
+ src?: string
8
+ async?: boolean
9
+ textContent?: string | null
10
+ removed: boolean
11
+ setAttribute: (name: string, value: string) => void
12
+ addEventListener: (type: string, listener: () => void) => void
13
+ remove: () => void
14
+ /** Run the listeners of `type`, as the browser would. */
15
+ fire: (type: string) => void
16
+ }
17
+
18
+ export interface TestPage {
19
+ document: CaptchaDocument
20
+ window: CaptchaWindow
21
+ /** What the loader appended to the head, in order (removed ones included, flagged). */
22
+ head: PageElement[]
23
+ /** The scripts still in the head. */
24
+ scripts: () => PageElement[]
25
+ /** Google's script arrives: `grecaptcha` (ready at once) is installed and `load` fires. */
26
+ arrive: (script: PageElement, grecaptcha?: Grecaptcha | null) => void
27
+ }
28
+
29
+ const makeElement = (tag: string): PageElement => {
30
+ const listeners: Record<string, (() => void)[]> = {}
31
+ const element: PageElement = {
32
+ tag,
33
+ attributes: {},
34
+ removed: false,
35
+ setAttribute: (name, value) => { element.attributes[name] = value },
36
+ addEventListener: (type, listener) => { (listeners[type] ??= []).push(listener) },
37
+ remove: () => { element.removed = true },
38
+ fire: type => { (listeners[type] ?? []).forEach(listener => listener()) },
39
+ }
40
+
41
+ return element
42
+ }
43
+
44
+ /** A page stand-in (document + window) for the loader's pure logic — no browser. */
45
+ export const makeTestPage = (): TestPage => {
46
+ const head: PageElement[] = []
47
+ const window: CaptchaWindow = {}
48
+
49
+ const querySelector = (selector: string): PageElement | null => {
50
+ const [, tag, attribute] = /^(\w+)\[([^\]=]+)\]$/.exec(selector) ?? []
51
+
52
+ return head.find(element => !element.removed && element.tag === tag && attribute in element.attributes) ?? null
53
+ }
54
+
55
+ const document = {
56
+ head: { appendChild: (element: PageElement) => { head.push(element); return element } },
57
+ createElement: makeElement,
58
+ querySelector,
59
+ } as unknown as CaptchaDocument
60
+
61
+ const arrive: TestPage['arrive'] = (script, grecaptcha) => {
62
+ window.grecaptcha = grecaptcha === null ? undefined : grecaptcha ?? {
63
+ ready: callback => { callback() },
64
+ execute: async (siteKey, { action }) => `token:${siteKey}:${action}`,
65
+ }
66
+ script.fire('load')
67
+ }
68
+
69
+ return {
70
+ document, window, head, arrive,
71
+ scripts: () => head.filter(element => element.tag === 'script' && !element.removed),
72
+ }
73
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ CAPTCHA_BADGE_CSS, CAPTCHA_SCRIPT_ATTRIBUTE, CAPTCHA_STYLE_ATTRIBUTE, RECAPTCHA_SCRIPT_URL,
4
+ } from '../src/consts.js'
5
+ import { CaptchaLoadError } from '../src/errors.js'
6
+ import { captchaLoader, createCaptchaLoader } from '../src/loader.js'
7
+ import type { CaptchaLoaderOptions } from '../src/types.js'
8
+ import { makeTestPage } from './context.js'
9
+
10
+ const loaderOn = (page = makeTestPage(), options: CaptchaLoaderOptions = {}) =>
11
+ ({ page, loader: createCaptchaLoader({ document: page.document, window: page.window, ...options }) })
12
+
13
+ const failure = async (promise: Promise<unknown>): Promise<CaptchaLoadError> => {
14
+ try {
15
+ await promise
16
+ } catch (error) {
17
+ expect(error).toBeInstanceOf(CaptchaLoadError)
18
+ return error as CaptchaLoadError
19
+ }
20
+ throw new Error('expected a failure')
21
+ }
22
+
23
+ describe('@owlmeans/web-captcha — loader', () => {
24
+ test('injects one script per site key, with the badge style once, and resolves when grecaptcha is ready', async () => {
25
+ const { page, loader } = loaderOn()
26
+
27
+ const first = loader.prepare('key one')
28
+ const again = loader.prepare('key one')
29
+ expect(page.scripts()).toHaveLength(1)
30
+ const [script] = page.scripts()
31
+ expect(script.src).toBe(`${RECAPTCHA_SCRIPT_URL}?render=key%20one`)
32
+ expect(script.async).toBe(true)
33
+ expect(script.attributes[CAPTCHA_SCRIPT_ATTRIBUTE]).toBe('key one')
34
+
35
+ page.arrive(script)
36
+ await Promise.all([first, again])
37
+ await loader.prepare('key one')
38
+ expect(page.scripts()).toHaveLength(1)
39
+
40
+ const second = loader.prepare('key-two')
41
+ page.arrive(page.scripts()[1])
42
+ await second
43
+ expect(page.scripts()).toHaveLength(2)
44
+ const styles = page.head.filter(element => element.tag === 'style')
45
+ expect(styles).toHaveLength(1)
46
+ expect(styles[0].attributes).toHaveProperty(CAPTCHA_STYLE_ATTRIBUTE)
47
+ expect(styles[0].textContent).toBe(CAPTCHA_BADGE_CSS)
48
+ })
49
+
50
+ test('a load that fails or never becomes ready is refused with its reason, and forgotten', async () => {
51
+ const { page, loader } = loaderOn(makeTestPage(), { loadTimeout: 5 })
52
+
53
+ const timedOut = await failure(loader.prepare('k'))
54
+ expect(timedOut.reason).toBe('timeout')
55
+ expect(page.scripts()).toHaveLength(0)
56
+
57
+ const erroring = loader.prepare('k')
58
+ page.scripts()[0].fire('error')
59
+ expect((await failure(erroring)).reason).toBe('load')
60
+
61
+ const bare = loader.prepare('k')
62
+ page.arrive(page.scripts()[0], null)
63
+ expect((await failure(bare)).reason).toBe('unavailable')
64
+
65
+ const retried = loader.prepare('k')
66
+ page.arrive(page.scripts()[0])
67
+ await retried
68
+ expect(page.head.filter(element => element.tag === 'script')).toHaveLength(4)
69
+ expect(page.scripts()).toHaveLength(1)
70
+ })
71
+
72
+ test('without a page nothing is touched at import, and a call is unavailable', async () => {
73
+ expect(typeof (globalThis as { document?: unknown }).document).toBe('undefined')
74
+ expect((await failure(captchaLoader.prepare('k'))).reason).toBe('unavailable')
75
+ expect((await failure(createCaptchaLoader().token('k', 'a'))).reason).toBe('unavailable')
76
+ })
77
+
78
+ test('hideBadge: false adds no document style; a script url with a query keeps it', async () => {
79
+ const { page, loader } = loaderOn(makeTestPage(), { hideBadge: false, scriptUrl: 'https://www.recaptcha.net/recaptcha/api.js?hl=pl' })
80
+
81
+ const ready = loader.prepare('k')
82
+ page.arrive(page.scripts()[0])
83
+ await ready
84
+ expect(page.head.map(element => element.tag)).toEqual(['script'])
85
+ expect(page.scripts()[0].src).toBe('https://www.recaptcha.net/recaptcha/api.js?hl=pl&render=k')
86
+ })
87
+ })
88
+
89
+ describe('@owlmeans/web-captcha — token', () => {
90
+ test('loads on demand and executes the action', async () => {
91
+ const { page, loader } = loaderOn()
92
+
93
+ const token = loader.token('k', 'email_pin')
94
+ page.arrive(page.scripts()[0])
95
+ expect(await token).toBe('token:k:email_pin')
96
+ expect(await loader.token('k', 'email_password')).toBe('token:k:email_password')
97
+ expect(page.scripts()).toHaveLength(1)
98
+ })
99
+
100
+ test('no token, a throwing or a hanging execute is an execute failure', async () => {
101
+ for (const execute of [
102
+ async () => '',
103
+ () => { throw new Error('denied') },
104
+ () => new Promise<string>(() => {}),
105
+ ]) {
106
+ const { page, loader } = loaderOn(makeTestPage(), { executeTimeout: 5 })
107
+ const token = loader.token('k', 'email_pin')
108
+ page.arrive(page.scripts()[0], { ready: callback => { callback() }, execute })
109
+ expect((await failure(token)).reason).toBe('execute')
110
+ }
111
+ })
112
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.react.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": [
11
+ "./dist/**/*",
12
+ "./build/**/*",
13
+ "./tests/**/*",
14
+ "./*.ts"
15
+ ]
16
+ }