surfsky 0.0.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Surfsky SDK contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,471 @@
1
+ # surfsky
2
+
3
+ TypeScript SDK for [Surfsky](https://surfsky.io), a cloud-based antidetect browser.
4
+
5
+ ![Node 22+](https://img.shields.io/badge/node-22%2B-blue)
6
+ ![Bun](https://img.shields.io/badge/bun-1.x-blue)
7
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
8
+
9
+ - Raw CDP, without the automation traces stock Playwright, Puppeteer or
10
+ Selenium leave in the page.
11
+ - Input goes through our
12
+ [human-emulation](https://docs.surfsky.io/human_emulation) framework: real
13
+ timing, typing, cursor movement.
14
+ - Residential and mobile proxies, 100M+ IPs, with geo, ASN and sticky-session
15
+ targeting. Or use your own.
16
+ - Real browser fingerprints from a pool of 2.5M+ devices, not synthetic ones.
17
+ - Browsers run in the cloud. Playwright-style API, fully typed, Node 22+ and
18
+ Bun.
19
+
20
+ REST API docs for the service: https://docs.surfsky.io/api-reference
21
+
22
+ ## Installation
23
+
24
+ ```sh
25
+ npm install surfsky
26
+ ```
27
+
28
+ or `bun add surfsky`, `pnpm add surfsky`. Requires Node 22 or newer, or Bun.
29
+ ESM only.
30
+
31
+ ## Quick start
32
+
33
+ Sign up at [surfsky.io](https://surfsky.io). Once you're logged in, the
34
+ [dashboard](https://app.surfsky.io) shows your API token and base URL. Put them
35
+ in the environment:
36
+
37
+ ```sh
38
+ export SURFSKY_API_TOKEN=...
39
+ export SURFSKY_API_BASE_URL=...
40
+ ```
41
+
42
+ Then start a browser:
43
+
44
+ ```ts
45
+ import { Surfsky } from "surfsky";
46
+
47
+ // or new Surfsky({ apiToken: "...", baseUrl: "..." }) instead of env vars
48
+ await using client = new Surfsky();
49
+ // starts a session, stops it when the block ends so it doesn't keep billing
50
+ await using browser = await client.browser({ proxy: { tier: "premium", country: "us" } });
51
+ await browser.goto("https://duckduckgo.com", { waitUntil: "domcontentloaded" });
52
+ await browser.type('[name="q"]', "surfsky cloud browser");
53
+ await browser.click("#searchbox_homepage button[type=submit]");
54
+ console.log(await browser.waitForUrl("?q="));
55
+ ```
56
+
57
+ `await using` needs Node 24+ or Bun (TypeScript compiles it down for Node 22).
58
+ Without it, `try { ... } finally { await browser.close() }` does the same.
59
+
60
+ More examples in [`examples/`](https://github.com/surfskyio/surfsky-js/tree/main/examples).
61
+
62
+ ## Browser automation
63
+
64
+ The API sticks to Playwright's names and semantics where it can: `click`,
65
+ `fill`, `hover`, `waitForSelector`, `innerText`, `selectOption`,
66
+ `keyboard.press`, `mouse.move` and so on. Input goes through Surfsky's
67
+ [human emulation](https://docs.surfsky.io/human_emulation). Reads use plain CDP
68
+ and run no JavaScript in the page.
69
+
70
+ When you need JavaScript:
71
+
72
+ ```ts
73
+ await browser.evaluate("document.title");
74
+ await browser.evaluate("(a, b) => a + b", { args: [1, 2] });
75
+ await browser.evaluate(() => document.title); // a function is stringified
76
+ ```
77
+
78
+ Scripts run in an isolated world the page can't see. Pass `isolated: false` to
79
+ use the page's own context.
80
+
81
+ Also useful:
82
+
83
+ - `client.browser({ blockResources: ["image", "font", "media"] })` skips those
84
+ downloads and saves proxy traffic.
85
+ - Grab the JSON a page fetches instead of parsing its HTML:
86
+
87
+ ```ts
88
+ await browser.captureResponses("/api/search");
89
+ await browser.goto(url);
90
+ const data = (await browser.waitForResponse("/api/search")).json();
91
+ ```
92
+
93
+ - Work with several pages at once. `browser.pages` has every open tab, popups
94
+ included, and `newPage()` adds one:
95
+
96
+ ```ts
97
+ await browser.newPage();
98
+ await browser.pages[1].goto("https://example.com");
99
+ console.log(await browser.pages[0].title(), await browser.pages[1].title());
100
+ ```
101
+
102
+ Every method is listed in the [API reference](#api-reference).
103
+
104
+ ## Running multiple browsers
105
+
106
+ `client.map` runs a function over a list of items in parallel, one browser per
107
+ item, and collects the results:
108
+
109
+ ```ts
110
+ async function title(browser: Browser, url: string) {
111
+ await browser.goto(url);
112
+ return browser.title();
113
+ }
114
+
115
+ for (const o of await client.map(title, urls)) {
116
+ console.log(o.item, o.ok ? o.value : o.error);
117
+ }
118
+ ```
119
+
120
+ Errors land in `o.error` instead of throwing, so one bad page doesn't stop the
121
+ run. By default the pool uses every browser your plan allows
122
+ (`concurrency: "auto"`). Pass `concurrency: 5` to cap it.
123
+
124
+ If you need more control, write your own loop on top of the pool:
125
+
126
+ ```ts
127
+ await using pool = await client.browsers();
128
+ await pool.lease(async (browser) => { // waits for a free browser
129
+ await browser.goto("https://example.com");
130
+ console.log(await browser.title());
131
+ });
132
+ ```
133
+
134
+ A browser keeps its fingerprint, proxy and cookies between leases.
135
+ `browser.data` carries your own state across them, `browser.retire()` swaps in
136
+ a fresh browser.
137
+
138
+ Neither `map` nor `lease` retries: an error is reported and the run goes on.
139
+ Retries belong to your handler, since only it knows a timeout from a 404. Loop
140
+ around `lease` and `retire()` the browser first, so the next attempt gets a
141
+ fresh browser: [`examples/retry.ts`](https://github.com/surfskyio/surfsky-js/blob/main/examples/retry.ts).
142
+
143
+ ## Using Playwright or Puppeteer
144
+
145
+ You don't have to use the SDK's browser API. `client.session` starts a browser
146
+ and gives you its WebSocket URL, so Playwright, Puppeteer or any other CDP
147
+ client can connect to it. The SDK still handles profiles, proxies and the
148
+ session lifecycle:
149
+
150
+ ```ts
151
+ import { chromium } from "playwright-core";
152
+ import { Surfsky } from "surfsky";
153
+
154
+ await using client = new Surfsky();
155
+ await using session = await client.session();
156
+ const browser = await chromium.connectOverCDP(session.ws_url);
157
+ const page = browser.contexts()[0].pages()[0];
158
+ await page.goto("https://example.com");
159
+ ```
160
+
161
+ Don't use stock Playwright or Puppeteer: both put back the traces this SDK
162
+ avoids, `Runtime.enable` above all. The patched forks strip them and are
163
+ drop-in replacements.
164
+
165
+ Selenium works too (`enable_chromedriver: true`, see
166
+ [`examples/selenium_connect.ts`](https://github.com/surfskyio/surfsky-js/blob/main/examples/selenium_connect.ts)) but isn't
167
+ recommended: chromedriver is easy to detect.
168
+
169
+ ## Proxies
170
+
171
+ Every session start accepts `proxy`. Use your own, or one of Surfsky's
172
+ built-in pools:
173
+
174
+ - Premium: clean residential and mobile IPs, targeted by country, region,
175
+ city, ASN or coordinates, with sticky sessions. Use it for production.
176
+ - Shared: a pool for testing. Don't rely on it against sites that matter.
177
+
178
+ ```ts
179
+ import { ProxyCycle, ProxyTemplate } from "surfsky";
180
+
181
+ proxy = { tier: "premium", country: "us", region: "ny", type: "mobile" }; // Surfsky premium
182
+ proxy = { tier: "shared", country: "us" }; // Surfsky shared, for tests
183
+ proxy = { country: "de" }; // premium if set up, else shared
184
+ proxy = "socks5://user:pass@host:1080"; // your own
185
+ proxy = new ProxyCycle(myProxies); // round-robin over your own list
186
+ proxy = new ProxyTemplate("http://user-sessid-{session}:pw@gate.example.com:7000");
187
+ proxy = async () => pickOne(); // any function, sync or async
188
+ ```
189
+
190
+ `client.proxies` lists available countries, regions and cities, plus your quota.
191
+
192
+ ## Profiles and the REST API
193
+
194
+ Profiles, proxies, fingerprints, extensions and account limits are all typed
195
+ calls on the client. You'll mostly use profiles: a profile is a saved browser,
196
+ so every session started on it gets the same fingerprint, proxy and cookies.
197
+
198
+ ```ts
199
+ await using client = new Surfsky();
200
+ const profile = await client.profiles.create({
201
+ title: "account-1",
202
+ fingerprint: { os: "win", os_arch: "x86", os_version: "11" },
203
+ proxy: { tier: "premium", country: "us" },
204
+ });
205
+ await using browser = await client.browser({ profileUuid: profile.uuid });
206
+ await browser.goto("https://example.com/login");
207
+ // log in once, the cookies stay with the profile
208
+ ```
209
+
210
+ Next time, `client.browser({ profileUuid })` brings back the same browser,
211
+ still logged in. `client.profiles.iterAll()` lists your profiles and
212
+ `delete(uuid)` removes one.
213
+
214
+ Fields on request and response objects use the API's own names
215
+ (`internal_uuid`, `os_arch`, `storage_options`), so the
216
+ [API reference](https://docs.surfsky.io/api-reference) maps 1:1. For endpoints
217
+ the SDK doesn't cover, `client.request(method, path, options)` sends the
218
+ request and returns the raw fetch `Response`.
219
+
220
+ ## API reference
221
+
222
+ Every browser method is async. `Browser` is a `Page` plus the connection: page
223
+ methods on it act on the session's first tab. Waits take `timeout` in
224
+ milliseconds, default 30 000, and throw `BrowserTimeoutError`.
225
+
226
+ ### Client
227
+
228
+ `new Surfsky({ apiToken, baseUrl, timeout: 30_000, maxRetries: 3, backoff: 500, headers, logger, fetch })`.
229
+
230
+ | Method | Description |
231
+ | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
232
+ | `session({ profileUuid, ...options })` | Start a session. Returns the `Session` (`internal_uuid`, `ws_url`) with `stop()` and `await using` support. |
233
+ | `browser({ profileUuid, blockResources, blockUrls, connectTimeout, commandTimeout, ...options })` | Start a session and connect a `Browser`. `close()` stops both. |
234
+ | `browsers({ concurrency: "auto", blockResources, blockUrls, ...options })` | An open `BrowserPool`. `close()` stops every browser. |
235
+ | `map(handler, items, poolOptions)` | `browsers()` and `pool.map()` in one call. |
236
+ | `withOptions({ timeout, maxRetries, headers })` | Copy with overrides. Same fetch and logger. |
237
+ | `request(method, path, { json, params, body, headers, timeout })` | Raw call. Returns the fetch `Response`, never throws on status. |
238
+ | `close()` | Nothing to release; here for `await using`. |
239
+
240
+ Session options: `fingerprint`, `proxy`, `browser_settings`
241
+ (`inactive_kill_timeout`, `cache_enabled`, `cache_key`), `enable_chromedriver`,
242
+ `extensions` (up to 5 uuids), `proxy_blacklist`, `domain_routes`, `cookies`.
243
+ `fingerprint` and `cookies` apply to one-time sessions only. Requests are
244
+ validated with [zod](https://zod.dev) before they leave: an unknown key, a
245
+ bad range or an impossible proxy targeting throws `ValidationError`, so a typo
246
+ never starts a billed session.
247
+
248
+ `logger` takes `{ debug, info, warn, error }` (any subset). By default warnings
249
+ and errors go to the console; `logger: null` silences the SDK.
250
+
251
+ ### Pool
252
+
253
+ | Member | Description |
254
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
255
+ | `pool.lease(fn)` | Runs `fn(browser)` on a live browser and hands it back after. Waits while all are busy. |
256
+ | `pool.map(handler, items)` | `handler(browser, item)` per item, `capacity` at a time. Returns `PoolOutcome` list in input order: `{ ok: true, item, index, value }` or `{ ok: false, item, index, error }`. Throw `StopRun` to end early. |
257
+ | `pool.capacity` | Max live browsers. `"auto"` is the plan's limit, `SURFSKY_MAX_BROWSERS` overrides it. |
258
+ | `browser.data` | Per-browser object. Survives leases. |
259
+ | `browser.useCount` | Leases so far, current included. |
260
+ | `browser.retire()` | Replace this browser with a fresh one after the lease. |
261
+ | `browser.internalUuid` | Session id. |
262
+ | `browser.connected` | Socket is up. |
263
+
264
+ The plan limit counts browsers started elsewhere with the same token. `lease()`
265
+ waits for one of its own and throws `RateLimitError` only if it has none.
266
+
267
+ ### Navigation
268
+
269
+ | Method | Description |
270
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
271
+ | `goto(url, { waitUntil: "load", timeout })` | Navigate. `waitUntil`: `commit`, `domcontentloaded`, `load`, `networkidle`. Follows redirects. |
272
+ | `reload({ waitUntil, timeout })` | Reload. |
273
+ | `goBack({ timeout })`, `goForward({ timeout })` | Returns the new URL, `null` at the end of history. |
274
+ | `waitForLoadState(state = "load", { timeout })` | Wait for the current document to reach `state`. |
275
+ | `waitForUrl(fragment, { timeout })` | Wait until the URL contains `fragment`. Returns the URL. |
276
+ | `status` | HTTP status of the current document. Set even when `goto` throws. |
277
+
278
+ ### Reading
279
+
280
+ | Method | Description |
281
+ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
282
+ | `url()`, `title()` | Current URL and title. |
283
+ | `content()` | Full HTML. |
284
+ | `outerHtml(selector)` | HTML of the first match, `null` if none. |
285
+ | `innerText(selector)`, `allInnerTexts(selector)` | Rendered text of the first match, or of every match. Runs script in the isolated world. |
286
+ | `getAttribute(selector, name)` | `null` if missing. |
287
+ | `count(selector)` | Number of matches. |
288
+ | `isVisible(selector)` | First match has a bounding box. |
289
+ | `waitForSelector(selector, { visible: true, timeout })` | Wait for the element, visible by default. |
290
+ | `screenshot({ selector, fullPage, format: "png", quality })` | `Uint8Array`. Viewport, one element or the full page. `format`: `png`, `jpeg`, `webp`. |
291
+
292
+ ### Input
293
+
294
+ Server-side human emulation. The first CSS match is used. `click`, `dblclick`
295
+ and `hover` also take `waitForVisible`, `scrollIntoView`, `preDelay`,
296
+ `postDelay`, `timeout`.
297
+
298
+ | Method | Description |
299
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
300
+ | `click(selector, { button, clickCount, modifiers })` | `button`: `left`, `right`, `middle`. `modifiers`: `Alt`, `Control`, `Meta`, `Shift`. Waits up to 30s for the element. |
301
+ | `dblclick(selector, ...)` | Double-click. |
302
+ | `hover(selector)` | Move the mouse over it. |
303
+ | `type(selector, text)` | Click, then type after the existing text. |
304
+ | `fill(selector, text)` | Select the existing text, then type over it. |
305
+ | `selectOption(selector, value)` / `selectOption(selector, { label })` | Pick an `<option>` by value or label. Returns the value. |
306
+ | `scroll({ deltaX, deltaY, duration })` | Animated scroll. |
307
+ | `scrollIntoView(selector, { behavior })`, `scrollTo({ x, y, behavior })` | `behavior`: `smooth`, `instant`. |
308
+ | `keyboard.type(text)`, `keyboard.press(key, { modifiers, delay })` | Keys to the focused element. `press("Enter")` submits a form; older pods need the button. |
309
+ | `mouse.move(x, y)`, `mouse.click(x, y)`, `mouse.down(x, y)`, `mouse.up(x, y)`, `mouse.wheel({ deltaX, deltaY })`, `mouse.drag({ startX, startY, endX, endY })` | Viewport coordinates. |
310
+
311
+ ### Script
312
+
313
+ | Method | Description |
314
+ | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
315
+ | `evaluate(expression, { args, isolated: true, awaitPromise: true })` | Run JS. A function, or a string that looks like one, is called with `args` as JSON; anything else is an expression. Isolated world by default. |
316
+ | `waitForFunction(expression, { args, isolated, timeout })` | Poll until truthy. Returns the value. |
317
+ | `send(method, params)` | Raw page-level CDP command. |
318
+ | `browser.cdp` | Raw browser-level client: `send`, `post`, `on`. |
319
+
320
+ ### Cookies and storage
321
+
322
+ | Method | Description |
323
+ | ----------------------------------------------- | --------------------------------- |
324
+ | `cookies()` | All cookies, `httpOnly` included. |
325
+ | `setCookies(cookies)` | Cookie objects. |
326
+ | `clearCookies()` | Remove every cookie. |
327
+ | `localStorage()`, `setLocalStorage(values)` | Current origin, as an object. |
328
+ | `sessionStorage()`, `setSessionStorage(values)` | Same for sessionStorage. |
329
+
330
+ ### Network
331
+
332
+ | Method | Description |
333
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
334
+ | `captureResponses(...fragments)` | Record responses whose URL contains a fragment. Call before navigating. |
335
+ | `waitForResponse(fragment, { timeout })` | First captured match. `CapturedResponse`: `url`, `status`, `headers`, `body` (`Uint8Array`), `text`, `json()`. |
336
+ | `responses` | Everything captured, oldest first. |
337
+ | `stopCapturing()` | Drop captures, stop recording. |
338
+
339
+ ### Dialogs
340
+
341
+ `page.onDialog = (kind, message) => ...`. `kind`: `alert`, `confirm`, `prompt`,
342
+ `beforeunload`. Return `true` to accept, `false` to dismiss, a string to answer
343
+ a prompt, `undefined` for the default. Default: dismiss, except `beforeunload`
344
+ is accepted.
345
+
346
+ ### Pages
347
+
348
+ | Member | Description |
349
+ | ------------------------------------------ | ------------------------------------------------------------------------------ |
350
+ | `browser.pages` | Every open page. The browser's own first, newest last. |
351
+ | `browser.newPage()` | Blank page in a new window. |
352
+ | `browser.waitForPage(action, { timeout })` | Await `action` (a click promise, or a function) and return the page it opened. |
353
+ | `page.close()` | Close the tab. On the browser itself: close the connection. |
354
+ | `page.closed` | `true` once gone. Commands then throw `PageClosedError`. |
355
+ | `page.bringToFront()` | Make it the visible tab. Screenshots of hidden tabs hang. |
356
+ | `page.targetId` | CDP target id. |
357
+
358
+ ### REST
359
+
360
+ | Namespace | Methods |
361
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
362
+ | `client.profiles` | `startOneTime(options)`, `start(uuid, options)`, `stop(session)`, `stopAll()`, `listActive()`, `create({ title, fingerprint, description, proxy, cookies, storage_options })`, `get(uuid)`, `update(uuid, fields)`, `delete(uuid)`, `deleteMany(uuids)`, `listPage({ page, page_len, ordering })`, `iterAll({ page_len, ordering })`, `exportCookies(uuid, { export_format })`, `importCookies(uuid, cookies)`, `scrape(session, url, { screenshot, wait, wait_until, wait_for, human_actions })` |
363
+ | `client.proxies` | `countries()`, `regions(country)`, `cities(country, region)`, `quota()`, `premiumStats()`, `sharedCountries()`, `sharedQuota()`, `sharedStats()`. The first four need a premium provider on the account. |
364
+ | `client.fingerprints` | `renderers(os, osArch)`, `screens(os, osArch)`, `deviceModels({ os, os_arch, os_version, device_type })` |
365
+ | `client.extensions` | `upload(file, name)` (path, bytes or Blob, zip up to 100 MB), `listAll()`, `get(uuid)`, `update(uuid, { name })`, `delete(uuid)` |
366
+ | `client.account` | `sessionLimits()`, `browserLimits()`, `maxBrowsers()` |
367
+
368
+ ### Errors
369
+
370
+ All subclasses of `SurfskyError`. `ValidationError` for a request that failed
371
+ validation before it was sent (`issues` lists the fields). HTTP: `APIError`
372
+ subclasses named after the status (`NotFoundError`, `RateLimitError`, ...) with
373
+ `statusCode`, `code`, `body`, `requestId`, `retryAfter` (ms). Browser:
374
+ `CDPError`, `BrowserTimeoutError`, `PageClosedError`. Idempotent requests retry
375
+ on 429, 5xx and connection errors. POST and PATCH retry on a 429 or a failure
376
+ that proves the request never left (a refused connection, a DNS error), so a
377
+ lost reply can't start a second billed session.
378
+
379
+ ## Examples
380
+
381
+ More examples in [`examples/`](https://github.com/surfskyio/surfsky-js/tree/main/examples). They import `surfsky` by name:
382
+
383
+ ```sh
384
+ bun run examples/one_time.ts # Bun resolves it to src/ directly
385
+ bun run build && node examples/one_time.ts # Node 24 runs .ts (and `await using`) natively, resolves dist/
386
+ ```
387
+
388
+ On Node 22 compile the examples first (`await using` is syntax Node 22 lacks):
389
+ `npx tsc -p tsconfig.json --noEmit false --outDir out` and run the output.
390
+
391
+ The Playwright, Puppeteer and Selenium examples need their dev dependencies
392
+ (`bun install` brings them in). Run `playwright_connect.ts` on Node: under Bun,
393
+ Playwright's own WebSocket transport opens the socket and then never sends a
394
+ command, so `connectOverCDP` times out. Bun runs the SDK and the Puppeteer
395
+ example fine.
396
+
397
+ ## Development
398
+
399
+ ```sh
400
+ bun install
401
+ bun run check # lint, typecheck, tests
402
+ bun run build
403
+ ```
404
+
405
+ Live tests start real sessions and bill your account:
406
+
407
+ ```sh
408
+ SURFSKY_LIVE_TESTS=1 SURFSKY_API_TOKEN=... SURFSKY_API_BASE_URL=... bun run test:live
409
+ ```
410
+
411
+ ## Releasing
412
+
413
+ One-time, GitHub:
414
+
415
+ ```sh
416
+ gh repo create surfskyio/surfsky-js --public --source=. --remote=origin --push
417
+ ```
418
+
419
+ CI (`.github/workflows/ci.yml`) then runs on every push and PR: lint, typecheck,
420
+ tests and a build on Node 22, 24 and 26, plus the same on Bun.
421
+
422
+ One-time, npm: publishing runs from `.github/workflows/release.yml` with no npm
423
+ token, so it needs [trusted
424
+ publishing](https://docs.npmjs.com/trusted-publishers) configured for the
425
+ package — provider GitHub Actions, repository `surfskyio/surfsky-js`, workflow
426
+ `release.yml`, environment `npm`, publish allowed. A trusted publisher is a
427
+ setting *on a package*, so the first version goes out by hand:
428
+
429
+ ```sh
430
+ npm login
431
+ bun run check && bun run build
432
+ npm publish # no --provenance: that needs the OIDC token only CI has
433
+ ```
434
+
435
+ Then attach the publisher, on npmjs.com under the package's settings or, with
436
+ npm new enough to have the command, from the shell:
437
+
438
+ ```sh
439
+ npm trust github surfsky --repo surfskyio/surfsky-js --file release.yml \
440
+ --env npm --allow-publish
441
+ ```
442
+
443
+ The alternative to all of it is a granular access token in the repo as
444
+ `NPM_TOKEN`, with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` added to the
445
+ publish step.
446
+
447
+ Two things bite here: npm never validates a trusted-publisher config when you
448
+ save it, so a wrong workflow name surfaces only as a failed publish, and
449
+ `repository.url` in `package.json` has to match the GitHub repository exactly.
450
+
451
+ The `npm` environment is also where a required reviewer goes, if a release
452
+ should wait for an approval.
453
+
454
+ Every release after that is a tag:
455
+
456
+ ```sh
457
+ # bump "version" in package.json first
458
+ bun run check && bun run build
459
+ git commit -am "release: v0.1.0"
460
+ git tag v0.1.0
461
+ git push origin main --tags
462
+ ```
463
+
464
+ The workflow refuses to publish when the tag and `package.json` disagree, then
465
+ runs `bun run check`, builds, imports `dist/index.js` once to prove the bundle
466
+ loads, and publishes. `files` limits the tarball to `dist/`, `README.md`,
467
+ `LICENSE` and `package.json` - check it with `npm pack --dry-run`.
468
+
469
+ ## License
470
+
471
+ MIT