Say which TypeScript OAuth helpers need Node, and fail fast outside it

RO
13h ago
+267-6

What

TypeScript only. Three OAuth helpers use Node built-ins and can't run in React Native or a browser: performInteractiveLogin and startCallbackServer (a node:http callback server) and FileTokenStore (node:fs). This PR does three things:

  • Fail fast outside Node. Each of the three first checks for process.versions.node. If it's missing, the helper throws a usage BasecampError that names the helper and says it needs Node.js. The error's hint points at the new README section. The check is in a new src/oauth/node-runtime.ts, which isn't exported. performInteractiveLogin now throws before discovery runs. Before, it got as far as discovery and failed there with a network error.
  • README: the install note and the "Getting a token" table now say these helpers are Node-only, and so does the Interactive Login section (whose example imports open, also Node-only).
  • New README section, "React Native and browsers": Launchpad has no PKCE and requires the client secret for both the code exchange and refresh, so both run on your server. For a native app: register a redirect URI the app owns, sign in through the system auth session (an Expo example is included), and keep tokens in secure storage behind your own TokenStore. Launchpad's refresh returns no new refresh token, so the example keeps the original. For a browser app: an https callback on your server, which keeps the tokens (Launchpad sends no CORS headers). The section also says which Node built-ins each entry point loads, and that a bundler refusing them fails before the guard can run.
  • Docblocks on the three helpers say the same.

Why

A developer used performInteractiveLogin with FileTokenStore in a React Native app, following our README example. The first error was promisify is not a function, which comes from the top of the open package (v7) once a bundler stubs out util. Next was OAuth discovery failed: Load failed, WebKit's message for a fetch that was blocked or couldn't connect. In a web build that is expected, because Launchpad's /.well-known/oauth-authorization-server sends no Access-Control-Allow-Origin; the error alone doesn't prove that was the cause. Neither error names the real cause. If both had been worked around, the flow would still have failed at the callback server. Nothing in the OAuth docs said these helpers need Node. Support card: https://app.basecamp.com/2914079/buckets/27/card_tables/cards/10390587859

Decisions

  • No packaging change in this PR. The clean fix is to keep Node-only code out of what React Native and browser bundles load. Today both entry points import Node built-ins when they load: @37signals/basecamp loads node:crypto (webhook signing) and node:http (the callback server), and @37signals/basecamp/oauth loads node:http plus node:fs, node:path and node:os (the file token store). Getting them out means one of two things. Either move the Node-only exports (performInteractiveLogin, startCallbackServer, FileTokenStore, verifyWebhookSignature/signWebhookPayload, createNodeHandler) to a @37signals/basecamp/node subpath. Or add react-native/browser export conditions that resolve to a core entry without them. Either way index.ts has to be split, and those imports disappear from the root for some consumers. That's a breaking change, so it should go in its own PR with a MIGRATING note. Proposed follow-up.
  • The check is process.versions.node, not "does node:http exist." In the reported case the bundler had replaced built-ins with empty modules, so the import "works" and only calling it fails. Node, Bun, Deno's Node compatibility and Electron's main process all report versions.node. Browsers have no process, and React Native's process has no versions.node. Both cases are tested.
  • The guard can't catch the first reported error. promisify is not a function comes from the open package when it loads, before any SDK call, so the fix for that one is the README marking open as Node-only. The guard covers the later failures.
  • The recipe uses the standard grant_type parameters, not useLegacyFormat. Launchpad accepts both, and its API docs call the legacy ones not recommended.
  • FileTokenStore throws from its constructor. That's the first point where a caller touches it, and it fails before any token is in flight.

Testing

  • New tests/oauth/node-runtime.test.ts covers two runtimes: no process (browser) and a process polyfill without versions.node (React Native). In each, performInteractiveLogin rejects before fetch, openBrowser or store.save is called, startCallbackServer rejects, and the FileTokenStore constructor throws. All three report the exact usage message and the README hint. The global is swapped only around the synchronous call and restored before vitest or msw run again.
  • Non-vacuity: with the check disabled (if (false && …)), 6 of the 6 guard tests fail. Restored, 9 of 9 pass.
  • Node is unaffected: the existing interactive-login, callback-server and token-store suites pass unchanged.
  • npm test: 97 files, 2081 tests pass (rerun at e479a0e9, along with typecheck, lint, build, make ts-check-drift and make doc-constants-check). npm run typecheck, npm run lint, npm run lint:test-timers and npm run build all pass.
  • make ts-check passes (Linux). Its ts-test-verb-refusal step needed a local shasum → sha1sum shim, because this box has no shasum; that's unrelated to this change.

Summary by cubic

Makes three Node-dependent OAuth helpers (performInteractiveLogin, startCallbackServer, FileTokenStore) fail fast with a clear usage BasecampError outside Node.js, instead of failing partway through with misleading errors (promisify is not a function, OAuth discovery failed: Load failed) that never named the cause.

  • Each helper checks for process.versions.node via the new unexported src/oauth/node-runtime.ts; FileTokenStore throws from its constructor. The guard only runs if the module loads — a bundler that refuses the static node: imports fails at import instead.
  • The README marks the helpers as Node-only and adds a "React Native and browsers" section: sign in through the platform's system auth session, exchange and refresh on a server (Launchpad requires the client secret for both), and keep tokens in secure storage — implemented behind a TokenStore in the app.
  • The manual refresh example now keeps the original refresh token, since Launchpad returns no new one on refresh. It also sends the client ID and secret, which Launchpad requires, and the native recipe prefers a universal link over a custom scheme.
  • New tests cover a missing process (browser) and a polyfill without versions.node (React Native); all guard tests fail when the check is disabled. Node is unaffected.