Say which TypeScript OAuth helpers need Node, and fail fast outside it
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 ausageBasecampErrorthat names the helper and says it needs Node.js. The error's hint points at the new README section. The check is in a newsrc/oauth/node-runtime.ts, which isn't exported.performInteractiveLoginnow 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/basecamploadsnode:crypto(webhook signing) andnode:http(the callback server), and@37signals/basecamp/oauthloadsnode:httpplusnode:fs,node:pathandnode: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/nodesubpath. Or addreact-native/browserexport conditions that resolve to a core entry without them. Either wayindex.tshas 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 "doesnode:httpexist." 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 reportversions.node. Browsers have noprocess, and React Native'sprocesshas noversions.node. Both cases are tested. - The guard can't catch the first reported error.
promisify is not a functioncomes from theopenpackage when it loads, before any SDK call, so the fix for that one is the README markingopenas Node-only. The guard covers the later failures. - The recipe uses the standard
grant_typeparameters, notuseLegacyFormat. Launchpad accepts both, and its API docs call the legacy ones not recommended. FileTokenStorethrows 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.tscovers two runtimes: noprocess(browser) and aprocesspolyfill withoutversions.node(React Native). In each,performInteractiveLoginrejects beforefetch,openBrowserorstore.saveis called,startCallbackServerrejects, and theFileTokenStoreconstructor throws. All three report the exactusagemessage 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-driftandmake doc-constants-check).npm run typecheck,npm run lint,npm run lint:test-timersandnpm run buildall pass.make ts-checkpasses (Linux). Itsts-test-verb-refusalstep needed a localshasum→sha1sumshim, because this box has noshasum; 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.nodevia the new unexportedsrc/oauth/node-runtime.ts;FileTokenStorethrows from its constructor. The guard only runs if the module loads — a bundler that refuses the staticnode: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
TokenStorein 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 withoutversions.node(React Native); all guard tests fail when the check is disabled. Node is unaffected.