diff --git a/.github/workflows/puterjs-tests.yaml b/.github/workflows/puterjs-tests.yaml index 2ada55b90..f24395522 100644 --- a/.github/workflows/puterjs-tests.yaml +++ b/.github/workflows/puterjs-tests.yaml @@ -23,6 +23,33 @@ permissions: pull-requests: write jobs: + # The JSDoc in src/puter-js/src is the source of truth for the SDK's public + # types. The declarations shipped to npm are generated from it at build time + # and never committed, so what needs guarding is the JSDoc itself: this + # generates the declarations and type-checks the published surface without + # skipLibCheck, which is how broken re-exports used to go unnoticed. + # + # Its own job rather than a step in `test`: that one builds bundles and + # installs a browser, and this needs neither. + types: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check the puter.js JSDoc produces declarations that type-check + run: npm run check:puterjs:types + test: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index fb2526108..0f05cf3f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,24 +98,23 @@ Follow the same layered structure inside an extension — unless it only needs a [src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on. -Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `KV.js`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), hand-maintained type declarations in [src/puter-js/types/](src/puter-js/types/), API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/). +Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `kv/`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), generated (gitignored) type declarations in `src/puter-js/types/`, API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/). -Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. Reference the hand-maintained declarations in [src/puter-js/types/](src/puter-js/types/) with `import(...)` types rather than re-declaring shapes: +Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. **The JSDoc is the source of truth for the SDK's types.** `src/puter-js/types/` is `tsc --emitDeclarationOnly` output: the SDK build (`npm run build` in `src/puter-js`) generates it, the npm tarball ships it so TypeScript consumers get declarations, and git ignores it — it is never committed and never edited by hand. `npm run check:puterjs:types` generates it in CI and type-checks the result without `skipLibCheck`. The one hand-written declaration file is [src/puter-js/index.d.ts](src/puter-js/index.d.ts), which decides what is public and re-exports the generated names. + +Declare a shape where it belongs, and reference it with an `import(...)` type from elsewhere. A shape more than one file needs lives in the module's `types.js`; a shape shared across modules lives in [src/puter-js/src/lib/types.js](src/puter-js/src/lib/types.js); a shape with one consumer can stay next to it: ```js -/** @typedef {import('../../types/modules/ai').ChatOptions} ChatOptions */ - -/** @type {ChatOptions} */ -const options = { model: 'gpt-5-nano' }; +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ ``` -When a shape only exists locally, declare it as an inline object literal — not the `@typedef {Object}` + `@property` list form — and use `unknown` over `*`: +Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`: ```js /** @typedef {{ key: string, value: unknown }} KVEntry */ ``` -Public (exposed) methods must carry JSDoc types — parameters and return value, matching the `.d.ts` declarations exactly. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. +Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads *are* the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface. Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example: @@ -133,7 +132,7 @@ Every SDK change carries all five of the following — a puter.js PR missing one 1. **Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected. 2. **Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md). 3. **Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`/.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly. -4. **Types.** Update [src/puter-js/types/modules/](src/puter-js/types/modules/)`.d.ts` and re-export new types through `index.d.ts` / `types/puter.d.ts`. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one. +4. **Types.** Type the change in JSDoc on the implementation, then run `npm run check:puterjs:types` to confirm it still produces declarations that type-check. Name any new type in [src/puter-js/index.d.ts](src/puter-js/index.d.ts) if consumers should be able to import it. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one. 5. **Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change. [doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK. diff --git a/config.template.jsonc b/config.template.jsonc index f64215def..5e9a5e942 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -437,9 +437,20 @@ ], // ── Metering ──────────────────────────────────────────────────────── - // When true, all metering checks pass — no per-actor limits enforced. + // When true, every account resolves to an unlimited policy: usage is still + // recorded, but nothing is ever refused for lack of budget. This is the + // setting for a deployment with no way to buy more — without it, accounts + // are held to the free monthly allowance and start getting 402s from the AI + // surfaces, file transfers and KV once they pass it. "unlimitedMetering": false, + // Whether an account that has spent its whole allowance is refused the + // operations that spend it — file transfers, KV calls. Recording is + // unaffected either way. `workers` extends the same refusal to + // worker-driven calls, which are exempt by default because a deployed + // worker has nowhere to surface a payment prompt. + // "meteringEnforcement": { "enabled": true, "workers": false }, + // Fleet-wide spend rate, in micro-cents per minute, past which metering // raises the `metering:excessiveGlobalUsageRate` alarm. Omit it (the // default) to leave the check off: the only useful value is a multiple of diff --git a/doc/contributing-apis.md b/doc/contributing-apis.md index bbea4b3f3..dfaa034c1 100644 --- a/doc/contributing-apis.md +++ b/doc/contributing-apis.md @@ -54,6 +54,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi - **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking. - **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them. +- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method. ### 3. puter.js @@ -62,7 +63,10 @@ Both are supported ways to define an API. **Prefer a controller when you need fi ### 4. Types -- Add or extend the declaration in [src/puter-js/types/modules/](../src/puter-js/types/modules/)`.d.ts` and re-export new types through `index.d.ts`. Declarations must match the runtime exactly — optionality, defaults, and return types included. +- Type the method where you wrote it, in JSDoc: `@param`/`@returns` on the implementation, one `@overload` block per accepted call form, and `@typedef {Object}` + `@property` for any new shape. The JSDoc is the source of truth — declarations are generated from it, so there is nothing to keep in sync by hand. +- Put a shape more than one file needs in the module's `types.js` (e.g. [src/puter-js/src/modules/kv/types.js](../src/puter-js/src/modules/kv/types.js)); anything shared across modules goes in [src/puter-js/src/lib/types.js](../src/puter-js/src/lib/types.js). A shape with one consumer can stay next to it. +- Run `npm run check:puterjs:types` — it generates the declarations and type-checks the published surface without `skipLibCheck`. **Never edit anything under `src/puter-js/types/`**: it is gitignored build output, produced by the SDK build and shipped in the npm tarball, and the next build overwrites it. +- Name the new type in [src/puter-js/index.d.ts](../src/puter-js/index.d.ts) if consumers should be able to import it. That file is the one hand-written declaration in the package: it decides what is public and re-exports nothing else. ### 5. Docs diff --git a/doc/self-hosting.md b/doc/self-hosting.md index 9ba1e763b..5ec87d8fd 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -370,6 +370,37 @@ Default is 100 MB per user. Set `is_storage_limited: false` for unlimited (bounded by host disk). +### Usage metering and budgets + +Puter meters what an account costs to serve — bytes sent back, object-store +requests, KV capacity, AI tokens — against a monthly budget, and refuses the +operations that spend it once that budget is gone. The refusal is a `402` with +code `insufficient_funds`; reads that only describe things, and every kind of +deletion, stay available so an account can always see what it has and clear it. + +On a self-hosted install this is almost certainly not what you want. There is +nowhere to buy more, so accounts are held to the free monthly allowance +(US$0.25 of measured cost — roughly 2 GiB of downloads) and start being refused +after that. Turn it off: + +```json +"unlimitedMetering": true +``` + +Every account then resolves to an unlimited policy. Usage is still recorded, so +the dashboard still shows what is being consumed; nothing is ever refused for +lack of budget. + +To keep the budgets but stop them blocking anything — recording only: + +```json +"meteringEnforcement": { "enabled": false } +``` + +Calls driven by a deployed worker are exempt from enforcement by default, +because a worker has no prompt to show and nobody watching it fail. Set +`"meteringEnforcement": { "workers": true }` to include them. + ### Captcha on signup / login Built-in proof-of-work captcha — no external service needed. diff --git a/extensions/metering.ts b/extensions/metering.ts index da61337dc..7613a68d4 100644 --- a/extensions/metering.ts +++ b/extensions/metering.ts @@ -3,6 +3,7 @@ import { HttpError } from '@heyputer/backend/src/core/http'; import { controllersContainers, driversContainers, + servicesContainers, } from '@heyputer/backend/src/exports'; import { extension } from '@heyputer/backend/src/extensions'; import type { Request, Response } from 'express'; @@ -18,7 +19,7 @@ function collectAllCosts(): Record[] { const all: Record[] = []; const collect = ( source: Record, - kind: 'driver' | 'controller', + kind: 'driver' | 'controller' | 'service', ) => { for (const [name, instance] of Object.entries(source)) { const fn = ( @@ -43,6 +44,9 @@ function collectAllCosts(): Record[] { }; collect(driversContainers as Record, 'driver'); collect(controllersContainers as Record, 'controller'); + // Services report the costs that aren't tied to one endpoint — egress, + // which is metered for every response there is. + collect(servicesContainers as Record, 'service'); return all; } diff --git a/package.json b/package.json index 493842e9b..b61a87021 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "check-translations": "node tools/check-translations.js", "prepare": "husky", "build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs", + "check:puterjs:types": "node tools/checkPuterjsTypes.mjs", "typecheck": "node tools/typecheck.mjs", "typecheck:update": "node tools/typecheck.mjs --update", "setupExtensions": "node ./tools/extensionSetup.mjs" diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index d5349c331..949fadcd2 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -345,6 +345,15 @@ export type EventMap = { ttlSeconds?: number; }; 'outer.fs.write-hash': { hash: string; uuid: string }; + /** + * Cache keys the KV read cache must stop serving, because the entries + * behind them were just written somewhere else. + * + * `outer.*` rather than `outer.pubsub.*` on purpose: the cache lives in the + * Redis a cluster shares, so one node applying the invalidation covers the + * whole cluster — fanning it out to siblings would just repeat the write. + */ + 'outer.kv.cacheInvalidated': { cacheKeys: string[] }; 'outer.gui.item.added': GuiEvent; 'outer.gui.item.updated': GuiEvent; 'outer.gui.item.moved': GuiEvent; @@ -443,6 +452,14 @@ export type EventMap = { * policy, so a purchase is only live once they have all dropped theirs. */ 'outer.pubsub.metering.subscription-changed': { userUuid: string }; + /** + * A user's purchased credit balance changed. Separate from a policy change + * because it moves the other half of the same budget, and carried on the + * `outer.pubsub.*` channel for the same reason: every node caches whether + * an account has budget left, so a top-up only lifts enforcement once they + * have all dropped that answer. + */ + 'outer.pubsub.metering.credits-changed': { userUuid: string }; }; /** diff --git a/src/backend/controllers/fs/FSController.test.ts b/src/backend/controllers/fs/FSController.test.ts index d4420b0af..89db117e1 100644 --- a/src/backend/controllers/fs/FSController.test.ts +++ b/src/backend/controllers/fs/FSController.test.ts @@ -2372,15 +2372,18 @@ describe('FSController.statEntry additional branches', () => { // ── #getReportedCosts ─────────────────────────────────────────────── describe('FSController.getReportedCosts', () => { - it('mirrors every FS_COSTS entry as a per-byte line item', async () => { - const { FS_COSTS } = await import('./costs.js'); + it('mirrors every storage-operation price as a per-operation line item', async () => { + const { STORAGE_OP_COSTS } = + await import('../../services/metering/costs.js'); const reported = controller.getReportedCosts(); - expect(reported.length).toBe(Object.keys(FS_COSTS).length); - for (const [usageType, ucentsPerUnit] of Object.entries(FS_COSTS)) { + expect(reported.length).toBe(Object.keys(STORAGE_OP_COSTS).length); + for (const [usageType, ucentsPerUnit] of Object.entries( + STORAGE_OP_COSTS, + )) { expect(reported).toContainEqual({ usageType, ucentsPerUnit, - unit: 'byte', + unit: 'operation', source: 'controller:fs', }); } diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index f9a05ffc5..17f4dc2ee 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -38,7 +38,7 @@ import { } from '../../util/concurrency.js'; import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js'; import { PuterController } from '../types.js'; -import { FS_COSTS } from './costs.js'; +import { STORAGE_OP_COSTS } from '../../services/metering/costs.js'; import { FS_MULTIPART_LIMIT, FS_MUTATE_LIMIT, @@ -117,18 +117,24 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8; @Controller('/fs') export class FSController extends PuterController { + // Object-store requests are reported here because the filesystem is what + // makes them. Bytes leaving the server are not: they are metered for every + // response, so `MeteringService` prices them. override getReportedCosts() { - return Object.entries(FS_COSTS).map(([usageType, ucentsPerUnit]) => ({ - usageType, - ucentsPerUnit, - unit: 'byte', - source: 'controller:fs', - })); + return Object.entries(STORAGE_OP_COSTS).map( + ([usageType, ucentsPerUnit]) => ({ + usageType, + ucentsPerUnit, + unit: 'operation', + source: 'controller:fs', + }), + ); } @Post('/startWrite', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_MULTIPART_LIMIT, }) async startWrite( @@ -191,6 +197,7 @@ export class FSController extends PuterController { @Post('/startBatchWrite', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_MULTIPART_LIMIT, }) async startBatchWrites( @@ -295,6 +302,7 @@ export class FSController extends PuterController { @Post('/completeWrite', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_MULTIPART_LIMIT, }) async completeWrite( @@ -330,6 +338,7 @@ export class FSController extends PuterController { @Post('/completeBatchWrite', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_MULTIPART_LIMIT, }) async completeBatchWrites( @@ -397,6 +406,7 @@ export class FSController extends PuterController { @Post('/signMultipartParts', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_MULTIPART_LIMIT, }) async signMultipartParts( @@ -414,6 +424,7 @@ export class FSController extends PuterController { @Post('/write', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_WRITE_LIMIT, concurrent: FS_WRITE_CONCURRENT, }) @@ -465,6 +476,7 @@ export class FSController extends PuterController { @Post('/batchWrite', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_WRITE_LIMIT, concurrent: FS_WRITE_CONCURRENT, }) @@ -1295,6 +1307,7 @@ export class FSController extends PuterController { @Get('/read', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_READ_LIMIT, concurrent: FS_READ_CONCURRENT, }) @@ -1341,15 +1354,6 @@ export class FSController extends PuterController { ); res.status(range ? 206 : 200); - const metering = this.services.metering as - | { - batchIncrementUsages?: ( - actor: unknown, - entries: unknown[], - ) => void; - } - | undefined; - try { await pipeline(download.body, res); } catch { @@ -1357,23 +1361,6 @@ export class FSController extends PuterController { // tore down both ends. Response is partially sent; nothing to do. return; } - - // Meter egress only on successful completion. - if (metering?.batchIncrementUsages && download.contentLength) { - try { - const bytes = download.contentLength; - metering.batchIncrementUsages(actor, [ - { - usageType: 'filesystem:egress:bytes', - usageAmount: bytes, - costOverride: - FS_COSTS['filesystem:egress:bytes'] * bytes, - }, - ]); - } catch { - // ignore — metering is non-critical. - } - } } // -- Mutation routes ------------------------------------------------ @@ -1542,6 +1529,7 @@ export class FSController extends PuterController { @Post('/copy', { subdomain: 'api', requireVerified: true, + requireCredits: true, rateLimit: FS_MUTATE_LIMIT, }) async copyEntry(req: Request, res: Response) { diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 6710298ef..a684215ff 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -35,6 +35,7 @@ import { } from '../../core/http/middleware/gates.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import type { ACLService } from '../../services/acl/ACLService.js'; +import { assertActorHasCredits } from '../../services/metering/enforcement.js'; import type { SignedFile } from '../../util/fileSigning.js'; import { verifySignature } from '../../util/fileSigning.js'; import { @@ -61,7 +62,6 @@ import { FS_SIGNED_WRITE_LIMIT, FS_STAT_LIMIT, } from './limits.js'; -import { FS_COSTS } from './costs.js'; import { asRecord, assertAccess, @@ -116,6 +116,14 @@ export class LegacyFSController extends PuterController { subdomain: 'api', requireVerified: true, } as RouteOptions; + // Operations that move file content or make object-store requests on + // the caller's behalf, and so are refused to an account with nothing + // left of its budget. The metadata routes above deliberately aren't: + // an account that has run out still has to be able to look at what it + // has and delete it. The signature-authorised routes aren't either — + // they carry no session to answer for, and `/sign` (which does) is + // where the URL that reaches them is minted. + const spends = { ...apiOptions, requireCredits: true } as RouteOptions; // Signed-URL routes: the handler validates the URL signature itself, // so no auth gate is applied (matches v1, which mounted these routers // with no middleware). @@ -139,7 +147,7 @@ export class LegacyFSController extends PuterController { this.readdir, ); router.post('/mkdir', mutate, this.mkdir); - router.post('/copy', mutate, this.copy); + router.post('/copy', { ...mutate, requireCredits: true }, this.copy); router.post('/move', mutate, this.move); router.post('/delete', mutate, this.delete); router.post('/rename', mutate, this.rename); @@ -156,7 +164,7 @@ export class LegacyFSController extends PuterController { router.get( '/read', { - ...apiOptions, + ...spends, rateLimit: FS_READ_LIMIT, concurrent: FS_READ_CONCURRENT, }, @@ -178,7 +186,7 @@ export class LegacyFSController extends PuterController { router.post( '/batch', { - ...apiOptions, + ...spends, rateLimit: FS_BATCH_LIMIT, concurrent: FS_BATCH_CONCURRENT, }, @@ -188,7 +196,10 @@ export class LegacyFSController extends PuterController { // Signed-URL + meta routes. router.post( '/sign', - { ...apiOptions, rateLimit: FS_SIGN_LIMIT }, + // Gated even though it moves nothing itself: the URL it returns + // outlives the request and is served by a route with no session to + // check, so this is the last point at which the account is known. + { ...spends, rateLimit: FS_SIGN_LIMIT }, this.sign, ); router.post( @@ -249,6 +260,7 @@ export class LegacyFSController extends PuterController { // (CSRF can't forge a header-credentialed request). allowFullAccessToken: true, requireVerified: true, + requireCredits: true, antiCsrf: true, rateLimit: FS_READ_LIMIT, concurrent: FS_READ_CONCURRENT, @@ -1184,32 +1196,6 @@ export class LegacyFSController extends PuterController { ); res.status(range ? 206 : 200); - // Best-effort egress metering. - const metering = this.services.metering as - | { - batchIncrementUsages?: ( - actor: unknown, - entries: unknown[], - ) => void; - } - | undefined; - if (metering?.batchIncrementUsages && download.contentLength) { - download.body.once('end', () => { - try { - const bytes = download.contentLength!; - metering.batchIncrementUsages!(actor, [ - { - usageType: 'filesystem:egress:bytes', - usageAmount: bytes, - costOverride: - FS_COSTS['filesystem:egress:bytes'] * bytes, - }, - ]); - } catch { - // ignore — non-critical. - } - }); - } download.body.on('error', (err) => { res.destroy(err); }); @@ -1242,6 +1228,17 @@ export class LegacyFSController extends PuterController { req.actor = assertResolvedActor(actor!); Context.set('actor', actor); + // And the budget gate the other read routes declare with + // `requireCredits`. The global auth probe only looks for `auth_token`, + // so `?token=` leaves `req.actor` unset for the whole gate chain and + // the declarative form would wave every request through — this streams + // file content like `/read` does, so it is refused on the same terms. + await assertActorHasCredits( + this.services.metering, + req.actor, + this.config, + ); + // Forward back to regular read after setting actor return this.read(req, res, undefined, { realMime: true }); }; @@ -1629,6 +1626,23 @@ export class LegacyFSController extends PuterController { }); } + // Name who this response's bytes are billed to. A signature authorises + // access to a file; it says nothing about who is asking, so an + // unidentified caller is billed to the account whose file it is — + // otherwise a signed URL is a way to serve content for free. A caller + // who did identify themselves pays for what they fetch, as on a hosted + // site. + if (owner?.uuid) { + req.egressActor = req.actor ?? { + user: { + uuid: owner.uuid, + id: owner.id, + username: owner.username, + suspended: !!(owner as { suspended?: unknown }).suspended, + }, + }; + } + // Directory: return a signed listing of direct children. // The caller only proved read access, so strip write_url from // each child to prevent privilege escalation via /writeFile. diff --git a/src/backend/controllers/fs/costs.ts b/src/backend/controllers/fs/costs.ts deleted file mode 100644 index d72cda3b2..000000000 --- a/src/backend/controllers/fs/costs.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { toMicroCents } from '../../services/metering/utils.js'; - -// Microcents per byte. Egress roughly matches S3 data-transfer-out -// (~$0.12/GiB); cached egress is CloudFront-backed (~$0.10/GiB). -// Ingress and deletes are currently free. -export const FS_COSTS = { - 'filesystem:ingress:bytes': 0, - 'filesystem:delete:bytes': 0, - 'filesystem:egress:bytes': toMicroCents(0.12 / 1024 / 1024 / 1024), - 'filesystem:cached-egress:bytes': toMicroCents(0.1 / 1024 / 1024 / 1024), -} as const; diff --git a/src/backend/controllers/fs/limits.ts b/src/backend/controllers/fs/limits.ts index 70c3f9f74..0ff257676 100644 --- a/src/backend/controllers/fs/limits.ts +++ b/src/backend/controllers/fs/limits.ts @@ -36,10 +36,11 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types'; // plan, the dev-only `unlimited`) falls through to the base, so new plans // are generous by default rather than accidentally throttled. // -// Storage quota already bounds total bytes. These bound request *count*, -// which quota does not: small-file spam still costs object-store writes -// and fsentry rows, and metadata reads cost database time while being -// entirely free to the caller. +// Storage quota already bounds total bytes, and egress and object-store +// requests are metered. These bound request *count*, which neither does: +// metadata reads cost database time while costing the caller almost +// nothing, and metering settles seconds behind the traffic, so a limit is +// what actually stops a runaway loop in the moment. /** Per-user sliding window. Free tiers are carved out of the paid base. */ const userWindow = ( @@ -113,9 +114,10 @@ export const FS_READDIR_LIMIT: RouteRateLimit[] = [ ]; /** - * Unindexed scan across the user's tree, and unmetered — `FS_COSTS` prices - * egress bytes only, so this is the cheapest way to occupy a database - * connection. Tightest limit in the file. + * Unindexed scan across the user's tree, and close to unmetered — a result set + * is a few hundred bytes of egress against an arbitrary amount of database + * work, so this is the cheapest way to occupy a connection. Tightest limit in + * the file. */ export const FS_SEARCH_LIMIT = userWindow('fs:search', 60, 30, 10); export const FS_SEARCH_CONCURRENT = userConcurrent('fs:search', 5, 2, 2); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index c36be3b6c..1168b795b 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -48,6 +48,7 @@ import { checkRateLimit, computeNetworkFingerprint, } from '../../core/http/middleware/rateLimit.js'; +import { assertActorHasCredits } from '../../services/metering/enforcement.js'; const DAV_HEADERS = { DAV: '1, 2, ordered-collections', @@ -60,6 +61,13 @@ const ALLOW_METHODS = // macOS creates these files; reject them to keep the FS clean. const MACOS_JUNK_REGEX = /(?:^\.DS_Store$|^\._)/; +/** + * Verbs that move file content or duplicate it in the object store, and so are + * refused to an account with nothing left of its budget. HEAD is here with GET + * because a client asking for headers is a client about to fetch the body. + */ +const CREDIT_GATED_DAV_METHODS = new Set(['GET', 'HEAD', 'PUT', 'COPY']); + /** * WebDAV controller — full RFC 4918 surface on the `dav.*` subdomain. * @@ -152,6 +160,19 @@ export class WebDAVController extends PuterController { assertNotSuspended(actor.user); assertVerifiedAccount(actor.user); + // And the same budget gate the FS routes declare with + // `requireCredits`, for the verbs that move content — DAV serves the + // same files over a metered host, so leaving it out would make mounting + // the drive the way around enforcement. The verbs that only describe or + // remove things stay open, as they do over HTTP. + if (CREDIT_GATED_DAV_METHODS.has(req.method.toUpperCase())) { + await assertActorHasCredits( + this.services.metering, + actor, + this.config, + ); + } + // Expand `~`/`~/...` against the authenticated actor's username. // WebDAV doesn't standardize `~`, but some clients do — and the // pre-existing behaviour silently expanded it via the FS store. diff --git a/src/backend/core/http/expressAugmentation.ts b/src/backend/core/http/expressAugmentation.ts index eb4fc2007..7221b8d76 100644 --- a/src/backend/core/http/expressAugmentation.ts +++ b/src/backend/core/http/expressAugmentation.ts @@ -18,6 +18,7 @@ */ import type { Actor } from '../actor'; +import type { StorageOpCounts } from '../storageOps'; import type { TokenSource } from './types'; /** @@ -103,6 +104,22 @@ declare global { * middleware. */ cookies?: Record; + + /** + * Who this response's egress is billed to, when that isn't the + * actor who made the request. Set by handlers that serve one + * account's bytes to an unidentified caller — a hosted site's + * visitor being the case that matters. Takes precedence over + * `actor` in the egress middleware. + */ + egressActor?: Actor; + + /** + * Object-store requests made while serving this request, by class. + * Tallied through `recordStorageOps` and billed when the response + * ends. + */ + storageOps?: StorageOpCounts; } } } diff --git a/src/backend/core/http/middleware/credits.ts b/src/backend/core/http/middleware/credits.ts new file mode 100644 index 000000000..2fb522e54 --- /dev/null +++ b/src/backend/core/http/middleware/credits.ts @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { RequestHandler } from 'express'; +import type { IConfig } from '../../../types'; +import { + assertActorHasCredits, + type CreditMeteringLike, +} from '../../../services/metering/enforcement.js'; +import '../expressAugmentation'; + +/** + * Reject an authenticated caller with nothing left of their budget, for routes + * that opt in with `requireCredits`. + * + * Anonymous callers pass: the signed-URL routes authorize on the URL rather + * than a session, and there is no account to charge or turn away. So do worker + * sessions, unless configured otherwise — see `creditEnforcementExempt`. + * + * The answer comes from a short-lived per-actor cache in the metering service, + * so this normally costs nothing beyond a map lookup. That is what makes it + * affordable on routes that are called hundreds of times a minute. + */ +export const requireCreditsGate = ( + metering: CreditMeteringLike | undefined, + config: IConfig, +): RequestHandler => { + return (req, _res, next) => { + assertActorHasCredits(metering, req.actor, config).then( + () => next(), + (err) => next(err), + ); + }; +}; diff --git a/src/backend/core/http/middleware/egressMetering.http.test.ts b/src/backend/core/http/middleware/egressMetering.http.test.ts new file mode 100644 index 000000000..6bbba3c10 --- /dev/null +++ b/src/backend/core/http/middleware/egressMetering.http.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Actor } from '../../actor'; +import { PERIOD_ESCAPE } from '../../../services/metering/consts.js'; +import type { UsageByType } from '../../../services/metering/types'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js'; + +/** + * Egress metering over real HTTP. The unit tests drive the middleware with a + * response double; only a listening server proves the byte counter survives the + * middleware stack it is installed under (compression included) and that the + * actor is resolvable by the time the response closes. + */ +describe('egress metering over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const escape = (usageType: string) => + usageType.replace(/\./g, PERIOD_ESCAPE); + + const usageFor = async (actor: Actor): Promise => { + await env.server.services.metering.flushBufferedUsages(); + const { usage } = + await env.server.services.metering.getActorCurrentMonthUsageDetails( + actor, + ); + return usage; + }; + + const actorFor = async (username: string): Promise => { + const user = await env.server.stores.user.getByUsername(username); + return { user: user! } as Actor; + }; + + it('bills a file read to the reader, bytes and object-store request alike', async () => { + const { username, token } = env.users.user; + const actor = await actorFor(username); + + const body = Buffer.from('x'.repeat(4096)); + await env.server.services.fs.write(actor.user.id!, { + fileMetadata: { + path: `/${username}/Desktop/egress.txt`, + size: body.byteLength, + contentType: 'text/plain', + }, + fileContent: body, + }); + + const before = await usageFor(actor); + const beforeEgress = + (before[escape('egress:bytes')] as { units?: number } | undefined) + ?.units ?? 0; + + const readUrl = new URL('/fs/read', env.apiOrigin); + readUrl.searchParams.set('path', `/${username}/Desktop/egress.txt`); + const read = await fetch(readUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(read.status).toBe(200); + expect(await read.text()).toHaveLength(body.byteLength); + + const after = await usageFor(actor); + const egress = after[escape('egress:bytes')] as { + units: number; + cost: number; + }; + // Compression may shrink the payload on the wire, so the floor is the + // headers rather than the file — what matters is that the read was + // counted at all, and that it cost something. + expect(egress.units).toBeGreaterThan(beforeEgress); + expect(egress.cost).toBeGreaterThan(0); + + const reads = after[escape('storage:read:ops')] as { + units: number; + }; + expect(reads.units).toBeGreaterThanOrEqual(1); + }); + + it('bills a signed-URL read to the account whose file it is', async () => { + const { username, token } = env.users.other; + const actor = await actorFor(username); + + const body = Buffer.from('y'.repeat(4096)); + const path = `/${username}/Desktop/signed-egress.txt`; + await env.server.services.fs.write(actor.user.id!, { + fileMetadata: { + path, + size: body.byteLength, + contentType: 'text/plain', + }, + fileContent: body, + }); + + const signed = await fetch(new URL('/sign', env.apiOrigin), { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ items: [{ path, action: 'read' }] }), + }); + expect(signed.status).toBe(200); + const readUrl = new URL( + (await signed.json()).signatures[0].read_url as string, + ); + + const before = await usageFor(actor); + const beforeEgress = + (before[escape('egress:bytes')] as { units?: number } | undefined) + ?.units ?? 0; + + // A signature proves access to the file, not who is asking — so this + // fetch carries no credential, and the owner is the only account there + // is to bill. + const fetched = await fetch( + new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin), + ); + expect(fetched.status).toBe(200); + expect(await fetched.text()).toHaveLength(body.byteLength); + + const after = await usageFor(actor); + const egress = after[escape('egress:bytes')] as { + units: number; + cost: number; + }; + expect(egress.units).toBeGreaterThan(beforeEgress); + expect(egress.cost).toBeGreaterThan(0); + }); + + it('leaves root-origin asset traffic out of the actor’s usage', async () => { + const { username, token } = env.users.user; + const actor = await actorFor(username); + + const before = await usageFor(actor); + const beforeEgress = + (before[escape('egress:bytes')] as { units?: number } | undefined) + ?.units ?? 0; + + const sdk = await fetch(new URL('/puter.js/v2', env.origin), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(sdk.status).toBe(200); + expect((await sdk.text()).length).toBeGreaterThan(1000); + + const after = await usageFor(actor); + const afterEgress = + (after[escape('egress:bytes')] as { units?: number } | undefined) + ?.units ?? 0; + expect(afterEgress).toBe(beforeEgress); + }); +}); diff --git a/src/backend/core/http/middleware/egressMetering.test.ts b/src/backend/core/http/middleware/egressMetering.test.ts new file mode 100644 index 000000000..b25e5a5ad --- /dev/null +++ b/src/backend/core/http/middleware/egressMetering.test.ts @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { EventEmitter } from 'node:events'; +import type { Request, Response } from 'express'; +import { describe, expect, it, vi } from 'vitest'; +import type { Actor } from '../../actor'; +import { SYSTEM_ACTOR } from '../../actor'; +import { + EGRESS_COSTS, + STORAGE_OP_COSTS, +} from '../../../services/metering/costs'; +import type { UsageInput } from '../../../services/metering/types'; +import { createEgressMeteringMiddleware } from './egressMetering'; + +const actor = (uuid = 'user-1'): Actor => + ({ user: { uuid, username: 'u' } }) as Actor; + +/** + * Response double that behaves like the parts of `http.ServerResponse` the + * middleware touches: writes go somewhere, `close` is emitted once the response + * is over, and headers are readable at that point. + */ +const makeRes = (headers: Record = {}) => { + const emitter = new EventEmitter(); + const written: unknown[] = []; + const res = Object.assign(emitter, { + write: vi.fn((chunk: unknown) => { + written.push(chunk); + return true; + }), + end: vi.fn((chunk?: unknown) => { + if (chunk !== undefined && typeof chunk !== 'function') + written.push(chunk); + return res; + }), + getHeaders: () => headers, + }) as unknown as Response & { written: unknown[] }; + return Object.assign(res, { written }); +}; + +const run = ( + reqPartial: Partial & Record = {}, + headers: Record = {}, +) => { + const buffered: Array<{ actor: Actor; usages: UsageInput[] }> = []; + const bufferIncrementUsages = vi.fn((a: Actor, usages: UsageInput[]) => { + buffered.push({ actor: a, usages }); + }); + const middleware = createEgressMeteringMiddleware({ + services: { metering: { bufferIncrementUsages } }, + }); + + const req = { + subdomains: ['api'], + actor: actor(), + ...reqPartial, + } as unknown as Request; + const res = makeRes(headers); + const next = vi.fn(); + middleware(req, res, next); + + return { req, res, next, buffered, bufferIncrementUsages }; +}; + +const finish = (res: Response) => res.emit('close'); + +const usageOf = (usages: UsageInput[], usageType: string) => + usages.find((u) => u.usageType === usageType); + +describe('createEgressMeteringMiddleware', () => { + it('passes the request straight through', () => { + const { next, res } = run(); + expect(next).toHaveBeenCalledOnce(); + // The write hooks must not swallow the payload. + res.write(Buffer.from('abc')); + res.end('de'); + expect((res as Response & { written: unknown[] }).written).toEqual([ + Buffer.from('abc'), + 'de', + ]); + }); + + it('bills every byte written, plus the headers, at the response cost', () => { + const { res, buffered } = run({}, { 'content-type': 'text/plain' }); + + res.write(Buffer.alloc(1000)); + res.end(Buffer.alloc(24)); + finish(res); + + expect(buffered).toHaveLength(1); + const egress = usageOf(buffered[0]!.usages, 'egress:bytes')!; + // Header estimate is small but non-zero, so the total is a floor. + expect(egress.usageAmount).toBeGreaterThan(1024); + expect(egress.usageAmount).toBeLessThan(1100); + expect(egress.costOverride).toBeCloseTo( + EGRESS_COSTS['egress:bytes'] * egress.usageAmount, + 10, + ); + }); + + it('counts string chunks by their encoded length, not their character count', () => { + const empty = run(); + finish(empty.res); + const headerBytes = usageOf( + empty.buffered[0]!.usages, + 'egress:bytes', + )!.usageAmount; + + const { res, buffered } = run(); + res.end('déjà'); + finish(res); + const withBody = usageOf( + buffered[0]!.usages, + 'egress:bytes', + )!.usageAmount; + + expect(withBody - headerBytes).toBe(Buffer.byteLength('déjà')); + }); + + it('meters a response that died mid-stream for what it managed to send', () => { + const { res, buffered } = run(); + res.write(Buffer.alloc(500)); + // No end() — the connection dropped. + finish(res); + + expect( + usageOf(buffered[0]!.usages, 'egress:bytes')!.usageAmount, + ).toBeGreaterThan(500); + }); + + it('bills the object-store requests made while serving the response', () => { + const { req, res, buffered } = run(); + (req as Request).storageOps = { write: 3, read: 2, delete: 5 }; + res.end('x'); + finish(res); + + const usages = buffered[0]!.usages; + expect(usageOf(usages, 'storage:write:ops')).toMatchObject({ + usageAmount: 3, + costOverride: STORAGE_OP_COSTS['storage:write:ops'] * 3, + }); + expect(usageOf(usages, 'storage:read:ops')).toMatchObject({ + usageAmount: 2, + }); + // Removals are counted but free. + expect(usageOf(usages, 'storage:delete:ops')).toMatchObject({ + usageAmount: 5, + costOverride: 0, + }); + }); + + it('bills `egressActor` ahead of the requesting actor', () => { + const owner = actor('owner-1'); + const { res, buffered } = run({ + subdomains: ['some-site'], + actor: undefined, + egressActor: owner, + }); + res.end('hello'); + finish(res); + + expect(buffered[0]!.actor).toBe(owner); + }); + + it('meters a host it otherwise ignores once a billing target is named', () => { + const { res, buffered } = run({ + subdomains: [], + egressActor: actor('owner-1'), + }); + res.end('hello'); + finish(res); + + expect(buffered).toHaveLength(1); + }); + + it('leaves first-party asset traffic unmetered', () => { + for (const subdomains of [[], ['js'], ['docs']]) { + const { res, bufferIncrementUsages } = run({ subdomains }); + res.end(Buffer.alloc(5_000_000)); + finish(res); + expect(bufferIncrementUsages).not.toHaveBeenCalled(); + } + }); + + it('meters the dav surface alongside the api', () => { + const { res, bufferIncrementUsages } = run({ subdomains: ['dav'] }); + res.end('hello'); + finish(res); + expect(bufferIncrementUsages).toHaveBeenCalledOnce(); + }); + + it('skips requests with no actor and the system actor', () => { + for (const req of [ + { actor: undefined }, + { actor: { user: {} } as Actor }, + { actor: SYSTEM_ACTOR }, + ]) { + const { res, bufferIncrementUsages } = run(req); + res.end('hello'); + finish(res); + expect(bufferIncrementUsages).not.toHaveBeenCalled(); + } + }); + + it('records nothing when metering is not installed', () => { + const middleware = createEgressMeteringMiddleware({ services: {} }); + const req = { + subdomains: ['api'], + actor: actor(), + } as unknown as Request; + const res = makeRes(); + const next = vi.fn(); + + middleware(req, res, next); + res.end('hello'); + expect(() => finish(res)).not.toThrow(); + expect(next).toHaveBeenCalledOnce(); + }); + + it('never lets a metering failure escape into the response path', () => { + const bufferIncrementUsages = vi.fn(() => { + throw new Error('metering down'); + }); + const middleware = createEgressMeteringMiddleware({ + services: { metering: { bufferIncrementUsages } }, + }); + const req = { + subdomains: ['api'], + actor: actor(), + } as unknown as Request; + const res = makeRes(); + + middleware(req, res, vi.fn()); + res.end('hello'); + expect(() => finish(res)).not.toThrow(); + }); +}); diff --git a/src/backend/core/http/middleware/egressMetering.ts b/src/backend/core/http/middleware/egressMetering.ts new file mode 100644 index 000000000..1798a6a8e --- /dev/null +++ b/src/backend/core/http/middleware/egressMetering.ts @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request, RequestHandler, Response } from 'express'; +import type { Actor } from '../../actor'; +import { isSystemActor } from '../../actor'; +import type { StorageOpClass } from '../../storageOps'; +import { + EGRESS_COSTS, + STORAGE_OP_COSTS, + STORAGE_OP_USAGE_TYPES, +} from '../../../services/metering/costs.js'; +import type { UsageInput } from '../../../services/metering/types'; +import '../expressAugmentation'; + +/** + * Subset of the metering service this middleware needs. Metering is optional + * from here — a deployment without it still serves traffic. + */ +interface MeteringLike { + bufferIncrementUsages?: (actor: Actor, usages: UsageInput[]) => void; +} + +interface Layers { + services: { metering?: MeteringLike }; +} + +/** + * Hosts whose responses are a user's own data or an account's app traffic, and + * so are billed to that account. Everything else the origin serves — the + * desktop shell, the SDK, the homepage, static assets — is Puter's own cost of + * being reachable and is deliberately not charged to whoever happens to be + * signed in while loading it. + * + * Hosted sites are not in here: they arrive on a per-site subdomain and opt in + * by naming who to bill (`req.egressActor`). + */ +const METERED_SUBDOMAINS = new Set(['api', 'dav']); + +/** + * Rough size of the status line and headers, which never reach `res.write` and + * so can only be estimated. Sized as `NAME: value\r\n` per header plus the + * status line and the blank line that ends the block. Small next to any real + * payload, but responses that are almost all headers (a 204, a redirect) are + * common enough that ignoring it would under-count a chatty client by a wide + * margin. + */ +const estimateHeaderBytes = (res: Response): number => { + // "HTTP/1.1 200 OK\r\n" plus the "\r\n" that terminates the block. + let bytes = 19; + let headers: ReturnType; + try { + headers = res.getHeaders(); + } catch { + return bytes; + } + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) continue; + const rendered = Array.isArray(value) + ? value.join(', ') + : String(value); + bytes += name.length + rendered.length + 4; + } + return bytes; +}; + +const chunkBytes = (chunk: unknown, encoding: unknown): number => { + if (typeof chunk === 'string') { + return Buffer.byteLength( + chunk, + typeof encoding === 'string' + ? (encoding as BufferEncoding) + : 'utf8', + ); + } + if (chunk instanceof Uint8Array || Buffer.isBuffer(chunk)) { + return chunk.byteLength; + } + return 0; +}; + +/** Who the response's bytes are billed to, or undefined if nobody. */ +const resolveEgressActor = (req: Request): Actor | undefined => { + const actor = req.egressActor ?? req.actor; + if (!actor?.user?.uuid) return undefined; + if (isSystemActor(actor)) return undefined; + return actor; +}; + +const isMeteredHost = (req: Request): boolean => { + // An explicit billing target is the opt-in for hosts that aren't metered + // by default, so honour it whatever the subdomain says. + if (req.egressActor) return true; + const subdomain = req.subdomains?.[req.subdomains.length - 1]; + return !!subdomain && METERED_SUBDOMAINS.has(subdomain); +}; + +const storageOpUsages = (req: Request): UsageInput[] => { + const ops = req.storageOps; + if (!ops) return []; + const usages: UsageInput[] = []; + for (const [opClass, count] of Object.entries(ops)) { + if (!count || count <= 0) continue; + const usageType = STORAGE_OP_USAGE_TYPES[opClass as StorageOpClass]; + if (!usageType) continue; + usages.push({ + usageType, + usageAmount: count, + costOverride: STORAGE_OP_COSTS[usageType] * count, + }); + } + return usages; +}; + +/** + * Meters what a request actually costs to serve: every byte written back to the + * client, plus the object-store requests made on its behalf. + * + * This is the only place egress is counted. Handlers that stream file content + * used to meter their own bytes, which measured the payload they handed to + * express rather than what left the process, missed every other response, and + * charged an increment per download. Counting here instead means one rule for + * all traffic, and bytes counted after compression has had its say. + * + * Install FIRST, ahead of the compression middleware: middleware that wraps + * `res.write` later ends up wrapping this one, so anything installed after + * compression sees the payload before it is compressed. The actor is read at + * the end of the response rather than here, by which time the auth probe has + * run. + * + * Never rejects, never delays the response: increments are handed to metering + * once the response is over, and metering buffers them rather than writing per + * request. + */ +export const createEgressMeteringMiddleware = ( + layers: Layers, +): RequestHandler => { + return (req, res, next) => { + const metering = layers.services.metering; + if (!metering?.bufferIncrementUsages) { + next(); + return; + } + + let bodyBytes = 0; + + const write = res.write.bind(res); + const end = res.end.bind(res); + + res.write = (( + chunk: unknown, + encoding?: unknown, + callback?: unknown, + ) => { + bodyBytes += chunkBytes(chunk, encoding); + return (write as (...args: unknown[]) => boolean)( + chunk, + encoding, + callback, + ); + }) as Response['write']; + + res.end = (( + chunk?: unknown, + encoding?: unknown, + callback?: unknown, + ) => { + // `end()` also takes a callback in the first or second slot. + if (typeof chunk !== 'function') { + bodyBytes += chunkBytes(chunk, encoding); + } + return (end as (...args: unknown[]) => Response)( + chunk, + encoding, + callback, + ); + }) as Response['end']; + + // 'close' rather than 'finish': it fires for a response that completed + // AND for one whose connection died mid-stream, and an aborted download + // still sent whatever it sent. + res.once('close', () => { + try { + if (!isMeteredHost(req)) return; + const actor = resolveEgressActor(req); + if (!actor) return; + + const usages = storageOpUsages(req); + const bytes = bodyBytes + estimateHeaderBytes(res); + if (bytes > 0) { + usages.push({ + usageType: 'egress:bytes', + usageAmount: bytes, + costOverride: EGRESS_COSTS['egress:bytes'] * bytes, + }); + } + if (usages.length === 0) return; + + metering.bufferIncrementUsages!(actor, usages); + } catch (e) { + // Metering is never worth failing a request that already + // succeeded over. + console.warn( + `[metering] egress metering failed: ${(e as Error).message}`, + ); + } + }); + + next(); + }; +}; diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 63a4c747c..d7017d7af 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -21,7 +21,6 @@ import type { RequestHandler } from 'express'; import { contentType as contentTypeFromMime } from 'mime-types'; import { posix as pathPosix } from 'node:path'; import type { puterClients } from '../../../clients'; -import { FS_COSTS } from '../../../controllers/fs/costs'; import type { puterServices } from '../../../services'; import type { puterStores } from '../../../stores'; import type { IConfig, LayerInstances } from '../../../types'; @@ -592,45 +591,20 @@ export const createPuterSiteMiddleware = ( res.setHeader('Access-Control-Allow-Origin', '*'); res.status(statusOverride ?? (range ? 206 : 200)); - // Best-effort egress metering against the site owner. The request - // itself is unauthenticated (public site visitor), so we can't use - // req.actor — charge the account that hosts the file. Same cost - // key as FS read egress (`filesystem:egress:bytes`). Fires once - // the body stream ends so we only meter bytes actually delivered - // (not aborted mid-stream). - const metering = layers.services.metering as unknown as - | { - batchIncrementUsages?: ( - actor: unknown, - entries: unknown[], - ) => void; - } - | undefined; - if (metering?.batchIncrementUsages && download.contentLength) { - const ownerActor = { - user: { - uuid: owner.uuid, - id: owner.id, - username: owner.username, - suspended: !!owner.suspended, - }, - }; - download.body.once('end', () => { - try { - const bytes = download.contentLength!; - metering.batchIncrementUsages!(ownerActor, [ - { - usageType: 'filesystem:egress:bytes', - usageAmount: bytes, - costOverride: - FS_COSTS['filesystem:egress:bytes'] * bytes, - }, - ]); - } catch { - // ignore — non-critical. - } - }); - } + // Name who this response's bytes are billed to; the egress middleware + // does the metering. A visitor carrying a token pays for what they + // fetch, and the account hosting the site covers everyone else — + // hosting is unauthenticated by design, so most visitors are nobody in + // particular. Set unconditionally because hosting subdomains are not + // metered by default: this is what opts the response in. + req.egressActor = req.actor ?? { + user: { + uuid: owner.uuid, + id: owner.id, + username: owner.username, + suspended: !!owner.suspended, + }, + }; req.on('close', () => download.body.destroy()); download.body.on('error', (err) => res.destroy(err)); diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 81a534aaa..22d47d2d2 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -72,8 +72,8 @@ export type RoutePath = string | RegExp | Array; * middleware chain in this order: * * subdomain → requireAuth (+ suspended) → emailConfirmed → - * requireUserActor → adminOnly → allowedAppIds → - * caller `middleware: []` → handler + * requireUserActor → adminOnly → allowedAppIds → rateLimit → + * requireCredits → concurrent → caller `middleware: []` → handler * * `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply `requireAuth`; * the materializer dedupes so only one auth gate ends up in the chain. @@ -316,6 +316,23 @@ export interface RouteOptions { backend?: 'memory' | 'redis' | 'kv'; }; + /** + * Reject an account with nothing left of its usage budget with 402 + * `insufficient_funds`. + * + * For routes that spend metered resources on the caller's behalf — moving + * file content, making object-store requests. Not for the routes that show + * an account what it has or let it delete things: an account that has run + * out still needs to be able to see its files, clear space, and reach its + * billing pages, and blocking that leaves no way out other than paying. + * + * Anonymous callers and worker sessions pass; see `requireCreditsGate`. + * Rate limits remain the bound on request _count_ — this bounds spend, and + * lags the traffic that produced it by the metering buffer window, so it + * stops sustained usage rather than a burst. + */ + requireCredits?: boolean; + // Reserved — wire as the corresponding features/services land: // bodyFiles?: string[]; // multer-style multipart fields // responseTimeout?: number; diff --git a/src/backend/core/storageOps.test.ts b/src/backend/core/storageOps.test.ts new file mode 100644 index 000000000..86c12a521 --- /dev/null +++ b/src/backend/core/storageOps.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Request } from 'express'; +import { describe, expect, it } from 'vitest'; +import { runWithContext } from './context'; +import { recordStorageOps } from './storageOps'; + +const withRequest = (fn: () => void): Request => { + const req = {} as Request; + runWithContext({ req }, fn); + return req; +}; + +describe('recordStorageOps', () => { + it('tallies each class on the request in scope', () => { + const req = withRequest(() => { + recordStorageOps('write'); + recordStorageOps('write', 3); + recordStorageOps('read'); + recordStorageOps('delete', 2); + }); + + expect(req.storageOps).toEqual({ write: 4, read: 1, delete: 2 }); + }); + + it('ignores counts that are not a positive number', () => { + const req = withRequest(() => { + recordStorageOps('write', 0); + recordStorageOps('write', -1); + recordStorageOps('write', Number.NaN); + }); + + expect(req.storageOps).toBeUndefined(); + }); + + it('does nothing outside a request', () => { + expect(() => recordStorageOps('write')).not.toThrow(); + }); + + it('does nothing when the context carries no request', () => { + expect(() => + runWithContext({}, () => recordStorageOps('write')), + ).not.toThrow(); + }); +}); diff --git a/src/backend/core/storageOps.ts b/src/backend/core/storageOps.ts new file mode 100644 index 000000000..a3d47c15c --- /dev/null +++ b/src/backend/core/storageOps.ts @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Context } from './context'; +import './http/expressAugmentation'; + +/** + * Object-store request classes. They are priced apart because they cost + * different amounts per request: uploads, copies and multipart parts are the + * expensive class, fetches and metadata lookups an order of magnitude cheaper, + * and removals are not charged for at all. + */ +export type StorageOpClass = 'write' | 'read' | 'delete'; + +export type StorageOpCounts = Partial>; + +/** + * Tally object-store requests against the request that caused them. + * + * The counts ride on the express request rather than going straight to + * metering, so that the whole cost of serving a request — its response bytes + * and the object-store calls behind them — settles as one write when the + * response ends. Work with no request in scope (boot, background sweeps) + * tallies nothing, which is why this never throws when called outside one. + */ +export const recordStorageOps = ( + opClass: StorageOpClass, + count: number = 1, +): void => { + if (!Number.isFinite(count) || count <= 0) return; + const req = Context.get('req'); + if (!req) return; + const ops = (req.storageOps ??= {}); + ops[opClass] = (ops[opClass] ?? 0) + count; +}; diff --git a/src/backend/drivers/kv/KVStoreDriver.readCache.test.ts b/src/backend/drivers/kv/KVStoreDriver.readCache.test.ts new file mode 100644 index 000000000..f3fe85316 --- /dev/null +++ b/src/backend/drivers/kv/KVStoreDriver.readCache.test.ts @@ -0,0 +1,106 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { Actor, makeActor } from '../../core/actor.ts'; +import { runWithContext } from '../../core/context.ts'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; +import { KV_CACHED_READ_RATE_SHARE, KV_COSTS } from './costs.ts'; +import type { KVStoreDriver } from './KVStoreDriver.ts'; + +describe('KVStoreDriver read-cache metering', () => { + let server: PuterServer; + let target: KVStoreDriver; + + beforeAll(async () => { + server = await setupTestServer({ + kvCache: { enabled: true, broadcastCoalesceMs: 0 }, + }); + target = server.drivers.kvStore; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const actorFor = (): Actor => + makeActor({ + user: { + uuid: `test-user-${Math.random().toString(36).slice(2)}`, + id: 1, + username: 'test-user', + email: 'test@test.com', + email_confirmed: true, + }, + app: { uid: 'test-app', id: 1 }, + }); + + /** Cache fills are not awaited by the read that triggers them. */ + const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + + it('prices a cached read at a tenth of the rate an uncached one pays', () => { + expect(KV_COSTS['kv:read:cached']).toBeCloseTo( + KV_COSTS['kv:read'] * KV_CACHED_READ_RATE_SHARE, + 10, + ); + }); + + it('charges the cached rate for the read the cache answered', async () => { + const actor = actorFor(); + const increment = vi.spyOn(server.services.metering, 'incrementUsage'); + const buffer = vi.spyOn( + server.services.metering, + 'bufferIncrementUsages', + ); + + // A key that was never written: the absence is what gets cached, so no + // write is involved and the second read is free to be served from it. + await runWithContext({ actor }, () => target.get({ key: 'absent' })); + const uncached = increment.mock.calls.find( + (call) => call[1] === 'kv:read', + ); + expect(uncached).toBeDefined(); + const units = uncached![2]; + expect(units).toBeGreaterThan(0); + expect(uncached![3]).toBe(KV_COSTS['kv:read'] * units); + + await settle(); + increment.mockClear(); + await runWithContext({ actor }, () => target.get({ key: 'absent' })); + + // Nothing consumed capacity, so nothing is charged at the read rate. + expect( + increment.mock.calls.filter((call) => call[1] === 'kv:read'), + ).toHaveLength(0); + expect(buffer).toHaveBeenCalledWith(actor, [ + { + usageType: 'kv:read:cached', + usageAmount: units, + costOverride: KV_COSTS['kv:read:cached'] * units, + }, + ]); + }); + + it('reports the cached rate alongside the rates it discounts', () => { + expect(target.getReportedCosts()).toEqual( + expect.arrayContaining([ + { + usageType: 'kv:read:cached', + ucentsPerUnit: KV_COSTS['kv:read:cached'], + unit: 'capacity-unit', + source: 'driver:kvStore', + }, + ]), + ); + }); +}); diff --git a/src/backend/drivers/kv/KVStoreDriver.test.ts b/src/backend/drivers/kv/KVStoreDriver.test.ts index 6b82f5e9b..762651cec 100644 --- a/src/backend/drivers/kv/KVStoreDriver.test.ts +++ b/src/backend/drivers/kv/KVStoreDriver.test.ts @@ -82,7 +82,10 @@ describe('KVStoreDriver', () => { it('coerces a non-string key to a string before lookup', async () => { const res = await inCtx(async () => { - await target.set({ key: 123 as unknown as string, value: 'numeric' }); + await target.set({ + key: 123 as unknown as string, + value: 'numeric', + }); return target.get({ key: '123' }); }); expect(res).toBe('numeric'); @@ -142,7 +145,11 @@ describe('KVStoreDriver', () => { it('honours expireAt — past timestamps make the value invisible', async () => { const past = Math.floor(Date.now() / 1000) - 10; const res = await inCtx(async () => { - await target.set({ key: 'gone', value: 'soon', expireAt: past }); + await target.set({ + key: 'gone', + value: 'soon', + expireAt: past, + }); return target.get({ key: 'gone' }); }); expect(res).toBeNull(); @@ -236,9 +243,7 @@ describe('KVStoreDriver', () => { }); it('returns true even when the key never existed', async () => { - const res = await inCtx(() => - target.del({ key: 'never-existed' }), - ); + const res = await inCtx(() => target.del({ key: 'never-existed' })); expect(res).toBe(true); }); @@ -422,70 +427,70 @@ describe('KVStoreDriver', () => { expect(res).toMatchObject({ n: 1 }); }); - it.each([ - ['incr' as const], - ['decr' as const], - ])('%s rejects a missing key', async (op) => { - await expect( - inCtx(() => - target[op]({ - key: undefined, - pathAndAmountMap: { n: 1 }, - }), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - }); + it.each([['incr' as const], ['decr' as const]])( + '%s rejects a missing key', + async (op) => { + await expect( + inCtx(() => + target[op]({ + key: undefined, + pathAndAmountMap: { n: 1 }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }, + ); - it.each([ - ['incr' as const], - ['decr' as const], - ])('%s rejects a missing pathAndAmountMap', async (op) => { - await expect( - inCtx(() => - target[op]({ - key: 'k', - pathAndAmountMap: undefined as unknown as Record< - string, - number - >, - }), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - }); + it.each([['incr' as const], ['decr' as const]])( + '%s rejects a missing pathAndAmountMap', + async (op) => { + await expect( + inCtx(() => + target[op]({ + key: 'k', + pathAndAmountMap: undefined as unknown as Record< + string, + number + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }, + ); - it.each([ - ['incr' as const], - ['decr' as const], - ])('%s rejects a non-object pathAndAmountMap', async (op) => { - await expect( - inCtx(() => - target[op]({ - key: 'k', - pathAndAmountMap: 'nope' as unknown as Record< - string, - number - >, - }), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - }); + it.each([['incr' as const], ['decr' as const]])( + '%s rejects a non-object pathAndAmountMap', + async (op) => { + await expect( + inCtx(() => + target[op]({ + key: 'k', + pathAndAmountMap: 'nope' as unknown as Record< + string, + number + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }, + ); // A path walking the prototype chain is a client error, not an // opaque 500 out of the document client. - it.each([ - ['incr' as const], - ['decr' as const], - ])('%s rejects a prototype-walking path as a 400', async (op) => { - await expect( - inCtx(() => - target[op]({ - key: 'proto-test', - pathAndAmountMap: { 'constructor.prototype.x': 1 }, - }), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - expect(({} as Record).x).toBeUndefined(); - }); + it.each([['incr' as const], ['decr' as const]])( + '%s rejects a prototype-walking path as a 400', + async (op) => { + await expect( + inCtx(() => + target[op]({ + key: 'proto-test', + pathAndAmountMap: { 'constructor.prototype.x': 1 }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(({} as Record).x).toBeUndefined(); + }, + ); }); describe('expireAt / expire', () => { @@ -673,9 +678,7 @@ describe('KVStoreDriver', () => { it('rejects an empty key', async () => { await expect( - inCtx(() => - target.add({ key: '', pathAndValueMap: { x: 1 } }), - ), + inCtx(() => target.add({ key: '', pathAndValueMap: { x: 1 } })), ).rejects.toMatchObject({ statusCode: 400 }); }); }); @@ -712,9 +715,7 @@ describe('KVStoreDriver', () => { it('rejects a missing key', async () => { await expect( - inCtx(() => - target.remove({ key: undefined, paths: ['x'] }), - ), + inCtx(() => target.remove({ key: undefined, paths: ['x'] })), ).rejects.toMatchObject({ statusCode: 400 }); }); }); @@ -876,10 +877,7 @@ describe('KVStoreDriver', () => { )) as { id: number; uid: string }; }; - const asApp = ( - owner: Actor, - app: { id: number; uid: string }, - ): Actor => + const asApp = (owner: Actor, app: { id: number; uid: string }): Actor => buildActor({ user: owner.user, app: { uid: app.uid, id: app.id }, @@ -940,7 +938,7 @@ describe('KVStoreDriver', () => { calendar.uid, appDataPermission(contacts.uid, 'kv', 'get'), ); - + // Also the positive control for the `not.toHaveBeenCalled()` // assertions below: it proves the spy is attached to the same // service instance the driver consults, so those negatives mean @@ -1496,4 +1494,92 @@ describe('KVStoreDriver', () => { } }); }); + + // ── Budget enforcement ─────────────────────────────────────────── + + describe('budget enforcement', () => { + // Spend the actor's whole monthly allowance, so the next call is the + // first one it can't afford. + const exhaust = async (spender: Actor) => { + const sub = + await server.services.metering.getActorSubscription(spender); + await server.services.metering.incrementUsage( + spender, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + }; + + it('refuses reads and writes once the allowance is spent', async () => { + await inCtx(() => target.set({ key: 'k', value: 'v' })); + await exhaust(actor); + + await expect( + inCtx(() => target.get({ key: 'k' })), + ).rejects.toMatchObject({ + statusCode: 402, + legacyCode: 'insufficient_funds', + }); + await expect( + inCtx(() => target.set({ key: 'k2', value: 'v' })), + ).rejects.toMatchObject({ statusCode: 402 }); + // `list` hands back the values unless asked otherwise, which is a + // read like any other. + await expect(inCtx(() => target.list({}))).rejects.toMatchObject({ + statusCode: 402, + }); + await expect( + inCtx(() => target.list({ as: 'values' })), + ).rejects.toMatchObject({ statusCode: 402 }); + }); + + it('still lets the account see which keys it has, so it can pick what to clear', async () => { + await inCtx(async () => { + await target.set({ key: 'keep', value: 'v' }); + await target.set({ key: 'drop', value: 'v' }); + }); + await exhaust(actor); + + const keys = await inCtx(() => target.list({ as: 'keys' })); + expect(keys).toEqual(expect.arrayContaining(['keep', 'drop'])); + + await expect( + inCtx(() => target.del({ key: 'drop' })), + ).resolves.toBe(true); + expect(await inCtx(() => target.list({ as: 'keys' }))).toEqual([ + 'keep', + ]); + }); + + it('still lets the account get rid of what it stored', async () => { + await inCtx(async () => { + await target.set({ key: 'k', value: 'v' }); + await target.set({ key: 'obj', value: { a: 1, b: 2 } }); + }); + await exhaust(actor); + + await expect(inCtx(() => target.del({ key: 'k' }))).resolves.toBe( + true, + ); + await expect( + inCtx(() => target.remove({ key: 'obj', paths: ['a'] })), + ).resolves.not.toThrow(); + await expect(inCtx(() => target.flush({}))).resolves.toBe(true); + }); + + it('exempts a worker session', async () => { + const worker = makeActor({ + session: { uid: 'worker-session', kind: 'worker' }, + }); + await exhaust(worker); + + await expect( + inCtx(() => target.set({ key: 'k', value: 'v' }), worker), + ).resolves.toBe(true); + await expect( + inCtx(() => target.get({ key: 'k' }), worker), + ).resolves.toBe('v'); + }); + }); }); diff --git a/src/backend/drivers/kv/KVStoreDriver.ts b/src/backend/drivers/kv/KVStoreDriver.ts index 9e4c16663..42db55906 100644 --- a/src/backend/drivers/kv/KVStoreDriver.ts +++ b/src/backend/drivers/kv/KVStoreDriver.ts @@ -33,6 +33,7 @@ import { appDataSharingAllowed, } from '../../services/permission/appDataScopes.js'; import type { KVOpts, KVUsage } from '../../stores/systemKv/SystemKVStore.js'; +import { assertActorHasCredits } from '../../services/metering/enforcement.js'; import { KV_COSTS } from './costs.js'; /** @@ -45,6 +46,18 @@ type KvCallArgs = { ttl?: unknown; }; +/** + * Methods that stay available to an account with nothing left of its budget. + * Each one only ever reduces what the account is storing, and turning those + * away would leave no way to stop spending other than paying. + * + * `list` is here for the step before that: deleting a key means knowing it + * exists, and `flush` — the only alternative — takes everything. It is gated + * again inside the method for the forms that return values, which are a read + * like any other. + */ +const CREDIT_UNGATED_KV_METHODS = new Set(['del', 'remove', 'flush', 'list']); + /** * KV store driver implementing the `puter-kvstore` interface. * @@ -138,6 +151,19 @@ export class KVStoreDriver extends PuterDriver { async #opts(method: string, args: KvCallArgs): Promise { const actor = Context.get('actor') as Actor | undefined; + + // Every method resolves its options here first, so this is the one + // place the budget gate has to go. `CREDIT_UNGATED_KV_METHODS` is what + // stays reachable after it: an account that has run out still has to be + // able to get its data out of the way of the next thing it stores. + if (!CREDIT_UNGATED_KV_METHODS.has(method)) { + await assertActorHasCredits( + this.services.metering, + actor, + this.config, + ); + } + const appUuid = args.optConfig?.appUuid; // Through the issuer chain, not `actor.app`: an access-token actor // carries no app of its own, so keying off `app` would read a token an @@ -256,6 +282,19 @@ export class KVStoreDriver extends PuterDriver { ), ); } + if (usage.cachedRead > 0) { + // A tenth of a read's rate is small enough that a metering write per + // call would cost more than the call records, which would undo the + // saving the cache exists for. Buffered in with the actor's other + // sub-microcent usage and written once for all of it. + metering.bufferIncrementUsages(actor, [ + { + usageType: 'kv:read:cached', + usageAmount: usage.cachedRead, + costOverride: KV_COSTS['kv:read:cached'] * usage.cachedRead, + }, + ]); + } if (usage.write > 0) { void metering .incrementUsage( @@ -395,6 +434,16 @@ export class KVStoreDriver extends PuterDriver { optConfig?: { appUuid?: string }; }): Promise { const opts = await this.#opts('list', args); + // Naming what it holds is how an account with nothing left decides what + // to delete, so the keys stay readable. Reading the values back out is + // the same egress every other read is turned away for. + if (args.as !== 'keys') { + await assertActorHasCredits( + this.services.metering, + opts.actor, + this.config, + ); + } const { res, usage } = await this.stores.kv.list( { as: args.as, diff --git a/src/backend/drivers/kv/costs.ts b/src/backend/drivers/kv/costs.ts index bde79374a..fe31a2fce 100644 --- a/src/backend/drivers/kv/costs.ts +++ b/src/backend/drivers/kv/costs.ts @@ -17,9 +17,20 @@ * along with this program. If not, see . */ +/** + * Share of an uncached read's rate charged for one the read cache served. It + * still costs us a lookup, a metering write, and the memory the entry occupies + * — just not the capacity a read of the underlying store consumes. + */ +export const KV_CACHED_READ_RATE_SHARE = 0.1; + // Microcents per underlying DynamoDB capacity unit, as reported by // SystemKVStore.KVUsage. Cost is `KV_COSTS[op] * usage.`. export const KV_COSTS = { 'kv:read': 17, 'kv:write': 90, + // 10% of `kv:read` — kept as a literal so the reported rate is exactly this + // and not a float artifact of the multiplication. The unit count is the one + // the equivalent uncached read consumed. + 'kv:read:cached': 1.7, } as const; diff --git a/src/backend/server.ts b/src/backend/server.ts index 4218043dd..c0daea161 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -48,6 +48,7 @@ import { subdomainGate, } from './core/http/middleware/gates'; import { guiOriginGate } from './core/http/middleware/originGate'; +import { requireCreditsGate } from './core/http/middleware/credits'; import { createStepUpGate } from './core/http/middleware/stepUpSession'; import { createNotFoundHandler } from './core/http/middleware/notFoundHandler'; import { @@ -65,6 +66,7 @@ import { createUserSubdomainRedirect, createNativeAppStatic, } from './core/http/middleware/hostRedirects'; +import { createEgressMeteringMiddleware } from './core/http/middleware/egressMetering'; import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy'; import { createPuterSiteMiddleware } from './core/http/middleware/puterSite'; import { PuterRouter } from './core/http/PuterRouter'; @@ -380,6 +382,15 @@ export class PuterServer { * `#materializeRoute` as those options ship. */ #installGlobalMiddleware() { + // -- Egress metering ----------------------------------------- + // First, so the byte counter wraps `res.write` before compression + // does and therefore counts what actually goes out rather than what + // the handler produced. Reads the actor when the response ends, by + // which point the auth probe below has run. + this.#app.use( + createEgressMeteringMiddleware({ services: this.services }), + ); + this.#app.use(cookieParser()); this.#app.use(compression()); @@ -976,6 +987,17 @@ export class PuterServer { } } + // 2b''. Budget enforcement. After the rate limit so a caller over + // both gets the cheaper, more specific answer, and before the + // concurrency slot so a rejected request never takes one. Answered + // from the metering service's per-actor cache, so ordering it here + // costs a map lookup rather than a store read. + if (opts.requireCredits) { + mwChain.push( + requireCreditsGate(this.services.metering, this.#config), + ); + } + // 2b'. Concurrent in-flight limiting. Same auth-ordering reason // (user key + bySubscription resolution needs req.actor); installed // after rateLimitGate so a rate-rejection short-circuits before diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts index c11289d0a..ad97e4415 100644 --- a/src/backend/services/metering/MeteringService.test.ts +++ b/src/backend/services/metering/MeteringService.test.ts @@ -697,6 +697,36 @@ describe('MeteringService', () => { auxSpy.mockRestore(); }); + // The per-app aggregate is what an app's developer reads. Usage with no + // app behind it belongs to nobody there, and writing it anyway costs a + // record per shard on every increment — which, now that ordinary + // traffic is metered, is most of them. + it('writes no per-app aggregate for an actor with no app', async () => { + const auxSpy = vi.spyOn(server.stores.meteringBuffer, 'incrAux'); + await target.batchIncrementUsages(actor, [ + { usageType: 'egress:bytes', usageAmount: 10, costOverride: 1 }, + ]); + const keys = auxSpy.mock.calls.map(([input]) => input.key); + expect( + keys.some((key) => key.startsWith(`${METRICS_PREFIX}:app:`)), + ).toBe(false); + auxSpy.mockRestore(); + }); + + it('still writes the per-app aggregate for an app actor', async () => { + const appActor: Actor = { ...actor, app: { uid: 'batch-app' } }; + await target.batchIncrementUsages(appActor, [ + { usageType: 'egress:bytes', usageAmount: 10, costOverride: 1 }, + ]); + await waitFor(async () => { + const usage = await target.getActorAppUsage( + appActor, + 'batch-app', + ); + expect(usage.total).toBe(1); + }); + }); + it('raises an alarm for any negative costOverride in the batch', async () => { const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); await target.batchIncrementUsages(actor, [ @@ -712,6 +742,228 @@ describe('MeteringService', () => { }); }); + // ── bufferIncrementUsages ──────────────────────────────────────── + + describe('bufferIncrementUsages', () => { + it('writes nothing until the buffer is flushed', async () => { + target.bufferIncrementUsages(actor, [ + { + usageType: 'egress:bytes', + usageAmount: 100, + costOverride: 5, + }, + ]); + const before = await target.getActorCurrentMonthUsageDetails(actor); + expect(before.usage.total ?? 0).toBe(0); + + await target.flushBufferedUsages(); + + const after = await target.getActorCurrentMonthUsageDetails(actor); + expect(after.usage.total).toBe(5); + expect(after.usage[escape('egress:bytes')]).toMatchObject({ + units: 100, + cost: 5, + }); + }); + + it('collapses an actor’s buffered usage into one write per type', async () => { + for (let i = 0; i < 5; i++) { + target.bufferIncrementUsages(actor, [ + { + usageType: 'egress:bytes', + usageAmount: 10, + costOverride: 2, + }, + { + usageType: 'storage:read:ops', + usageAmount: 1, + costOverride: 1, + }, + ]); + } + const incrSpy = vi.spyOn(server.stores.meteringBuffer, 'incr'); + await target.flushBufferedUsages(); + expect(incrSpy).toHaveBeenCalledOnce(); + incrSpy.mockRestore(); + + const { usage } = + await target.getActorCurrentMonthUsageDetails(actor); + expect(usage[escape('egress:bytes')]).toMatchObject({ + units: 50, + cost: 10, + // One write stands in for all five requests. + count: 1, + }); + expect(usage[escape('storage:read:ops')]).toMatchObject({ + units: 5, + cost: 5, + }); + }); + + it('keeps actors and their apps in separate buckets', async () => { + const other = makeActor(); + const appActor: Actor = { ...actor, app: { uid: 'app-1' } }; + target.bufferIncrementUsages(actor, [ + { usageType: 'egress:bytes', usageAmount: 10, costOverride: 1 }, + ]); + target.bufferIncrementUsages(appActor, [ + { usageType: 'egress:bytes', usageAmount: 20, costOverride: 2 }, + ]); + target.bufferIncrementUsages(other, [ + { usageType: 'egress:bytes', usageAmount: 40, costOverride: 4 }, + ]); + await target.flushBufferedUsages(); + + const mine = await target.getActorCurrentMonthUsageDetails(actor); + const theirs = await target.getActorCurrentMonthUsageDetails(other); + expect(mine.usage.total).toBe(3); + expect(theirs.usage.total).toBe(4); + await waitFor(async () => { + const appUsage = await target.getActorAppUsage( + appActor, + 'app-1', + ); + expect(appUsage.total).toBe(2); + }); + }); + + it('ignores the system actor, empty lists, and unusable entries', async () => { + const incrSpy = vi.spyOn(server.stores.meteringBuffer, 'incr'); + target.bufferIncrementUsages(SYSTEM_ACTOR, [ + { usageType: 'egress:bytes', usageAmount: 1, costOverride: 1 }, + ]); + target.bufferIncrementUsages(actor, []); + target.bufferIncrementUsages(actor, [ + { usageType: '', usageAmount: 5, costOverride: 5 }, + { usageType: 'egress:bytes', usageAmount: 0, costOverride: 5 }, + ]); + await target.flushBufferedUsages(); + expect(incrSpy).not.toHaveBeenCalled(); + incrSpy.mockRestore(); + }); + + // A cycle holds a bucket for every actor active in the window. Firing + // them all into one tick is how a flush becomes a latency spike for + // everything else on those connections. + it('paces the writes rather than releasing every bucket at once', async () => { + const concurrency = (target.constructor as typeof MeteringService) + .USAGE_FLUSH_CONCURRENCY; + let inFlight = 0; + let peak = 0; + const spy = vi + .spyOn(server.stores.meteringBuffer, 'incr') + .mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight--; + return { res: { total: 0 }, exact: false }; + }); + + try { + for (let i = 0; i < concurrency * 3; i++) { + target.bufferIncrementUsages(makeActor(), [ + { + usageType: 'egress:bytes', + usageAmount: 1, + costOverride: 1, + }, + ]); + } + await target.flushBufferedUsages(); + expect(spy).toHaveBeenCalledTimes(concurrency * 3); + expect(peak).toBeLessThanOrEqual(concurrency); + } finally { + spy.mockRestore(); + } + }); + + it('joins a cycle already running instead of stacking another', async () => { + let started = 0; + const spy = vi + .spyOn(server.stores.meteringBuffer, 'incr') + .mockImplementation(async () => { + started++; + await new Promise((resolve) => setTimeout(resolve, 20)); + return { res: { total: 0 }, exact: false }; + }); + + try { + target.bufferIncrementUsages(actor, [ + { + usageType: 'egress:bytes', + usageAmount: 1, + costOverride: 1, + }, + ]); + await Promise.all([ + target.flushBufferedUsages(), + target.flushBufferedUsages(), + target.flushBufferedUsages(), + ]); + expect(started).toBe(1); + } finally { + spy.mockRestore(); + } + }); + + it('flushes early once too many actors are buffered', async () => { + const limit = (target.constructor as typeof MeteringService) + .USAGE_BUFFER_LIMIT; + (target.constructor as typeof MeteringService).USAGE_BUFFER_LIMIT = + 2; + try { + for (const each of [makeActor(), makeActor()]) { + target.bufferIncrementUsages(each, [ + { + usageType: 'egress:bytes', + usageAmount: 1, + costOverride: 1, + }, + ]); + } + await waitFor(() => { + expect( + ( + target as unknown as { + usageBuffer: Map; + } + ).usageBuffer.size, + ).toBe(0); + }); + } finally { + ( + target.constructor as typeof MeteringService + ).USAGE_BUFFER_LIMIT = limit; + } + }); + + it('drains on prepare-shutdown, while the layers it writes through are up', async () => { + target.bufferIncrementUsages(actor, [ + { + usageType: 'egress:bytes', + usageAmount: 4_096, + costOverride: 512, + }, + ]); + + // Shutdown hooks run clients first, so a drain deferred to + // `onServerShutdown` would be writing through a closed stack. + await target.onServerPrepareShutdown(); + + expect( + (target as unknown as { usageBuffer: Map }) + .usageBuffer.size, + ).toBe(0); + const { usage } = + await target.getActorCurrentMonthUsageDetails(actor); + expect(usage[escape('egress:bytes')]).toMatchObject({ + units: 4_096, + cost: 512, + }); + }); + }); + // ── utilRecordUsageObject ──────────────────────────────────────── describe('utilRecordUsageObject', () => { @@ -1057,6 +1309,149 @@ describe('MeteringService', () => { }); }); + // ── hasAnyUsageCached ──────────────────────────────────────────── + + describe('hasAnyUsageCached', () => { + type CreditCache = Map< + string, + { hasCredits: boolean; expiresAt: number } + >; + const creditCache = () => + (target as unknown as { creditCache: CreditCache }).creditCache; + const creditRefreshes = () => + ( + target as unknown as { + creditRefreshes: Map>; + } + ).creditRefreshes; + + it('answers the same as hasAnyUsage', async () => { + const sub = await target.getActorSubscription(actor); + expect(await target.hasAnyUsageCached(actor)).toBe(true); + + await target.incrementUsage( + actor, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + expect(await target.hasAnyUsageCached(actor)).toBe(false); + }); + + it('is answered by the increment that spent the budget, without a read of its own', async () => { + const sub = await target.getActorSubscription(actor); + // Nothing has asked about this actor yet, so the only thing that + // can have filled the cache is the increment itself. + expect(creditCache().has(actor.user.uuid!)).toBe(false); + + await target.incrementUsage( + actor, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + + const entry = creditCache().get(actor.user.uuid!); + expect(entry?.hasCredits).toBe(false); + + const usageSpy = vi.spyOn(target, 'getActorAddons'); + expect(await target.hasAnyUsageCached(actor)).toBe(false); + expect(usageSpy).not.toHaveBeenCalled(); + usageSpy.mockRestore(); + }); + + it('serves a stale answer and replaces it behind the request', async () => { + expect(await target.hasAnyUsageCached(actor)).toBe(true); + + const sub = await target.getActorSubscription(actor); + await server.stores.meteringBuffer.incr({ + key: `${METRICS_PREFIX}:actor:${actor.user.uuid}:${new Date().toISOString().slice(0, 7)}`, + pathAndAmountMap: { total: sub.monthUsageAllowance }, + }); + + const entry = creditCache().get(actor.user.uuid!)!; + entry.expiresAt = Date.now() - 1; + + // The stale answer is what this call returns... + expect(await target.hasAnyUsageCached(actor)).toBe(true); + // ...and the refresh it kicked off is what the next one sees. + await creditRefreshes().get(actor.user.uuid!); + expect(await target.hasAnyUsageCached(actor)).toBe(false); + }); + + it('shares one refresh across concurrent callers with nothing cached', async () => { + expect(creditCache().has(actor.user.uuid!)).toBe(false); + + const addonsSpy = vi.spyOn(target, 'getActorAddons'); + const answers = await Promise.all( + Array.from({ length: 8 }, () => + target.hasAnyUsageCached(actor), + ), + ); + + expect(answers).toEqual(Array(8).fill(true)); + // Without single-flight this is one read per caller — the cache is + // empty until the first refresh resolves, so every one of them + // misses. + expect(addonsSpy).toHaveBeenCalledTimes(1); + expect(creditRefreshes().size).toBe(0); + addonsSpy.mockRestore(); + }); + + it('drops the cached answer when credit is added', async () => { + const sub = await target.getActorSubscription(actor); + await target.incrementUsage( + actor, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + expect(await target.hasAnyUsageCached(actor)).toBe(false); + + await target.updateAddonCredit(actor.user.uuid!, 5_000); + expect(await target.hasAnyUsageCached(actor)).toBe(true); + }); + + it('drops the cached answer when the subscription changes', async () => { + expect(await target.hasAnyUsageCached(actor)).toBe(true); + expect(creditCache().has(actor.user.uuid!)).toBe(true); + + target.invalidateActorSubscription(actor.user.uuid!); + expect(creditCache().has(actor.user.uuid!)).toBe(false); + }); + + it('treats a policy with no metered allowance as never out of budget', async () => { + target.registerPolicy({ + id: 'test-unmetered', + monthUsageAllowance: 0, + monthlyStorageAllowance: 0, + } as never); + target.registerSubscriptionResolver(() => 'test-unmetered'); + target.invalidateActorSubscription(actor.user.uuid!); + + const addonsSpy = vi.spyOn(target, 'getActorAddons'); + expect(await target.hasAnyUsageCached(actor)).toBe(true); + // An unmetered policy has nothing to run out of, so the reads that + // would answer the question are never made. + expect(addonsSpy).not.toHaveBeenCalled(); + addonsSpy.mockRestore(); + }); + + it('does not block when the balance cannot be read', async () => { + const failing = vi + .spyOn(target, 'getActorAddons') + .mockRejectedValue(new Error('store down')); + expect(await target.hasAnyUsageCached(actor)).toBe(true); + failing.mockRestore(); + }); + + it('has no answer to give for an actor with no user', async () => { + expect(await target.hasAnyUsageCached({ user: {} } as Actor)).toBe( + true, + ); + }); + }); + // ── getGlobalUsage ─────────────────────────────────────────────── describe('getGlobalUsage', () => { diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index b230e5fa1..cf83efa31 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -32,6 +32,7 @@ import { POLICY_PREFIX, UNLIMITED_SUBSCRIPTION, } from './consts'; +import { EGRESS_COSTS } from './costs'; import type { AppTotals, UsageAddons, @@ -40,7 +41,9 @@ import type { UsageRecord, } from './types'; +import { LOCAL_UNLIMITED_USER } from '../../data/subPolicies/localUnlimitedUserPolicy.js'; import { SUB_POLICIES } from '../../data/subPolicies/index.js'; +import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; // -- Types ------------------------------------------------------------ @@ -117,7 +120,45 @@ export class MeteringService extends PuterService { static SUBSCRIPTION_CACHE_MS = 60_000; static SUBSCRIPTION_CACHE_LIMIT = 50_000; + /** + * How long "does this actor have budget left" is reused before being + * recomputed, and how many actors are remembered at once. + * + * This answer gates operations that arrive by the hundred per minute and + * cost a fraction of a microcent each — file reads, KV calls — so computing + * it per request would put two store reads in front of every one of them, + * costing more than the operations being gated. The window is deliberately + * a little wider than `USAGE_BUFFER_FLUSH_MS`: the buffered usage those + * operations produce settles on that cycle, and settling is what refreshes + * this (see `rememberRemainingCredits`), so an active actor's answer is + * normally replaced by a write that was happening anyway rather than by a + * read this cache had to make. + * + * Staleness is bounded by the same argument that bounds the buffer: the + * usage in flight is worth a fraction of a microcent per request, and + * request count is bounded by the rate and concurrency limits the same + * routes declare. A change we know about — a purchase, a plan change — is + * announced and applied at once rather than waited out. + */ + static CREDIT_CACHE_MS = 15_000; + static CREDIT_CACHE_LIMIT = 50_000; + + /** + * How long usage that isn't decided on may sit in memory before it is + * written, and how many actor buckets are held at once. Egress and + * object-store requests arrive once per HTTP request and cost a fraction of + * a microcent each; writing them as they land would spend more on metering + * than the usage is worth. The window is the exposure: a host lost without + * warning takes at most this much unbilled usage with it. + */ + static USAGE_BUFFER_FLUSH_MS = 10_000; + static USAGE_BUFFER_LIMIT = 5_000; + + /** Buckets written at once per flush. Matches the buffer store's own pacing. */ + static USAGE_FLUSH_CONCURRENCY = 20; + private rateCheckTimer: ReturnType | null = null; + private usageBufferTimer: ReturnType | null = null; private extraPolicies: SubscriptionPolicy[] = []; private subscriptionResolvers: SubscriptionResolver[] = []; private defaultSubscriptionResolvers: SubscriptionResolver[] = []; @@ -128,6 +169,21 @@ export class MeteringService extends PuterService { { policy: SubscriptionPolicy; expiresAt: number } >(); + /** Uuid → whether the actor had budget left. See CREDIT_CACHE_MS. */ + private creditCache = new Map< + string, + { hasCredits: boolean; expiresAt: number } + >(); + + /** + * Uuid → the refresh currently running for it, so concurrent requests share + * one. This matters most where there is nothing cached at all: a process + * that has just started, or an actor evicted from the cache, has every + * request that arrives before the first answer landing on the same three + * store reads. One per actor, not one per request. + */ + private creditRefreshes = new Map>(); + /** Actors settled for `settledMonth`; see MONTHLY_CHARGE_MEMO_LIMIT. */ private settledMonth: string | null = null; private settledActors = new Set(); @@ -139,6 +195,23 @@ export class MeteringService extends PuterService { */ private claimsInFlight = new Set(); + /** + * Usage waiting to be written, keyed by actor and app so each bucket + * settles against the same records a direct increment would have. Holds the + * actor it was recorded for — the flush needs a subject, and the buckets + * are capped. + */ + private usageBuffer = new Map< + string, + { + actor: Actor; + amounts: Map; + } + >(); + + /** The flush cycle currently running, so ticks join it instead of stacking. */ + private usageFlushInFlight: Promise | null = null; + // -- Lifecycle ---------------------------------------------------- override onServerStart(): void { @@ -147,7 +220,18 @@ export class MeteringService extends PuterService { this.clients.event.on( 'outer.pubsub.metering.subscription-changed', (_key, data) => { - if (data?.userUuid) this.#dropCachedSubscription(data.userUuid); + if (!data?.userUuid) return; + this.#dropCachedSubscription(data.userUuid); + // The allowance is half of what "has budget left" is computed + // from, so a plan change invalidates that answer too. + this.#dropCachedCredits(data.userUuid); + }, + ); + + this.clients.event.on( + 'outer.pubsub.metering.credits-changed', + (_key, data) => { + if (data?.userUuid) this.#dropCachedCredits(data.userUuid); }, ); @@ -160,13 +244,83 @@ export class MeteringService extends PuterService { 1000 * 60 * 25, ); this.rateCheckTimer.unref?.(); + + const flushInterval = + this.config.meteringUsageBufferFlushMs && + this.config.meteringUsageBufferFlushMs > 0 + ? this.config.meteringUsageBufferFlushMs + : MeteringService.USAGE_BUFFER_FLUSH_MS; + this.usageBufferTimer = setInterval(() => { + this.flushBufferedUsages().catch((e) => { + console.error('[metering] usage buffer flush failed', e); + }); + }, flushInterval); + this.usageBufferTimer.unref?.(); } - override onServerShutdown(): void { + /** + * Drain the buffer while the layers it writes through are still up. + * + * This is the hook that has to do the work, not `onServerShutdown`: both + * run clients first, then stores, then services, so by the time a service's + * shutdown hook is reached the Redis cluster is closed, the metering buffer + * store has drained and stopped, and the database pool a subscription + * lookup needs is gone — a flush there resolves the buckets against layers + * that have already said goodbye and drops them. + */ + override async onServerPrepareShutdown(): Promise { + // The timer is deliberately left running: connections are still open at + // this point, so usage keeps arriving, and the ordinary cycle is the + // only thing that can still write it through a live stack. + await this.#drainUsageBuffer(); + } + + override async onServerShutdown(): Promise { if (this.rateCheckTimer) { clearInterval(this.rateCheckTimer); this.rateCheckTimer = null; } + if (this.usageBufferTimer) { + clearInterval(this.usageBufferTimer); + this.usageBufferTimer = null; + } + + // Whatever landed after the drain above — the responses that were still + // in flight when the listener was severed. Worth attempting because the + // buffer store falls back to writing straight through when its own + // buffer is gone, and worth nothing if that fails too. + await this.#drainUsageBuffer(); + } + + /** + * Write everything buffered, and everything that arrives while that is + * happening. Looped because a cycle already in flight took its buckets + * before the ones added since, and joining it says nothing about those. + */ + async #drainUsageBuffer(): Promise { + try { + for (let pass = 0; pass < 3; pass++) { + await this.flushBufferedUsages(); + if (this.usageBuffer.size === 0) break; + } + } catch (e) { + console.warn('[metering] usage buffer shutdown flush failed', e); + } + } + + /** + * Egress is priced here because it is metered for every host, not per + * feature. + */ + getReportedCosts(): Record[] { + return Object.entries(EGRESS_COSTS).map( + ([usageType, ucentsPerUnit]) => ({ + usageType, + ucentsPerUnit, + unit: 'byte', + source: 'service:metering', + }), + ); } // -- Extension hooks ---------------------------------------------- @@ -348,6 +502,13 @@ export class MeteringService extends PuterService { costOverride, }); + this.rememberRemainingCredits( + userId, + actorUsages.total, + actorSubscription.monthUsageAllowance, + actorAddons, + ); + return ( (await this.applyMonthlyCharges( actor, @@ -470,13 +631,20 @@ export class MeteringService extends PuterService { pathAndAmountMap: aggregated, }), ); - this.handleAuxPromise( - `appUsage ${appId}/${userId}`, - this.stores.meteringBuffer.incrAux({ - key: this.appUsageKey(appId, userId, currentMonth), - pathAndAmountMap: aggregated, - }), - ); + // Only for usage an app actually incurred. The sentinel stands for + // "no app", so writing it here would spread one record per shard + // across an aggregate that exists for app developers to read — + // paid for on every increment that has no app behind it, which is + // most of them. `incrementUsage` has always skipped it. + if (appId !== GLOBAL_APP_KEY) { + this.handleAuxPromise( + `appUsage ${appId}/${userId}`, + this.stores.meteringBuffer.incrAux({ + key: this.appUsageKey(appId, userId, currentMonth), + pathAndAmountMap: aggregated, + }), + ); + } this.handleAuxPromise( `actorAppTotals ${userId}`, this.stores.meteringBuffer.incrAux({ @@ -519,6 +687,13 @@ export class MeteringService extends PuterService { batchUsages: usages, }); + this.rememberRemainingCredits( + userId, + actorUsages.total, + actorSubscription.monthUsageAllowance, + actorAddons, + ); + return ( (await this.applyMonthlyCharges( actor, @@ -550,6 +725,98 @@ export class MeteringService extends PuterService { } } + /** + * Record usage that nothing is about to decide on, to be written with the + * same actor's other usage a few seconds later. + * + * For usage that arrives per HTTP request — response bytes, object-store + * requests — this is the increment to reach for: each one costs a fraction + * of a microcent, and collapsing a busy actor's requests into one write is + * the difference between metering paying for itself and costing more than + * it records. Returns nothing, because the running total it would return is + * one this call has not applied yet; use `batchIncrementUsages` where the + * answer gates what happens next. + * + * Per-type `count` therefore counts flushes rather than requests. Units and + * cost are exact. + */ + bufferIncrementUsages(actor: Actor, usages: UsageInput[]): void { + if (!usages?.length || !actor?.user?.uuid) return; + if (isSystemActor(actor)) return; + + const key = `${actor.user.uuid}:${actor.app?.uid ?? GLOBAL_APP_KEY}`; + let bucket = this.usageBuffer.get(key); + if (!bucket) { + bucket = { actor, amounts: new Map() }; + this.usageBuffer.set(key, bucket); + } + + for (const { usageType, usageAmount, costOverride } of usages) { + if (!usageType) continue; + if (!Number.isFinite(usageAmount) || usageAmount <= 0) continue; + const cost = + Number.isFinite(costOverride) && (costOverride as number) > 0 + ? (costOverride as number) + : 0; + + const amount = bucket.amounts.get(usageType) ?? { + units: 0, + cost: 0, + }; + amount.units += usageAmount; + amount.cost += cost; + bucket.amounts.set(usageType, amount); + } + + if (this.usageBuffer.size >= MeteringService.USAGE_BUFFER_LIMIT) { + this.flushBufferedUsages().catch((e) => { + console.error('[metering] usage buffer flush failed', e); + }); + } + } + + /** + * Write everything buffered so far. Buckets are taken before the first + * await so usage recorded while this runs lands in the next cycle instead + * of being written twice. + * + * Paced rather than fired at once: a cycle can hold a bucket for every + * actor active in the window, and each one is several counter writes and a + * read. Releasing all of them into the same tick is how a flush turns into + * a latency spike for everything else sharing those connections. + * + * A cycle already running is joined rather than doubled — a flush slower + * than the interval would otherwise have every subsequent tick pile another + * fan-out on top of it. + */ + flushBufferedUsages(): Promise { + if (this.usageFlushInFlight) return this.usageFlushInFlight; + if (this.usageBuffer.size === 0) return Promise.resolve(); + + const buckets = [...this.usageBuffer.values()]; + this.usageBuffer.clear(); + + this.usageFlushInFlight = runWithConcurrencyLimitSettled( + buckets, + MeteringService.USAGE_FLUSH_CONCURRENCY, + ({ actor, amounts }) => + this.batchIncrementUsages( + actor, + [...amounts].map(([usageType, { units, cost }]) => ({ + usageType, + usageAmount: units, + costOverride: cost, + })), + ), + ) + .then((): void => undefined) + .finally(() => { + this.usageFlushInFlight = null; + }); + + return this.usageFlushInFlight; + } + // -- Public API: read usage --------------------------------------- async getActorCurrentMonthUsageDetails(actor: Actor): Promise<{ @@ -664,6 +931,10 @@ export class MeteringService extends PuterService { }) ).res as unknown as UsageByType; + // An adjustment moves the month's total in either direction, so what + // every node believes about this account's budget is now wrong. + this.invalidateActorCredits(userId); + this.handleAuxPromise( `puterConsumption ${userId}/${appId}`, this.stores.meteringBuffer.incrAux({ @@ -744,27 +1015,40 @@ export class MeteringService extends PuterService { ], ); - // Overage past the allowance is already charged to purchased credits - // via consumedPurchaseCredits, so the allowance and the credit pool - // must be netted separately — subtracting month usage AND consumed - // credits from one combined pool would charge the overage twice. + return { + remaining: MeteringService.remainingFrom( + currentMonthUsage.usage.total || 0, + userSubscription.monthUsageAllowance, + addons, + ), + monthUsageAllowance: userSubscription.monthUsageAllowance, + addons, + }; + } + + /** + * What's left of an actor's budget, from the three numbers it's made of. + * + * Overage past the allowance is already charged to purchased credits via + * `consumedPurchaseCredits`, so the allowance and the credit pool are + * netted separately — subtracting month usage AND consumed credits from one + * combined pool would charge the overage twice. + */ + private static remainingFrom( + monthUsageTotal: number, + monthUsageAllowance: number, + addons: UsageAddons | null | undefined, + ): number { const remainingAllowance = Math.max( 0, - (userSubscription.monthUsageAllowance || 0) - - (currentMonthUsage.usage.total || 0), + (monthUsageAllowance || 0) - (monthUsageTotal || 0), ); const remainingPurchasedCredits = Math.max( 0, (addons?.purchasedCredits || 0) - (addons?.consumedPurchaseCredits || 0), ); - const remaining = remainingAllowance + remainingPurchasedCredits; - - return { - remaining, - monthUsageAllowance: userSubscription.monthUsageAllowance, - addons, - }; + return remainingAllowance + remainingPurchasedCredits; } async hasAnyUsage(actor: Actor): Promise { @@ -775,6 +1059,157 @@ export class MeteringService extends PuterService { return (await this.getRemainingUsage(actor)) >= amount; } + /** + * Whether the actor has any budget left, answered from a short-lived cache. + * + * For gating an operation whose own cost is a rounding error — a file read, + * a KV call — where what matters is whether the account has anything left + * at all, not how much. `hasEnoughCredits` is the one to use when the + * amount matters (an inference call, an email) and is worth two store reads + * to get right; this one is for surfaces where those reads would cost more + * than the operation they gate. + * + * Never throws: a metering failure resolves to `true`. Not being able to + * read a balance is our problem, and the alternative is a storage outage + * that presents as every account being out of credit. + */ + async hasAnyUsageCached(actor: Actor): Promise { + const uuid = actor?.user?.uuid; + if (!uuid) return true; + + const now = Date.now(); + const cached = this.creditCache.get(uuid); + if (cached) { + if (cached.expiresAt > now) return cached.hasCredits; + // Stale: answer with what we have and replace it behind the + // request. Waiting on the refresh would put the store read this + // cache exists to avoid back on the hot path, once per window per + // actor, for an answer that is about to be one increment out of + // date either way. + void this.#refreshCreditsOnce(actor, uuid); + return cached.hasCredits; + } + + await this.#refreshCreditsOnce(actor, uuid); + return this.creditCache.get(uuid)?.hasCredits ?? true; + } + + /** + * `#refreshCredits`, with the one already running for this actor reused + * instead of started again. Never rejects, so the stale path can drop the + * promise on the floor. + */ + #refreshCreditsOnce(actor: Actor, uuid: string): Promise { + const existing = this.creditRefreshes.get(uuid); + if (existing) return existing; + + const refresh = this.#refreshCredits(actor).finally(() => { + this.creditRefreshes.delete(uuid); + }); + this.creditRefreshes.set(uuid, refresh); + return refresh; + } + + /** + * Drop the cached budget answer for an actor, everywhere. Call after + * anything that adds to what they may spend — a credit purchase, an admin + * grant — so it applies now rather than at the end of the cache window. + */ + invalidateActorCredits(userUuid: string): void { + this.#dropCachedCredits(userUuid); + this.clients.event.emit( + 'outer.pubsub.metering.credits-changed', + { userUuid }, + {}, + ); + } + + /** Local-only drop. The announcement path is `invalidateActorCredits`. */ + #dropCachedCredits(userUuid: string): void { + this.creditCache.delete(userUuid); + } + + async #refreshCredits(actor: Actor): Promise { + const uuid = actor.user?.uuid; + if (!uuid) return; + try { + const subscription = await this.getActorSubscription(actor); + // A non-positive allowance is how a policy says it isn't metered + // (the overuse alarm reads it the same way) — no budget to run out + // of, and no reason to pay for the reads below. + if (!(subscription.monthUsageAllowance > 0)) { + this.rememberHasCredits(uuid, true); + return; + } + const [addons, currentMonthUsage] = await Promise.all([ + this.getActorAddons(actor), + this.getActorCurrentMonthUsageDetails(actor), + ]); + this.rememberRemainingCredits( + uuid, + currentMonthUsage.usage.total || 0, + subscription.monthUsageAllowance, + addons, + ); + } catch (e) { + // Leave whatever is cached in place rather than caching a failure; + // an actor with no entry answers `true` and is tried again next + // request. + console.warn( + `[metering] credit refresh failed for ${uuid}: ${(e as Error).message}`, + ); + } + } + + /** + * Record what an increment already knows about an actor's balance. + * + * Every increment reads the month's total and resolves the subscription and + * addons to price and alarm on the usage, so the answer this cache holds + * falls out of work that has already happened. That is what keeps the gated + * surfaces free of reads of their own: an active actor's entry is refreshed + * by their own usage settling, and the cache window only has to cover an + * actor who has gone quiet. + */ + private rememberRemainingCredits( + userId: string, + monthUsageTotal: number, + monthUsageAllowance: number, + addons: UsageAddons | null | undefined, + ): void { + if (!(monthUsageAllowance > 0)) { + this.rememberHasCredits(userId, true); + return; + } + this.rememberHasCredits( + userId, + MeteringService.remainingFrom( + monthUsageTotal, + monthUsageAllowance, + addons, + ) > 0, + ); + } + + private rememberHasCredits(userId: string, hasCredits: boolean): void { + const existing = this.creditCache.get(userId); + if (existing) { + existing.hasCredits = hasCredits; + existing.expiresAt = Date.now() + MeteringService.CREDIT_CACHE_MS; + return; + } + // Map preserves insertion order; FIFO-evict so a flood of one-shot + // actors can't grow this without bound. + if (this.creditCache.size >= MeteringService.CREDIT_CACHE_LIMIT) { + const oldest = this.creditCache.keys().next().value; + if (oldest !== undefined) this.creditCache.delete(oldest); + } + this.creditCache.set(userId, { + hasCredits, + expiresAt: Date.now() + MeteringService.CREDIT_CACHE_MS, + }); + } + /** * Drop the cached subscription for an actor. Call after anything that * changes which policy they resolve to (a purchase landing, a cancellation, @@ -850,7 +1285,10 @@ export class MeteringService extends PuterService { const availablePolicies: SubscriptionPolicy[] = [ ...this.extraPolicies, ...SUB_POLICIES, - ...(this.config.unlimitedMetering ? [UNLIMITED_SUBSCRIPTION] : []), + // The policy, not the id: this list is searched by `id`, so putting + // the bare string in it resolved nothing and left a deployment that + // asked for unlimited metering with no policy at all. + ...(this.config.unlimitedMetering ? [LOCAL_UNLIMITED_USER] : []), ] as SubscriptionPolicy[]; return ( availablePolicies.find((p) => p.id === resolvedUser) ?? @@ -940,6 +1378,9 @@ export class MeteringService extends PuterService { key: `${POLICY_PREFIX}:actor:${userId}:addons`, pathAndAmountMap: { purchasedCredits: tokenAmount }, }); + // Credit that lands while the account is being turned away has to take + // effect on the next request, not at the end of the cache window. + this.invalidateActorCredits(userId); } // -- Internals ---------------------------------------------------- diff --git a/src/backend/services/metering/costs.ts b/src/backend/services/metering/costs.ts new file mode 100644 index 000000000..d3b008b6f --- /dev/null +++ b/src/backend/services/metering/costs.ts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { StorageOpClass } from '../../core/storageOps'; +import { toMicroCents } from './utils.js'; + +const BYTES_PER_GIB = 1024 * 1024 * 1024; + +/** + * Microcents per byte sent to a client, counted once for the whole response + * rather than per subsystem — a file, a JSON body and a rendered page all leave + * by the same door and cost the same per byte (~$0.12/GiB). + */ +export const EGRESS_COSTS = { + 'egress:bytes': toMicroCents(0.12 / BYTES_PER_GIB), +} as const; + +/** + * Microcents per object-store request. Requests are billed by class regardless + * of how much data moves, so a directory of tiny files costs far more per byte + * than one large one — which is what these price in. Removals are free. + */ +export const STORAGE_OP_COSTS = { + 'storage:write:ops': toMicroCents(0.005 / 1000), + 'storage:read:ops': toMicroCents(0.0004 / 1000), + 'storage:delete:ops': 0, +} as const; + +export type StorageOpUsageType = keyof typeof STORAGE_OP_COSTS; + +export const STORAGE_OP_USAGE_TYPES: Record< + StorageOpClass, + StorageOpUsageType +> = { + write: 'storage:write:ops', + read: 'storage:read:ops', + delete: 'storage:delete:ops', +}; diff --git a/src/backend/services/metering/enforcement.http.test.ts b/src/backend/services/metering/enforcement.http.test.ts new file mode 100644 index 000000000..c1938340c --- /dev/null +++ b/src/backend/services/metering/enforcement.http.test.ts @@ -0,0 +1,224 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +/** + * What an account that has spent its whole allowance can and cannot still do, + * over real HTTP. + * + * The unit tests cover the decision; this covers the wiring — that the route + * option reaches the middleware chain, that the routes which opt out really are + * still reachable, and that a hosted site keeps serving for an owner who is out + * of budget. + */ +describe('metering enforcement over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const actorFor = async (username: string): Promise => { + const user = await env.server.stores.user.getByUsername(username); + return { user: user! } as Actor; + }; + + /** Spend the account's whole monthly allowance. */ + const exhaust = async (actor: Actor): Promise => { + const metering = env.server.services.metering; + const sub = await metering.getActorSubscription(actor); + await metering.incrementUsage( + actor, + 'egress:bytes', + 1, + sub.monthUsageAllowance, + ); + expect(await metering.hasAnyUsageCached(actor)).toBe(false); + }; + + const writeFile = async (actor: Actor, path: string, body: Buffer) => { + await env.server.services.fs.write(actor.user.id!, { + fileMetadata: { + path, + size: body.byteLength, + contentType: 'text/plain', + }, + fileContent: body, + }); + }; + + const driverCall = ( + token: string, + method: string, + args: Record, + ) => + fetch(new URL('/drivers/call', env.apiOrigin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + interface: 'puter-kvstore', + method, + args, + }), + }); + + it('refuses a file read and admits the routes that do not spend', async () => { + const { username, token } = env.users.other; + const actor = await actorFor(username); + const path = `/${username}/Desktop/enforcement.txt`; + await writeFile(actor, path, Buffer.from('contents')); + + const readUrl = new URL('/fs/read', env.apiOrigin); + readUrl.searchParams.set('path', path); + const auth = { Authorization: `Bearer ${token}` }; + + const before = await fetch(readUrl, { headers: auth }); + expect(before.status).toBe(200); + + await exhaust(actor); + + const read = await fetch(readUrl, { headers: auth }); + expect(read.status).toBe(402); + expect(await read.json()).toMatchObject({ code: 'insufficient_funds' }); + + // Looking at the account's own files is not spending, and neither is + // getting rid of them — an account with no budget left still has to be + // able to see what it has and clear it. + const statUrl = new URL('/fs/stat', env.apiOrigin); + const stat = await fetch(statUrl, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + expect(stat.status).toBe(200); + + const readdirUrl = new URL('/fs/readdir', env.apiOrigin); + readdirUrl.searchParams.set('path', `/${username}/Desktop`); + const readdir = await fetch(readdirUrl, { headers: auth }); + expect(readdir.status).toBe(200); + + const remove = await fetch(new URL('/fs/delete', env.apiOrigin), { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + expect(remove.status).toBe(200); + }); + + it('refuses a KV read but not a KV delete, and never a worker session', async () => { + const { username, token, workerToken } = env.users.admin; + const actor = await actorFor(username); + + expect( + (await driverCall(token, 'set', { key: 'k', value: 'v' })).status, + ).toBe(200); + + await exhaust(actor); + + const get = await driverCall(token, 'get', { key: 'k' }); + expect(get.status).toBe(402); + expect(await get.json()).toMatchObject({ code: 'insufficient_funds' }); + + // Naming what is stored is how the account decides what to delete, so + // the keys-only form of `list` stays open while the forms that hand + // back the values do not. + const keys = await driverCall(token, 'list', { as: 'keys' }); + expect(keys.status).toBe(200); + expect((await keys.json()).result).toContain('k'); + expect((await driverCall(token, 'list', {})).status).toBe(402); + expect((await driverCall(token, 'list', { as: 'values' })).status).toBe( + 402, + ); + + expect((await driverCall(token, 'del', { key: 'k' })).status).toBe(200); + + // Same account, worker credential: a deployed program keeps running. + const workerGet = await driverCall(workerToken, 'get', { key: 'k' }); + expect(workerGet.status).toBe(200); + expect((await workerGet.json()).success).toBe(true); + }); + + it('refuses a token-read, which authenticates itself past the gate chain', async () => { + const { username } = env.users.user; + const actor = await actorFor(username); + const path = `/${username}/Desktop/token-read.txt`; + await writeFile(actor, path, Buffer.from('contents')); + const entry = (await env.server.stores.fsEntry.getEntryByPath(path))!; + + const accessToken = await env.server.services.auth.createAccessToken( + actor as never, + [[`fs:${entry.uuid}:read`]], + { label: 'enforcement-token-read' }, + ); + + const url = new URL('/token-read', env.apiOrigin); + url.searchParams.set('uid', entry.uuid); + url.searchParams.set('token', accessToken); + + expect((await fetch(url)).status).toBe(200); + + await exhaust(actor); + + const after = await fetch(url); + expect(after.status).toBe(402); + expect(await after.json()).toMatchObject({ + code: 'insufficient_funds', + }); + }); + + it('keeps serving a hosted site whose owner is out of budget', async () => { + const { username } = env.users.user; + const actor = await actorFor(username); + const home = await env.server.stores.fsEntry.getEntryByPath( + `/${username}`, + ); + const subdomain = `enforcement-${Math.random().toString(36).slice(2, 8)}`; + await env.server.stores.subdomain.create({ + userId: actor.user.id!, + subdomain, + rootDirId: home!.id, + }); + await writeFile( + actor, + `/${username}/index.html`, + Buffer.from('hosted'), + ); + + await exhaust(actor); + + const port = new URL(env.origin).port; + const site = await fetch( + `http://${subdomain}.site.puter.localhost:${port}/index.html`, + ); + // Visitors have no say in the owner's balance, so hosting is metered + // and never gated. + expect(site.status).toBe(200); + expect(await site.text()).toContain('hosted'); + }); +}); diff --git a/src/backend/services/metering/enforcement.test.ts b/src/backend/services/metering/enforcement.test.ts new file mode 100644 index 000000000..ffd842dee --- /dev/null +++ b/src/backend/services/metering/enforcement.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import { SYSTEM_ACTOR, type Actor } from '../../core/actor.js'; +import { HttpError } from '../../core/http/HttpError.js'; +import type { IConfig } from '../../types'; +import { + assertActorHasCredits, + creditEnforcementExempt, + enforcementEnabled, +} from './enforcement.js'; + +const userActor = (overrides: Partial = {}): Actor => + ({ + user: { uuid: 'user-uuid', username: 'user' }, + ...overrides, + }) as Actor; + +const workerActor = (): Actor => + userActor({ session: { uid: 'session-uid', kind: 'worker' } }); + +const config = (overrides: Partial = {}): IConfig => + overrides as IConfig; + +const brokeMetering = { hasAnyUsageCached: vi.fn().mockResolvedValue(false) }; +const fundedMetering = { hasAnyUsageCached: vi.fn().mockResolvedValue(true) }; + +describe('enforcementEnabled', () => { + it('is on unless turned off', () => { + expect(enforcementEnabled(config())).toBe(true); + expect(enforcementEnabled(config({ meteringEnforcement: {} }))).toBe( + true, + ); + expect( + enforcementEnabled( + config({ meteringEnforcement: { enabled: true } }), + ), + ).toBe(true); + expect( + enforcementEnabled( + config({ meteringEnforcement: { enabled: false } }), + ), + ).toBe(false); + }); +}); + +describe('creditEnforcementExempt', () => { + it('exempts callers there is no account to charge', () => { + expect(creditEnforcementExempt(undefined, config())).toBe(true); + expect(creditEnforcementExempt({ user: {} } as Actor, config())).toBe( + true, + ); + }); + + it('exempts the system actor', () => { + expect(creditEnforcementExempt(SYSTEM_ACTOR, config())).toBe(true); + }); + + it('exempts worker sessions by default, and stops when told to', () => { + expect(creditEnforcementExempt(workerActor(), config())).toBe(true); + expect( + creditEnforcementExempt( + workerActor(), + config({ meteringEnforcement: { workers: true } }), + ), + ).toBe(false); + }); + + it('does not exempt an ordinary user or app caller', () => { + expect(creditEnforcementExempt(userActor(), config())).toBe(false); + expect( + creditEnforcementExempt( + userActor({ app: { uid: 'app-uid', id: 1 } }), + config(), + ), + ).toBe(false); + }); +}); + +describe('assertActorHasCredits', () => { + const expect402 = async (promise: Promise) => { + await expect(promise).rejects.toBeInstanceOf(HttpError); + await expect(promise).rejects.toMatchObject({ + statusCode: 402, + // Same code the AI surfaces reject with, so a client that already + // handles running out of budget handles this too. + legacyCode: 'insufficient_funds', + }); + }; + + it('rejects an account with nothing left', async () => { + await expect402( + assertActorHasCredits(brokeMetering, userActor(), config()), + ); + }); + + it('admits an account with budget left', async () => { + await expect( + assertActorHasCredits(fundedMetering, userActor(), config()), + ).resolves.toBeUndefined(); + }); + + it('admits everyone when enforcement is off', async () => { + await expect( + assertActorHasCredits( + brokeMetering, + userActor(), + config({ meteringEnforcement: { enabled: false } }), + ), + ).resolves.toBeUndefined(); + }); + + it('admits everyone with no metering service to ask', async () => { + await expect( + assertActorHasCredits(undefined, userActor(), config()), + ).resolves.toBeUndefined(); + await expect( + assertActorHasCredits({}, userActor(), config()), + ).resolves.toBeUndefined(); + }); + + it('does not ask about an exempt caller', async () => { + const metering = { + hasAnyUsageCached: vi.fn().mockResolvedValue(false), + }; + await expect( + assertActorHasCredits(metering, workerActor(), config()), + ).resolves.toBeUndefined(); + expect(metering.hasAnyUsageCached).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/services/metering/enforcement.ts b/src/backend/services/metering/enforcement.ts new file mode 100644 index 000000000..55f0841e1 --- /dev/null +++ b/src/backend/services/metering/enforcement.ts @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import type { Actor } from '../../core/actor'; +import { isSystemActor } from '../../core/actor'; +import { HttpError } from '../../core/http/HttpError.js'; +import type { IConfig } from '../../types'; + +// -- Credit enforcement ---------------------------------------------- +// +// Storage and KV usage is recorded per request and settles seconds behind +// the traffic, so there is nothing to enforce at the point it is measured. +// What can be enforced is the state that usage produced: an account with +// nothing left of its budget is turned away from the operations that spend +// it, on the way in. +// +// Which operations those are is a per-route/per-method decision made where +// the surface is declared (`RouteOptions.requireCredits`, the KV driver's +// exempt list). Two rules hold across all of them: +// +// - Only spending is gated. Listing, stat-ing and deleting stay open: an +// account that has run out still has to be able to see what it has and +// get rid of it, and turning away the operations that free resources +// leaves no way back other than paying. +// - Only the account's own traffic is gated. Serving a hosted site is +// billed to the account hosting it but driven by visitors who have no +// say in its balance, so it is metered and never blocked. + +/** + * The subset of the metering service enforcement needs. Metering is optional + * from a gate's point of view — a deployment without it enforces nothing. + */ +export interface CreditMeteringLike { + hasAnyUsageCached?: (actor: Actor) => Promise; +} + +/** Config knobs; see `IConfig.meteringEnforcement`. */ +type EnforcementConfig = Pick; + +export const enforcementEnabled = (config: EnforcementConfig): boolean => + config.meteringEnforcement?.enabled !== false; + +/** + * Actors whose usage is recorded but never blocked. + * + * A worker is a deployed program rather than someone sitting in front of a + * screen: it finds out it has been cut off by failing mid-run, with no prompt + * to read and nobody to act on it. Workers are exempt until that failure has + * somewhere to surface — `meteringEnforcement.workers` turns it on. + */ +export const creditEnforcementExempt = ( + actor: Actor | undefined, + config: EnforcementConfig, +): boolean => { + if (!actor?.user?.uuid) return true; + if (isSystemActor(actor)) return true; + if ( + actor.session?.kind === 'worker' && + config.meteringEnforcement?.workers !== true + ) { + return true; + } + return false; +}; + +/** + * Reject an actor with nothing left to spend. Same status and code the AI + * surfaces use, so a client that already handles one handles this. + */ +export const assertActorHasCredits = async ( + metering: CreditMeteringLike | undefined, + actor: Actor | undefined, + config: EnforcementConfig, +): Promise => { + if (!metering?.hasAnyUsageCached) return; + if (!enforcementEnabled(config)) return; + if (creditEnforcementExempt(actor, config)) return; + + if (!(await metering.hasAnyUsageCached(actor!))) { + throw new HttpError(402, 'No usage left for request.', { + legacyCode: 'insufficient_funds', + }); + } +}; diff --git a/src/backend/stores/fs/S3ObjectStore.test.ts b/src/backend/stores/fs/S3ObjectStore.test.ts index d59a6faee..870fc7405 100644 --- a/src/backend/stores/fs/S3ObjectStore.test.ts +++ b/src/backend/stores/fs/S3ObjectStore.test.ts @@ -3,23 +3,26 @@ * * This file is part of Puter. * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * Puter is free software: you can redistribute it and/or modify it under the + * terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. * * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { Readable } from 'node:stream'; +import type { Request } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { runWithContext } from '../../core/context.js'; import { configContainer } from '../../exports.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; @@ -1064,3 +1067,104 @@ describe('S3ObjectStore batching and command shapes', () => { }); }); }); + +describe('S3ObjectStore request accounting', () => { + const opsFor = async (fn: () => Promise) => { + const req = {} as Request; + await runWithContext({ req }, fn); + return req.storageOps; + }; + + it('counts a server-side upload, read and copy by class', async () => { + const key = `ops-${uuidv4()}`; + const ops = await opsFor(async () => { + await putObject(key, 'hello'); + const read = await store().getObjectStream( + { bucket, objectKey: key }, + region, + ); + await readAll(read.body); + await store().headObjectSize(bucket, key, region); + await store().copyObject( + { + sourceBucket: bucket, + sourceKey: key, + destinationBucket: bucket, + destinationKey: `${key}-copy`, + }, + region, + ); + }); + + expect(ops).toEqual({ write: 2, read: 2 }); + }); + + it('counts removals separately, one per batch request', async () => { + const key = `ops-${uuidv4()}`; + const ops = await opsFor(async () => { + await putObject(key, 'hello'); + await store().deleteObject(bucket, key, region); + await store().deleteObjects( + { bucket, objectKeys: [`${key}-a`, `${key}-b`] }, + region, + ); + await store().deleteObjects({ bucket, objectKeys: [] }, region); + }); + + expect(ops).toEqual({ write: 1, delete: 2 }); + }); + + it('counts the uploads a signed multipart hands off to the client', async () => { + const { store: fake } = makeFakeStore({ + respond: (command) => + command.name === 'CreateMultipartUploadCommand' + ? { UploadId: 'upload-1' } + : {}, + maxSingleUploadSize: 10, + partSize: 10, + realPresign: true, + }); + + const ops = await opsFor(() => + fake.batchCreateSignedUploadUrls( + [ + { + bucket, + objectKey: 'k', + contentType: 'text/plain', + size: 25, + uploadMode: 'multipart', + expiresInSeconds: 300, + }, + ], + region, + ), + ); + + // One to open the upload, then one per part the client will send. + expect(ops).toEqual({ write: 4 }); + }); + + it('counts a signed single upload the client makes directly', async () => { + const ops = await opsFor(() => + store().createSignedUploadUrl( + { + bucket, + objectKey: 'k', + contentType: 'text/plain', + size: 1, + uploadMode: 'single', + expiresInSeconds: 300, + }, + region, + ), + ); + + expect(ops).toEqual({ write: 1 }); + }); + + it('tallies nothing outside a request', async () => { + const key = `ops-${uuidv4()}`; + await expect(putObject(key, 'hello')).resolves.not.toThrow(); + }); +}); diff --git a/src/backend/stores/fs/S3ObjectStore.ts b/src/backend/stores/fs/S3ObjectStore.ts index 0355131f5..fe98b1cb7 100644 --- a/src/backend/stores/fs/S3ObjectStore.ts +++ b/src/backend/stores/fs/S3ObjectStore.ts @@ -44,6 +44,7 @@ import type { SignedUploadPart, SignedUploadResult, } from './s3Types.js'; +import { recordStorageOps } from '../../core/storageOps.js'; import { Span } from '../../util/span.js'; import { PuterStore } from '../types.js'; @@ -142,6 +143,11 @@ export class S3ObjectStore extends PuterStore { const url = await getSignedUrl(presignClient, command, { expiresIn: expiresInSeconds, }); + // Signing costs nothing; the upload it authorizes is a + // request against the object store that we never see, + // because the client makes it directly. Counted here + // because here is where we know it is going to happen. + recordStorageOps('write'); return { uploadMode: 'single' as const, expiresAt, @@ -159,6 +165,7 @@ export class S3ObjectStore extends PuterStore { let multipartUploadId: string | undefined; try { + recordStorageOps('write'); const multipartResult = await client.send( new CreateMultipartUploadCommand({ Bucket: fileMetadata.bucket, @@ -268,6 +275,10 @@ export class S3ObjectStore extends PuterStore { Math.min(60 * 60, input.expiresInSeconds), ); + // One upload request per part, made by the client against the URLs + // handed back below. + recordStorageOps('write', input.partNumbers.length); + return Promise.all( input.partNumbers.map(async (partNumber) => { const command = new UploadPartCommand({ @@ -293,6 +304,7 @@ export class S3ObjectStore extends PuterStore { region: string, ): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('write'); await client.send( new CompleteMultipartUploadCommand({ Bucket: input.bucket, @@ -321,6 +333,7 @@ export class S3ObjectStore extends PuterStore { objectKey: string, ): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('delete'); await client.send( new AbortMultipartUploadCommand({ Bucket: bucket, @@ -346,6 +359,7 @@ export class S3ObjectStore extends PuterStore { resolvedContentLength > maxSingleUploadSize; if (!shouldUseMultipart) { + recordStorageOps('write'); await client.send( new PutObjectCommand({ Bucket: input.bucket, @@ -386,6 +400,7 @@ export class S3ObjectStore extends PuterStore { region: string, ): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('read'); const response = await client.send( new HeadObjectCommand({ Bucket: bucket, @@ -404,6 +419,7 @@ export class S3ObjectStore extends PuterStore { region: string, ): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('delete'); await client.send( new DeleteObjectCommand({ Bucket: bucket, @@ -427,6 +443,7 @@ export class S3ObjectStore extends PuterStore { offset += MAX_BATCH ) { const chunk = input.objectKeys.slice(offset, offset + MAX_BATCH); + recordStorageOps('delete'); await client.send( new DeleteObjectsCommand({ Bucket: input.bucket, @@ -443,6 +460,7 @@ export class S3ObjectStore extends PuterStore { @Span('s3.copyObject') async copyObject(input: CopyObjectInput, region: string): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('write'); await client.send( new CopyObjectCommand({ Bucket: input.destinationBucket, @@ -464,6 +482,7 @@ export class S3ObjectStore extends PuterStore { region: string, ): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('read'); const response = await client.send( new GetObjectCommand({ Bucket: input.bucket, @@ -536,6 +555,7 @@ export class S3ObjectStore extends PuterStore { partSize: number, ): Promise { const client = this.#getClientForRegion(region); + recordStorageOps('write'); const createResult = await client.send( new CreateMultipartUploadCommand({ Bucket: input.bucket, @@ -553,6 +573,7 @@ export class S3ObjectStore extends PuterStore { let partNumber = 1; const uploadPart = async (partBody: Buffer) => { + recordStorageOps('write'); const uploadPartResult = await client.send( new UploadPartCommand({ Bucket: input.bucket, @@ -630,6 +651,7 @@ export class S3ObjectStore extends PuterStore { } if (completedParts.length === 0) { + recordStorageOps('delete'); await client.send( new AbortMultipartUploadCommand({ Bucket: input.bucket, @@ -637,6 +659,7 @@ export class S3ObjectStore extends PuterStore { UploadId: uploadId, }), ); + recordStorageOps('write'); await client.send( new PutObjectCommand({ Bucket: input.bucket, @@ -649,6 +672,7 @@ export class S3ObjectStore extends PuterStore { return; } + recordStorageOps('write'); await client.send( new CompleteMultipartUploadCommand({ Bucket: input.bucket, @@ -660,6 +684,7 @@ export class S3ObjectStore extends PuterStore { }), ); } catch (error) { + recordStorageOps('delete'); await client .send( new AbortMultipartUploadCommand({ diff --git a/src/backend/stores/metering/MeteringBufferStore.test.ts b/src/backend/stores/metering/MeteringBufferStore.test.ts index b9dc0dc22..4cfc36a41 100644 --- a/src/backend/stores/metering/MeteringBufferStore.test.ts +++ b/src/backend/stores/metering/MeteringBufferStore.test.ts @@ -28,11 +28,16 @@ import { } from 'vitest'; import { PuterServer } from '../../server.ts'; import { setupTestServer } from '../../testUtil.ts'; -import type { SystemKVStore } from '../systemKv/SystemKVStore.ts'; +import { + INCR_EXPRESSION_BUDGET_BYTES, + incrExpressionBytes, + type SystemKVStore, +} from '../systemKv/SystemKVStore.ts'; import { bucketTag, chunkAmounts, flattenAmounts, + isBilledCounter, pairsToAmounts, parsePendingEntry, unflattenAmounts, @@ -85,18 +90,54 @@ describe('MeteringBufferStore', () => { it('leaves a counter that already fits in one piece', () => { const amounts = { total: 5, 'ai:chat.units': 2 }; - expect(chunkAmounts(amounts, 24)).toEqual([amounts]); + expect(chunkAmounts(amounts)).toEqual([amounts]); }); it('splits a counter too wide for one write, losing nothing', () => { const amounts: Record = {}; - for (let i = 0; i < 7; i++) amounts[`ai${i}.units`] = i; + for (let i = 0; i < 200; i++) amounts[`ai${i}.units`] = i; - const chunks = chunkAmounts(amounts, 3); + const chunks = chunkAmounts(amounts); - expect(chunks.map((c) => Object.keys(c).length)).toEqual([3, 3, 1]); + expect(chunks.length).toBeGreaterThan(1); expect(Object.assign({}, ...chunks)).toEqual(amounts); }); + + it('splits on the size of the write, not the number of paths', () => { + // Long usage types are the case a path count gets wrong: the name + // is what makes an expression long, and it lands in it twice. + const long: Record = {}; + const short: Record = {}; + for (let i = 0; i < 24; i++) { + long[ + `together:meta-llama/Meta-Llama-3_dot_1-405B-Instruct-Turbo:kind${i}.units` + ] = 1; + short[`m${i}.units`] = 1; + } + + expect(chunkAmounts(short)).toHaveLength(1); + expect(chunkAmounts(long).length).toBeGreaterThan(1); + expect(Object.assign({}, ...chunkAmounts(long))).toEqual(long); + }); + }); + + describe('counter kinds', () => { + it("tells an actor's own month from the aggregates", () => { + const uuid = '0f6a1b2c-3d4e-5f60-7182-93a4b5c6d7e8'; + expect(isBilledCounter(`metering:actor:${uuid}:2026-08`)).toBe( + true, + ); + expect( + isBilledCounter(`metering:actor:${uuid}:app:app-1:2026-08`), + ).toBe(false); + expect(isBilledCounter(`metering:actor:${uuid}:apps:2026-08`)).toBe( + false, + ); + expect(isBilledCounter('metering:puter:412:2026-08')).toBe(false); + expect(isBilledCounter('metering:app:app-1:412:2026-08')).toBe( + false, + ); + }); }); describe('pending index entries', () => { @@ -207,11 +248,11 @@ describe('MeteringBufferStore', () => { const incrSpy = vi.spyOn(kv, 'incr'); await target.flushCycle(); - expect(incrSpy).toHaveBeenCalledTimes(3); + expect(incrSpy.mock.calls.length).toBeGreaterThan(1); for (const [input] of incrSpy.mock.calls) { expect( - Object.keys(input.pathAndAmountMap).length, - ).toBeLessThanOrEqual(24); + incrExpressionBytes(Object.keys(input.pathAndAmountMap)), + ).toBeLessThanOrEqual(INCR_EXPRESSION_BUDGET_BYTES); } incrSpy.mockRestore(); @@ -219,6 +260,50 @@ describe('MeteringBufferStore', () => { expect(Object.keys(stored)).toHaveLength(60); }); + it('settles a counter whose paths are long, losing nothing', async () => { + // A count-based split accepted these and the store rejected the + // write, which cost the whole counter: two models' worth of long + // usage types renders past what one expression can hold. + const models = [ + 'together:meta-llama/Meta-Llama-3_dot_1-405B-Instruct-Turbo', + 'openrouter:anthropic/claude-sonnet-4_dot_5-20250929', + ]; + const paths: Record = { total: 12 }; + for (const model of models) { + for (const kind of [ + 'input_tokens', + 'output_tokens', + 'cache_read_input_tokens', + 'usd_cents', + ]) { + for (const field of ['units', 'cost', 'count']) + paths[`${model}:${kind}.${field}`] = 1; + } + } + await target.incr({ key, pathAndAmountMap: paths }); + + const incrSpy = vi.spyOn(kv, 'incr'); + const logged = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + await target.flushCycle(); + expect(logged).not.toHaveBeenCalled(); + logged.mockRestore(); + + // The store the real deployment writes to rejects an expression + // past its limit outright, so what matters is that none of these + // writes was ever built that big. + for (const [input] of incrSpy.mock.calls) { + expect( + incrExpressionBytes(Object.keys(input.pathAndAmountMap)), + ).toBeLessThanOrEqual(INCR_EXPRESSION_BUDGET_BYTES); + } + incrSpy.mockRestore(); + + const stored = flattenAmounts((await kv.get({ key })).res); + expect(stored).toEqual(paths); + }); + it('counts what is already stored when it first sees a counter', async () => { await kv.incr({ key, pathAndAmountMap: { total: 80 } }); @@ -400,6 +485,9 @@ describe('MeteringBufferStore', () => { expect( await server.clients.redis.hgetall(`meter:pending:{${tag}}`), ).toEqual({}); + expect( + await server.clients.redis.zcard(`meter:inflight:{${tag}}`), + ).toBe(0); }); it('maintains the base from what the store returned', async () => { @@ -590,8 +678,12 @@ describe('MeteringBufferStore', () => { ).toEqual({}); }); - it('gives up on a claim the store will never accept', async () => { - await target.incr({ key, pathAndAmountMap: { total: 5 } }); + it('gives up on a path the store will never accept', async () => { + // Named so the give-up is remembered against a path no other test + // settles — the memo that stops it being rediscovered every cycle + // outlives this test. + const doomed = 'never_writable_by_this_test.units'; + await target.incr({ key, pathAndAmountMap: { [doomed]: 5 } }); // A rejection, not an outage: the next attempt would be rejected // identically. Re-driving it every cycle for as long as the counter // exists is what turned one bad counter into a write loop. @@ -605,6 +697,7 @@ describe('MeteringBufferStore', () => { const logged = vi .spyOn(console, 'error') .mockImplementation(() => {}); + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); await target.flushCycle(); boom.mockRestore(); @@ -620,6 +713,16 @@ describe('MeteringBufferStore', () => { ); logged.mockRestore(); + // Losing an actor's own spending is somebody's money, so it pages. + expect(alarmSpy).toHaveBeenCalledWith( + `metering_usage_dropped:${key}`, + expect.stringContaining('under-billed'), + expect.objectContaining({ key, paths: [doomed] }), + 'critical', + expect.objectContaining({ dedup: true }), + ); + alarmSpy.mockRestore(); + // And a second cycle finds nothing left to re-drive. const after = vi.spyOn(kv, 'incr'); await target.flushCycle(); @@ -627,6 +730,81 @@ describe('MeteringBufferStore', () => { after.mockRestore(); }); + it('keeps every path an unwritable one was batched with', async () => { + const doomed = 'poison_path_kept_test.units'; + await target.incr({ + key, + pathAndAmountMap: { + total: 9, + 'ai:chat.units': 4, + [doomed]: 1, + 'egress:bytes.units': 7, + }, + }); + + // Only the one path is refused; anything batched with it is fine. + const passThrough = kv.incr.bind(kv); + const picky = vi + .spyOn(kv, 'incr') + .mockImplementation((...args: Parameters) => { + if (doomed in args[0].pathAndAmountMap) { + return Promise.reject( + Object.assign(new Error('nope'), { + name: 'ValidationException', + }), + ); + } + return passThrough(...args); + }); + const logged = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await target.flushCycle(); + picky.mockRestore(); + logged.mockRestore(); + + // The good paths land in full; only the refused one is missing. + const stored = flattenAmounts((await kv.get({ key })).res); + expect(stored).toEqual({ + total: 9, + 'ai:chat.units': 4, + 'egress:bytes.units': 7, + }); + }); + + it('records an aggregate drop without paging', async () => { + const aggregate = `metering:puter:7:2026-08`; + const doomed = 'aggregate_poison_test.units'; + await target.incrAux({ + key: aggregate, + pathAndAmountMap: { [doomed]: 3 }, + }); + + const boom = vi.spyOn(kv, 'incr').mockRejectedValue( + Object.assign(new Error('nope'), { + name: 'ValidationException', + }), + ); + const logged = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + + await target.flushCycle(); + boom.mockRestore(); + logged.mockRestore(); + + expect(alarmSpy).toHaveBeenCalledWith( + 'metering_aggregate_usage_dropped', + expect.stringContaining('under-count'), + expect.objectContaining({ key: aggregate }), + 'warning', + expect.objectContaining({ dedup: true }), + ); + alarmSpy.mockRestore(); + }); + it('does not write an applied chunk twice when a later one fails', async () => { const paths: Record = {}; for (let i = 0; i < 30; i++) paths[`ai${i}.units`] = 1; @@ -686,6 +864,139 @@ describe('MeteringBufferStore', () => { expect(Object.keys(pending)).toHaveLength(1); expect(await storedTotal(key)).toBe(0); }); + + it('recovers a counter whose claim never happens', async () => { + await target.incr({ key, pathAndAmountMap: { total: 5 } }); + const tag = bucketTag(key); + + // A cycle that takes the counter and then cannot claim it — the + // cache dropped the call, or the deployment went away mid-flush. + // Between those two steps the in-flight record is the only thing + // that knows this counter has amounts waiting. + const boom = vi + .spyOn( + server.clients.redis as unknown as { + meterClaim: () => Promise; + }, + 'meterClaim', + ) + .mockRejectedValue(new Error('cache unreachable')); + const warned = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + await target.flushCycle(); + boom.mockRestore(); + + expect(await storedTotal(key)).toBe(0); + expect( + await server.clients.redis.zscore( + `meter:inflight:{${tag}}`, + key, + ), + ).not.toBeNull(); + + // Nothing has been in flight long enough to look abandoned yet, so + // a cycle right behind it leaves the counter alone. + await target.flushCycle(); + expect(await storedTotal(key)).toBe(0); + + // Once it has, the counter goes back on the dirty set and settles + // in full — the amounts were never at risk, only delayed. + await server.clients.redis.zadd( + `meter:inflight:{${tag}}`, + String(Date.now() - 60_000), + key, + ); + await target.flushCycle(); + warned.mockRestore(); + + expect(await storedTotal(key)).toBe(5); + expect( + await server.clients.redis.zcard(`meter:inflight:{${tag}}`), + ).toBe(0); + }); + + it('takes each counter for one deployment only', async () => { + // Two drains overlapping in time must divide a bucket rather than + // both working through it from the front, or the drain rate stops + // improving when deployments are added. + const keys = Array.from( + { length: 6 }, + (_, i) => `${key}-share-${i}`, + ); + const tag = 'mshare'; + for (const k of keys) { + await server.clients.redis.sadd(`meter:dirty:{${tag}}`, k); + } + + const drain = (): Promise<[string[], number]> => + ( + server.clients.redis as unknown as { + meterDrain: ( + ...args: string[] + ) => Promise<[string[], number]>; + } + ).meterDrain( + `meter:dirty:{${tag}}`, + `meter:inflight:{${tag}}`, + '3', + String(Date.now()), + '60000', + String(Date.now() - 30_000), + ); + + const [first, remainingAfterFirst] = await drain(); + const [second, remainingAfterSecond] = await drain(); + + expect(first).toHaveLength(3); + expect(second).toHaveLength(3); + expect([...first, ...second].sort()).toEqual([...keys].sort()); + expect(Number(remainingAfterFirst)).toBe(3); + expect(Number(remainingAfterSecond)).toBe(0); + }); + + it('does not write a settled counter twice when its base is not replaced', async () => { + await target.incr({ key, pathAndAmountMap: { total: 5 } }); + + // The write onward lands, then the cache call that adopts the new + // base fails. The amounts are already applied, so a re-drive must + // not apply them again. + const boom = vi + .spyOn( + server.clients.redis as unknown as { + meterSettle: () => Promise; + }, + 'meterSettle', + ) + .mockRejectedValue(new Error('cache unreachable')); + const warned = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + await target.flushCycle(); + boom.mockRestore(); + expect(await storedTotal(key)).toBe(5); + + // Age whatever is left of the claim so the sweep takes it. + const tag = bucketTag(key); + const pending = await server.clients.redis.hgetall( + `meter:pending:{${tag}}`, + ); + for (const nonce of Object.keys(pending)) { + await server.clients.redis.hset( + `meter:pending:{${tag}}`, + nonce, + `${Date.now() - 60_000}:${key}`, + ); + } + + await target.flushCycle(); + await target.flushCycle(); + warned.mockRestore(); + + expect(await storedTotal(key)).toBe(5); + }); }); describe('month boundaries', () => { diff --git a/src/backend/stores/metering/MeteringBufferStore.ts b/src/backend/stores/metering/MeteringBufferStore.ts index 30597adcb..d3d73196e 100644 --- a/src/backend/stores/metering/MeteringBufferStore.ts +++ b/src/backend/stores/metering/MeteringBufferStore.ts @@ -19,7 +19,10 @@ import { randomUUID } from 'node:crypto'; import murmurhash from 'murmurhash'; -import type { RecursiveRecord } from '../systemKv/SystemKVStore'; +import { + chunkPathsForIncr, + type RecursiveRecord, +} from '../systemKv/SystemKVStore'; import { PuterStore } from '../types'; // -- Types ------------------------------------------------------------ @@ -71,11 +74,16 @@ const BUFFER_TTL_MS = 40 * 24 * 60 * 60 * 1000; /** How many counters are written onward at once, to keep the load even. */ const SETTLE_CONCURRENCY = 20; -const SETTLE_PATHS_PER_WRITE = 24; - /** Roughly a minute between compression reports, so they don't flood the log. */ const CYCLES_PER_COMPRESSION_REPORT = 12; +/** + * How many path names this deployment remembers as unwritable. Bounded because + * the names come from usage types, and a caller can invent those; forgetting + * one costs the handful of writes that identify it again. + */ +const UNWRITABLE_PATH_MEMO_LIMIT = 1000; + // -- Keys ------------------------------------------------------------- export const bucketTag = (key: string): string => @@ -88,6 +96,8 @@ const dirtyKey = (tag: string): string => `meter:dirty:{${tag}}`; const pendingKey = (tag: string, nonce: string): string => `meter:p:{${tag}}:${nonce}`; const pendingIndexKey = (tag: string): string => `meter:pending:{${tag}}`; +/** Counters taken off the dirty set but not yet claimed, scored by when. */ +const inflightKey = (tag: string): string => `meter:inflight:{${tag}}`; // -- Shape helpers ---------------------------------------------------- @@ -154,27 +164,44 @@ const toScriptArgs = (amounts: Record): string[] => { return args; }; -/** Split counters into groups of at most `size` paths, preserving order. */ -export const chunkAmounts = ( - amounts: FlatAmounts, - size: number, -): FlatAmounts[] => { - const entries = Object.entries(amounts); - if (entries.length <= size) return [amounts]; +/** The subset of `amounts` at `paths`. */ +const pickAmounts = (amounts: FlatAmounts, paths: string[]): FlatAmounts => + Object.fromEntries(paths.map((path) => [path, amounts[path]!])); - const chunks: FlatAmounts[] = []; - for (let i = 0; i < entries.length; i += size) { - chunks.push(Object.fromEntries(entries.slice(i, i + size))); - } - return chunks; -}; +/** + * Split a counter into pieces the KV store will each accept in one write. + * + * Sized by what the write will actually be, not by how many paths it carries: a + * path's name is what makes an update expression long, and usage types name + * themselves, so a fixed count of long ones overflows where the same count of + * short ones is nowhere near. + */ +export const chunkAmounts = (amounts: FlatAmounts): FlatAmounts[] => + chunkPathsForIncr(Object.keys(amounts)).map((paths) => + pickAmounts(amounts, paths), + ); +/** + * Whether sending this write again unchanged would fail the same way. Such a + * write is worth narrowing down rather than retrying; a failure that isn't one + * of these is left for the sweep to re-drive. + */ const isPermanentSettleError = (err: Error): boolean => { if (err.name === 'ValidationException') return true; const status = (err as { statusCode?: unknown }).statusCode; return typeof status === 'number' && status >= 400 && status < 500; }; +/** + * Whether this counter is the one an actor's own spending is read from, as + * opposed to the aggregates that only feed reporting. Both are worth keeping, + * but losing amounts from this one under-bills a specific account, so it is + * escalated differently. Matches the per-actor month record `MeteringService` + * builds, and deliberately not its per-app or per-shard siblings. + */ +export const isBilledCounter = (key: string): boolean => + /:actor:[^:]+:\d{4}-\d{2}$/.test(key); + // -- Scripts ---------------------------------------------------------- /** @@ -199,13 +226,49 @@ return { redis.call('HGETALL', KEYS[1]), redis.call('HGETALL', KEYS[2]) } `; /** - * KEYS: delta, pending, pending index. ARGV: nonce, index value, ttl. + * KEYS: dirty set, in-flight set. ARGV: how many, now, ttl, abandoned-before. + * + * Takes a slice of a bucket's counters for this deployment alone to flush, and + * records what it took. Taking them means two deployments flushing the same + * bucket divide it between them instead of both working through the same + * counters from the front, so the drain keeps up by adding deployments. + * + * What was taken has to be written down in the same step, because between here + * and the claim the counter has nothing else pointing at it: a deployment lost + * in that window would leave a delta nobody ever looks at again. Anything taken + * long enough ago to mean that happened goes back on the dirty set to be taken + * again — by whichever deployment gets to it, this one included. + */ +const DRAIN_SCRIPT = ` +local abandoned = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[4]) or {} +for i = 1, #abandoned do + redis.call('SADD', KEYS[1], abandoned[i]) + redis.call('ZREM', KEYS[2], abandoned[i]) +end +local taken = redis.call('SPOP', KEYS[1], ARGV[1]) or {} +for i = 1, #taken do + redis.call('ZADD', KEYS[2], ARGV[2], taken[i]) +end +if #taken > 0 then redis.call('PEXPIRE', KEYS[2], ARGV[3]) end +return { taken, redis.call('SCARD', KEYS[1]) } +`; + +/** + * KEYS: delta, pending, pending index, in-flight set. ARGV: nonce, index value, + * ttl, in-flight member. * * The rename is the claim, and it is atomic: if two flushes race for one delta, * one takes all of it and the other sees nothing. Increments arriving mid-flush * start a fresh delta and go out on the next cycle. + * + * The claim is also where the counter stops being in flight, in the same step: + * from here the pending key is what records that it has amounts waiting, so + * this hands the counter from one durable record to the next without a gap in + * between. A counter whose delta is already gone was flushed by someone else, + * and stops being in flight just the same. */ const CLAIM_SCRIPT = ` +redis.call('ZREM', KEYS[4], ARGV[4]) if redis.call('EXISTS', KEYS[1]) == 0 then return nil end redis.call('RENAME', KEYS[1], KEYS[2]) redis.call('PEXPIRE', KEYS[2], ARGV[3]) @@ -275,6 +338,7 @@ return redis.call('HGETALL', KEYS[1]) type ScriptRunner = { meterIncr(...args: string[]): Promise<[string[], string[]]>; meterRead(...args: string[]): Promise<[string[], string[]]>; + meterDrain(...args: string[]): Promise<[string[], number]>; meterClaim(...args: string[]): Promise; meterReclaim(...args: string[]): Promise; meterSettle(...args: string[]): Promise; @@ -302,8 +366,12 @@ export class MeteringBufferStore extends PuterStore { #definedScripts = false; #absorbedCount = 0; #flushedCount = 0; + #droppedPathCount = 0; #cyclesSinceReport = 0; + /** Path names the KV store has refused on their own; see `#settle`. */ + #unwritablePaths = new Set(); + // -- Lifecycle ---------------------------------------------------- override onServerStart(): void { @@ -438,8 +506,12 @@ export class MeteringBufferStore extends PuterStore { numberOfKeys: 2, lua: READ_SCRIPT, }); + client.defineCommand('meterDrain', { + numberOfKeys: 2, + lua: DRAIN_SCRIPT, + }); client.defineCommand('meterClaim', { - numberOfKeys: 3, + numberOfKeys: 4, lua: CLAIM_SCRIPT, }); client.defineCommand('meterReclaim', { @@ -546,12 +618,25 @@ export class MeteringBufferStore extends PuterStore { const work: Array<() => Promise> = []; let truncated = 0; - const buckets = await Promise.all( + // Settled, not all-or-nothing: one bucket the cache could not answer + // for must not discard the other 63 buckets' work for this cycle. + const drained = await Promise.allSettled( Array.from({ length: BUCKET_COUNT }, (_, bucket) => this.#drainBucket(`m${bucket}`), ), ); + const buckets = []; + for (const outcome of drained) { + if (outcome.status === 'fulfilled') { + buckets.push(outcome.value); + continue; + } + console.warn( + `[metering] could not take a bucket's counters: ${(outcome.reason as Error)?.message}`, + ); + } + for (const bucket of buckets) { if (bucket.truncated) truncated++; for (const key of bucket.keys) { @@ -564,7 +649,7 @@ export class MeteringBufferStore extends PuterStore { if (truncated > 0) { console.warn( - `[metering] ${truncated} bucket(s) hit the per-cycle claim cap; the rest flush next cycle`, + `[metering] ${truncated} bucket(s) still held counters after this deployment took its share; they go out on a following cycle, or to another deployment`, ); } @@ -595,33 +680,46 @@ export class MeteringBufferStore extends PuterStore { const absorbed = this.#absorbedCount; const writes = this.#flushedCount; + const dropped = this.#droppedPathCount; this.#absorbedCount = 0; this.#flushedCount = 0; + this.#droppedPathCount = 0; this.#cyclesSinceReport = 0; if (writes === 0) return; console.log( - `[metering] buffer absorbed ${absorbed} increments into ${writes} writes (${(absorbed / writes).toFixed(2)}x)`, + `[metering] buffer absorbed ${absorbed} increments into ${writes} writes (${(absorbed / writes).toFixed(2)}x)` + + (dropped > 0 ? `, dropped ${dropped} unwritable path(s)` : ''), ); } + /** + * Take this deployment's share of a bucket's counters, and list the claims + * in it that look abandoned. + */ async #drainBucket(tag: string): Promise<{ tag: string; keys: string[]; orphans: Array<{ nonce: string; key: string }>; truncated: boolean; }> { - const redis = this.clients.redis; - const [popped, pending] = await Promise.all([ - redis.spop(dirtyKey(tag), CLAIMS_PER_BUCKET), - redis.hgetall(pendingIndexKey(tag)), + const now = Date.now(); + const cutoff = now - ORPHAN_AGE_MS; + + const [drained, pending] = await Promise.all([ + this.#redis.meterDrain( + dirtyKey(tag), + inflightKey(tag), + String(CLAIMS_PER_BUCKET), + String(now), + String(BUFFER_TTL_MS), + String(cutoff), + ), + this.clients.redis.hgetall(pendingIndexKey(tag)), ]); - // An empty pop can come back as nothing at all rather than an empty - // list, depending on the client. - const keys = popped ?? []; + const [taken, remaining] = drained ?? [[], 0]; - const cutoff = Date.now() - ORPHAN_AGE_MS; const orphans: Array<{ nonce: string; key: string }> = []; for (const [nonce, encoded] of Object.entries(pending ?? {})) { const parsed = parsePendingEntry(encoded); @@ -631,9 +729,11 @@ export class MeteringBufferStore extends PuterStore { return { tag, - keys, + keys: taken ?? [], orphans, - truncated: keys.length >= CLAIMS_PER_BUCKET, + // What the bucket still holds after this deployment took its share, + // which another deployment may be taking at the same moment. + truncated: Number(remaining) > 0, }; } @@ -643,9 +743,11 @@ export class MeteringBufferStore extends PuterStore { deltaKey(tag, key), pendingKey(tag, nonce), pendingIndexKey(tag), + inflightKey(tag), nonce, encodePendingEntry(Date.now(), key), String(BUFFER_TTL_MS), + key, ); // Nothing buffered for this counter — another flush already took it. if (!claimed) return; @@ -678,52 +780,100 @@ export class MeteringBufferStore extends PuterStore { await this.#settle(tag, orphan.key, nonce, pairsToAmounts(reclaimed)); } + /** + * Write a claimed counter onward, then replace its base with what the KV + * store now holds. + * + * A write the store will never accept is narrowed down rather than + * abandoned: the batch is halved and each half tried, until either it goes + * through or a single path is left standing alone as the one thing that + * cannot be written. Only that path is given up on — everything it was + * batched with still lands. Amounts here are already spent, so the bar for + * dropping any of them is that there is provably nothing else to try. + */ async #settle( tag: string, key: string, nonce: string, amounts: FlatAmounts, ): Promise { - const total = Object.keys(amounts).length; - if (total === 0) { + if (Object.keys(amounts).length === 0) { // Nothing to write onward. Retire the claim rather than settling // it, which would clear a base that is still good. await this.#retireClaim(tag, nonce); return; } - const chunks = chunkAmounts(amounts, SETTLE_PATHS_PER_WRITE); - // Each write returns the whole counter, so after the last chunk this - // holds every path the KV store now has — including the earlier chunks - // and anything another deployment contributed. + // Paths already known to be unwritable are dropped without spending a + // write to rediscover it, which is what keeps one bad usage type from + // costing a round of narrowing on every cycle for as long as it arrives. + const known = Object.keys(amounts).filter((path) => + this.#unwritablePaths.has(path), + ); + if (known.length > 0) { + this.#recordDrop( + key, + pickAmounts(amounts, known), + 'known unwritable', + ); + await this.#dropFromClaim(tag, nonce, known); + } + + const pending = chunkAmounts( + pickAmounts( + amounts, + Object.keys(amounts).filter( + (path) => !this.#unwritablePaths.has(path), + ), + ), + ); + + // Each write returns the whole counter, so the last one to succeed + // holds every path the KV store now has — including earlier batches and + // anything another deployment contributed. let settled: unknown; let written = 0; - for (const chunk of chunks) { + + while (pending.length > 0) { + const batch = pending.shift()!; + const paths = Object.keys(batch); try { ({ res: settled } = await this.stores.kv.incr({ key, - pathAndAmountMap: chunk, + pathAndAmountMap: batch, })); } catch (e) { const err = e as Error; if (!isPermanentSettleError(err)) throw e; - console.error( - `[metering] dropping ${total - written} unwritable path(s) of ${key}: ${err.message}`, - ); - await this.#retireClaim(tag, nonce); - return; - } - written += Object.keys(chunk).length; - // This chunk is applied for good now, so take it off the claim: if a - // later one fails, the re-drive picks up only what is still - // outstanding instead of adding these amounts a second time. - if (chunks.length > 1) { - await this.clients.redis.hdel( - pendingKey(tag, nonce), - ...Object.keys(chunk), - ); + if (paths.length > 1) { + const middle = Math.ceil(paths.length / 2); + pending.unshift( + pickAmounts(batch, paths.slice(0, middle)), + pickAmounts(batch, paths.slice(middle)), + ); + continue; + } + + this.#rememberUnwritable(paths[0]!); + this.#recordDrop(key, batch, err.message); + await this.#dropFromClaim(tag, nonce, paths); + continue; } + written += paths.length; + + // This batch is applied for good now, so take it off the claim + // before anything else can fail: a re-drive then picks up only what + // is still outstanding instead of adding these amounts a second + // time. + await this.#dropFromClaim(tag, nonce, paths); + } + + if (written === 0) { + // Nothing reached the store, so there is no newer base to adopt — + // settling would replace a good one with this counter's old value. + await this.#retireClaim(tag, nonce); + return; } const flat = flattenAmounts(settled); @@ -738,6 +888,56 @@ export class MeteringBufferStore extends PuterStore { ); } + /** Take paths off a claim, so a re-drive doesn't carry them again. */ + async #dropFromClaim( + tag: string, + nonce: string, + paths: string[], + ): Promise { + if (paths.length === 0) return; + await this.clients.redis.hdel(pendingKey(tag, nonce), ...paths); + } + + #rememberUnwritable(path: string): void { + if (this.#unwritablePaths.size >= UNWRITABLE_PATH_MEMO_LIMIT) { + const oldest = this.#unwritablePaths.keys().next().value; + if (oldest !== undefined) this.#unwritablePaths.delete(oldest); + } + this.#unwritablePaths.add(path); + } + + /** + * Say loudly that metered usage was lost. These amounts have already been + * spent and are gone from the cache with the claim, so nothing downstream + * will notice on its own — which is exactly why this cannot be a log line. + */ + #recordDrop(key: string, dropped: FlatAmounts, reason: string): void { + const paths = Object.keys(dropped); + this.#droppedPathCount += paths.length; + console.error( + `[metering] dropping ${paths.length} unwritable path(s) of ${key}: ${reason}`, + ); + + if (isBilledCounter(key)) { + this.clients.alarm.create( + `metering_usage_dropped:${key}`, + `Usage could not be persisted for ${key} — the amounts are lost and the account is under-billed`, + { key, paths, amounts: dropped, reason }, + 'critical', + { dedup: true }, + ); + return; + } + + this.clients.alarm.create( + 'metering_aggregate_usage_dropped', + `An aggregate usage counter could not be persisted (${key}) — reporting totals will under-count`, + { key, paths, amounts: dropped, reason }, + 'warning', + { dedup: true }, + ); + } + /** * Forget a claim and its index entry, leaving the base alone. Settling * would also replace the base, which is only correct when something was diff --git a/src/backend/stores/systemKv/SystemKVStore.readCache.test.ts b/src/backend/stores/systemKv/SystemKVStore.readCache.test.ts new file mode 100644 index 000000000..bf00d18c5 --- /dev/null +++ b/src/backend/stores/systemKv/SystemKVStore.readCache.test.ts @@ -0,0 +1,406 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { Actor } from '../../core/actor.ts'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; +import type { SystemKVStore } from './SystemKVStore.ts'; +import { kvCacheKey } from './readCache.ts'; +import { PUTER_KV_STORE_TABLE_NAME } from './tableDefinition.ts'; + +const BLOCK_SECONDS = 1; + +describe('SystemKVStore read cache', () => { + let server: PuterServer; + let target: SystemKVStore; + + beforeAll(async () => { + server = await setupTestServer({ + kvCache: { + enabled: true, + blockSeconds: BLOCK_SECONDS, + // Emit as each invalidation happens so a test can assert on it + // without waiting out a coalescing window. + broadcastCoalesceMs: 0, + }, + }); + target = server.stores.kv; + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + let actor: Actor; + let opts: { actor: Actor }; + let namespace: string; + beforeEach(() => { + const uuid = `test-user-${Math.random().toString(36).slice(2)}`; + actor = { user: { uuid } }; + opts = { actor }; + // Mirrors the store's own namespacing for an actor with no app. + namespace = `v1:${uuid}:os-global`; + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** + * Seed an entry without going through the store, so no write block is left + * behind and the very next read is free to cache what it finds. + */ + const seed = ( + key: string, + value: unknown, + extra: Record = {}, + ) => + server.clients.dynamo.put(PUTER_KV_STORE_TABLE_NAME, { + namespace, + key, + value, + ...extra, + }); + + /** Cache fills are deliberately not awaited by the read that triggers them. */ + const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + + describe('get', () => { + it('answers a repeat read without touching the underlying store', async () => { + await seed('k', 'cached-value'); + await target.get({ key: 'k' }, opts); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + const result = await target.get({ key: 'k' }, opts); + + expect(result.res).toBe('cached-value'); + expect(get).not.toHaveBeenCalled(); + }); + + it('reports cached units separately from consumed capacity', async () => { + await seed('k', 'v'); + const uncached = await target.get({ key: 'k' }, opts); + await settle(); + const cached = await target.get({ key: 'k' }, opts); + + expect(uncached.usage.read).toBeGreaterThan(0); + expect(uncached.usage.cachedRead).toBe(0); + // The units a cached read replays are the ones the uncached read + // consumed — the rate they are priced at is the driver's business. + expect(cached.usage.read).toBe(0); + expect(cached.usage.cachedRead).toBe(uncached.usage.read); + }); + + it('answers a repeat read of a missing key without touching the store', async () => { + const first = await target.get({ key: 'never-written' }, opts); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + const second = await target.get({ key: 'never-written' }, opts); + + expect(first.res).toBeNull(); + expect(second.res).toBeNull(); + expect(get).not.toHaveBeenCalled(); + }); + + it('reads through when the caller asks for a consistent read', async () => { + await seed('k', 'v'); + await target.get({ key: 'k' }, opts); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + await target.get({ key: 'k', consistentRead: true }, opts); + + expect(get).toHaveBeenCalledTimes(1); + }); + + it('never caches the system namespace', async () => { + const key = `sys-${Math.random().toString(36).slice(2)}`; + await target.set({ key, value: 'v' }); + const get = vi.spyOn(server.clients.dynamo, 'get'); + + await target.get({ key }); + await settle(); + await target.get({ key }); + + expect(get).toHaveBeenCalledTimes(2); + }); + + it('leaves an oversized value uncached', async () => { + await seed('big', 'x'.repeat(40 * 1024)); + await target.get({ key: 'big' }, opts); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + const result = await target.get({ key: 'big' }, opts); + + expect(result.res).toHaveLength(40 * 1024); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('stops serving an entry once its own expiry lapses', async () => { + // Three seconds, not one: the entry has to still be live when the + // first read lands, and a one-second window is one the seed, the + // read and a loaded machine can eat between them. + const expiresAt = Math.floor(Date.now() / 1000) + 3; + await seed('short-lived', 'v', { ttl: expiresAt }); + const before = await target.get({ key: 'short-lived' }, opts); + await settle(); + expect(before.res).toBe('v'); + + await sleep(Math.max(0, expiresAt * 1000 - Date.now()) + 200); + const after = await target.get({ key: 'short-lived' }, opts); + expect(after.res).toBeNull(); + }); + }); + + describe('batch get', () => { + it('fetches only the keys the cache could not answer', async () => { + await seed('a', 1); + await seed('b', 2); + await target.get({ key: ['a'] }, opts); + await settle(); + + const batchGet = vi.spyOn(server.clients.dynamo, 'batchGet'); + const result = await target.get( + { key: ['a', 'b', 'absent'] }, + opts, + ); + + expect(result.res).toEqual([1, 2, null]); + expect(batchGet).toHaveBeenCalledTimes(1); + const requested = ( + batchGet.mock.calls[0][0] as { + items: { key: string }; + }[] + ).map((request) => request.items.key); + expect(requested.sort()).toEqual(['absent', 'b']); + }); + + it('skips the store entirely when every key is cached', async () => { + await seed('a', 1); + await seed('b', 2); + await target.get({ key: ['a', 'b'] }, opts); + await settle(); + + const batchGet = vi.spyOn(server.clients.dynamo, 'batchGet'); + const result = await target.get({ key: ['a', 'b'] }, opts); + + expect(result.res).toEqual([1, 2]); + expect(batchGet).not.toHaveBeenCalled(); + }); + + it('mixes cached and fetched units in one usage figure', async () => { + await seed('a', 1); + await seed('b', 2); + await target.get({ key: ['a'] }, opts); + await settle(); + + const { usage } = await target.get({ key: ['a', 'b'] }, opts); + expect(usage.cachedRead).toBeGreaterThan(0); + expect(usage.read).toBeGreaterThan(0); + }); + }); + + describe('invalidation', () => { + const warm = async (key: string, value: unknown) => { + await seed(key, value); + await target.get({ key }, opts); + await settle(); + }; + + it('serves the new value after a set', async () => { + await warm('k', 'old'); + await target.set({ key: 'k', value: 'new' }, opts); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toBe('new'); + }); + + it('serves nothing after a del', async () => { + await warm('k', 'v'); + await target.del({ key: 'k' }, opts); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toBeNull(); + }); + + it('serves the new value after a batchPut', async () => { + await warm('a', 'old-a'); + await warm('b', 'old-b'); + await target.batchPut( + { + items: [ + { key: 'a', value: 'new-a' }, + { key: 'b', value: 'new-b' }, + ], + }, + opts, + ); + const result = await target.get({ key: ['a', 'b'] }, opts); + expect(result.res).toEqual(['new-a', 'new-b']); + }); + + it('serves the new value after an update', async () => { + await warm('k', { count: 1 }); + await target.update( + { key: 'k', pathAndValueMap: { count: 9 } }, + opts, + ); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toEqual({ count: 9 }); + }); + + it('serves the new value after an incr', async () => { + await warm('k', { count: 1 }); + await target.incr( + { key: 'k', pathAndAmountMap: { count: 2 } }, + opts, + ); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toEqual({ count: 3 }); + }); + + it('serves the new value after an add', async () => { + await warm('k', { items: ['a'] }); + await target.add( + { key: 'k', pathAndValueMap: { items: ['b'] } }, + opts, + ); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toEqual({ items: ['a', 'b'] }); + }); + + it('serves the new value after a remove', async () => { + await warm('k', { keep: 1, drop: 2 }); + await target.remove({ key: 'k', paths: ['drop'] }, opts); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toEqual({ keep: 1 }); + }); + + it('serves nothing after an expire lapses', async () => { + await warm('k', 'v'); + await target.expire({ key: 'k', ttl: 1 }, opts); + await sleep(1200); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toBeNull(); + }); + + it('serves nothing after a flush', async () => { + await warm('a', 1); + await warm('b', 2); + await target.flush(opts); + const result = await target.get({ key: ['a', 'b'] }, opts); + expect(result.res).toEqual([null, null]); + }); + + it('keeps reads off the cache for a window, then lets it fill again', async () => { + await warm('k', 'old'); + await target.set({ key: 'k', value: 'new' }, opts); + + // Inside the window a read cannot prove its value is the current + // one, so nothing is cached and every read goes to the store. + const blocked = vi.spyOn(server.clients.dynamo, 'get'); + await target.get({ key: 'k' }, opts); + await settle(); + await target.get({ key: 'k' }, opts); + expect(blocked).toHaveBeenCalledTimes(2); + blocked.mockRestore(); + + await sleep(BLOCK_SECONDS * 1000 + 200); + await target.get({ key: 'k' }, opts); + await settle(); + + const after = vi.spyOn(server.clients.dynamo, 'get'); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toBe('new'); + expect(after).not.toHaveBeenCalled(); + }); + }); + + describe('cross-region broadcast', () => { + it('announces the cache keys a write invalidated', async () => { + const emit = vi.spyOn(server.clients.event, 'emit'); + await target.set({ key: 'k', value: 'v' }, opts); + + expect(emit).toHaveBeenCalledWith( + 'outer.kv.cacheInvalidated', + { cacheKeys: [kvCacheKey(namespace, 'k')] }, + {}, + ); + }); + + it('says nothing for a write to the system namespace', async () => { + const emit = vi.spyOn(server.clients.event, 'emit'); + await target.set({ key: 'sys-key', value: 'v' }); + + expect(emit).not.toHaveBeenCalledWith( + 'outer.kv.cacheInvalidated', + expect.anything(), + expect.anything(), + ); + }); + + it('applies an invalidation that arrived from another region', async () => { + await seed('k', 'v'); + await target.get({ key: 'k' }, opts); + await settle(); + + server.clients.event.emit( + 'outer.kv.cacheInvalidated', + { cacheKeys: [kvCacheKey(namespace, 'k')] }, + { from_outside: true }, + ); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + await target.get({ key: 'k' }, opts); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('ignores its own announcement, which it has already applied', async () => { + await seed('k', 'v'); + await target.get({ key: 'k' }, opts); + await settle(); + + server.clients.event.emit( + 'outer.kv.cacheInvalidated', + { cacheKeys: [kvCacheKey(namespace, 'k')] }, + {}, + ); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + await target.get({ key: 'k' }, opts); + expect(get).not.toHaveBeenCalled(); + }); + }); + + describe('private entries', () => { + it('hides a private entry from a cross-app read the cache answers', async () => { + await seed('secret', 'value', { noShare: true }); + + // Warmed by a read from the owning side, which is allowed to see it. + const owner = await target.get({ key: 'secret' }, opts); + expect(owner.res).toBe('value'); + await settle(); + + const get = vi.spyOn(server.clients.dynamo, 'get'); + const crossApp = await target.get( + { key: 'secret' }, + { actor, namespaceAppUuid: 'os-global' }, + ); + + expect(crossApp.res).toBeNull(); + expect(get).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/backend/stores/systemKv/SystemKVStore.test.ts b/src/backend/stores/systemKv/SystemKVStore.test.ts index 1c8623e7a..758d5d62b 100644 --- a/src/backend/stores/systemKv/SystemKVStore.test.ts +++ b/src/backend/stores/systemKv/SystemKVStore.test.ts @@ -9,10 +9,60 @@ import { vi, } from 'vitest'; import { setupTestServer } from '../../testUtil.ts'; -import type { SystemKVStore } from './SystemKVStore.ts'; +import { + chunkPathsForIncr, + INCR_EXPRESSION_BUDGET_BYTES, + incrExpressionBytes, + type SystemKVStore, +} from './SystemKVStore.ts'; import { PuterServer } from '../../server.ts'; import type { Actor } from '../../core/actor.ts'; +describe('incr expression sizing', () => { + const longPath = (i: number): string => + `together:meta-llama/Meta-Llama-3_dot_1-405B-Instruct-Turbo:kind${i}.units`; + + it('grows with the length of the path names, not just their count', () => { + const long = Array.from({ length: 10 }, (_, i) => longPath(i)); + const short = Array.from({ length: 10 }, (_, i) => `m${i}.units`); + expect(incrExpressionBytes(long)).toBeGreaterThan( + incrExpressionBytes(short), + ); + }); + + it('keeps every batch within the budget', () => { + const paths = Array.from({ length: 120 }, (_, i) => longPath(i)); + const batches = chunkPathsForIncr(paths); + + expect(batches.length).toBeGreaterThan(1); + for (const batch of batches) { + expect(incrExpressionBytes(batch)).toBeLessThanOrEqual( + INCR_EXPRESSION_BUDGET_BYTES, + ); + } + expect(batches.flat()).toEqual(paths); + }); + + it('leaves paths that already fit in a single batch', () => { + const paths = ['total', 'ai:chat.units', 'ai:chat.cost']; + expect(chunkPathsForIncr(paths)).toEqual([paths]); + }); + + it('still batches a path that cannot fit on its own', () => { + // A caller narrowing down a rejection needs the single-path attempt to + // happen rather than being handed nothing to try. + const enormous = `${'x'.repeat(INCR_EXPRESSION_BUDGET_BYTES)}.units`; + expect(chunkPathsForIncr([enormous, 'total'])).toEqual([ + [enormous], + ['total'], + ]); + }); + + it('makes no batches out of no paths', () => { + expect(chunkPathsForIncr([])).toEqual([]); + }); +}); + describe('SystemKVStore', () => { let server: PuterServer; let target: SystemKVStore; diff --git a/src/backend/stores/systemKv/SystemKVStore.ts b/src/backend/stores/systemKv/SystemKVStore.ts index ae8a3254a..9f15f1251 100644 --- a/src/backend/stores/systemKv/SystemKVStore.ts +++ b/src/backend/stores/systemKv/SystemKVStore.ts @@ -35,6 +35,17 @@ import { normalizeLimit, normalizeOffset, } from '../../util/pagination'; +import { + cacheTtlSecondsFor, + decodeCachedRead, + encodeCachedHit, + encodeCachedMiss, + kvCacheKey, + KV_CACHE_BLOCK_MARKER, + resolveKvCacheSettings, + type KvCachedItem, + type KvCacheSettings, +} from './readCache'; // -- Types ------------------------------------------------------------ @@ -42,6 +53,13 @@ import { export interface KVUsage { read: number; write: number; + /** + * Units for reads the cache answered, which consumed no capacity upstream: + * the number the equivalent uncached read did consume, so a caller pricing + * these has the same quantity to price against a different rate. Kept out + * of `read` precisely so it can be priced differently. + */ + cachedRead: number; } /** @@ -82,6 +100,9 @@ const PATH_CLEANER_REGEX = /[^A-Za-z0-9_]/g; // is bounded; cursors are the recommended way to page. const MAX_LIST_OFFSET = 5000; const MAX_FILL_PAGES = 10; +// Cache keys carried by one invalidation broadcast. A peer applies them in a +// single pass either way; the cap only keeps an individual message a sane size. +const KV_CACHE_BROADCAST_CHUNK = 500; /** * Marks an entry private to the app that wrote it. Beside `value`/`ttl`, so @@ -110,21 +131,30 @@ const listFilter = (now: number, crossApp: boolean) => { }; }; -const emptyUsage = (): KVUsage => ({ read: 0, write: 0 }); +const emptyUsage = (): KVUsage => ({ read: 0, write: 0, cachedRead: 0 }); const readUsage = (units: number | undefined): KVUsage => ({ read: Number(units ?? 0), write: 0, + cachedRead: 0, }); const writeUsage = (units: number | undefined): KVUsage => ({ read: 0, write: Number(units ?? 0), + cachedRead: 0, +}); + +const cachedReadUsage = (units: number): KVUsage => ({ + read: 0, + write: 0, + cachedRead: units, }); const addUsage = (a: KVUsage, b: KVUsage): KVUsage => ({ read: a.read + b.read, write: a.write + b.write, + cachedRead: a.cachedRead + b.cachedRead, }); const ensureActor = (opts?: KVOpts): Actor => opts?.actor ?? SYSTEM_ACTOR; @@ -274,6 +304,58 @@ const objectsEqual = (left: unknown, right: unknown): boolean => { const cleanAttrName = (chunk: string): string => `#${chunk.replaceAll(PATH_CLEANER_REGEX, '')}`; +/** The `SET` assignment `incr` renders for one path. */ +const incrSetStatement = (valPath: string, idx: number): string => { + const attrName = ['value', ...valPath.split('.')] + .filter(Boolean) + .map(cleanAttrName) + .join('.'); + return `${attrName} = if_not_exists(${attrName}, :start${idx}) + :incr${idx}`; +}; + +/** + * Ceiling a caller assembling its own batches should keep each one under. + * + * The store rejects an update expression past its own size limit, and that + * limit is on the rendered string — where every path appears twice, at its full + * length — so a count of paths says nothing about whether a write will be + * accepted. Sized below the limit so the optional TTL clause and any encoding + * variance still fit. + */ +export const INCR_EXPRESSION_BUDGET_BYTES = 3584; + +/** Size of the update expression `incr` would send for `paths`. */ +export const incrExpressionBytes = (paths: string[]): number => + Buffer.byteLength(`SET ${paths.map(incrSetStatement).join(', ')}`); + +/** + * Split paths into batches whose expressions each fit `maxBytes`, preserving + * order. A path always gets a batch even when it can't fit in one: a caller + * narrowing down which path a rejection belongs to needs the single-path + * attempt to happen rather than being told it is impossible. + */ +export const chunkPathsForIncr = ( + paths: string[], + maxBytes: number = INCR_EXPRESSION_BUDGET_BYTES, +): string[][] => { + const batches: string[][] = []; + let batch: string[] = []; + + for (const path of paths) { + const wouldOverflow = + batch.length > 0 && + incrExpressionBytes([...batch, path]) > maxBytes; + if (wouldOverflow) { + batches.push(batch); + batch = []; + } + batch.push(path); + } + if (batch.length > 0) batches.push(batch); + + return batches; +}; + // -- SystemKVStore ---------------------------------------------------- /** @@ -291,7 +373,13 @@ export class SystemKVStore extends PuterStore { private tableName = PUTER_KV_STORE_TABLE_NAME; private initialized: Promise | null = null; + #cache: KvCacheSettings = resolveKvCacheSettings(this.config); + #pendingInvalidations = new Set(); + #invalidationTimer: ReturnType | null = null; + override async onServerStart(): Promise { + if (this.#cache.enabled) this.#subscribeRemoteInvalidations(); + // For local/dynalite runs we need to create the table up front. // Real AWS deployments provision tables externally (Terraform), so // we skip — unless the operator explicitly opts in via @@ -307,6 +395,264 @@ export class SystemKVStore extends PuterStore { await this.initialized; } + override async onServerPrepareShutdown(): Promise { + if (this.#invalidationTimer) { + clearTimeout(this.#invalidationTimer); + this.#invalidationTimer = null; + } + // Peers would otherwise keep serving entries this node invalidated in + // the last coalescing window. + this.#flushInvalidationBroadcast(); + } + + // -- Read cache --------------------------------------------------- + + /** + * Whether reads in this namespace may be served from the cache. + * + * The system namespace is where internal state lives — metering counters, + * one-time codes, permission rows. Those callers read to decide something + * on the spot and can't be handed a value that was true a moment ago, so + * they always read through. It is also why nothing outside this store needs + * to opt in or out: the namespace already says which kind of data it is. + */ + #cacheable(namespace: string): boolean { + return this.#cache.enabled && namespace !== SYSTEM_NAMESPACE; + } + + /** + * Look `keys` up in the cache. `resolved` holds the keys the cache answered + * — a hit or a cached absence — and is what the caller subtracts from the + * set it still has to fetch. + */ + async #cacheRead( + namespace: string, + keys: string[], + ): Promise<{ + items: KvCachedItem[]; + resolved: Set; + readUnits: number; + }> { + const empty = { + items: [] as KvCachedItem[], + resolved: new Set(), + readUnits: 0, + }; + if (keys.length === 0) return empty; + + try { + const raw = + keys.length === 1 + ? [ + await this.clients.redis.get( + kvCacheKey(namespace, keys[0]), + ), + ] + : await this.#cachePipelineGet(namespace, keys); + + const items: KvCachedItem[] = []; + const resolved = new Set(); + let readUnits = 0; + const now = Date.now() / 1000; + + keys.forEach((key, index) => { + const cached = decodeCachedRead(raw[index], key); + if (cached.state === 'hit') { + // The entry carries its own deadline and the cache TTL is + // only an upper bound on it, so an entry that lapsed since + // it was written counts as nothing cached at all. + if (cached.item.ttl && cached.item.ttl <= now) return; + items.push(cached.item); + resolved.add(key); + readUnits += cached.readUnits; + return; + } + if (cached.state === 'miss') { + resolved.add(key); + readUnits += cached.readUnits; + } + }); + + return { items, resolved, readUnits }; + } catch (e) { + // A cache that is down degrades to no cache, never to an error. + console.warn( + '[kv] read cache lookup failed:', + (e as Error).message, + ); + return empty; + } + } + + /** + * Multi-key lookup as a pipeline rather than an `MGET`, so keys landing in + * different hash slots don't have to share one. + */ + async #cachePipelineGet( + namespace: string, + keys: string[], + ): Promise<(string | null)[]> { + const pipeline = this.clients.redis.pipeline(); + for (const key of keys) pipeline.get(kvCacheKey(namespace, key)); + const results = await pipeline.exec(); + return keys.map((_key, index) => { + const entry = results?.[index]; + if (!entry || entry[0]) return null; + return (entry[1] as string | null) ?? null; + }); + } + + /** + * Cache what a read just fetched. `keys` is what was asked of the + * underlying store, so a key with no entry in `items` is cached as a known + * absence. + */ + #cachePopulate(params: { + namespace: string; + keys: string[]; + items: KvCachedItem[]; + readUnitsPerKey: number; + startedAt: number; + }): void { + // A write that landed during this read left a block marker, and `NX` + // below is what keeps the pre-write value out — but only for as long as + // the marker lives. A read slower than that can no longer show its value + // is the current one, so it doesn't get to cache it. + if (Date.now() - params.startedAt > this.#cache.blockSeconds * 1000) { + return; + } + + const now = Date.now() / 1000; + const byKey = new Map(params.items.map((item) => [item.key, item])); + const writes: Array<{ key: string; payload: string; ttl: number }> = []; + + for (const key of params.keys) { + const item = byKey.get(key); + const ttl = item + ? cacheTtlSecondsFor(this.#cache, item.ttl, now) + : this.#cache.missTtlSeconds; + if (ttl === null) continue; + const payload = item + ? encodeCachedHit(item, params.readUnitsPerKey) + : encodeCachedMiss(params.readUnitsPerKey); + if ( + Buffer.byteLength(payload, 'utf8') > this.#cache.maxEntryBytes + ) { + continue; + } + writes.push({ + key: kvCacheKey(params.namespace, key), + payload, + ttl, + }); + } + if (writes.length === 0) return; + + // Deliberately not awaited: a read shouldn't wait on its own cache fill. + const pipeline = this.clients.redis.pipeline(); + for (const { key, payload, ttl } of writes) { + pipeline.set(key, payload, 'EX', ttl, 'NX'); + } + void Promise.resolve(pipeline.exec()).catch((e: unknown) => { + console.warn( + '[kv] read cache populate failed:', + (e as Error).message, + ); + }); + } + + /** + * Stop serving cached reads for `keys`, here and in every peer region. + * + * Awaited for the local part so a caller's own next read can't be answered + * from the cache it just made wrong; the broadcast is fire-and-forget. + */ + async #invalidate(namespace: string, keys: string[]): Promise { + if (!this.#cacheable(namespace) || keys.length === 0) return; + const cacheKeys = [...new Set(keys)].map((key) => + kvCacheKey(namespace, key), + ); + await this.publishCacheKeys({ + keys: cacheKeys, + serializedData: KV_CACHE_BLOCK_MARKER, + ttlSeconds: this.#cache.blockSeconds, + }); + this.#queueInvalidationBroadcast(cacheKeys); + } + + /** + * Accumulate invalidations and send them as one message. + * + * Every broadcast is serialized twice on the way out — once to dedupe it, + * once to sign it — and a per-write message would pay both plus its own + * envelope for a single cache key. Batching keeps a write-heavy namespace + * from turning the cache into a net cost. + */ + #queueInvalidationBroadcast(cacheKeys: string[]): void { + for (const key of cacheKeys) this.#pendingInvalidations.add(key); + + if (this.#cache.broadcastCoalesceMs === 0) { + this.#flushInvalidationBroadcast(); + return; + } + if (this.#invalidationTimer) return; + this.#invalidationTimer = setTimeout(() => { + this.#invalidationTimer = null; + this.#flushInvalidationBroadcast(); + }, this.#cache.broadcastCoalesceMs); + this.#invalidationTimer.unref?.(); + } + + #flushInvalidationBroadcast(): void { + if (this.#pendingInvalidations.size === 0) return; + const cacheKeys = [...this.#pendingInvalidations]; + this.#pendingInvalidations.clear(); + + for (let i = 0; i < cacheKeys.length; i += KV_CACHE_BROADCAST_CHUNK) { + this.clients.event.emit( + 'outer.kv.cacheInvalidated', + { + cacheKeys: cacheKeys.slice(i, i + KV_CACHE_BROADCAST_CHUNK), + }, + {}, + ); + } + } + + /** + * Apply invalidations a peer region sent us. + * + * A marker, not a delete, and for the local block window rather than + * anything the sender named: the entry reaches this region's copy of the + * underlying store on its own schedule, so the point is to keep reads going + * through until it has. + */ + #subscribeRemoteInvalidations(): void { + this.clients.event.on( + 'outer.kv.cacheInvalidated', + (_key, data, meta) => { + // Our own emit reaches local listeners too, and the local half + // of the invalidation already ran before it went out. + if (!(meta as { from_outside?: boolean })?.from_outside) return; + + const cacheKeys = + (data as { cacheKeys?: unknown })?.cacheKeys ?? []; + if (!Array.isArray(cacheKeys)) return; + const keys = cacheKeys.filter( + (key): key is string => + typeof key === 'string' && key !== '', + ); + if (keys.length === 0) return; + + void this.publishCacheKeys({ + keys, + serializedData: KV_CACHE_BLOCK_MARKER, + ttlSeconds: this.#cache.blockSeconds, + }); + }, + ); + } + // -- Public API --------------------------------------------------- /** @@ -349,9 +695,7 @@ export class SystemKVStore extends PuterStore { ): Promise { if (!isCrossApp(opts) || keys.length === 0) return emptyUsage(); const { entries, usage } = await this.getBatches(namespace, keys); - const isPrivate = (entries as Array<{ noShare?: boolean }>).some( - (entry) => entry?.noShare, - ); + const isPrivate = entries.some((entry) => entry?.noShare); if (isPrivate) { throw new HttpError( 403, @@ -377,33 +721,63 @@ export class SystemKVStore extends PuterStore { for (const k of keys) assertKey(k); - let kvEntries: Array<{ - key: string; - value?: unknown; - ttl?: number; - noShare?: boolean; - }> = []; + let kvEntries: KvCachedItem[] = []; let usage = emptyUsage(); - if (multi) { - const { entries, usage: u } = await this.getBatches( - namespace, - keys, - ); - kvEntries = entries; - usage = u; - } else { - const response = await this.clients.dynamo.get( - this.tableName, - { namespace, key }, - consistentRead, - ); - kvEntries = response.Item - ? [response.Item as (typeof kvEntries)[number]] - : []; - usage = readUsage( - response.ConsumedCapacity?.CapacityUnits as number | undefined, - ); + // A consistent read is asking for the source of truth by definition. + const useCache = !consistentRead && this.#cacheable(namespace); + // Deduped so a key repeated in a batch is looked up — and charged for — + // once, matching what `getBatches` already does. + const wanted = [...new Set(keys)]; + let toFetch = wanted; + + if (useCache) { + const cached = await this.#cacheRead(namespace, wanted); + kvEntries = cached.items; + usage = addUsage(usage, cachedReadUsage(cached.readUnits)); + toFetch = wanted.filter((k) => !cached.resolved.has(k)); + } + + if (toFetch.length > 0) { + const startedAt = Date.now(); + let fetched: KvCachedItem[]; + let fetchUnits: number; + + if (multi) { + const { entries, usage: u } = await this.getBatches( + namespace, + toFetch, + ); + fetched = entries; + fetchUnits = u.read; + } else { + const response = await this.clients.dynamo.get( + this.tableName, + { namespace, key: toFetch[0] }, + consistentRead, + ); + fetched = response.Item ? [response.Item as KvCachedItem] : []; + fetchUnits = Number( + (response.ConsumedCapacity?.CapacityUnits as + | number + | undefined) ?? 0, + ); + } + + kvEntries = kvEntries.concat(fetched); + usage = addUsage(usage, readUsage(fetchUnits)); + + if (useCache) { + // Capacity is reported per call, not per item, so a cached read + // replays the batch's average rather than an exact figure. + this.#cachePopulate({ + namespace, + keys: toFetch, + items: fetched, + readUnitsPerKey: fetchUnits / toFetch.length, + startedAt, + }); + } } const now = Date.now() / 1000; @@ -450,6 +824,7 @@ export class SystemKVStore extends PuterStore { ttl: expireAt, ...(disableSharing ? { [KV_PRIVATE_ATTR]: true } : {}), }); + await this.#invalidate(namespace, [key]); return { res: true, @@ -520,6 +895,7 @@ export class SystemKVStore extends PuterStore { })); const response = await this.clients.dynamo.batchPut(putParams); + await this.#invalidate(namespace, [...byKey.keys()]); const units = response.ConsumedCapacity?.reduce( (acc, curr) => acc + Number(curr.CapacityUnits ?? 0), @@ -544,6 +920,7 @@ export class SystemKVStore extends PuterStore { namespace, key, }); + await this.#invalidate(namespace, [key]); return { res: true, usage: addUsage( @@ -793,6 +1170,13 @@ export class SystemKVStore extends PuterStore { ); usage = addUsage(usage, writeUsage(deleteUnits)); + // Exactly the keys the query saw, which is also exactly what was + // deleted — anything a truncated query missed is still there to read. + await this.#invalidate( + namespace, + entries.map((entry) => String(entry.key)), + ); + return { res: true, usage }; } @@ -805,6 +1189,7 @@ export class SystemKVStore extends PuterStore { const namespace = getNamespace(actor, opts); const probeUsage = await this.#assertNotPrivate(namespace, key, opts); const usage = await this.rawExpireAt(namespace, key, Number(timestamp)); + await this.#invalidate(namespace, [key]); return { res: undefined, usage: addUsage(probeUsage, usage) }; } @@ -818,6 +1203,7 @@ export class SystemKVStore extends PuterStore { const probeUsage = await this.#assertNotPrivate(namespace, key, opts); const timestamp = Math.floor(Date.now() / 1000) + Number(ttl); const usage = await this.rawExpireAt(namespace, key, timestamp); + await this.#invalidate(namespace, [key]); return { res: undefined, usage: addUsage(probeUsage, usage) }; } @@ -852,14 +1238,8 @@ export class SystemKVStore extends PuterStore { const probeUsage = await this.#assertNotPrivate(namespace, key, opts); - const setStatements = Object.entries(pathAndAmountMap).map( - ([valPath, _amt], idx) => { - const attrName = ['value', ...valPath.split('.')] - .filter(Boolean) - .map(cleanAttrName) - .join('.'); - return `${attrName} = if_not_exists(${attrName}, :start${idx}) + :incr${idx}`; - }, + const setStatements = Object.keys(pathAndAmountMap).map( + (valPath, idx) => incrSetStatement(valPath, idx), ); const valueAttributeValues = Object.entries(pathAndAmountMap).reduce( (acc, [_path, amt], idx) => { @@ -934,6 +1314,7 @@ export class SystemKVStore extends PuterStore { ); response = await runUpdate(); } + await this.#invalidate(namespace, [key]); const usage = addUsage( probeUsage, @@ -1024,6 +1405,7 @@ export class SystemKVStore extends PuterStore { valueAttributeValues, { ...valueAttributeNames, '#value': 'value' }, ); + await this.#invalidate(namespace, [key]); const usage = addUsage( probeUsage, @@ -1084,6 +1466,7 @@ export class SystemKVStore extends PuterStore { undefined, { ...valueAttributeNames, '#value': 'value' }, ); + await this.#invalidate(namespace, [key]); return { res: response.Attributes?.value, usage: addUsage( @@ -1197,6 +1580,8 @@ export class SystemKVStore extends PuterStore { { ...valueAttributeNames, '#value': 'value' }, ); + await this.#invalidate(namespace, [key]); + const usage = addUsage( probeUsage, writeUsage( @@ -1214,7 +1599,7 @@ export class SystemKVStore extends PuterStore { namespace: string, allKeys: string[], ): Promise<{ - entries: Array<{ key: string; value?: unknown; ttl?: number }>; + entries: KvCachedItem[]; usage: KVUsage; }> { const batches: string[][] = []; @@ -1230,11 +1615,7 @@ export class SystemKVStore extends PuterStore { })); const response = await this.clients.dynamo.batchGet(requests); const entries = (response.Responses?.[this.tableName] ?? - []) as Array<{ - key: string; - value?: unknown; - ttl?: number; - }>; + []) as KvCachedItem[]; const units = response.ConsumedCapacity?.reduce( (acc, curr) => acc + Number(curr.CapacityUnits ?? 0), @@ -1251,11 +1632,7 @@ export class SystemKVStore extends PuterStore { return acc; }, { - entries: [] as Array<{ - key: string; - value?: unknown; - ttl?: number; - }>, + entries: [] as KvCachedItem[], usage: emptyUsage(), }, ); diff --git a/src/backend/stores/systemKv/readCache.test.ts b/src/backend/stores/systemKv/readCache.test.ts new file mode 100644 index 000000000..1db1347de --- /dev/null +++ b/src/backend/stores/systemKv/readCache.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import type { IConfig } from '../../types.ts'; +import { + cacheTtlSecondsFor, + decodeCachedRead, + encodeCachedHit, + encodeCachedMiss, + kvCacheKey, + KV_CACHE_BLOCK_MARKER, + resolveKvCacheSettings, +} from './readCache.ts'; + +const settings = (overrides: Partial = {}) => + resolveKvCacheSettings({ + kvCache: { enabled: true, ...overrides }, + } as IConfig); + +describe('kv readCache', () => { + describe('resolveKvCacheSettings', () => { + it('is off when no config is present', () => { + const resolved = resolveKvCacheSettings({} as IConfig); + expect(resolved.enabled).toBe(false); + }); + + it('is off unless `enabled` is exactly true', () => { + const resolved = resolveKvCacheSettings({ + kvCache: { enabled: 1 as unknown as boolean }, + } as IConfig); + expect(resolved.enabled).toBe(false); + }); + + it('falls back to defaults for nonsense values', () => { + const resolved = settings({ + ttlSeconds: -5, + missTtlSeconds: 0, + maxEntryBytes: Number.NaN, + }); + expect(resolved.ttlSeconds).toBe(60); + expect(resolved.missTtlSeconds).toBe(10); + expect(resolved.maxEntryBytes).toBe(32 * 1024); + }); + + it('keeps a zero coalescing window, which means broadcast immediately', () => { + expect( + settings({ broadcastCoalesceMs: 0 }).broadcastCoalesceMs, + ).toBe(0); + }); + }); + + describe('kvCacheKey', () => { + it('keeps the namespace readable and hashes the caller key', () => { + const key = kvCacheKey('v1:user-1:app-1', 'some/entry'); + expect(key.startsWith('kvc:v1:v1:user-1:app-1:')).toBe(true); + expect(key).not.toContain('some/entry'); + }); + + it('is stable for the same pair and distinct across keys', () => { + expect(kvCacheKey('ns', 'a')).toBe(kvCacheKey('ns', 'a')); + expect(kvCacheKey('ns', 'a')).not.toBe(kvCacheKey('ns', 'b')); + expect(kvCacheKey('ns', 'a')).not.toBe(kvCacheKey('other', 'a')); + }); + + it('stays short for a key at the KV size limit', () => { + const key = kvCacheKey('v1:user-1:app-1', 'k'.repeat(1024)); + expect(key.length).toBeLessThan(128); + }); + }); + + describe('envelopes', () => { + it('round-trips a value', () => { + const raw = encodeCachedHit( + { key: 'k', value: { nested: [1, 2] } }, + 0.5, + ); + expect(decodeCachedRead(raw, 'k')).toEqual({ + state: 'hit', + item: { key: 'k', value: { nested: [1, 2] } }, + readUnits: 0.5, + }); + }); + + it('round-trips the expiry and the private flag', () => { + const raw = encodeCachedHit( + { key: 'k', value: 'v', ttl: 1234, noShare: true }, + 1, + ); + expect(decodeCachedRead(raw, 'k')).toEqual({ + state: 'hit', + item: { key: 'k', value: 'v', ttl: 1234, noShare: true }, + readUnits: 1, + }); + }); + + it('keeps a stored null distinguishable from a cached absence', () => { + const hit = decodeCachedRead( + encodeCachedHit({ key: 'k', value: null }, 0.5), + 'k', + ); + const miss = decodeCachedRead(encodeCachedMiss(0.5), 'k'); + expect(hit).toMatchObject({ state: 'hit' }); + expect(miss).toMatchObject({ state: 'miss', readUnits: 0.5 }); + }); + + it('reads the block marker as blocked', () => { + expect(decodeCachedRead(KV_CACHE_BLOCK_MARKER, 'k')).toEqual({ + state: 'blocked', + }); + }); + + it('treats nothing cached, junk, and a foreign shape alike', () => { + for (const raw of [ + null, + undefined, + '', + 'not json', + '[]', + '{"z":1}', + ]) { + expect(decodeCachedRead(raw, 'k')).toEqual({ state: 'absent' }); + } + }); + }); + + describe('cacheTtlSecondsFor', () => { + const now = 1_000_000; + + it('uses the cache window for an entry with no expiry', () => { + expect(cacheTtlSecondsFor(settings(), undefined, now)).toBe(60); + }); + + it('never outlives the entry it caches', () => { + expect(cacheTtlSecondsFor(settings(), now + 5, now)).toBe(5); + }); + + it('keeps the cache window when the entry outlives it', () => { + expect(cacheTtlSecondsFor(settings(), now + 3600, now)).toBe(60); + }); + + it('refuses to cache an entry that has already lapsed', () => { + expect(cacheTtlSecondsFor(settings(), now - 1, now)).toBeNull(); + expect(cacheTtlSecondsFor(settings(), now, now)).toBeNull(); + }); + }); +}); diff --git a/src/backend/stores/systemKv/readCache.ts b/src/backend/stores/systemKv/readCache.ts new file mode 100644 index 000000000..481858d01 --- /dev/null +++ b/src/backend/stores/systemKv/readCache.ts @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createHash } from 'node:crypto'; +import type { IConfig } from '../../types'; + +/** + * Key/envelope format for the KV point-read cache. + * + * Bump the version segment when the envelope changes shape in a way an older + * reader would misparse — entries written under the previous prefix become + * unreachable and age out on their own expiry instead. + */ +const CACHE_KEY_PREFIX = 'kvc:v1'; + +/** An item as the KV store hands it back, which is what the cache round-trips. */ +export interface KvCachedItem { + key: string; + value?: unknown; + ttl?: number; + noShare?: boolean; +} + +/** + * What a cache lookup resolved to. + * + * `miss` is a cached _absence_ — a definitive "no such entry", as good an + * answer as a hit. `absent` means nothing is cached. `blocked` means a recent + * write left a marker: read through to the underlying store and don't + * populate. + */ +export type KvCachedRead = + | { state: 'hit'; item: KvCachedItem; readUnits: number } + | { state: 'miss'; readUnits: number } + | { state: 'blocked' } + | { state: 'absent' }; + +/** + * Wire envelope. Single-letter fields because every byte is multiplied by the + * number of cached entries: + * + * - `h` — 1 when the entry exists, 0 when it is known-absent + * - `v` — the stored value + * - `t` — the entry's own expiry, epoch seconds + * - `p` — 1 when the entry is private to the app that wrote it + * - `u` — read units the uncached read consumed, replayed for billing + * - `b` — 1 on a write block marker (no other field is set) + */ +interface KvCacheEnvelope { + h?: 0 | 1; + v?: unknown; + t?: number; + p?: 1; + u?: number; + b?: 1; +} + +/** + * Written over a key by every mutation, in place of deleting it. + * + * A delete leaves the key free for a read that started before the write to fill + * with the value it already fetched; a marker occupies the key, and populates + * are `NX`, so that read can't land. It also gives the peer regions something + * to hold across replication lag, where the invalidation arrives after the + * write. + */ +export const KV_CACHE_BLOCK_MARKER = JSON.stringify({ b: 1 }); + +export interface KvCacheSettings { + /** Off unless a config explicitly turns it on. */ + enabled: boolean; + /** How long a cached entry is served for. */ + ttlSeconds: number; + /** How long a cached absence is served for. */ + missTtlSeconds: number; + /** How long after a write that key's reads bypass the cache entirely. */ + blockSeconds: number; + /** Entries whose envelope exceeds this are read through, never cached. */ + maxEntryBytes: number; + /** + * How long invalidations accumulate before one cross-region broadcast + * carries them all. 0 broadcasts each one as it happens. + */ + broadcastCoalesceMs: number; +} + +const positive = (value: unknown, fallback: number, min = 1): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < min) return fallback; + return Math.floor(parsed); +}; + +export const resolveKvCacheSettings = (config: IConfig): KvCacheSettings => { + const cfg = config.kvCache ?? {}; + return { + enabled: cfg.enabled === true, + ttlSeconds: positive(cfg.ttlSeconds, 60), + missTtlSeconds: positive(cfg.missTtlSeconds, 10), + blockSeconds: positive(cfg.blockSeconds, 5), + maxEntryBytes: positive(cfg.maxEntryBytes, 32 * 1024), + broadcastCoalesceMs: positive(cfg.broadcastCoalesceMs, 250, 0), + }; +}; + +/** + * Cache key for one entry. The caller's key is hashed rather than embedded: KV + * keys run to 1KB of arbitrary bytes, and the digest keeps the cache key short, + * bounded, and free of characters that would need escaping. The namespace stays + * readable so an operator can tell whose entries they are looking at. + */ +export const kvCacheKey = (namespace: string, key: string): string => { + const digest = createHash('sha1').update(key, 'utf8').digest('base64url'); + return `${CACHE_KEY_PREFIX}:${namespace}:${digest}`; +}; + +export const encodeCachedHit = ( + item: KvCachedItem, + readUnits: number, +): string => { + const envelope: KvCacheEnvelope = { + h: 1, + v: item.value ?? null, + u: readUnits, + }; + if (item.ttl !== undefined) envelope.t = item.ttl; + if (item.noShare) envelope.p = 1; + return JSON.stringify(envelope); +}; + +export const encodeCachedMiss = (readUnits: number): string => + JSON.stringify({ h: 0, u: readUnits } satisfies KvCacheEnvelope); + +export const decodeCachedRead = ( + raw: string | null | undefined, + key: string, +): KvCachedRead => { + if (typeof raw !== 'string' || raw === '') return { state: 'absent' }; + + let envelope: KvCacheEnvelope; + try { + envelope = JSON.parse(raw) as KvCacheEnvelope; + } catch { + // Someone else's key, or an entry from a format we no longer read. + return { state: 'absent' }; + } + if (!envelope || typeof envelope !== 'object') return { state: 'absent' }; + if (envelope.b === 1) return { state: 'blocked' }; + + const readUnits = Number.isFinite(envelope.u) ? Number(envelope.u) : 0; + if (envelope.h === 0) return { state: 'miss', readUnits }; + if (envelope.h !== 1) return { state: 'absent' }; + + const item: KvCachedItem = { key, value: envelope.v ?? null }; + if (Number.isFinite(envelope.t)) item.ttl = Number(envelope.t); + if (envelope.p === 1) item.noShare = true; + return { state: 'hit', item, readUnits }; +}; + +/** + * Seconds to cache an entry for, or `null` when it shouldn't be cached at all. + * + * An entry with an expiry of its own never outlives it in the cache, so a `ttl` + * shorter than the cache window still takes effect on the second. + */ +export const cacheTtlSecondsFor = ( + settings: KvCacheSettings, + entryExpiresAt: number | undefined, + nowSeconds: number, +): number | null => { + if (entryExpiresAt === undefined) return settings.ttlSeconds; + const remaining = Math.floor(entryExpiresAt - nowSeconds); + if (remaining <= 0) return null; + return Math.min(settings.ttlSeconds, remaining); +}; diff --git a/src/backend/types.ts b/src/backend/types.ts index 5a4e2ceb2..518f1209c 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -69,6 +69,50 @@ export interface IRedisConfig { useMock?: boolean; } +/** + * Redis-backed read cache in front of the KV store's point reads (`get`, and + * the per-key half of a batch `get`). Off unless `enabled` is set. + * + * Only user/app namespaces are cached; internal state under the system + * namespace is always read through, as are consistent reads. + * + * Turning the cache off does not clear what it already holds. Entries stop + * being read but also stop being invalidated, so a disable followed by a + * re-enable inside `ttlSeconds` can serve values written in between — wait out + * `ttlSeconds` before switching back on. + */ +export interface IKvCacheConfig { + /** Master switch. Default false. */ + enabled?: boolean; + /** Seconds a cached value is served for. Default 60. */ + ttlSeconds?: number; + /** + * Seconds a cached absence is served for. Shorter than `ttlSeconds` because + * a key that doesn't exist yet is the one most likely to appear. Default + * 10. + */ + missTtlSeconds?: number; + /** + * Seconds after a write during which that key's reads bypass the cache. + * Must comfortably exceed how long a mutation takes to become visible to + * every reader, or a read that raced the write can re-cache the old value. + * Default 5. + */ + blockSeconds?: number; + /** + * Largest cached entry, in bytes of serialized envelope. Bigger values are + * read through — they earn the least per byte of cache memory. Default + * 32768. + */ + maxEntryBytes?: number; + /** + * Milliseconds invalidations accumulate for before one broadcast carries + * them all. Local invalidation is always immediate; this only batches the + * message to peers. 0 sends one per write. Default 250. + */ + broadcastCoalesceMs?: number; +} + /** * Alert severity. Ordered `info` < `warning` < `error` < `critical`; each alert * transport takes everything at or above its own `minSeverity`, so the severity @@ -800,6 +844,8 @@ interface IConfigOptional { dynamo: IDynamoConfig; redis: IRedisConfig; + /** Read cache in front of KV point reads. Off unless `enabled` is set. */ + kvCache: IKvCacheConfig; pager: IPagerConfig; email: IEmailConfig; /** Optional — only set when SMS phone verification (Prelude) is wired in. */ @@ -916,6 +962,32 @@ interface IConfigOptional { * unset or non-positive value turns the check off rather than guessing. */ maxGlobalUsagePerMinute?: number; + /** + * How long per-request usage (response bytes, object-store requests) is + * held in memory before being written, in milliseconds. This is the dial + * between how promptly that usage lands and how many writes it costs: every + * actor active in a window settles once per window, so halving it doubles + * the write rate. Unset uses the service default; a non-positive value is + * ignored. + */ + meteringUsageBufferFlushMs?: number; + /** + * Whether recorded usage is also enforced: an account with nothing left of + * its budget is turned away from the operations that spend it (file + * transfers, KV calls) with a 402. Metadata reads and deletions stay open, + * as does serving a hosted site — those bytes are billed to the account + * hosting it but requested by visitors who have no say in its balance. + * + * - `enabled` — defaults to on. The switch to reach for if enforcement is + * turning away traffic it shouldn't; usage is still recorded either way. + * - `workers` — extend enforcement to worker-driven calls. Off by default: a + * worker has no prompt to show and nobody watching, so being cut off + * presents as a program that started failing. + */ + meteringEnforcement?: { + enabled?: boolean; + workers?: boolean; + }; } /** diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index dddc39adc..14adc15c2 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -439,11 +439,15 @@ const ipc_listener = async (event, handled) => { // setItem //-------------------------------------------------------- else if ( event.data.msg === 'setItem' && event.data.key && event.data.value ) { + // The legacy protocol has no failure reply for these three, and the + // app-side promise only settles when a message with its id comes back + // — so a rejected call is answered anyway rather than left hanging + // forever. Logged here because that is the only place it is visible. puter.kv.set({ key: event.data.key, value: event.data.value, app_uid: app_uuid, - }).then(() => { + }).catch(err => console.warn('kv.setItem failed for app:', err)).then(() => { // send confirmation to requester window target_iframe.contentWindow.postMessage({ original_msg_id: msg_id, @@ -457,6 +461,9 @@ const ipc_listener = async (event, handled) => { puter.kv.get({ key: event.data.key, app_uid: app_uuid, + }).catch(err => { + console.warn('kv.getItem failed for app:', err); + return null; }).then((result) => { // send confirmation to requester window target_iframe.contentWindow.postMessage({ @@ -473,7 +480,7 @@ const ipc_listener = async (event, handled) => { puter.kv.del({ key: event.data.key, app_uid: app_uuid, - }).then(() => { + }).catch(err => console.warn('kv.removeItem failed for app:', err)).then(() => { // send confirmation to requester window target_iframe.contentWindow.postMessage({ original_msg_id: msg_id, diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 5c9733731..d3f615e0f 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -271,14 +271,16 @@ const TabFiles = { this.typeSearchTerm = ''; this.typeSearchTimeout = null; this.selectModeActive = false; - this.currentView = await puter.kv.get('view_mode') || 'list'; + // Preference reads are best-effort: the tab renders with the + // defaults rather than not rendering at all. + this.currentView = await puter.kv.get('view_mode').catch(() => null) || 'list'; // Sorting state - this.sortColumn = await puter.kv.get('sort_column') || 'name'; - this.sortDirection = await puter.kv.get('sort_direction') || 'asc'; + this.sortColumn = await puter.kv.get('sort_column').catch(() => null) || 'name'; + this.sortDirection = await puter.kv.get('sort_direction').catch(() => null) || 'asc'; // Column widths state (for resizing) - const savedWidths = await puter.kv.get('column_widths'); + const savedWidths = await puter.kv.get('column_widths').catch(() => null); this.columnWidths = savedWidths ? JSON.parse(savedWidths) : { name: null, // auto/flex size: 100, @@ -1567,7 +1569,8 @@ const TabFiles = { $(document).on('mouseup.colresize', function () { $(document).off('mousemove.colresize mouseup.colresize'); - puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)); + puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)) + .catch(err => console.warn('Could not save column_widths:', err)); }); }); @@ -1611,7 +1614,8 @@ const TabFiles = { // Apply the new width _this.columnWidths[column] = Math.ceil(maxWidth); _this.applyColumnWidths(); - puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)); + puter.kv.set('column_widths', JSON.stringify(_this.columnWidths)) + .catch(err => console.warn('Could not save column_widths:', err)); }); }, @@ -1902,8 +1906,10 @@ const TabFiles = { this.sortDirection = 'asc'; } - await puter.kv.set('sort_column', this.sortColumn); - await puter.kv.set('sort_direction', this.sortDirection); + await puter.kv.set('sort_column', this.sortColumn) + .catch(err => console.warn('Could not save sort_column:', err)); + await puter.kv.set('sort_direction', this.sortDirection) + .catch(err => console.warn('Could not save sort_direction:', err)); this.updateSortIndicators(); this.renderDirectory(this.currentPath); @@ -3275,7 +3281,8 @@ const TabFiles = { this.currentView = mode; this.applyViewMode(); - puter.kv.set('view_mode', mode); + puter.kv.set('view_mode', mode) + .catch(err => console.warn('Could not save view_mode:', err)); // Refresh content to update icons for the new view mode if ( this.currentPath ) { diff --git a/src/gui/src/UI/Dashboard/TabUsage.js b/src/gui/src/UI/Dashboard/TabUsage.js index 5afe8e7ac..c80de67be 100644 --- a/src/gui/src/UI/Dashboard/TabUsage.js +++ b/src/gui/src/UI/Dashboard/TabUsage.js @@ -316,7 +316,7 @@ async function update_usage_details ($el_window) { // Format units for display let formattedUnits; - if ( key.startsWith('filesystem:') && key.endsWith(':bytes') ) { + if ( key.endsWith(':bytes') ) { formattedUnits = window.byte_format(rawUnits); } else { formattedUnits = window.number_format(rawUnits, { decimals: 0, thousandSeparator: ',' }); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index bc06d21d7..a476bbb11 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -67,7 +67,10 @@ async function UIDesktop (options) { window.toolbar_auto_hide_enabled = true; // Set default value // Load the toolbar auto-hide preference - let toolbar_auto_hide_enabled_val = await puter.kv.get('toolbar_auto_hide_enabled'); + // Preferences are best-effort: a KV read that fails (offline, rate + // limited, no usage left on the account) leaves the default in place + // rather than stopping the desktop from rendering. + let toolbar_auto_hide_enabled_val = await puter.kv.get('toolbar_auto_hide_enabled').catch(() => null); if ( toolbar_auto_hide_enabled_val === 'false' || toolbar_auto_hide_enabled_val === false ) { window.toolbar_auto_hide_enabled = false; } @@ -110,9 +113,11 @@ async function UIDesktop (options) { } // Set flag to true - puter.kv.set('has_set_default_app_user_permissions', true); + await puter.kv.set('has_set_default_app_user_permissions', true); } - }); + // Awaited above so a failed write reaches this: it leaves the flag unset + // and the whole thing is retried next boot, which is what we want. + }).catch(err => console.warn('Could not apply default app permissions:', err)); // connect socket. window.socket = io(`${window.gui_origin }/`, { auth: { @@ -655,7 +660,7 @@ async function UIDesktop (options) { h += ''; // Get window sidebar width - puter.kv.get('window_sidebar_width').then(async (val) => { + puter.kv.get('window_sidebar_width').catch(() => null).then(async (val) => { let value = parseInt(val); // if value is a valid number if ( !isNaN(value) && value > 0 ) { @@ -666,7 +671,9 @@ async function UIDesktop (options) { // load window sidebar items from KV puter.kv.get('sidebar_items').then(async (val) => { window.sidebar_items = val; - }); + // Catch on the chain rather than the read, so a failure leaves whatever is + // already there instead of overwriting it with a fallback. + }).catch(err => console.warn('Could not load sidebar_items:', err)); // Remove `?ref=...` from navbar URL, keeping the current path if ( window.url_query_params.has('ref') ) { @@ -697,7 +704,7 @@ async function UIDesktop (options) { // update default apps { - const entries = await puter.kv.list('user_preferences.default_apps.*', true); + const entries = await puter.kv.list('user_preferences.default_apps.*', true).catch(() => []); for ( const entry of entries ) { user_preferences[entry.key.substring(17)] = entry.value; } @@ -719,7 +726,7 @@ async function UIDesktop (options) { if ( window.desktop_icons_hidden ) { hideDesktopIcons(); } - }); + }).catch(err => console.warn('Could not load desktop_icons_hidden:', err)); // --------------------------------------------------------------- // Taskbar @@ -1063,11 +1070,14 @@ async function UIDesktop (options) { if ( !window.url_paths[0]?.toLocaleLowerCase() === 'app' || !window.url_paths[1] ) { if ( !isMobile.phone && !isMobile.tablet ) { setTimeout(() => { + // The catch goes on the end of the chain, not on the read: + // falling back to null would read as "never seen it" and + // show the window to someone who has. puter.kv.get('has_seen_welcome_window').then(async (val) => { if ( val === null ) { await UIWindowWelcome(); } - }); + }).catch(err => console.warn('Could not check has_seen_welcome_window:', err)); }, 1000); } } @@ -1157,7 +1167,7 @@ async function UIDesktop (options) { // Toolbar // ---------------------------------------------------- // Has user seen the toolbar animation? - window.has_seen_toolbar_animation = await puter.kv.get('has_seen_toolbar_animation') ?? false; + window.has_seen_toolbar_animation = await puter.kv.get('has_seen_toolbar_animation').catch(() => null) ?? false; let ht = ''; let style = ''; @@ -1391,7 +1401,7 @@ async function UIDesktop (options) { puter.kv.set({ key: 'has_seen_toolbar_animation', value: true, - }); + }).catch(err => console.warn('Could not save has_seen_toolbar_animation:', err)); window.has_seen_toolbar_animation = true; } @@ -1991,7 +2001,8 @@ $(document).on('contextmenu taphold', '.toolbar', function (event) { window.toolbar_auto_hide_enabled = !window.toolbar_auto_hide_enabled; // Save the preference - puter.kv.set('toolbar_auto_hide_enabled', window.toolbar_auto_hide_enabled.toString()); + puter.kv.set('toolbar_auto_hide_enabled', window.toolbar_auto_hide_enabled.toString()) + .catch(err => console.warn('Could not save toolbar_auto_hide_enabled:', err)); // If auto-hide was just disabled and toolbar is currently hidden, show it if ( !window.toolbar_auto_hide_enabled && $('.toolbar').hasClass('toolbar-hidden') ) { @@ -2469,7 +2480,8 @@ window.toggleDesktopIcons = function () { } // Save preference - puter.kv.set('desktop_icons_hidden', window.desktop_icons_hidden.toString()); + puter.kv.set('desktop_icons_hidden', window.desktop_icons_hidden.toString()) + .catch(err => console.warn('Could not save desktop_icons_hidden:', err)); }; $(document).on('click', '.btn-show-ai', function () { diff --git a/src/gui/src/UI/UITaskbar.js b/src/gui/src/UI/UITaskbar.js index 78d957ffe..2c5bcb615 100644 --- a/src/gui/src/UI/UITaskbar.js +++ b/src/gui/src/UI/UITaskbar.js @@ -32,15 +32,15 @@ async function UITaskbar (options) { // if first visit ever, set taskbar position to left if ( window.first_visit_ever ) { - puter.kv.set('taskbar_position', 'left'); + puter.kv.set('taskbar_position', 'left').catch(err => console.warn('Could not save taskbar_position:', err)); taskbar_position = 'left'; } else { - taskbar_position = await puter.kv.get('taskbar_position'); + taskbar_position = await puter.kv.get('taskbar_position').catch(() => null); // if this is not first visit, set taskbar position to bottom since it's from a user that // used puter before customizing taskbar position was added and the taskbar position was set to bottom if ( ! taskbar_position ) { taskbar_position = 'bottom'; // default position - puter.kv.set('taskbar_position', taskbar_position); + puter.kv.set('taskbar_position', taskbar_position).catch(err => console.warn('Could not save taskbar_position:', err)); } } @@ -500,7 +500,7 @@ window.update_taskbar_position = async function (new_position) { } // Store the new position - puter.kv.set('taskbar_position', new_position); + puter.kv.set('taskbar_position', new_position).catch(err => console.warn('Could not save taskbar_position:', err)); window.taskbar_position = new_position; // Remove old position classes and add new one diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index c0359b4ec..66c0550d2 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -2214,7 +2214,8 @@ async function UIWindow (options) { $('.window').css('pointer-events', 'initial'); const new_width = $(el_window_sidebar).width(); // save new width in the cloud, to user's settings - puter.kv.set({ key: 'window_sidebar_width', value: new_width }); + puter.kv.set({ key: 'window_sidebar_width', value: new_width }) + .catch(err => console.warn('Could not save window_sidebar_width:', err)); // save new width locally, to window object window.window_sidebar_width = new_width; window.a_window_sidebar_is_resizing = false; diff --git a/src/gui/src/UI/UIWindowWelcome.js b/src/gui/src/UI/UIWindowWelcome.js index 8c849c9b8..df00f890c 100644 --- a/src/gui/src/UI/UIWindowWelcome.js +++ b/src/gui/src/UI/UIWindowWelcome.js @@ -71,7 +71,7 @@ async function UIWindowWelcome (options) { window_class: 'window-welcome', on_close: function () { // save the fact that the user has seen the welcome window - puter.kv.set('has_seen_welcome_window', true); + puter.kv.set('has_seen_welcome_window', true).catch(err => console.warn('Could not save has_seen_welcome_window:', err)); }, body_css: { width: 'initial', diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index c3e18226e..4f64a4fc7 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -704,7 +704,8 @@ window.update_auth_data = async (auth_token, user) => { window.mutate_user_preferences = function (user_preferences_delta) { for ( const [key, value] of Object.entries(user_preferences_delta) ) { // Don't wait for set to be done for better efficiency - puter.kv.set(`user_preferences.${key}`, value); + puter.kv.set(`user_preferences.${key}`, value) + .catch(err => console.warn(`Could not save user_preferences.${key}:`, err)); } // There may be syncing issues across multiple devices window.update_user_preferences({ ...window.user_preferences, ...user_preferences_delta }); @@ -986,6 +987,8 @@ window.sendItemChangeEventToWatchingApps = function (item_uid, event_data) { */ window.show_save_account_notice_if_needed = function (message) { + // A failed read must not read as "not shown yet" — that would repeat the + // notice on every save — so the catch goes on the end of the chain. puter.kv.get({ key: 'save_account_notice_shown', }).then(async function (value) { @@ -993,7 +996,7 @@ window.show_save_account_notice_if_needed = function (message) { puter.kv.set({ key: 'save_account_notice_shown', value: true, - }); + }).catch(err => console.warn('Could not save save_account_notice_shown:', err)); // Show the notice setTimeout(async () => { const alert_resp = await UIAlert({ @@ -1044,7 +1047,7 @@ window.show_save_account_notice_if_needed = function (message) { } }, window.desktop_loading_fade_delay + 1000); } - }); + }).catch(err => console.warn('Could not check save_account_notice_shown:', err)); }; window.sort_items = (item_container, sort_by, sort_order) => { @@ -3347,14 +3350,18 @@ window.undo_delete = async (items) => { }; window.store_auto_arrange_preference = (preference) => { - puter.kv.set('user_preferences.auto_arrange_desktop', preference); + // localStorage still carries it for this device either way. + puter.kv.set('user_preferences.auto_arrange_desktop', preference) + .catch(err => console.warn('Could not save auto_arrange_desktop:', err)); localStorage.setItem('auto_arrange', preference); }; window.get_auto_arrange_data = async () => { - const preferenceValue = await puter.kv.get('user_preferences.auto_arrange_desktop'); + // Best-effort, like every other preference read: falling back to the + // default keeps the desktop arranging itself rather than not appearing. + const preferenceValue = await puter.kv.get('user_preferences.auto_arrange_desktop').catch(() => null); window.is_auto_arrange_enabled = preferenceValue === null ? true : preferenceValue; - const positions = await puter.kv.get('desktop_item_positions'); + const positions = await puter.kv.get('desktop_item_positions').catch(() => null); window.desktop_item_positions = (!positions || typeof positions !== 'object' || Array.isArray(positions)) ? {} : positions; }; @@ -3383,12 +3390,14 @@ window.set_desktop_item_positions = async (el_desktop) => { }; window.save_desktop_item_positions = () => { - puter.kv.set('desktop_item_positions', window.desktop_item_positions); + puter.kv.set('desktop_item_positions', window.desktop_item_positions) + .catch(err => console.warn('Could not save desktop_item_positions:', err)); }; window.delete_desktop_item_positions = () => { window.desktop_item_positions = {}; - puter.kv.del('desktop_item_positions'); + puter.kv.del('desktop_item_positions') + .catch(err => console.warn('Could not clear desktop_item_positions:', err)); }; // Finds the `.window` element for the given app instance ID diff --git a/src/puter-js/.gitignore b/src/puter-js/.gitignore index 45ff2d1ba..c445bcefe 100644 --- a/src/puter-js/.gitignore +++ b/src/puter-js/.gitignore @@ -138,4 +138,8 @@ playwright-report/ # config file src/config.js ssl -ssl/ \ No newline at end of file +ssl/ + +# Generated from the JSDoc in src/ by `npm run build:types`; shipped in the +# published tarball (see `files`), never committed. +/types/ diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index d88c4d2dd..504e7726e 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -1,22 +1,13 @@ -import type { Puter } from './types/puter.d.ts'; -import type { AI, ChatMessage, ChatOptions, ChatResponse, ChatResponseChunk, Img2TxtOptions, Speech2SpeechOptions, Speech2TxtOptions, Txt2ImgOptions, Txt2SpeechCallable, Txt2SpeechOptions, Txt2VidOptions } from './types/modules/ai.d.ts'; -import type { Apps, AppListOptions, AppRecord, CreateAppOptions, UpdateAppAttributes } from './types/modules/apps.d.ts'; -import type { Auth, APIUsage, AllowanceInfo, AppUsage, AuthUser, DetailedAppUsage, MonthlyUsage } from './types/modules/auth.d.ts'; -import type { Debug } from './types/modules/debug.d.ts'; -import type { Driver, Drivers } from './types/modules/drivers.d.ts'; -import type { Email, EmailAttachment, EmailSendOptions, EmailSendResult } from './types/modules/email.d.ts'; -import type { FS, CopyOptions, DeleteOptions, MkdirOptions, MoveOptions, ReadOptions, ReaddirOptions, SignResult, SpaceInfo, UploadOptions, WriteOptions } from './types/modules/filesystem.d.ts'; -import type { FSItem, FileSignatureInfo, InternalFSProperties } from './types/modules/fs-item.d.ts'; -import type { Hosting, Subdomain } from './types/modules/hosting.d.ts'; -import type { KV, KVIncrementPath, KVPair } from './types/modules/kv.d.ts'; -import type { Networking, PSocket, PTLSSocket } from './types/modules/networking.d.ts'; -import type { OS } from './types/modules/os.d.ts'; -import type { Perms } from './types/modules/perms.d.ts'; -import type Peer, { PuterPeerConnection, PuterPeerServer } from './types/modules/peer.d.ts'; -import type { AlertButton, AppConnection, AppConnectionCloseEvent, CancelAwarePromise, ContextMenuItem, ContextMenuOptions, DirectoryPickerOptions, FilePickerOptions, LaunchAppOptions, MenuItem, MenubarOptions, ThemeData, UI, WindowOptions } from './types/modules/ui.d.ts'; -import type Util, { UtilRPC } from './types/modules/util.d.ts'; -import type { WorkerDeployment, WorkerInfo, WorkersHandler } from './types/modules/workers.d.ts'; -import type { APICallLogger, APILoggingConfig, PaginationOptions, PaginatedResult, PuterEnvironment, RequestCallbacks, ToolSchema } from './types/shared.d.ts'; +// The published type surface of puter.js. +// +// This is the only hand-written declaration file in the package: it names what +// the SDK exports and nothing more. Every type it re-exports is generated from +// the JSDoc in `src/` by `npm run build:types` — edit the JSDoc, not `types/`. + +import type { Puter } from './types/index.js'; + +export type { Puter }; +export { puter, default } from './types/index.js'; declare global { interface Window { @@ -24,99 +15,208 @@ declare global { } } -declare const puter: Puter; - -export default puter; -export { puter }; - +// -- Shared -- export type { - AI, - APIUsage, - APICallLogger, APILoggingConfig, - AlertButton, - AllowanceInfo, - CancelAwarePromise, - AppConnection, - AppConnectionCloseEvent, - AppListOptions, - AppRecord, - AppUsage, - Apps, - Auth, - AuthUser, + ListPage, + ListPaginationOptions, + ListStreamOptions, + PaginatedResult, + PaginationOptions, + PuterEnvironment, + RequestCallbacks, + ToolSchema, +} from './types/lib/types.js'; +export type { default as APICallLogger } from './types/lib/APICallLogger.js'; + +// -- puter.ai -- +export type { + AIMessageContent, ChatMessage, ChatOptions, ChatResponse, ChatResponseChunk, - ContextMenuItem, - ContextMenuOptions, - CopyOptions, + ImageContent, + Img2TxtOptions, + ListTTSEnginesOptions, + ListTTSVoicesOptions, + Speech2SpeechOptions, + Speech2TxtOptions, + Speech2TxtResult, + Speech2TxtWord, + StreamingChatOptions, + TextFormatSpeech2TxtOptions, + Tool, + ToolCall, + TTSEngine, + TTSVoice, + Txt2ImgOptions, + Txt2SpeechOptions, + Txt2VidOptions, +} from './types/modules/ai/types.js'; +export type { Txt2Speech } from './types/modules/ai/index.js'; + +// -- puter.apps -- +export type { + App, + AppListOptions, + AppUser, + CheckAppNameResult, CreateAppOptions, - Debug, - DeleteOptions, + CreateAppResult, + GetUsersOptions, + UpdateAppAttributes, +} from './types/modules/apps/types.js'; + +// -- puter.auth -- +export type { + AllowanceInfo, + APIUsage, + AppUsage, DetailedAppUsage, - DirectoryPickerOptions, - Driver, - Drivers, - Email, + MonthlyUsage, + SignInResult, + User, +} from './types/modules/Auth.js'; + +// -- puter.debug -- +export type { Debug } from './types/modules/Debug.js'; + +// -- puter.drivers -- +export type { Driver } from './types/modules/Drivers.js'; + +// -- puter.email -- +export type { EmailAttachment, EmailSendOptions, EmailSendResult, - FSItem, - FilePickerOptions, - FileSignatureInfo, - Hosting, - Img2TxtOptions, - InternalFSProperties, - KV, - KVIncrementPath, - KVPair, - LaunchAppOptions, - MenuItem, - MenubarOptions, +} from './types/modules/Email.js'; + +// -- puter.fs -- +export type { + CopyOptions, + DeleteOptions, MkdirOptions, - MonthlyUsage, MoveOptions, - Networking, - OS, - PaginatedResult, - PaginationOptions, - Peer, - Perms, - PSocket, - PuterPeerConnection, - PuterPeerServer, - PTLSSocket, - Puter, - PuterEnvironment, - FS, ReadOptions, ReaddirOptions, - RequestCallbacks, + RenameOptions, SignResult, SpaceInfo, - Speech2SpeechOptions, - Speech2TxtOptions, - Subdomain, - ThemeData, - ToolSchema, - Txt2ImgOptions, - Txt2SpeechCallable, - Txt2SpeechOptions, - Txt2VidOptions, - UI, - UpdateAppAttributes, + StatOptions, + UploadBatchError, + UploadItems, + UploadOperationResult, UploadOptions, - Util, - UtilRPC, + WriteOptions, +} from './types/modules/FileSystem/types.js'; +export type { + FileSignatureInfo, + FSItem, + InternalFSProperties, +} from './types/modules/FSItem.js'; + +// -- puter.hosting -- +export type { Subdomain } from './types/modules/hosting/types.js'; + +// -- puter.kv -- +export type { + KVAddPath, + KVIncrementPath, + KVListOptions, + KVListPage, + KVListPaginationOptions, + KVListStreamOptions, + KVOptConfig, + KVPair, + KVScalar, + KVSetBatch, + KVSetItem, + KVSetObject, + KVUpdateObject, + KVUpdatePath, + KVValue, +} from './types/modules/kv/types.js'; + +// -- puter.net -- +export type { Networking, SocketEvent } from './types/modules/networking/types.js'; +export type { PSocket } from './types/modules/networking/PSocket.js'; +export type { PTLSSocket } from './types/modules/networking/PTLS.js'; + +// -- puter.os -- + +// -- puter.peer -- +export type { + PuterPeerConnection, + PuterPeerDescription, + PuterPeerIceCandidate, + PuterPeerMessage, + PuterPeerOptions, + PuterPeerServer, + PuterPeerUser, +} from './types/modules/Peer.js'; + +// -- puter.perms -- +export type { + AppDataClass, + AppDataFsScope, + AppDataKvScope, + AppDataScopePair, + AppDataScopes, + AppDataStore, +} from './types/modules/perms/types.js'; + +// -- puter.ui -- +export type { AppConnection } from './types/modules/UI.js'; +export type { + AlertButton, + AlertOptions, + AppConnectionCloseEvent, + CancelAwarePromise, + ColorPickerOptions, + ConnectionEvent, + ContextMenuItem, + ContextMenuOptions, + DirectoryPickerOptions, + FilePickerOptions, + FontPickerOptions, + LaunchAppOptions, + LaunchAppResult, + MenuItem, + MenubarOptions, + NotificationOptions, + PromptOptions, + ThemeData, + WindowHandle, + WindowIdentifier, WindowOptions, +} from './types/modules/UI.js'; + +// -- puter.util -- +export type { default as Util, UtilRPC } from './types/modules/Util.js'; + +// -- puter.workers -- +export type { WorkerDeployment, WorkerInfo, - WorkersHandler, - WriteOptions, - Puter -}; +} from './types/modules/Workers.js'; -// NOTE: Provider-specific response bodies (AI, drivers, workers logging stream) intentionally -// remain loosely typed because the SDK does not yet expose stable shapes for those payloads. +// -- Module instance types -- +// +// Each `puter.` handle. Named here rather than re-exported, because the +// generated modules export a constructor value plus its constructor type, and +// what a consumer annotates with is the instance. + +export type AI = InstanceType; +export type Apps = InstanceType; +export type Auth = InstanceType; +export type Drivers = InstanceType; +export type Email = InstanceType; +export type FS = InstanceType; +export type Hosting = InstanceType; +export type KV = InstanceType; +export type OS = InstanceType; +export type Peer = InstanceType; +export type Perms = InstanceType; +export type UI = InstanceType; +export type WorkersHandler = InstanceType; diff --git a/src/puter-js/package.json b/src/puter-js/package.json index 7866ca994..833e5f800 100644 --- a/src/puter-js/package.json +++ b/src/puter-js/package.json @@ -37,7 +37,8 @@ "test:e2e:record": "PUTER_TEST_RECORD=1 playwright test", "test:e2e:report": "playwright show-report", "playwright:install": "playwright install chromium", - "build": "webpack && { echo \"// Copyright 2024-present Puter Technologies Inc. All rights reserved.\"; echo \"// Generated on $(date '+%Y-%m-%d %H:%M')\n\"; cat ./dist/puter.js; } > temp && mv temp ./dist/puter.js", + "build": "npm run build:types && webpack && { echo \"// Copyright 2024-present Puter Technologies Inc. All rights reserved.\"; echo \"// Generated on $(date '+%Y-%m-%d %H:%M')\n\"; cat ./dist/puter.js; } > temp && mv temp ./dist/puter.js", + "build:types": "tsc -p tsconfig.types.json", "build:coverage": "webpack --env coverage", "prepublishOnly": "npm run build && mv dist/puter.js dist/puter.cjs && npm version patch" }, diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index dcf626cae..423ebade9 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -105,1631 +105,1633 @@ const STORAGE_KEY_V1 = 'puter.auth.token'; // can't harvest a previously-stored token and forward it to a foreign origin. const STORAGE_KEY_ORIGIN_V2 = 'puter.auth.token.origin.v2'; -const puterInit = function () { - 'use strict'; +export class Puter { + /** + * The environment that the SDK is running in. + * + * `gui` means the SDK is running in the Puter GUI, i.e. Puter.com. + * `app` means it is running as a Puter app, i.e. within an iframe in + * the Puter GUI. `web` means it is running in a 3rd-party website. + * + * @type {import('./lib/types.js').PuterEnvironment} + */ + env; - class Puter { - /** - * The environment that the SDK is running in. - * - * `gui` means the SDK is running in the Puter GUI, i.e. Puter.com. - * `app` means it is running as a Puter app, i.e. within an iframe in - * the Puter GUI. `web` means it is running in a 3rd-party website. - * - * @type {import('../types/shared').PuterEnvironment} - */ - env; + /** + * Arguments the host environment launched this app with. + * + * @type {Record} + */ + args = {}; - /** - * Arguments the host environment launched this app with. - * - * @type {Record} - */ - args = {}; + /** + * The token the SDK authenticates API calls with, if any. + * + * @type {string | null} + */ + authToken = null; - /** - * The token the SDK authenticates API calls with, if any. - * - * @type {string | null} - */ - authToken = null; + /** + * Origin every API call is sent to. + * + * @type {string} + */ + APIOrigin; - /** - * Origin every API call is sent to. - * - * @type {string} - */ - APIOrigin; + /** + * Tool schemas this app exposes to `puter.ai`. + * + * @type {import('./lib/types.js').ToolSchema[]} + */ + tools = []; - /** - * Tool schemas this app exposes to `puter.ai`. - * - * @type {import('../types/shared').ToolSchema[]} - */ - tools = []; + // The modules, declared here rather than left to inference because + // `initSubmodules` assigns them outside the constructor, which would + // otherwise make every one of them possibly-undefined for consumers. + // Each type comes from the module's own public export, so the + // implementation stays the source of truth. - // The modules, declared here rather than left to inference because - // `initSubmodules` assigns them outside the constructor, which would - // otherwise make every one of them possibly-undefined for consumers. - // Each type comes from the module's own public export, so the - // implementation stays the source of truth. + /** @type {InstanceType} */ + util; + /** @type {InstanceType} */ + auth; + /** @type {InstanceType} */ + os; + /** @type {InstanceType} */ + fs; + /** @type {InstanceType} */ + ui; + /** @type {InstanceType} */ + hosting; + /** @type {InstanceType} */ + apps; + /** @type {InstanceType} */ + ai; + /** @type {InstanceType} */ + kv; + /** @type {InstanceType} */ + email; + /** @type {InstanceType} */ + perms; + /** @type {InstanceType} */ + drivers; + /** @type {InstanceType} */ + debug; + /** @type {InstanceType} */ + peer; + /** @type {InstanceType} */ + workers; + /** + * The `path-browserify` helpers, re-exposed so apps can build Puter paths + * without pulling in their own copy. Spelled out rather than taken from + * the package, which ships no types of its own. + * + * @type {{ + * join: (...parts: string[]) => string, + * dirname: (p: string) => string, + * basename: (p: string) => string, + * normalize?: (p: string) => string, + * [key: string]: unknown, + * }} + */ + path; - /** @type {InstanceType} */ - util; - /** @type {InstanceType} */ - auth; - /** @type {InstanceType} */ - os; - /** @type {InstanceType} */ - fs; - /** @type {InstanceType} */ - ui; - /** @type {InstanceType} */ - hosting; - /** @type {InstanceType} */ - apps; - /** @type {InstanceType} */ - ai; - /** @type {InstanceType} */ - kv; - /** @type {InstanceType} */ - email; - /** @type {InstanceType} */ - perms; - /** @type {InstanceType} */ - drivers; - /** @type {InstanceType} */ - debug; - /** @type {InstanceType} */ - peer; - /** @type {InstanceType} */ - workers; - /** @type {typeof path} */ - path; + #defaultAPIOrigin = 'https://api.puter.com'; + #defaultGUIOrigin = 'https://puter.com'; - #defaultAPIOrigin = 'https://api.puter.com'; - #defaultGUIOrigin = 'https://puter.com'; + /** @returns {string} */ + get defaultAPIOrigin() { + return ( + globalThis.PUTER_API_ORIGIN || + globalThis.PUTER_API_ORIGIN_ENV || + this.#defaultAPIOrigin + ); + } + set defaultAPIOrigin(v) { + this.#defaultAPIOrigin = v; + } - /** @returns {string} */ - get defaultAPIOrigin() { - return ( - globalThis.PUTER_API_ORIGIN || - globalThis.PUTER_API_ORIGIN_ENV || - this.#defaultAPIOrigin - ); + /** @returns {string} */ + get defaultGUIOrigin() { + return ( + globalThis.PUTER_ORIGIN || + globalThis.PUTER_ORIGIN_ENV || + this.#defaultGUIOrigin + ); + } + set defaultGUIOrigin(v) { + this.#defaultGUIOrigin = v; + } + + /** + * Called once the user is authenticated. Set by the app using the SDK. + * + * @type {((user: Record) => void) | undefined} + */ + onAuth; + + /** + * State object to keep track of the authentication request status. This + * is used to prevent multiple authentication popups from showing up by + * different parts of the app. + */ + puterAuthState = { + isPromptOpen: false, + authGranted: null, + resolver: null, + }; + + // Holds the unique app instance ID that is provided by the host environment + appInstanceID; + + // Holds the unique app instance ID for the parent (if any), which is provided by the host environment + parentInstanceID; + + // Expose the FSItem class + static FSItem = FSItem; + + // Event handling properties + eventHandlers = {}; + + // Reauth coordinator state. When the backend signals + // `401 { code: 'reauth_required' }`, in-flight requests await this + // promise; the first caller drives the interactive flow, everyone + // else replays after it resolves. + _reauthInflight = null; + + // Subscribers to token / API origin changes. Modules read both live + // off this instance, so this is only for the few that hold an open + // connection and have to rebuild it. + _authStateListeners = new Set(); + + // debug flag + debugMode = false; + + // Whether to suppress the developer CTA in the console + quiet = false; + + /** + * Puter.js Modules + * + * These are the modules you see on docs.puter.com; for example: + * + * - Puter.fs + * - Puter.kv + * - Puter.ui + * + * InitSubmodules is called from the constructor of this class. + */ + initSubmodules() { + // Util + this.util = new Util(); + + this.auth = this.registerModule('auth', Auth); + this.os = this.registerModule('os', OS); + this.fs = this.registerModule('fs', PuterJSFileSystemModule); + this.ui = this.registerModule('ui', UI, { + appInstanceID: this.appInstanceID, + parentInstanceID: this.parentInstanceID, + }); + this.hosting = this.registerModule('hosting', Hosting); + this.apps = this.registerModule('apps', Apps); + this.ai = this.registerModule('ai', AI); + this.kv = this.registerModule('kv', KV); + this.email = this.registerModule('email', Email); + this.perms = this.registerModule('perms', Perms); + this.drivers = this.registerModule('drivers', Drivers); + this.debug = this.registerModule('debug', Debug); + this.peer = this.registerModule('peer', Peer); + this.workers = this.registerModule('workers', WorkersHandler); + + // Path + this.path = path; + + // Register web components for standalone UI fallback + registerComponents(); + } + + normalizeAuthTokenCandidate = function (tokenCandidate) { + if (typeof tokenCandidate !== 'string') return null; + const trimmedTokenCandidate = tokenCandidate.trim(); + if ( + !trimmedTokenCandidate || + trimmedTokenCandidate === 'null' || + trimmedTokenCandidate === 'undefined' + ) { + return null; } - set defaultAPIOrigin(v) { - this.#defaultAPIOrigin = v; + return trimmedTokenCandidate; + }; + + decodeJwtPayload = function (tokenCandidate) { + if (typeof tokenCandidate !== 'string') return null; + const tokenParts = tokenCandidate.split('.'); + if (tokenParts.length < 2) return null; + + let payloadPart = tokenParts[1]; + payloadPart = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); + const missingPaddingLength = payloadPart.length % 4; + if (missingPaddingLength) { + payloadPart += '='.repeat(4 - missingPaddingLength); } - /** @returns {string} */ - get defaultGUIOrigin() { - return ( - globalThis.PUTER_ORIGIN || - globalThis.PUTER_ORIGIN_ENV || - this.#defaultGUIOrigin - ); - } - set defaultGUIOrigin(v) { - this.#defaultGUIOrigin = v; - } - - /** - * Called once the user is authenticated. Set by the app using the SDK. - * - * @type {((user: Record) => void) | undefined} - */ - onAuth; - - /** - * State object to keep track of the authentication request status. This - * is used to prevent multiple authentication popups from showing up by - * different parts of the app. - */ - puterAuthState = { - isPromptOpen: false, - authGranted: null, - resolver: null, - }; - - // Holds the unique app instance ID that is provided by the host environment - appInstanceID; - - // Holds the unique app instance ID for the parent (if any), which is provided by the host environment - parentInstanceID; - - // Expose the FSItem class - static FSItem = FSItem; - - // Event handling properties - eventHandlers = {}; - - // Reauth coordinator state. When the backend signals - // `401 { code: 'reauth_required' }`, in-flight requests await this - // promise; the first caller drives the interactive flow, everyone - // else replays after it resolves. - _reauthInflight = null; - - // Subscribers to token / API origin changes. Modules read both live - // off this instance, so this is only for the few that hold an open - // connection and have to rebuild it. - _authStateListeners = new Set(); - - // debug flag - debugMode = false; - - // Whether to suppress the developer CTA in the console - quiet = false; - - /** - * Puter.js Modules - * - * These are the modules you see on docs.puter.com; for example: - * - * - Puter.fs - * - Puter.kv - * - Puter.ui - * - * InitSubmodules is called from the constructor of this class. - */ - initSubmodules() { - // Util - this.util = new Util(); - - this.auth = this.registerModule('auth', Auth); - this.os = this.registerModule('os', OS); - this.fs = this.registerModule('fs', PuterJSFileSystemModule); - this.ui = this.registerModule('ui', UI, { - appInstanceID: this.appInstanceID, - parentInstanceID: this.parentInstanceID, - }); - this.hosting = this.registerModule('hosting', Hosting); - this.apps = this.registerModule('apps', Apps); - this.ai = this.registerModule('ai', AI); - this.kv = this.registerModule('kv', KV); - this.email = this.registerModule('email', Email); - this.perms = this.registerModule('perms', Perms); - this.drivers = this.registerModule('drivers', Drivers); - this.debug = this.registerModule('debug', Debug); - this.peer = this.registerModule('peer', Peer); - this.workers = this.registerModule('workers', WorkersHandler); - - // Path - this.path = path; - - // Register web components for standalone UI fallback - registerComponents(); - } - - normalizeAuthTokenCandidate = function (tokenCandidate) { - if (typeof tokenCandidate !== 'string') return null; - const trimmedTokenCandidate = tokenCandidate.trim(); - if ( - !trimmedTokenCandidate || - trimmedTokenCandidate === 'null' || - trimmedTokenCandidate === 'undefined' - ) { + try { + let decodedPayloadText; + if (typeof globalThis.atob === 'function') { + decodedPayloadText = decodeURIComponent( + Array.prototype.map + .call( + globalThis.atob(payloadPart), + (character) => + `%${`00${character.charCodeAt(0).toString(16)}`.slice(-2)}`, + ) + .join(''), + ); + } else if (typeof globalThis.Buffer !== 'undefined') { + decodedPayloadText = globalThis.Buffer.from( + payloadPart, + 'base64', + ).toString('utf8'); + } else { return null; } - return trimmedTokenCandidate; - }; + const parsedPayload = JSON.parse(decodedPayloadText); + return parsedPayload && typeof parsedPayload === 'object' + ? parsedPayload + : null; + } catch { + return null; + } + }; - decodeJwtPayload = function (tokenCandidate) { - if (typeof tokenCandidate !== 'string') return null; - const tokenParts = tokenCandidate.split('.'); - if (tokenParts.length < 2) return null; + normalizeStringCandidate = function (valueCandidate) { + if (typeof valueCandidate !== 'string') return null; + const trimmedValueCandidate = valueCandidate.trim(); + return trimmedValueCandidate || null; + }; - let payloadPart = tokenParts[1]; - payloadPart = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); - const missingPaddingLength = payloadPart.length % 4; - if (missingPaddingLength) { - payloadPart += '='.repeat(4 - missingPaddingLength); - } + decodeCompressedAppID = function (compressedAppIDCandidate) { + const normalizedCompressedAppID = this.normalizeStringCandidate( + compressedAppIDCandidate, + ); + if (!normalizedCompressedAppID) return null; - try { - let decodedPayloadText; - if (typeof globalThis.atob === 'function') { - decodedPayloadText = decodeURIComponent( - Array.prototype.map - .call( - globalThis.atob(payloadPart), - (character) => - `%${`00${character.charCodeAt(0).toString(16)}`.slice(-2)}`, - ) - .join(''), - ); - } else if (typeof globalThis.Buffer !== 'undefined') { - decodedPayloadText = globalThis.Buffer.from( - payloadPart, - 'base64', - ).toString('utf8'); - } else { - return null; - } - const parsedPayload = JSON.parse(decodedPayloadText); - return parsedPayload && typeof parsedPayload === 'object' - ? parsedPayload - : null; - } catch { + // TokenService may already provide an expanded UID value. + if (normalizedCompressedAppID.includes('-')) { + return normalizedCompressedAppID; + } + + try { + let decodedBytes; + if (typeof globalThis.Buffer !== 'undefined') { + decodedBytes = globalThis.Buffer.from( + normalizedCompressedAppID, + 'base64', + ); + } else if (typeof globalThis.atob === 'function') { + const decodedBinary = globalThis.atob( + normalizedCompressedAppID, + ); + decodedBytes = Uint8Array.from(decodedBinary, (character) => + character.charCodeAt(0), + ); + } else { return null; } - }; - normalizeStringCandidate = function (valueCandidate) { - if (typeof valueCandidate !== 'string') return null; - const trimmedValueCandidate = valueCandidate.trim(); - return trimmedValueCandidate || null; - }; + if (!decodedBytes || decodedBytes.length !== 16) return null; - decodeCompressedAppID = function (compressedAppIDCandidate) { - const normalizedCompressedAppID = this.normalizeStringCandidate( - compressedAppIDCandidate, - ); - if (!normalizedCompressedAppID) return null; + const decodedHex = + typeof globalThis.Buffer !== 'undefined' && + typeof globalThis.Buffer.isBuffer === 'function' && + globalThis.Buffer.isBuffer(decodedBytes) + ? decodedBytes.toString('hex') + : Array.from(decodedBytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + if (decodedHex.length !== 32) return null; - // TokenService may already provide an expanded UID value. - if (normalizedCompressedAppID.includes('-')) { - return normalizedCompressedAppID; - } + return `app-${[ + decodedHex.slice(0, 8), + decodedHex.slice(8, 12), + decodedHex.slice(12, 16), + decodedHex.slice(16, 20), + decodedHex.slice(20), + ].join('-')}`; + } catch { + return null; + } + }; - try { - let decodedBytes; - if (typeof globalThis.Buffer !== 'undefined') { - decodedBytes = globalThis.Buffer.from( - normalizedCompressedAppID, - 'base64', - ); - } else if (typeof globalThis.atob === 'function') { - const decodedBinary = globalThis.atob( - normalizedCompressedAppID, - ); - decodedBytes = Uint8Array.from(decodedBinary, (character) => - character.charCodeAt(0), - ); - } else { - return null; - } + getAppIDFromAuthToken = function (tokenCandidate) { + const payload = this.decodeJwtPayload(tokenCandidate); + if (!payload) return null; - if (!decodedBytes || decodedBytes.length !== 16) return null; + const uncompressedAppUid = this.normalizeStringCandidate( + payload.app_uid, + ); + if (uncompressedAppUid) return uncompressedAppUid; - const decodedHex = - typeof globalThis.Buffer !== 'undefined' && - typeof globalThis.Buffer.isBuffer === 'function' && - globalThis.Buffer.isBuffer(decodedBytes) - ? decodedBytes.toString('hex') - : Array.from(decodedBytes) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join(''); - if (decodedHex.length !== 32) return null; + // `auth` JWT scope may compress `app_uid` to `au`. + return this.decodeCompressedAppID(payload.au); + }; - return `app-${[ - decodedHex.slice(0, 8), - decodedHex.slice(8, 12), - decodedHex.slice(12, 16), - decodedHex.slice(16, 20), - decodedHex.slice(20), - ].join('-')}`; - } catch { - return null; - } - }; + // -------------------------------------------- + // Constructor + // -------------------------------------------- + constructor() { + // Initialize the cache using kv.js + this._cache = new kvjs({ dbName: 'puter_cache' }); + this._opscache = new kvjs(); - getAppIDFromAuthToken = function (tokenCandidate) { - const payload = this.decodeJwtPayload(tokenCandidate); - if (!payload) return null; + // Holds the query parameters found in the current URL + let URLParams = new URLSearchParams(globalThis.location?.search); - const uncompressedAppUid = this.normalizeStringCandidate( - payload.app_uid, - ); - if (uncompressedAppUid) return uncompressedAppUid; - - // `auth` JWT scope may compress `app_uid` to `au`. - return this.decodeCompressedAppID(payload.au); - }; - - // -------------------------------------------- - // Constructor - // -------------------------------------------- - constructor() { - // Initialize the cache using kv.js - this._cache = new kvjs({ dbName: 'puter_cache' }); - this._opscache = new kvjs(); - - // Holds the query parameters found in the current URL - let URLParams = new URLSearchParams(globalThis.location?.search); - - // Figure out the environment in which the SDK is running - if (URLParams.has('puter.app_instance_id')) { - this.env = 'app'; - } else if (globalThis.puter_gui_enabled === true) { - this.env = 'gui'; - } else if (globalThis.WorkerGlobalScope) { - if (globalThis.ServiceWorkerGlobalScope) { - this.env = 'service-worker'; - if (!globalThis.XMLHttpRequest) { - globalThis.XMLHttpRequest = xhrshim; - } - if (!globalThis.location) { - globalThis.location = new URL('https://puter.site/'); - } - // XHRShimGlobalize here - } else { - this.env = 'web-worker'; - } - if (!globalThis.localStorage) { - globalThis.localStorage = localStorageMemory; - } - } else if (globalThis.process) { - this.env = 'nodejs'; - if (!globalThis.localStorage) { - globalThis.localStorage = localStorageMemory; - } + // Figure out the environment in which the SDK is running + if (URLParams.has('puter.app_instance_id')) { + this.env = 'app'; + } else if (globalThis.puter_gui_enabled === true) { + this.env = 'gui'; + } else if (globalThis.WorkerGlobalScope) { + if (globalThis.ServiceWorkerGlobalScope) { + this.env = 'service-worker'; if (!globalThis.XMLHttpRequest) { globalThis.XMLHttpRequest = xhrshim; } if (!globalThis.location) { - globalThis.location = new URL('https://nodejs.puter.site/'); - } - if (!globalThis.addEventListener) { - globalThis.addEventListener = () => {}; // API Stub + globalThis.location = new URL('https://puter.site/'); } + // XHRShimGlobalize here } else { - this.env = 'web'; + this.env = 'web-worker'; } - - // There are some specific situations where puter is definitely loaded in GUI mode - // we're going to check for those situations here so that we don't break anything unintentionally - // if navigator URL's hostname is 'puter.com' - if (this.env !== 'gui') { - // Retrieve the hostname from the URL: Remove the trailing dot if it exists. This is to handle the case where the URL is, for example, `https://puter.com.` (note the trailing dot). - // This is necessary because the trailing dot can cause the hostname to not match the expected value. - let hostname = location.hostname.replace(/\.$/, ''); - - // Create a new URL object with the URL string - const url = new URL(PROD_ORIGIN); - - // Extract hostname from the URL object - const gui_hostname = url.hostname; - - // If the hostname matches the GUI hostname, then the SDK is running in the GUI environment - if (hostname === gui_hostname) { - this.env = 'gui'; - } + if (!globalThis.localStorage) { + globalThis.localStorage = localStorageMemory; } - - // Get the 'args' from the URL. This is used to pass arguments to the app. - if (URLParams.has('puter.args')) { - this.args = JSON.parse( - decodeURIComponent(URLParams.get('puter.args')), - ); - } else { - this.args = {}; + } else if (globalThis.process) { + this.env = 'nodejs'; + if (!globalThis.localStorage) { + globalThis.localStorage = localStorageMemory; } - - // Try to extract appInstanceID from the URL. appInstanceID is included in every messaage - // sent to the host environment. This is used to help host environment identify the app - // instance that sent the message and communicate back to it. - if (URLParams.has('puter.app_instance_id')) { - this.appInstanceID = decodeURIComponent( - URLParams.get('puter.app_instance_id'), - ); + if (!globalThis.XMLHttpRequest) { + globalThis.XMLHttpRequest = xhrshim; } - - // Try to extract parentInstanceID from the URL. If another app launched this app instance, parentInstanceID - // holds its instance ID, and is used to communicate with that parent app. - if (URLParams.has('puter.parent_instance_id')) { - this.parentInstanceID = decodeURIComponent( - URLParams.get('puter.parent_instance_id'), - ); + if (!globalThis.location) { + globalThis.location = new URL('https://nodejs.puter.site/'); } - - // Try to extract `puter.app.id` from the URL. `puter.app.id` is the unique ID of the app. - // App ID is useful for identifying the app when communicating with the Puter API, among other things. - if (URLParams.has('puter.app.id')) { - this.appID = decodeURIComponent(URLParams.get('puter.app.id')); + if (!globalThis.addEventListener) { + globalThis.addEventListener = () => {}; // API Stub } + } else { + this.env = 'web'; + } - // Extract app name (added later) - if (URLParams.has('puter.app.name')) { - this.appName = decodeURIComponent( - URLParams.get('puter.app.name'), - ); + // There are some specific situations where puter is definitely loaded in GUI mode + // we're going to check for those situations here so that we don't break anything unintentionally + // if navigator URL's hostname is 'puter.com' + if (this.env !== 'gui') { + // Retrieve the hostname from the URL: Remove the trailing dot if it exists. This is to handle the case where the URL is, for example, `https://puter.com.` (note the trailing dot). + // This is necessary because the trailing dot can cause the hostname to not match the expected value. + let hostname = location.hostname.replace(/\.$/, ''); + + // Create a new URL object with the URL string + const url = new URL(PROD_ORIGIN); + + // Extract hostname from the URL object + const gui_hostname = url.hostname; + + // If the hostname matches the GUI hostname, then the SDK is running in the GUI environment + if (hostname === gui_hostname) { + this.env = 'gui'; } + } - // Construct this App's AppData path based on the appID. AppData path is used to store files that are specific to this app. - // The default AppData path is `~/AppData/`. - if (this.appID) { - this.appDataPath = `~/AppData/${this.appID}`; - } + // Get the 'args' from the URL. This is used to pass arguments to the app. + if (URLParams.has('puter.args')) { + this.args = JSON.parse( + decodeURIComponent(URLParams.get('puter.args')), + ); + } else { + this.args = {}; + } - // Construct APIOrigin from the URL. APIOrigin is used to build the URLs for the Puter API endpoints. - // The default APIOrigin is https://api.puter.com. However, if the URL contains a `puter.api_origin` query parameter, - // then that value is used as the APIOrigin. If the URL contains a `puter.domain` query parameter, then the APIOrigin - // is constructed as `https://api.`. - // This should only be done when the SDK is running in 'app' mode. - this.APIOrigin = this.defaultAPIOrigin; - if (URLParams.has('puter.api_origin') && this.env === 'app') { - this.APIOrigin = decodeURIComponent( - URLParams.get('puter.api_origin'), - ); - } else if (URLParams.has('puter.domain') && this.env === 'app') { - this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`; - } + // Try to extract appInstanceID from the URL. appInstanceID is included in every messaage + // sent to the host environment. This is used to help host environment identify the app + // instance that sent the message and communicate back to it. + if (URLParams.has('puter.app_instance_id')) { + this.appInstanceID = decodeURIComponent( + URLParams.get('puter.app_instance_id'), + ); + } - // === START :: Logger === + // Try to extract parentInstanceID from the URL. If another app launched this app instance, parentInstanceID + // holds its instance ID, and is used to communicate with that parent app. + if (URLParams.has('puter.parent_instance_id')) { + this.parentInstanceID = decodeURIComponent( + URLParams.get('puter.parent_instance_id'), + ); + } - // Basic logger replacement (console-based) - let logger = new SimpleLogger(); - this.logger = logger; + // Try to extract `puter.app.id` from the URL. `puter.app.id` is the unique ID of the app. + // App ID is useful for identifying the app when communicating with the Puter API, among other things. + if (URLParams.has('puter.app.id')) { + this.appID = decodeURIComponent(URLParams.get('puter.app.id')); + } - // Initialize API call logger - this.apiCallLogger = new APICallLogger({ - enabled: false, // Disabled by default - }); + // Extract app name (added later) + if (URLParams.has('puter.app.name')) { + this.appName = decodeURIComponent( + URLParams.get('puter.app.name'), + ); + } - // `/rao` state, set up before the environment branches below - // because those call setAuthToken, which requests `/rao`. - // Lock to prevent multiple requests to `/rao` - this.lock_rao_ = new Lock(); - // Promise that resolves when it's okay to request `/rao` - this.p_can_request_rao_ = Promise.resolve(); - // Flag that indicates if a request to `/rao` has been made - this.rao_requested_ = false; - // The in-flight boot `/whoami`, awaited by anything that wants the - // cached user without issuing its own request. - this.whoamiCache_ = null; + // Construct this App's AppData path based on the appID. AppData path is used to store files that are specific to this app. + // The default AppData path is `~/AppData/`. + if (this.appID) { + this.appDataPath = `~/AppData/${this.appID}`; + } - // === Start :: Modules === // + // Construct APIOrigin from the URL. APIOrigin is used to build the URLs for the Puter API endpoints. + // The default APIOrigin is https://api.puter.com. However, if the URL contains a `puter.api_origin` query parameter, + // then that value is used as the APIOrigin. If the URL contains a `puter.domain` query parameter, then the APIOrigin + // is constructed as `https://api.`. + // This should only be done when the SDK is running in 'app' mode. + this.APIOrigin = this.defaultAPIOrigin; + if (URLParams.has('puter.api_origin') && this.env === 'app') { + this.APIOrigin = decodeURIComponent( + URLParams.get('puter.api_origin'), + ); + } else if (URLParams.has('puter.domain') && this.env === 'app') { + this.APIOrigin = `https://api.${URLParams.get('puter.domain')}`; + } - // The SDK is running in the Puter GUI (i.e. 'gui') - if (this.env === 'gui') { - this.authToken = window.auth_token; - // initialize submodules - this.initSubmodules(); - } - // Loaded in an iframe in the Puter GUI (i.e. 'app') - // When SDK is loaded in App mode the initiation process should start when the DOM is ready - else if (this.env === 'app') { - const bootstrapAuthToken = this.normalizeAuthTokenCandidate( - URLParams.get('puter.auth.token') ?? - URLParams.get('auth_token'), - ); - try { - let selectedAuthToken = bootstrapAuthToken; - if (bootstrapAuthToken) { - this.setAuthToken(bootstrapAuthToken); - } else { - // No token in the URL — fall back to a stored token, - // but ONLY if it is allowed for the current API origin. - // In app mode `puter.api_origin` is URL-controlled, so a - // stored token must be bound to (and matched against) the - // origin it was minted for. A custom (non-default) origin - // additionally requires an explicit binding; an unbound - // token is only honored against the default origin. - const boundOrigin = this.normalizeStringCandidate( - localStorage.getItem(STORAGE_KEY_ORIGIN_V2), - ); - const storedToken = this.normalizeAuthTokenCandidate( - localStorage.getItem(STORAGE_KEY_V2), - ); - if ( - storedToken && - this._storedTokenUsableForCurrentOrigin(boundOrigin) - ) { - this.setAuthToken(storedToken); - selectedAuthToken = storedToken; - } else if (storedToken) { - // A token exists but is not valid for this API - // origin (a URL-supplied custom/attacker origin, or - // an unbound token against a custom origin). Treat - // as unauthenticated and force a reauth for this - // origin instead of replaying the token. - this._needsOriginReauth = { - reason: 'api_origin_mismatch', - }; - } - } - const tokenAppID = - this.getAppIDFromAuthToken(selectedAuthToken); - if (!tokenAppID && !this.appID) { - // if appID is already set in localStorage, then we don't need to show the dialog - const storedAppID = - localStorage.getItem('puter.app.id'); - if (storedAppID) { - this.setAppID(storedAppID); - } - } - } catch (error) { - // Handle the error here - console.error('Error accessing localStorage:', error); - } - this.initSubmodules(); - if (this._needsOriginReauth) { - const reauthSignal = this._needsOriginReauth; - this._needsOriginReauth = null; - // The URL-supplied API origin was rejected as untrusted, so - // snap the API origin back to the trusted default before - // reauthing. This guarantees the fresh token is bound to — - // and only ever sent to — the trusted default origin, never - // the URL-supplied one. Reauth itself is pinned to the - // configured GUI origin (see triggerReauth). - this.setAPIOrigin(this.defaultAPIOrigin); - this.triggerReauth(reauthSignal); - } - } - // SDK was loaded in a 3rd-party website. - // When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because - // the SDK needs to show a dialog to the user to ask for permission to access their Puter account. - else if (this.env === 'web') { - // initialize submodules - this.initSubmodules(); - try { + // === START :: Logger === + + // Basic logger replacement (console-based) + let logger = new SimpleLogger(); + this.logger = logger; + + // Initialize API call logger + this.apiCallLogger = new APICallLogger({ + enabled: false, // Disabled by default + }); + + // `/rao` state, set up before the environment branches below + // because those call setAuthToken, which requests `/rao`. + // Lock to prevent multiple requests to `/rao` + this.lock_rao_ = new Lock(); + // Promise that resolves when it's okay to request `/rao` + this.p_can_request_rao_ = Promise.resolve(); + // Flag that indicates if a request to `/rao` has been made + this.rao_requested_ = false; + // The in-flight boot `/whoami`, awaited by anything that wants the + // cached user without issuing its own request. + this.whoamiCache_ = null; + + // === Start :: Modules === // + + // The SDK is running in the Puter GUI (i.e. 'gui') + if (this.env === 'gui') { + this.authToken = window.auth_token; + // initialize submodules + this.initSubmodules(); + } + // Loaded in an iframe in the Puter GUI (i.e. 'app') + // When SDK is loaded in App mode the initiation process should start when the DOM is ready + else if (this.env === 'app') { + const bootstrapAuthToken = this.normalizeAuthTokenCandidate( + URLParams.get('puter.auth.token') ?? + URLParams.get('auth_token'), + ); + try { + let selectedAuthToken = bootstrapAuthToken; + if (bootstrapAuthToken) { + this.setAuthToken(bootstrapAuthToken); + } else { + // No token in the URL — fall back to a stored token, + // but ONLY if it is allowed for the current API origin. + // In app mode `puter.api_origin` is URL-controlled, so a + // stored token must be bound to (and matched against) the + // origin it was minted for. A custom (non-default) origin + // additionally requires an explicit binding; an unbound + // token is only honored against the default origin. + const boundOrigin = this.normalizeStringCandidate( + localStorage.getItem(STORAGE_KEY_ORIGIN_V2), + ); const storedToken = this.normalizeAuthTokenCandidate( localStorage.getItem(STORAGE_KEY_V2), ); - if (storedToken) this.setAuthToken(storedToken); + if ( + storedToken && + this._storedTokenUsableForCurrentOrigin(boundOrigin) + ) { + this.setAuthToken(storedToken); + selectedAuthToken = storedToken; + } else if (storedToken) { + // A token exists but is not valid for this API + // origin (a URL-supplied custom/attacker origin, or + // an unbound token against a custom origin). Treat + // as unauthenticated and force a reauth for this + // origin instead of replaying the token. + this._needsOriginReauth = { + reason: 'api_origin_mismatch', + }; + } + } + const tokenAppID = + this.getAppIDFromAuthToken(selectedAuthToken); + if (!tokenAppID && !this.appID) { // if appID is already set in localStorage, then we don't need to show the dialog - if (!this.appID && localStorage.getItem('puter.app.id')) { - this.setAppID(localStorage.getItem('puter.app.id')); - } - } catch (error) { - // Handle the error here - console.error('Error accessing localStorage:', error); - } - - // Print a CTA for developers to publish their app on the Puter App Store - this.printDevCTA(); - - // If the page was opened directly from disk (file:// protocol), - // Puter.js cannot function. Warn the developer immediately on - // load rather than waiting for an action that triggers auth. - this.warnUnsupportedProtocol(); - } else if ( - this.env === 'web-worker' || - this.env === 'service-worker' || - this.env === 'nodejs' - ) { - this.initSubmodules(); - } - - // Wherever tokens are stored, a value under the retired key is - // dead weight — drop it even when there's no new token to write - // over it (`setAuthToken` handles that case). - if (this.env === 'web' || this.env === 'app') { - this.discardRetiredAuthToken_(); - } - - // Add prefix logger (needed to happen after modules are initialized) - (async () => { - try { - // Reuses the boot `/whoami` rather than issuing a second - // one. Nothing to prefix with when there's no token (or the - // lookup failed), same as when this awaited its own call. - const whoami = await this.whoamiCache_; - if (!whoami) return; - const prefix = `[${ - whoami.app_name ?? this.appInstanceID ?? 'HOST' - }]`; - logger = logger.fields({ prefix }); - this.logger = logger; - } catch (error) { - if (this.debugMode) { - console.error( - 'Failed to initialize prefix logger', - error, - ); + const storedAppID = + localStorage.getItem('puter.app.id'); + if (storedAppID) { + this.setAppID(storedAppID); } } - })(); - - /** @type {import('../types/modules/networking').Networking} */ - this.net = { - /** - * Mints a relay URL (server + single-use token) for speaking - * the Wisp v1 protocol directly, which is what the sockets - * below do for you. - * - * @returns {Promise} - */ - generateWispV1URL: async () => { - const { token: wispToken, server: wispServer } = await ( - await fetchUrl( - `${this.APIOrigin}/wisp/relay-token/create`, - { - method: 'POST', - includePuterAuth: true, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }, - ) - ).json(); - return `${wispServer}/${wispToken}/`; - }, - Socket: PSocket, - tls: { - TLSSocket: PTLSSocket, - }, - fetch: pFetch, - }; - - // Initialize network connectivity monitoring and cache purging - this.initNetworkMonitoring(); - } - - /** - * @internal - * Makes a request to `/rao`. This method aquires a lock to prevent - * multiple requests, and is effectively idempotent. - */ - async request_rao_() { - await this.p_can_request_rao_; - - // Don't record an app open when running inside the Puter GUI, or - // when running as a Puter app (i.e. within an iframe in the GUI). - if (this.env === 'gui' || this.env === 'app') { - return; - } - - // setAuthToken is called more than once when auth completes, which - // causes multiple requests to /rao. This lock prevents that. - await this.lock_rao_.acquire(); - if (this.rao_requested_) { - this.lock_rao_.release(); - return; - } - - try { - const resp = await fetchUrl(`${this.APIOrigin}/rao`, { - method: 'POST', - includePuterAuth: true, - headers: { - Origin: location.origin, // This is ignored in the browser but needed for workers and nodejs - }, - // Recording an app open is ours, not the user's: a stale - // token must not turn a page load into a sign-in prompt. - interactiveReauth: false, - }); - // Set inside the lock: a caller parked on `p_can_request_rao_` - // acquires it the moment this releases, and would otherwise - // record the same open a second time. - this.rao_requested_ = true; - return await resp.json(); - } catch (e) { - console.error(e); - } finally { - this.lock_rao_.release(); - } - } - - /** - * @returns {Promise} The - * cached user, or null when there was no token, the token was - * rejected, or the request failed. - * @internal - * Populates `puter.whoami` for callers that read the cached user - * synchronously. Non-interactive for the same reason as `/rao`: this - * runs on every load without the user asking, so a stale token must not - * raise sign-in UI. Callers that need a definite answer (and the prompt - * that comes with it) use `puter.auth.getUser()`. - * - * Uses `fetchUrl` rather than `this.auth` because `setAuthToken` runs - * before `initSubmodules` in the app environment, and carries the token - * explicitly because `includePuterAuth` reads `globalThis.puter`, which - * isn't assigned until the constructor returns. - */ - async cacheWhoami_() { - if (!this.authToken) return null; - try { - const resp = await fetchUrl(`${this.APIOrigin}/whoami`, { - authToken: this.authToken, - interactiveReauth: false, - logContext: { - service: 'auth', - operation: 'whoami', - params: {}, - }, - }); - if (!resp.ok) return null; - this.whoami = await resp.json(); - return this.whoami; - } catch (e) { - // Best-effort cache — a network failure leaves it unset. - return null; - } - } - - /** - * Instantiates a module, registers it for auth/origin updates, and - * hands it back for the caller to attach. Assigning the result to a - * named property rather than having this write `this[name]` is what - * keeps each module's type visible to consumers of the SDK. - * - * @template T - * @param {string} name - * @param {new ( - * puter: Puter, - * parameters: Record, - * ) => T} cls - * @param {Record} [parameters] - * @returns {T} - */ - registerModule(name, cls, parameters = {}) { - const instance = new cls(this, parameters); - instance.puter = this; - this[name] = instance; - if (instance._init) instance._init({ puter: this }); - return instance; - } - - /** - * Subscribes to auth token / API origin changes. Modules read both live - * off this instance, so this is only needed by the ones holding a - * connection that has to be rebuilt. - * - * @param {() => void} listener - * @returns {() => void} Unsubscribes the listener. - */ - onAuthStateChanged(listener) { - this._authStateListeners.add(listener); - return () => this._authStateListeners.delete(listener); - } - - _emitAuthStateChanged() { - for (const listener of this._authStateListeners) { - try { - listener(); - } catch (error) { - if (this.debugMode) { - console.error('Auth state listener failed', error); - } - } - } - } - - /** @param {string} appID */ - setAppID = function (appID) { - // save to localStorage - try { - localStorage.setItem('puter.app.id', appID); } catch (error) { // Handle the error here console.error('Error accessing localStorage:', error); } - this.appID = appID; - this.appDataPath = appID ? `~/AppData/${appID}` : undefined; - }; - - /** @param {string} authToken */ - setAuthToken = function (authToken) { - const normalizedAuthToken = - this.normalizeAuthTokenCandidate(authToken); - this.authToken = normalizedAuthToken; - - // Keep app identity consistent with token claims whenever available. - const tokenAppID = this.getAppIDFromAuthToken(normalizedAuthToken); - if (tokenAppID) { - this.setAppID(tokenAppID); + this.initSubmodules(); + if (this._needsOriginReauth) { + const reauthSignal = this._needsOriginReauth; + this._needsOriginReauth = null; + // The URL-supplied API origin was rejected as untrusted, so + // snap the API origin back to the trusted default before + // reauthing. This guarantees the fresh token is bound to — + // and only ever sent to — the trusted default origin, never + // the URL-supplied one. Reauth itself is pinned to the + // configured GUI origin (see triggerReauth). + this.setAPIOrigin(this.defaultAPIOrigin); + this.triggerReauth(reauthSignal); } - - // If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage - if (this.env === 'web' || this.env === 'app') { - try { - if (normalizedAuthToken) { - localStorage.setItem( - STORAGE_KEY_V2, - normalizedAuthToken, - ); - // Persist the origin this token is bound to alongside - // it, so a later boot only reuses it for that origin. - localStorage.setItem( - STORAGE_KEY_ORIGIN_V2, - this.APIOrigin, - ); - } else { - localStorage.removeItem(STORAGE_KEY_V2); - localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); - } - // Clear the retired key on every write, so a stale value - // never outlives the token that replaced it. - localStorage.removeItem(STORAGE_KEY_V1); - } catch (error) { - // Handle the error here - console.error('Error accessing localStorage:', error); - } - } - // initialize loop for updating caches for major directories - if (this.env === 'gui') { - // check and update gui fs cache regularly - setInterval(puter.checkAndUpdateGUIFScache, 10000); - } - this._emitAuthStateChanged(); - - // rao - this.request_rao_(); - - // perform whoami and cache results - this.whoamiCache_ = this.cacheWhoami_(); - }; - - /** - * Decides whether a stored token may be attached to requests for the - * current API origin. - * - * - A bound token may only ever be replayed to the exact origin it was - * minted against. - * - An unbound (legacy) token is only honored against the default API - * origin — never against a URL-supplied custom `puter.api_origin`. - */ - _storedTokenUsableForCurrentOrigin = function (boundOrigin) { - return isStoredTokenUsableForOrigin({ - boundOrigin, - currentOrigin: this.APIOrigin, - defaultAPIOrigin: this.defaultAPIOrigin, - }); - }; - - /** @param {string} APIOrigin */ - setAPIOrigin = function (APIOrigin) { - this.APIOrigin = APIOrigin; - this._emitAuthStateChanged(); - }; - - runWhenPuterHappensCallbacks = function () { - if (this.env !== 'gui') return; - if (!globalThis.when_puter_happens) return; - - const callbacks = Array.isArray(globalThis.when_puter_happens) - ? globalThis.when_puter_happens - : [globalThis.when_puter_happens]; - - for (const fn of callbacks) { - try { - fn({ puter: this }); - } catch (error) { - if (this.debugMode) { - console.error( - 'when_puter_happens callback failed', - error, - ); - } - } - } - }; - - /** - * Forget the current token, in memory and (on a 3rd-party site or an - * app) in localStorage. Callers own the surrounding policy — emitting - * events, driving reauth — this only drops the value. - * - * @private - */ - _clearAuthToken = function () { - this.authToken = null; - if (this.env === 'web' || this.env === 'app') { - try { - localStorage.removeItem(STORAGE_KEY_V2); - localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); - localStorage.removeItem(STORAGE_KEY_V1); - } catch (error) { - // Handle the error here - console.error('Error accessing localStorage:', error); - } - } - }; - - resetAuthToken = function () { - if (this.env === 'web-worker' || this.env === 'service-worker') { - throw new Error( - 'Sign out is not permitted from WebWorkers or ServiceWorkers', + } + // SDK was loaded in a 3rd-party website. + // When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because + // the SDK needs to show a dialog to the user to ask for permission to access their Puter account. + else if (this.env === 'web') { + // initialize submodules + this.initSubmodules(); + try { + const storedToken = this.normalizeAuthTokenCandidate( + localStorage.getItem(STORAGE_KEY_V2), ); + if (storedToken) this.setAuthToken(storedToken); + // if appID is already set in localStorage, then we don't need to show the dialog + if (!this.appID && localStorage.getItem('puter.app.id')) { + this.setAppID(localStorage.getItem('puter.app.id')); + } + } catch (error) { + // Handle the error here + console.error('Error accessing localStorage:', error); } - this._clearAuthToken(); - this._emitAuthStateChanged(); - }; - /** - * Reauth coordinator. Called by the network layer (lib/utils.js) when - * the backend returns `401 { code: 'reauth_required', reason, auth_id - * }`. - * - * Behavior is environment-specific: - * - * - `web` / `app`: clear the stored token, emit an event, and drive the - * existing puter.com login popup. Returns a promise that resolves - * when the user signs in (so callers can replay) or rejects if reauth - * fails / is canceled. - * - `gui`: no-op — the GUI environment renders its own modal and host - * code is responsible for the flow. - * - Workers / nodejs: there's no UI surface to drive, so reject with a - * structured error and let worker code react. - * - * Idempotent: parallel callers share a single in-flight promise. - * - * @param {{ reason?: string; auth_id?: string }} signal - */ - triggerReauth = async function (signal = {}) { - const { reason, auth_id } = signal; - if (this._reauthInflight) return this._reauthInflight; + // Print a CTA for developers to publish their app on the Puter App Store + this.printDevCTA(); - // Emit before clearing so listeners can read state if needed. - this._emitReauthEvent({ reason, auth_id }); + // If the page was opened directly from disk (file:// protocol), + // Puter.js cannot function. Warn the developer immediately on + // load rather than waiting for an action that triggers auth. + this.warnUnsupportedProtocol(); + } else if ( + this.env === 'web-worker' || + this.env === 'service-worker' || + this.env === 'nodejs' + ) { + this.initSubmodules(); + } - // Drop the stored token immediately so a failed/canceled reauth - // doesn't leave a poisoned value in localStorage. The new token - // (if reauth succeeds) is written by setAuthToken downstream. - this._clearAuthToken(); - this._emitAuthStateChanged(); + // Wherever tokens are stored, a value under the retired key is + // dead weight — drop it even when there's no new token to write + // over it (`setAuthToken` handles that case). + if (this.env === 'web' || this.env === 'app') { + this.discardRetiredAuthToken_(); + } - this._reauthInflight = (async () => { - if (this.env === 'gui') { - // GUI handles its own modal at the layer above puter-js. - return; + // Add prefix logger (needed to happen after modules are initialized) + (async () => { + try { + // Reuses the boot `/whoami` rather than issuing a second + // one. Nothing to prefix with when there's no token (or the + // lookup failed), same as when this awaited its own call. + const whoami = await this.whoamiCache_; + if (!whoami) return; + const prefix = `[${ + whoami.app_name ?? this.appInstanceID ?? 'HOST' + }]`; + logger = logger.fields({ prefix }); + this.logger = logger; + } catch (error) { + if (this.debugMode) { + console.error( + 'Failed to initialize prefix logger', + error, + ); } - if ( - this.env === 'web-worker' || - this.env === 'service-worker' || - this.env === 'nodejs' - ) { - const err = new Error('reauth_required'); - err.code = 'reauth_required'; - err.reason = reason; - err.auth_id = auth_id; - throw err; - } - if (this.env === 'web') { - // Drives the puter.com login popup. On success, the - // postMessage handler at the bottom of this file calls - // setAuthToken() and updates this.authToken. - await this.ui.authenticateWithPuter({ auth_id, reason }); - return; - } - if (this.env === 'app') { - try { - globalThis.parent?.postMessage?.( - { - msg: 'reauth_required', - appInstanceID: this.appInstanceID, - reason, - auth_id, + } + })(); + + /** @type {import('./modules/networking/types.js').Networking} */ + this.net = { + /** + * Mints a relay URL (server + single-use token) for speaking + * the Wisp v1 protocol directly, which is what the sockets + * below do for you. + * + * @returns {Promise} + */ + generateWispV1URL: async () => { + const { token: wispToken, server: wispServer } = await ( + await fetchUrl( + `${this.APIOrigin}/wisp/relay-token/create`, + { + method: 'POST', + includePuterAuth: true, + headers: { + 'Content-Type': 'application/json', }, - this.defaultGUIOrigin, - ); - } catch (e) { - // Best-effort: if postMessage isn't available - // (sandboxed iframe), fall through to error. - } - // Wait for the parent to deliver a fresh token. - // Validate both event.origin AND event.source — origin - // alone lets any same-origin frame on the GUI domain - // deliver a token; pinning source to globalThis.parent - // ensures the message came from the actual embedder. - await new Promise((resolve, reject) => { - const expectedSource = globalThis.parent; - const onToken = (event) => { - if (event.origin !== this.defaultGUIOrigin) return; - if ( - expectedSource && - event.source !== expectedSource - ) - return; - if (event.data?.msg !== 'puter.token') return; - globalThis.removeEventListener('message', onToken); - resolve(); - }; - globalThis.addEventListener?.('message', onToken); - // Give the user a generous window to re-auth. - setTimeout( - () => { - globalThis.removeEventListener?.( - 'message', - onToken, - ); - reject(new Error('reauth_timeout')); - }, - 5 * 60 * 1000, - ); - }); - } - })(); - - try { - await this._reauthInflight; - } finally { - this._reauthInflight = null; - } + body: JSON.stringify({}), + }, + ) + ).json(); + return `${wispServer}/${wispToken}/`; + }, + Socket: PSocket, + tls: { + TLSSocket: PTLSSocket, + }, + fetch: pFetch, }; - /** - * @param {{ - * reason?: string; - * auth_id?: string; - * sentToken?: string; - * }} signal - * @internal - * The non-interactive half of the reauth policy, called by the network - * layer (lib/networkUtils.js) when a request the user didn't initiate - * comes back `401 { code: 'reauth_required' | 'token_auth_failed' }`. - * - * Boot-time telemetry and cache warmers run on every page load, so - * escalating their 401 to `triggerReauth` puts a sign-in popup (or the - * consent dialog, when there's no user activation to open one with) in - * front of a visitor who did nothing but load the page. Instead: forget - * the dead token and announce it, leaving the prompt to the next call - * the user actually makes. - * - * `sentToken` is the token the failed request carried. A reauth may have - * completed while it was in flight, so a token that no longer matches is - * left alone rather than signing the user back out. - */ - dropStaleAuthToken = function ({ reason, auth_id, sentToken } = {}) { - if (sentToken && sentToken !== this.authToken) return; - this._emitReauthEvent({ reason, auth_id }); - this._clearAuthToken(); - this._emitAuthStateChanged(); - }; - - _emitReauthEvent = function ({ reason, auth_id }) { - try { - const handlers = - this.eventHandlers?.['puter.auth.reauth_required']; - if (Array.isArray(handlers)) { - for (const h of handlers) { - try { - h({ reason, auth_id }); - } catch (e) { - /* swallow per-handler errors */ - } - } - } - } catch (e) { - // Never let event delivery break the reauth flow itself. - } - }; - - /** - * Register a listener for SDK events. Used by host apps to react to - * `puter.auth.reauth_required`. - */ - on = function (eventName, handler) { - if (!this.eventHandlers[eventName]) - this.eventHandlers[eventName] = []; - this.eventHandlers[eventName].push(handler); - return () => this.off(eventName, handler); - }; - - off = function (eventName, handler) { - const handlers = this.eventHandlers[eventName]; - if (!handlers) return; - const idx = handlers.indexOf(handler); - if (idx >= 0) handlers.splice(idx, 1); - }; - - /** - * @internal - * Delete a token left behind under the retired `puter.auth.token` key. - * The backend no longer honors that format, so a visitor holding one is - * simply signed out — sending it would only earn a 401, and leaving it - * in storage would keep tempting later readers. - */ - discardRetiredAuthToken_ = function () { - try { - localStorage.removeItem(STORAGE_KEY_V1); - } catch (e) { - // No storage to clean up. - } - }; - - exit = function (statusCode = 0) { - if (statusCode && typeof statusCode !== 'number') { - console.warn( - 'puter.exit() requires status code to be a number. Treating it as 1', - ); - statusCode = 1; - } - - globalThis.parent.postMessage( - { - msg: 'exit', - appInstanceID: this.appInstanceID, - statusCode, - }, - '*', - ); - }; - - /** - * A function that generates a domain-safe name by combining a random - * adjective, a random noun, and a random number (between 0 and 9999). - * The result is returned as a string with components separated by - * hyphens. It is useful when you need to create unique identifiers that - * are also human-friendly. - * - * @param {string} [separateWith='-'] - The character to use to separate - * the components of the generated name. Default is `'-'` - * @returns {string} A unique, hyphen-separated string comprising of an - * adjective, a noun, and a number. - */ - randName = function (separateWith = '-') { - const first_adj = [ - 'helpful', - 'sensible', - 'loyal', - 'honest', - 'clever', - 'capable', - 'calm', - 'smart', - 'genius', - 'bright', - 'charming', - 'creative', - 'diligent', - 'elegant', - 'fancy', - 'colorful', - 'avid', - 'active', - 'gentle', - 'happy', - 'intelligent', - 'jolly', - 'kind', - 'lively', - 'merry', - 'nice', - 'optimistic', - 'polite', - 'quiet', - 'relaxed', - 'silly', - 'victorious', - 'witty', - 'young', - 'zealous', - 'strong', - 'brave', - 'agile', - 'bold', - ]; - - const nouns = [ - 'street', - 'roof', - 'floor', - 'tv', - 'idea', - 'morning', - 'game', - 'wheel', - 'shoe', - 'bag', - 'clock', - 'pencil', - 'pen', - 'magnet', - 'chair', - 'table', - 'house', - 'dog', - 'room', - 'book', - 'car', - 'cat', - 'tree', - 'flower', - 'bird', - 'fish', - 'sun', - 'moon', - 'star', - 'cloud', - 'rain', - 'snow', - 'wind', - 'mountain', - 'river', - 'lake', - 'sea', - 'ocean', - 'island', - 'bridge', - 'road', - 'train', - 'plane', - 'ship', - 'bicycle', - 'horse', - 'elephant', - 'lion', - 'tiger', - 'bear', - 'zebra', - 'giraffe', - 'monkey', - 'snake', - 'rabbit', - 'duck', - 'goose', - 'penguin', - 'frog', - 'crab', - 'shrimp', - 'whale', - 'octopus', - 'spider', - 'ant', - 'bee', - 'butterfly', - 'dragonfly', - 'ladybug', - 'snail', - 'camel', - 'kangaroo', - 'koala', - 'panda', - 'piglet', - 'sheep', - 'wolf', - 'fox', - 'deer', - 'mouse', - 'seal', - 'chicken', - 'cow', - 'dinosaur', - 'puppy', - 'kitten', - 'circle', - 'square', - 'garden', - 'otter', - 'bunny', - 'meerkat', - 'harp', - ]; - - // return a random combination of first_adj + noun + number (between 0 and 9999) - // e.g. clever-idea-123 - return ( - first_adj[Math.floor(Math.random() * first_adj.length)] + - separateWith + - nouns[Math.floor(Math.random() * nouns.length)] + - separateWith + - Math.floor(Math.random() * 10000) - ); - }; - - getUser = function (...args) { - let options; - - // If first argument is an object, it's the options - if (typeof args[0] === 'object' && args[0] !== null) { - options = args[0]; - } else { - // Otherwise, we assume separate arguments are provided - options = { - success: args[0], - error: args[1], - }; - } - - return new Promise((resolve, reject) => { - const xhr = utils.initXhr( - '/whoami', - this.APIOrigin, - this.authToken, - 'get', - ); - // set up event handlers for load and error events - utils.setupXhrEventHandlers( - xhr, - options.success, - options.error, - resolve, - reject, - ); - - xhr.send(); - }); - }; - - print = function (...args) { - // Check if the last argument is an options object with escapeHTML or code property - let options = {}; - if ( - args.length > 0 && - typeof args[args.length - 1] === 'object' && - args[args.length - 1] !== null && - ('escapeHTML' in args[args.length - 1] || - 'code' in args[args.length - 1]) - ) { - options = args.pop(); - } - - for (let arg of args) { - // Escape HTML if the option is set to true or if code option is true - if ( - (options.escapeHTML === true || options.code === true) && - typeof arg === 'string' - ) { - arg = arg - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - // Wrap in code/pre tags if code option is true - if (options.code === true) { - arg = `
${arg}
`; - } - - document.body.innerHTML += arg; - } - }; - - /** - * Configures API call logging settings - * - * @param {import('../types/shared').APILoggingConfig} [config] - * @returns {this} - */ - configureAPILogging = function (config = {}) { - if (this.apiCallLogger) { - this.apiCallLogger.updateConfig(config); - } - return this; - }; - - /** - * Enables API call logging with optional configuration - * - * @param {import('../types/shared').APILoggingConfig} [config] - * @returns {this} - */ - enableAPILogging = function (config = {}) { - if (this.apiCallLogger) { - this.apiCallLogger.updateConfig({ ...config, enabled: true }); - } - return this; - }; - - /** - * Disables API call logging - * - * @returns {this} - */ - disableAPILogging = function () { - if (this.apiCallLogger) { - this.apiCallLogger.disable(); - } - return this; - }; - - /** - * Initializes network connectivity monitoring to purge cache when - * connection is lost - * - * @private - */ - initNetworkMonitoring = function () { - // Only initialize in environments that support navigator.onLine and window events - if ( - typeof globalThis.navigator === 'undefined' || - typeof globalThis.addEventListener !== 'function' - ) { - return; - } - - // Track previous online state - let wasOnline = navigator.onLine; - - // Function to handle network state changes - const handleNetworkChange = () => { - const isOnline = navigator.onLine; - - // If we went from online to offline, purge the cache - if (wasOnline && !isOnline) { - console.log('Network connection lost - purging cache'); - try { - this._cache.flushall(); - console.log('Cache purged successfully'); - } catch (error) { - console.error('Error purging cache:', error); - } - } - - // Update the previous state - wasOnline = isOnline; - }; - - // Listen for online/offline events - globalThis.addEventListener('online', handleNetworkChange); - globalThis.addEventListener('offline', handleNetworkChange); - - // Also listen for visibility change as an additional indicator - // (some browsers don't fire offline events reliably) - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', () => { - // Small delay to allow network state to update - setTimeout(handleNetworkChange, 100); - }); - } - }; - - /** - * Prints a styled CTA in the browser console encouraging developers to - * publish their app on the Puter App Store. - * - * @private - */ - printDevCTA = function () { - if (this.quiet || globalThis.PUTER_QUIET) return; - const isDark = - globalThis.matchMedia && - globalThis.matchMedia('(prefers-color-scheme: dark)').matches; - const asciiColor = isDark ? '#7c8cff' : '#000fd8'; - const headingColor = isDark ? '#cbd5f5' : 'rgb(0, 57, 137)'; - const linkColor = isDark ? '#93c5fd' : '#3b82f6'; - const mutedColor = isDark ? '#64748b' : '#94a3b8'; - console.log( - '%c' + - ' ____ _ _ _____ _____ ____ _ ____ \n' + - '| _ \\| | | |_ _| ____| _ \\ | / ___| \n' + - '| |_) | | | | | | | _| | |_) | _ | \\___ \\ \n' + - '| __/| |_| | | | | |___| _ < | |_| |___) |\n' + - '|_| \\___/ |_| |_____|_| \\_(_)___/|____/ ', - `color: ${asciiColor}; font-weight: bold; font-size: 14px; font-family: monospace;`, - ); - console.log( - '%cSubmit this app to the Puter App Store:\n' + - '%chttps://apps.puter.com/', - `color: ${headingColor}; font-size: 18px; font-weight: bold;`, - `color: ${linkColor}; font-size: 18px; font-weight: bold; text-decoration: underline;`, - ); - console.log( - '%cTo disable this message: %cputer.quiet = true', - `color: ${mutedColor}; font-size: 11px;`, - `color: ${mutedColor}; font-size: 11px; font-style: italic;`, - ); - }; - - /** - * Shows the "Unsupported Protocol" warning dialog when the SDK is - * loaded directly from the file:// protocol. Runs once on load (when - * the DOM is ready) so the developer is told to use a web server - * immediately, instead of only when an action triggers the auth flow. - * - * @private - */ - warnUnsupportedProtocol = function () { - if (globalThis.location?.protocol !== 'file:') return; - if (this._fileProtocolWarned) return; - this._fileProtocolWarned = true; - - const showDialog = () => { - // On file:// PuterDialog renders the "Unsupported Protocol" - // warning instead of the auth consent content. - const dialog = new PuterDialog( - () => {}, - () => {}, - ); - document.body.appendChild(dialog); - dialog.open(); - }; - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', showDialog, { - once: true, - }); - } else { - showDialog(); - } - }; - - /** - * Checks and updates the GUI FS cache for most-commonly used paths - * - * @private - */ - checkAndUpdateGUIFScache = function () { - // only run in gui environment - if (puter.env !== 'gui') return; - // only run if user is authenticated - if (!puter.whoami) return; - - let username = puter.whoami.username; - - // Nothing awaits these refreshes, so a path the user doesn't - // have — or any transient failure — must not escape as an - // unhandled rejection. - const warm = (refresh) => { - refresh.catch(() => {}); - }; - - // common paths - let home_path = `/${username}`; - let desktop_path = `/${username}/Desktop`; - let documents_path = `/${username}/Documents`; - let public_path = `/${username}/Public`; - - // item:Home - if (!puter._cache.get(`item:${home_path}`)) { - console.log( - `/${username} item is not cached, refetching cache`, - ); - // fetch home - warm(puter.fs.stat(home_path)); - } - // item:Desktop - if (!puter._cache.get(`item:${desktop_path}`)) { - console.log( - `/${username}/Desktop item is not cached, refetching cache`, - ); - // fetch desktop - warm(puter.fs.stat(desktop_path)); - } - // item:Documents - if (!puter._cache.get(`item:${documents_path}`)) { - console.log( - `/${username}/Documents item is not cached, refetching cache`, - ); - // fetch documents - warm(puter.fs.stat(documents_path)); - } - // item:Public - if (!puter._cache.get(`item:${public_path}`)) { - console.log( - `/${username}/Public item is not cached, refetching cache`, - ); - // fetch public - warm(puter.fs.stat(public_path)); - } - - // readdir:Home - if (!puter._cache.get(`readdir:${home_path}`)) { - console.log(`/${username} is not cached, refetching cache`); - // fetch home - warm(puter.fs.readdir(home_path)); - } - // readdir:Desktop - if (!puter._cache.get(`readdir:${desktop_path}`)) { - console.log( - `/${username}/Desktop is not cached, refetching cache`, - ); - // fetch desktop - warm(puter.fs.readdir(desktop_path)); - } - // readdir:Documents - if (!puter._cache.get(`readdir:${documents_path}`)) { - console.log( - `/${username}/Documents is not cached, refetching cache`, - ); - // fetch documents - warm(puter.fs.readdir(documents_path)); - } - // readdir:Public - if (!puter._cache.get(`readdir:${public_path}`)) { - console.log( - `/${username}/Public is not cached, refetching cache`, - ); - // fetch public - warm(puter.fs.readdir(public_path)); - } - }; + // Initialize network connectivity monitoring and cache purging + this.initNetworkMonitoring(); } - // Create a new Puter object and return it - const puterobj = new Puter(); + /** + * @internal + * Makes a request to `/rao`. This method aquires a lock to prevent + * multiple requests, and is effectively idempotent. + */ + async request_rao_() { + await this.p_can_request_rao_; - // Return the Puter object - return puterobj; -}; + // Don't record an app open when running inside the Puter GUI, or + // when running as a Puter app (i.e. within an iframe in the GUI). + if (this.env === 'gui' || this.env === 'app') { + return; + } -export const puter = puterInit(); + // setAuthToken is called more than once when auth completes, which + // causes multiple requests to /rao. This lock prevents that. + await this.lock_rao_.acquire(); + if (this.rao_requested_) { + this.lock_rao_.release(); + return; + } + + try { + const resp = await fetchUrl(`${this.APIOrigin}/rao`, { + method: 'POST', + includePuterAuth: true, + headers: { + Origin: location.origin, // This is ignored in the browser but needed for workers and nodejs + }, + // Recording an app open is ours, not the user's: a stale + // token must not turn a page load into a sign-in prompt. + interactiveReauth: false, + }); + // Set inside the lock: a caller parked on `p_can_request_rao_` + // acquires it the moment this releases, and would otherwise + // record the same open a second time. + this.rao_requested_ = true; + return await resp.json(); + } catch (e) { + console.error(e); + } finally { + this.lock_rao_.release(); + } + } + + /** + * @returns {Promise} The + * cached user, or null when there was no token, the token was + * rejected, or the request failed. + * @internal + * Populates `puter.whoami` for callers that read the cached user + * synchronously. Non-interactive for the same reason as `/rao`: this + * runs on every load without the user asking, so a stale token must not + * raise sign-in UI. Callers that need a definite answer (and the prompt + * that comes with it) use `puter.auth.getUser()`. + * + * Uses `fetchUrl` rather than `this.auth` because `setAuthToken` runs + * before `initSubmodules` in the app environment, and carries the token + * explicitly because `includePuterAuth` reads `globalThis.puter`, which + * isn't assigned until the constructor returns. + */ + async cacheWhoami_() { + if (!this.authToken) return null; + try { + const resp = await fetchUrl(`${this.APIOrigin}/whoami`, { + authToken: this.authToken, + interactiveReauth: false, + logContext: { + service: 'auth', + operation: 'whoami', + params: {}, + }, + }); + if (!resp.ok) return null; + this.whoami = await resp.json(); + return this.whoami; + } catch (e) { + // Best-effort cache — a network failure leaves it unset. + return null; + } + } + + /** + * Instantiates a module, registers it for auth/origin updates, and + * hands it back for the caller to attach. Assigning the result to a + * named property rather than having this write `this[name]` is what + * keeps each module's type visible to consumers of the SDK. + * + * @template T + * @param {string} name + * @param {new ( + * puter: Puter, + * parameters: Record, + * ) => T} cls + * @param {Record} [parameters] + * @returns {T} + */ + registerModule(name, cls, parameters = {}) { + const instance = new cls(this, parameters); + instance.puter = this; + this[name] = instance; + if (instance._init) instance._init({ puter: this }); + return instance; + } + + /** + * Subscribes to auth token / API origin changes. Modules read both live + * off this instance, so this is only needed by the ones holding a + * connection that has to be rebuilt. + * + * @param {() => void} listener + * @returns {() => void} Unsubscribes the listener. + */ + onAuthStateChanged(listener) { + this._authStateListeners.add(listener); + return () => this._authStateListeners.delete(listener); + } + + _emitAuthStateChanged() { + for (const listener of this._authStateListeners) { + try { + listener(); + } catch (error) { + if (this.debugMode) { + console.error('Auth state listener failed', error); + } + } + } + } + + /** @param {string} appID */ + setAppID = function (appID) { + // save to localStorage + try { + localStorage.setItem('puter.app.id', appID); + } catch (error) { + // Handle the error here + console.error('Error accessing localStorage:', error); + } + this.appID = appID; + this.appDataPath = appID ? `~/AppData/${appID}` : undefined; + }; + + /** @param {string} authToken */ + setAuthToken = function (authToken) { + const normalizedAuthToken = + this.normalizeAuthTokenCandidate(authToken); + this.authToken = normalizedAuthToken; + + // Keep app identity consistent with token claims whenever available. + const tokenAppID = this.getAppIDFromAuthToken(normalizedAuthToken); + if (tokenAppID) { + this.setAppID(tokenAppID); + } + + // If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage + if (this.env === 'web' || this.env === 'app') { + try { + if (normalizedAuthToken) { + localStorage.setItem( + STORAGE_KEY_V2, + normalizedAuthToken, + ); + // Persist the origin this token is bound to alongside + // it, so a later boot only reuses it for that origin. + localStorage.setItem( + STORAGE_KEY_ORIGIN_V2, + this.APIOrigin, + ); + } else { + localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); + } + // Clear the retired key on every write, so a stale value + // never outlives the token that replaced it. + localStorage.removeItem(STORAGE_KEY_V1); + } catch (error) { + // Handle the error here + console.error('Error accessing localStorage:', error); + } + } + // initialize loop for updating caches for major directories + if (this.env === 'gui') { + // check and update gui fs cache regularly + setInterval(puter.checkAndUpdateGUIFScache, 10000); + } + this._emitAuthStateChanged(); + + // rao + this.request_rao_(); + + // perform whoami and cache results + this.whoamiCache_ = this.cacheWhoami_(); + }; + + /** + * Decides whether a stored token may be attached to requests for the + * current API origin. + * + * - A bound token may only ever be replayed to the exact origin it was + * minted against. + * - An unbound (legacy) token is only honored against the default API + * origin — never against a URL-supplied custom `puter.api_origin`. + */ + _storedTokenUsableForCurrentOrigin = function (boundOrigin) { + return isStoredTokenUsableForOrigin({ + boundOrigin, + currentOrigin: this.APIOrigin, + defaultAPIOrigin: this.defaultAPIOrigin, + }); + }; + + /** @param {string} APIOrigin */ + setAPIOrigin = function (APIOrigin) { + this.APIOrigin = APIOrigin; + this._emitAuthStateChanged(); + }; + + runWhenPuterHappensCallbacks = function () { + if (this.env !== 'gui') return; + if (!globalThis.when_puter_happens) return; + + const callbacks = Array.isArray(globalThis.when_puter_happens) + ? globalThis.when_puter_happens + : [globalThis.when_puter_happens]; + + for (const fn of callbacks) { + try { + fn({ puter: this }); + } catch (error) { + if (this.debugMode) { + console.error( + 'when_puter_happens callback failed', + error, + ); + } + } + } + }; + + /** + * Forget the current token, in memory and (on a 3rd-party site or an + * app) in localStorage. Callers own the surrounding policy — emitting + * events, driving reauth — this only drops the value. + * + * @internal + */ + _clearAuthToken = function () { + this.authToken = null; + if (this.env === 'web' || this.env === 'app') { + try { + localStorage.removeItem(STORAGE_KEY_V2); + localStorage.removeItem(STORAGE_KEY_ORIGIN_V2); + localStorage.removeItem(STORAGE_KEY_V1); + } catch (error) { + // Handle the error here + console.error('Error accessing localStorage:', error); + } + } + }; + + resetAuthToken = function () { + if (this.env === 'web-worker' || this.env === 'service-worker') { + throw new Error( + 'Sign out is not permitted from WebWorkers or ServiceWorkers', + ); + } + this._clearAuthToken(); + this._emitAuthStateChanged(); + }; + + /** + * Reauth coordinator. Called by the network layer (lib/utils.js) when + * the backend returns `401 { code: 'reauth_required', reason, auth_id + * }`. + * + * Behavior is environment-specific: + * + * - `web` / `app`: clear the stored token, emit an event, and drive the + * existing puter.com login popup. Returns a promise that resolves + * when the user signs in (so callers can replay) or rejects if reauth + * fails / is canceled. + * - `gui`: no-op — the GUI environment renders its own modal and host + * code is responsible for the flow. + * - Workers / nodejs: there's no UI surface to drive, so reject with a + * structured error and let worker code react. + * + * Idempotent: parallel callers share a single in-flight promise. + * + * @param {{ reason?: string; auth_id?: string }} signal + */ + triggerReauth = async function (signal = {}) { + const { reason, auth_id } = signal; + if (this._reauthInflight) return this._reauthInflight; + + // Emit before clearing so listeners can read state if needed. + this._emitReauthEvent({ reason, auth_id }); + + // Drop the stored token immediately so a failed/canceled reauth + // doesn't leave a poisoned value in localStorage. The new token + // (if reauth succeeds) is written by setAuthToken downstream. + this._clearAuthToken(); + this._emitAuthStateChanged(); + + this._reauthInflight = (async () => { + if (this.env === 'gui') { + // GUI handles its own modal at the layer above puter-js. + return; + } + if ( + this.env === 'web-worker' || + this.env === 'service-worker' || + this.env === 'nodejs' + ) { + const err = new Error('reauth_required'); + err.code = 'reauth_required'; + err.reason = reason; + err.auth_id = auth_id; + throw err; + } + if (this.env === 'web') { + // Drives the puter.com login popup. On success, the + // postMessage handler at the bottom of this file calls + // setAuthToken() and updates this.authToken. + await this.ui.authenticateWithPuter({ auth_id, reason }); + return; + } + if (this.env === 'app') { + try { + globalThis.parent?.postMessage?.( + { + msg: 'reauth_required', + appInstanceID: this.appInstanceID, + reason, + auth_id, + }, + this.defaultGUIOrigin, + ); + } catch (e) { + // Best-effort: if postMessage isn't available + // (sandboxed iframe), fall through to error. + } + // Wait for the parent to deliver a fresh token. + // Validate both event.origin AND event.source — origin + // alone lets any same-origin frame on the GUI domain + // deliver a token; pinning source to globalThis.parent + // ensures the message came from the actual embedder. + await new Promise((resolve, reject) => { + const expectedSource = globalThis.parent; + const onToken = (event) => { + if (event.origin !== this.defaultGUIOrigin) return; + if ( + expectedSource && + event.source !== expectedSource + ) + return; + if (event.data?.msg !== 'puter.token') return; + globalThis.removeEventListener('message', onToken); + resolve(); + }; + globalThis.addEventListener?.('message', onToken); + // Give the user a generous window to re-auth. + setTimeout( + () => { + globalThis.removeEventListener?.( + 'message', + onToken, + ); + reject(new Error('reauth_timeout')); + }, + 5 * 60 * 1000, + ); + }); + } + })(); + + try { + await this._reauthInflight; + } finally { + this._reauthInflight = null; + } + }; + + /** + * @param {{ + * reason?: string; + * auth_id?: string; + * sentToken?: string; + * }} signal + * @internal + * The non-interactive half of the reauth policy, called by the network + * layer (lib/networkUtils.js) when a request the user didn't initiate + * comes back `401 { code: 'reauth_required' | 'token_auth_failed' }`. + * + * Boot-time telemetry and cache warmers run on every page load, so + * escalating their 401 to `triggerReauth` puts a sign-in popup (or the + * consent dialog, when there's no user activation to open one with) in + * front of a visitor who did nothing but load the page. Instead: forget + * the dead token and announce it, leaving the prompt to the next call + * the user actually makes. + * + * `sentToken` is the token the failed request carried. A reauth may have + * completed while it was in flight, so a token that no longer matches is + * left alone rather than signing the user back out. + */ + dropStaleAuthToken = function ({ reason, auth_id, sentToken } = {}) { + if (sentToken && sentToken !== this.authToken) return; + this._emitReauthEvent({ reason, auth_id }); + this._clearAuthToken(); + this._emitAuthStateChanged(); + }; + + _emitReauthEvent = function ({ reason, auth_id }) { + try { + const handlers = + this.eventHandlers?.['puter.auth.reauth_required']; + if (Array.isArray(handlers)) { + for (const h of handlers) { + try { + h({ reason, auth_id }); + } catch (e) { + /* swallow per-handler errors */ + } + } + } + } catch (e) { + // Never let event delivery break the reauth flow itself. + } + }; + + /** + * Register a listener for SDK events. Used by host apps to react to + * `puter.auth.reauth_required`. + */ + on = function (eventName, handler) { + if (!this.eventHandlers[eventName]) + this.eventHandlers[eventName] = []; + this.eventHandlers[eventName].push(handler); + return () => this.off(eventName, handler); + }; + + off = function (eventName, handler) { + const handlers = this.eventHandlers[eventName]; + if (!handlers) return; + const idx = handlers.indexOf(handler); + if (idx >= 0) handlers.splice(idx, 1); + }; + + /** + * @internal + * Delete a token left behind under the retired `puter.auth.token` key. + * The backend no longer honors that format, so a visitor holding one is + * simply signed out — sending it would only earn a 401, and leaving it + * in storage would keep tempting later readers. + */ + discardRetiredAuthToken_ = function () { + try { + localStorage.removeItem(STORAGE_KEY_V1); + } catch (e) { + // No storage to clean up. + } + }; + + exit = function (statusCode = 0) { + if (statusCode && typeof statusCode !== 'number') { + console.warn( + 'puter.exit() requires status code to be a number. Treating it as 1', + ); + statusCode = 1; + } + + globalThis.parent.postMessage( + { + msg: 'exit', + appInstanceID: this.appInstanceID, + statusCode, + }, + '*', + ); + }; + + /** + * A function that generates a domain-safe name by combining a random + * adjective, a random noun, and a random number (between 0 and 9999). + * The result is returned as a string with components separated by + * hyphens. It is useful when you need to create unique identifiers that + * are also human-friendly. + * + * @param {string} [separateWith='-'] - The character to use to separate + * the components of the generated name. Default is `'-'` + * @returns {string} A unique, hyphen-separated string comprising of an + * adjective, a noun, and a number. + */ + randName = function (separateWith = '-') { + const first_adj = [ + 'helpful', + 'sensible', + 'loyal', + 'honest', + 'clever', + 'capable', + 'calm', + 'smart', + 'genius', + 'bright', + 'charming', + 'creative', + 'diligent', + 'elegant', + 'fancy', + 'colorful', + 'avid', + 'active', + 'gentle', + 'happy', + 'intelligent', + 'jolly', + 'kind', + 'lively', + 'merry', + 'nice', + 'optimistic', + 'polite', + 'quiet', + 'relaxed', + 'silly', + 'victorious', + 'witty', + 'young', + 'zealous', + 'strong', + 'brave', + 'agile', + 'bold', + ]; + + const nouns = [ + 'street', + 'roof', + 'floor', + 'tv', + 'idea', + 'morning', + 'game', + 'wheel', + 'shoe', + 'bag', + 'clock', + 'pencil', + 'pen', + 'magnet', + 'chair', + 'table', + 'house', + 'dog', + 'room', + 'book', + 'car', + 'cat', + 'tree', + 'flower', + 'bird', + 'fish', + 'sun', + 'moon', + 'star', + 'cloud', + 'rain', + 'snow', + 'wind', + 'mountain', + 'river', + 'lake', + 'sea', + 'ocean', + 'island', + 'bridge', + 'road', + 'train', + 'plane', + 'ship', + 'bicycle', + 'horse', + 'elephant', + 'lion', + 'tiger', + 'bear', + 'zebra', + 'giraffe', + 'monkey', + 'snake', + 'rabbit', + 'duck', + 'goose', + 'penguin', + 'frog', + 'crab', + 'shrimp', + 'whale', + 'octopus', + 'spider', + 'ant', + 'bee', + 'butterfly', + 'dragonfly', + 'ladybug', + 'snail', + 'camel', + 'kangaroo', + 'koala', + 'panda', + 'piglet', + 'sheep', + 'wolf', + 'fox', + 'deer', + 'mouse', + 'seal', + 'chicken', + 'cow', + 'dinosaur', + 'puppy', + 'kitten', + 'circle', + 'square', + 'garden', + 'otter', + 'bunny', + 'meerkat', + 'harp', + ]; + + // return a random combination of first_adj + noun + number (between 0 and 9999) + // e.g. clever-idea-123 + return ( + first_adj[Math.floor(Math.random() * first_adj.length)] + + separateWith + + nouns[Math.floor(Math.random() * nouns.length)] + + separateWith + + Math.floor(Math.random() * 10000) + ); + }; + + getUser = function (...args) { + let options; + + // If first argument is an object, it's the options + if (typeof args[0] === 'object' && args[0] !== null) { + options = args[0]; + } else { + // Otherwise, we assume separate arguments are provided + options = { + success: args[0], + error: args[1], + }; + } + + return new Promise((resolve, reject) => { + const xhr = utils.initXhr( + '/whoami', + this.APIOrigin, + this.authToken, + 'get', + ); + // set up event handlers for load and error events + utils.setupXhrEventHandlers( + xhr, + options.success, + options.error, + resolve, + reject, + ); + + xhr.send(); + }); + }; + + print = function (...args) { + // Check if the last argument is an options object with escapeHTML or code property + let options = {}; + if ( + args.length > 0 && + typeof args[args.length - 1] === 'object' && + args[args.length - 1] !== null && + ('escapeHTML' in args[args.length - 1] || + 'code' in args[args.length - 1]) + ) { + options = args.pop(); + } + + for (let arg of args) { + // Escape HTML if the option is set to true or if code option is true + if ( + (options.escapeHTML === true || options.code === true) && + typeof arg === 'string' + ) { + arg = arg + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Wrap in code/pre tags if code option is true + if (options.code === true) { + arg = `
${arg}
`; + } + + document.body.innerHTML += arg; + } + }; + + /** + * Configures API call logging settings + * + * @param {import('./lib/types.js').APILoggingConfig} [config] + * @returns {this} + */ + configureAPILogging = function (config = {}) { + if (this.apiCallLogger) { + this.apiCallLogger.updateConfig(config); + } + return this; + }; + + /** + * Enables API call logging with optional configuration + * + * @param {import('./lib/types.js').APILoggingConfig} [config] + * @returns {this} + */ + enableAPILogging = function (config = {}) { + if (this.apiCallLogger) { + this.apiCallLogger.updateConfig({ ...config, enabled: true }); + } + return this; + }; + + /** + * Disables API call logging + * + * @returns {this} + */ + disableAPILogging = function () { + if (this.apiCallLogger) { + this.apiCallLogger.disable(); + } + return this; + }; + + /** + * Initializes network connectivity monitoring to purge cache when + * connection is lost + * + * @internal + */ + initNetworkMonitoring = function () { + // Only initialize in environments that support navigator.onLine and window events + if ( + typeof globalThis.navigator === 'undefined' || + typeof globalThis.addEventListener !== 'function' + ) { + return; + } + + // Track previous online state + let wasOnline = navigator.onLine; + + // Function to handle network state changes + const handleNetworkChange = () => { + const isOnline = navigator.onLine; + + // If we went from online to offline, purge the cache + if (wasOnline && !isOnline) { + console.log('Network connection lost - purging cache'); + try { + this._cache.flushall(); + console.log('Cache purged successfully'); + } catch (error) { + console.error('Error purging cache:', error); + } + } + + // Update the previous state + wasOnline = isOnline; + }; + + // Listen for online/offline events + globalThis.addEventListener('online', handleNetworkChange); + globalThis.addEventListener('offline', handleNetworkChange); + + // Also listen for visibility change as an additional indicator + // (some browsers don't fire offline events reliably) + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + // Small delay to allow network state to update + setTimeout(handleNetworkChange, 100); + }); + } + }; + + /** + * Prints a styled CTA in the browser console encouraging developers to + * publish their app on the Puter App Store. + * + * @internal + */ + printDevCTA = function () { + if (this.quiet || globalThis.PUTER_QUIET) return; + const isDark = + globalThis.matchMedia && + globalThis.matchMedia('(prefers-color-scheme: dark)').matches; + const asciiColor = isDark ? '#7c8cff' : '#000fd8'; + const headingColor = isDark ? '#cbd5f5' : 'rgb(0, 57, 137)'; + const linkColor = isDark ? '#93c5fd' : '#3b82f6'; + const mutedColor = isDark ? '#64748b' : '#94a3b8'; + console.log( + '%c' + + ' ____ _ _ _____ _____ ____ _ ____ \n' + + '| _ \\| | | |_ _| ____| _ \\ | / ___| \n' + + '| |_) | | | | | | | _| | |_) | _ | \\___ \\ \n' + + '| __/| |_| | | | | |___| _ < | |_| |___) |\n' + + '|_| \\___/ |_| |_____|_| \\_(_)___/|____/ ', + `color: ${asciiColor}; font-weight: bold; font-size: 14px; font-family: monospace;`, + ); + console.log( + '%cSubmit this app to the Puter App Store:\n' + + '%chttps://apps.puter.com/', + `color: ${headingColor}; font-size: 18px; font-weight: bold;`, + `color: ${linkColor}; font-size: 18px; font-weight: bold; text-decoration: underline;`, + ); + console.log( + '%cTo disable this message: %cputer.quiet = true', + `color: ${mutedColor}; font-size: 11px;`, + `color: ${mutedColor}; font-size: 11px; font-style: italic;`, + ); + }; + + /** + * Shows the "Unsupported Protocol" warning dialog when the SDK is + * loaded directly from the file:// protocol. Runs once on load (when + * the DOM is ready) so the developer is told to use a web server + * immediately, instead of only when an action triggers the auth flow. + * + * @internal + */ + warnUnsupportedProtocol = function () { + if (globalThis.location?.protocol !== 'file:') return; + if (this._fileProtocolWarned) return; + this._fileProtocolWarned = true; + + const showDialog = () => { + // On file:// PuterDialog renders the "Unsupported Protocol" + // warning instead of the auth consent content. + const dialog = new PuterDialog( + () => {}, + () => {}, + ); + document.body.appendChild(dialog); + dialog.open(); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', showDialog, { + once: true, + }); + } else { + showDialog(); + } + }; + + /** + * Checks and updates the GUI FS cache for most-commonly used paths + * + * @internal + */ + checkAndUpdateGUIFScache = function () { + // only run in gui environment + if (puter.env !== 'gui') return; + // only run if user is authenticated + if (!puter.whoami) return; + + let username = puter.whoami.username; + + // Nothing awaits these refreshes, so a path the user doesn't + // have — or any transient failure — must not escape as an + // unhandled rejection. + const warm = (refresh) => { + refresh.catch(() => {}); + }; + + // common paths + let home_path = `/${username}`; + let desktop_path = `/${username}/Desktop`; + let documents_path = `/${username}/Documents`; + let public_path = `/${username}/Public`; + + // item:Home + if (!puter._cache.get(`item:${home_path}`)) { + console.log( + `/${username} item is not cached, refetching cache`, + ); + // fetch home + warm(puter.fs.stat(home_path)); + } + // item:Desktop + if (!puter._cache.get(`item:${desktop_path}`)) { + console.log( + `/${username}/Desktop item is not cached, refetching cache`, + ); + // fetch desktop + warm(puter.fs.stat(desktop_path)); + } + // item:Documents + if (!puter._cache.get(`item:${documents_path}`)) { + console.log( + `/${username}/Documents item is not cached, refetching cache`, + ); + // fetch documents + warm(puter.fs.stat(documents_path)); + } + // item:Public + if (!puter._cache.get(`item:${public_path}`)) { + console.log( + `/${username}/Public item is not cached, refetching cache`, + ); + // fetch public + warm(puter.fs.stat(public_path)); + } + + // readdir:Home + if (!puter._cache.get(`readdir:${home_path}`)) { + console.log(`/${username} is not cached, refetching cache`); + // fetch home + warm(puter.fs.readdir(home_path)); + } + // readdir:Desktop + if (!puter._cache.get(`readdir:${desktop_path}`)) { + console.log( + `/${username}/Desktop is not cached, refetching cache`, + ); + // fetch desktop + warm(puter.fs.readdir(desktop_path)); + } + // readdir:Documents + if (!puter._cache.get(`readdir:${documents_path}`)) { + console.log( + `/${username}/Documents is not cached, refetching cache`, + ); + // fetch documents + warm(puter.fs.readdir(documents_path)); + } + // readdir:Public + if (!puter._cache.get(`readdir:${public_path}`)) { + console.log( + `/${username}/Public is not cached, refetching cache`, + ); + // fetch public + warm(puter.fs.readdir(public_path)); + } + }; +} + +export const puter = new Puter(); export default puter; globalThis.puter = puter; puter.runWhenPuterHappensCallbacks(); diff --git a/src/puter-js/src/init.d.cts b/src/puter-js/src/init.d.cts index b6ed80151..bcc5a1519 100644 --- a/src/puter-js/src/init.d.cts +++ b/src/puter-js/src/init.d.cts @@ -1,4 +1,4 @@ -import type { Puter } from '../types/puter.d.ts'; +import type { Puter } from '../types/index.js'; export declare function init(authToken?: string): Puter; export declare function getAuthToken(guiOrigin?: string): Promise; diff --git a/src/puter-js/src/lib/EventListener.js b/src/puter-js/src/lib/EventListener.js index eedc41719..c7d4d012a 100644 --- a/src/puter-js/src/lib/EventListener.js +++ b/src/puter-js/src/lib/EventListener.js @@ -1,6 +1,15 @@ /** * Minimal named-event emitter. Subclasses declare the event names they * support; listening for anything else is reported and ignored. + * + * A subclass names its events — and the payload each one carries — by passing + * an event map through `@extends`, which is what gives callers a typed + * `handler` argument per event name: + * + * /** @extends {EventListener<{ open: void, data: Uint8Array }>} *\/ + * class MySocket extends EventListener { ... } + * + * @template {Record} [EventMap=Record] */ export default class EventListener { // Array of all supported event names. @@ -9,7 +18,7 @@ export default class EventListener { // Map of eventName -> array of listeners #eventListeners; - /** @param {string[]} eventNames */ + /** @param {(keyof EventMap & string)[] | string[]} eventNames */ constructor (eventNames) { this.#eventNames = eventNames; @@ -23,8 +32,11 @@ export default class EventListener { } /** - * @param {string} eventName - * @param {unknown} [data] + * Calls every handler registered for `eventName` with `data`. + * + * @template {keyof EventMap & string} K + * @param {K} eventName + * @param {EventMap[K]} [data] * @returns {void} */ emit (eventName, data) { @@ -38,8 +50,12 @@ export default class EventListener { } /** - * @param {string} eventName - * @param {(data: never) => void} callback + * Registers `callback` for `eventName`. Returns `undefined` — after + * reporting it — when the event is not one this emitter supports. + * + * @template {keyof EventMap & string} K + * @param {K} eventName + * @param {(data: EventMap[K]) => void} callback * @returns {this | undefined} */ on (eventName, callback) { @@ -52,8 +68,11 @@ export default class EventListener { } /** - * @param {string} eventName - * @param {(data: never) => void} callback + * Removes a handler previously registered with `on`. + * + * @template {keyof EventMap & string} K + * @param {K} eventName + * @param {(data: EventMap[K]) => void} callback * @returns {this | undefined} */ off (eventName, callback) { @@ -68,4 +87,4 @@ export default class EventListener { } return this; } -} \ No newline at end of file +} diff --git a/src/puter-js/src/lib/PuterModule.js b/src/puter-js/src/lib/PuterModule.js index a9cb0cdd7..dc001e7b2 100644 --- a/src/puter-js/src/lib/PuterModule.js +++ b/src/puter-js/src/lib/PuterModule.js @@ -1,4 +1,4 @@ -/** @typedef {import('../../types/puter').Puter} Puter */ +/** @typedef {import('../index.js').Puter} Puter */ /** * Base for the `puter.*` modules. Holds the owning Puter instance and reads diff --git a/src/puter-js/src/lib/types.js b/src/puter-js/src/lib/types.js index e1c2cbf42..aaaf1206f 100644 --- a/src/puter-js/src/lib/types.js +++ b/src/puter-js/src/lib/types.js @@ -1,4 +1,8 @@ -// Shared JSDoc-only utility types. No runtime exports. +// Shared JSDoc-only types. No runtime exports. +// +// These are the shapes more than one module needs. Everything here is emitted +// into `types/lib/types.d.ts` by `npm run build:puterjs:types`; nothing under +// `types/` is written by hand. /** * Constructor of `Class` whose instances omit the `Keys` members. Cast a @@ -13,4 +17,92 @@ * @typedef {new (...args: ConstructorParameters) => Omit, Keys>} OmitMembers */ +/** + * The environment the SDK is running in. + * + * @typedef {'app' | 'gui' | 'web' | 'web-worker' | 'service-worker' | 'nodejs'} PuterEnvironment + */ + +/** + * The legacy positional callbacks most methods accept alongside the promise + * they return. + * + * @template [T=unknown] + * @typedef {Object} RequestCallbacks + * @property {(value: T) => void} [success] Called with the result once the call succeeds. + * @property {(reason: unknown) => void} [error] Called with the rejection reason if the call fails. + */ + +/** + * @typedef {Object} APILoggingConfigOwn + * @property {boolean} [enabled] Whether request logging is on. + */ + +/** @typedef {APILoggingConfigOwn & Record} APILoggingConfig */ + +/** + * Standard pagination request params shared by list APIs + * (`puter.apps.list()`, `puter.hosting.list()`, `puter.workers.list()`, + * `puter.fs.readdir()`). + * + * @typedef {Object} ListPaginationOptions + * @property {number} [limit] Maximum items per page. Each endpoint documents its cap and default. + * @property {number} [offset] Skips the given number of items. Cannot be combined with `cursor` or + * `stream`; prefer `cursor` — requests get slower and more expensive the larger the offset. + * @property {string | null} [cursor] Opaque continuation cursor. Pass `null` for the first page, then + * each page's `cursor` to fetch the next one. + * @property {boolean} [includeTotal] When `true`, the result includes a `total` count of every item + * across all pages. + */ + +/** + * One page of a paginated listing. + * + * @template [T=unknown] + * @typedef {Object} ListPage + * @property {T[]} items The items on this page. A page may hold fewer than `limit` items while more pages exist. + * @property {string} [cursor] Present only while more pages exist; pass it to the next call to resume. + * @property {number} [total] Total item count across all pages; present when requested via `includeTotal`. + */ + +/** + * The `stream: true` form of list methods: returns an async iterator of + * pages for `for await ... of` instead of a promise. + * + * @typedef {Object} ListStreamOptions + * @property {true} stream Stream page envelopes as they are fetched. + * @property {number} [limit] Maximum items per page. Defaults to the endpoint's page size. + * @property {string | null} [cursor] Start streaming from a previous page's `cursor` instead of the beginning. + * @property {boolean} [includeTotal] Include a `total` count on the first streamed page. + */ + +/** + * @deprecated Superseded by {@link ListPaginationOptions}; kept for callers still + * passing the page/per-page form. + * + * @typedef {Object} PaginationOptions + * @property {number} [page] + * @property {number} [per_page] + */ + +/** + * @deprecated Superseded by {@link ListPage}. + * + * @template [T=unknown] + * @typedef {Object} PaginatedResult + * @property {T[]} data + * @property {number} [page] + * @property {number} [pages] + */ + +/** + * A tool the SDK exposes to a parent app over the `puter.tools` bridge. + * + * @typedef {Object} ToolSchema + * @property {{ name: string, description: string, parameters: Record, strict?: boolean }} function + * The tool's JSON-schema description, in OpenAI function-calling form. + * @property {(parameters: Record) => unknown | Promise} exec + * Runs the tool with the parameters the caller supplied. + */ + export {}; diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index eff497d2f..09eecb380 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -4,16 +4,88 @@ import { PuterModule } from '../lib/PuterModule.js'; import PuterDialog from './PuterDialog.js'; import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js'; -/** @typedef {import('../../types/modules/auth').DetailedAppUsage} DetailedAppUsage */ -/** @typedef {import('../../types/modules/auth').MonthlyUsage} MonthlyUsage */ -/** @typedef {import('../../types/modules/auth').SignInResult} SignInResult */ -/** @typedef {import('../../types/modules/auth').User} User */ +/** + * Puter user details, as returned by `getUser()`. + * + * @typedef {Object} User + * @property {string} uuid Unique identifier of the user. + * @property {string} username The user's username. + * @property {boolean | number} [email_confirmed] Whether the user's email address has been confirmed. + * @property {number} [actual_free_storage] The user's free storage. + * @property {string} [app_name] The current active app. + * @property {number} [created_ts] When the account was created, in unix seconds. Only returned to user + * tokens — apps acting on a user's behalf do not receive it. + * @property {Record} [feature_flags] + * @property {boolean} [hasDevAccountAccess] + * @property {boolean} [is_temp] Whether the user's account is temporary. + * @property {number} [last_activity_ts] The user's last active timestamp. + * @property {boolean} [otp] + * @property {number} [paid_storage] The amount of paid storage. + * @property {string} [referral_code] The user's referral code. + * @property {boolean | number} [requires_email_confirmation] Whether the user's account needs email confirmation. + * @property {boolean} [subscribed] Whether the user is subscribed. + */ + +/** + * Information about the user's resource allowance and consumption. + * + * @typedef {Object} AllowanceInfo + * @property {number} monthUsageAllowance Total resource allowance for the month. + * @property {number} remaining The remaining allowance that can be used. + */ + +/** + * Total usage for a single application. + * + * @typedef {Object} AppUsage + * @property {number} count Number of Puter API calls for the application. + * @property {number} total Total resources consumed by the application. + */ + +/** + * Usage information for a single API. + * + * @typedef {Object} APIUsage + * @property {number} cost Total resource consumed by this API. + * @property {number} count Number of times the API is called. + * @property {number} units Units of measurement for the API (e.g. tokens for AI calls, bytes for FS + * operations). + */ + +/** + * The user's monthly resource usage in the Puter ecosystem. Resources are + * measured in microcents (e.g. `$0.01` = `1,000,000`). + * + * @typedef {Object} MonthlyUsage + * @property {AllowanceInfo} allowanceInfo The user's resource allowance and consumption. + * @property {Record} appTotals Total usage by application, keyed by application id. + * @property {Record} usage Usage information per API, keyed by API name. + */ + +/** + * Detailed resource usage statistics for a specific application. Resources are + * measured in microcents (e.g. `$0.01` = `1,000,000`). + * + * @typedef {{ total: number } & Record} DetailedAppUsage + */ + +/** + * The result of a sign-in operation. + * + * @typedef {Object} SignInResult + * @property {boolean} success Whether the sign-in operation was successful. + * @property {string} token The authentication token. + * @property {string} [app_uid] Unique identifier of the application. + * @property {string} [username] Username of the user who signed in. + * @property {string} [error] Error message if the sign-in operation failed. + * @property {string} [msg] Additional message about the sign-in operation. + */ /** * The `puter.auth` module. Most Puter methods authenticate on their own; these * are for apps that drive the sign-in flow themselves. */ -class Auth extends PuterModule { +export class AuthModule extends PuterModule { // Used to generate a unique message id for each message sent to the host environment // we start from 1 because 0 is falsy and we want to avoid that for the message id #messageID = 1; @@ -323,4 +395,16 @@ class Auth extends PuterModule { } } +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../lib/types.js').OmitMembers< + * typeof AuthModule, + * 'puter' | 'authToken' + * >} AuthConstructor + */ + +export const Auth = /** @type {AuthConstructor} */ (AuthModule); + export default Auth; diff --git a/src/puter-js/src/modules/Debug.js b/src/puter-js/src/modules/Debug.js index 085b48e01..e5b59901b 100644 --- a/src/puter-js/src/modules/Debug.js +++ b/src/puter-js/src/modules/Debug.js @@ -1,4 +1,12 @@ +/** + * Turns on the SDK's category loggers, either from an `enabled_logs` query + * parameter or on request from the parent window. + */ export class Debug { + /** + * @param {import('../index.js').Puter} puter + * @param {Record} [parameters] + */ constructor (puter, parameters) { this.puter = puter; this.parameters = parameters; diff --git a/src/puter-js/src/modules/Drivers.js b/src/puter-js/src/modules/Drivers.js index ca306c4a2..cc8430842 100644 --- a/src/puter-js/src/modules/Drivers.js +++ b/src/puter-js/src/modules/Drivers.js @@ -5,9 +5,9 @@ import { driverCallEnvelope, fetchUrl } from '../lib/networkUtils.js'; * `puter.drivers.get()`. Calls resolve the response envelope rather than the * unwrapped result — see `driverCallEnvelope`. */ -class Driver { +export class Driver { /** - * @param {import('../../types/puter').Puter} puter + * @param {import('../index.js').Puter} puter * @param {string} ifaceName */ constructor (puter, ifaceName) { @@ -30,8 +30,11 @@ class Driver { } } -class Drivers { - /** @param {import('../../types/puter').Puter} puter */ +/** + * The `puter.drivers` module: call driver interfaces directly. + */ +export class DriversModule { + /** @param {import('../index.js').Puter} puter */ constructor (puter) { this.puter = puter; this.drivers_ = {}; @@ -98,4 +101,16 @@ class Drivers { } } +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the driver cache omitted. + * + * @typedef {import('../lib/types.js').OmitMembers< + * typeof DriversModule, + * 'puter' | 'drivers_' | '_init' + * >} DriversConstructor + */ + +export const Drivers = /** @type {DriversConstructor} */ (DriversModule); + export default Drivers; diff --git a/src/puter-js/src/modules/Email.js b/src/puter-js/src/modules/Email.js index 0e9fff73f..d88ae1f94 100644 --- a/src/puter-js/src/modules/Email.js +++ b/src/puter-js/src/modules/Email.js @@ -1,8 +1,46 @@ import { PuterModule } from '../lib/PuterModule.js'; import * as utils from '../lib/utils.js'; -/** @typedef {import('../../types/modules/email').EmailSendOptions} EmailSendOptions */ -/** @typedef {import('../../types/modules/email').EmailSendResult} EmailSendResult */ +/** + * One attachment: either inline base64 `content`, or a Puter FS reference + * (`path`/`uid`) read server-side with the caller's — falling back to the + * authorizing worker's — file permissions. + * + * @typedef {Object} EmailAttachment + * @property {string} [filename] Required with `content`; defaults to the file's name for FS refs. + * @property {string} [content] Base64 file body. Mutually exclusive with `path`/`uid`. + * @property {string} [path] Puter FS path (supports `~/`). Mutually exclusive with `content`. + * @property {string} [uid] Puter FS entry uid. Mutually exclusive with `content`. + * @property {string} [contentType] MIME type of the attachment. + */ + +/** + * The options form of `send()`. + * + * @typedef {Object} EmailSendOptions + * @property {string | string[]} to Recipient address(es). + * @property {string} subject + * @property {string} [text] Plain-text body. At least one of `text` / `html` is required. + * @property {string} [html] HTML body. + * @property {string | string[]} [cc] + * @property {string | string[]} [bcc] + * @property {string} [replyTo] + * @property {string} [emailAccessToken] A worker's auth token authorizing the send when the caller is + * not itself a worker (inside a worker: `me.puter.authToken`). The caller stays the billed and + * rate-limited identity. + * @property {EmailAttachment[]} [attachments] + */ + +/** + * What one `send()` resolves to. + * + * @typedef {Object} EmailSendResult + * @property {string | null} messageId First transport message id reported for this send, when available. + * @property {number} cost Total charge for this send, in microcents. + * @property {string[]} suppressed Recipients omitted because they opted out of this sender's mail. + * @property {string[]} failed Recipients whose delivery attempt failed. Everyone else got their copy — + * retry with just these addresses. A send where every delivery fails rejects instead. + */ /** * Restricted outbound email (the `puter-email` driver interface). @@ -37,7 +75,7 @@ import * as utils from '../lib/utils.js'; * their copy — retry with just those addresses); the call only rejects * when no recipient could be delivered. */ -class Email extends PuterModule { +export class EmailModule extends PuterModule { /** * Sends one email. The positional form is shorthand for a plain-text body; * everything else (html, cc/bcc, attachments, `emailAccessToken`) goes @@ -67,4 +105,16 @@ class Email extends PuterModule { }); } +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../lib/types.js').OmitMembers< + * typeof EmailModule, + * 'puter' | 'authToken' + * >} EmailConstructor + */ + +export const Email = /** @type {EmailConstructor} */ (EmailModule); + export default Email; diff --git a/src/puter-js/src/modules/FSItem.js b/src/puter-js/src/modules/FSItem.js index 5f4390190..c3411c3d7 100644 --- a/src/puter-js/src/modules/FSItem.js +++ b/src/puter-js/src/modules/FSItem.js @@ -1,12 +1,38 @@ import path from 'path-browserify'; -/** @typedef {import('../../types/modules/fs-item').FSItem} FSItemType */ +/** + * The `/sign`-endpoint shape of an item, as `_internalProperties.file_signature` + * computes it. Passing it to `launch_app` lets another app open the file. + * + * @typedef {Object} FileSignatureInfo + * @property {string} [read_url] + * @property {string} [write_url] + * @property {string} [metadata_url] + * @property {number} [fsentry_accessed] + * @property {number} [fsentry_modified] + * @property {number} [fsentry_created] + * @property {boolean} [fsentry_is_dir] + * @property {number | null} [fsentry_size] + * @property {string} [fsentry_name] + * @property {string} [path] + * @property {string} [uid] + */ + +/** + * Non-enumerable bag of values that are not part of the public item surface + * and may change or disappear. + * + * @typedef {Object} InternalFSProperties + * @property {string | null} [signature] + * @property {string | null} [expires] + * @property {FileSignatureInfo} file_signature + */ /** * A file or a directory in the Puter file system. Accepts an entry in any of * the shapes the API returns it in (`is_dir`, `fsentry_is_dir`, …). */ -class FSItem { +export class FSItem { // Declared rather than left to inference: the constructor reads each one // off a loosely-typed entry, which would otherwise make them all // `unknown` to consumers. @@ -172,7 +198,7 @@ class FSItem { * Writes data to the file, replacing its contents. * * @param {string | File | Blob | ArrayBuffer | ArrayBufferView} data - * @returns {Promise} + * @returns {Promise} */ write = async function (data) { return puter.fs.write(this.path, data, { @@ -183,13 +209,12 @@ class FSItem { // -- Not implemented yet -- // These are part of the published surface but are still stubs: they accept - // their arguments and do nothing. Declared in types/modules/fs-item.d.ts - // as placeholders too. + // their arguments and do nothing. /** * Would call `callback` with the item whenever it changes. * - * @type {(callback: (item: FSItemType) => void) => void} + * @type {(callback: (item: FSItem) => void) => void} */ watch = function (callback) { // todo - implement @@ -198,7 +223,7 @@ class FSItem { /** * Would open the item in its associated app. * - * @type {(callback: (item: FSItemType) => void) => void} + * @type {(callback: (item: FSItem) => void) => void} */ open = function (callback) { // todo - implement @@ -217,7 +242,7 @@ class FSItem { * Renames the item. * * @param {string} newName - * @returns {Promise} + * @returns {Promise} */ rename = function (newName) { // Address by uid when we have one: the item may have been moved since @@ -234,7 +259,7 @@ class FSItem { * @param {string} destination * @param {boolean} [overwrite] * @param {string} [newName] - * @returns {Promise} + * @returns {Promise} */ move = function (destination, overwrite = false, newName) { return puter.fs.move(this.path, destination, { overwrite, newName }); @@ -246,7 +271,7 @@ class FSItem { * @param {string} destinationDirectory * @param {boolean} [autoRename] pick a free name instead of conflicting * @param {boolean} [overwrite] - * @returns {Promise} + * @returns {Promise} */ copy = function (destinationDirectory, autoRename, overwrite = false) { return puter.fs.copy(this.path, destinationDirectory, { @@ -290,7 +315,7 @@ class FSItem { * * @param {string} name * @param {boolean} [autoRename] pick a free name instead of conflicting - * @returns {Promise} + * @returns {Promise} */ mkdir = async function (name, autoRename = false) { // Don't proceed if this is not a directory, throw error @@ -314,7 +339,7 @@ class FSItem { /** * Lists the contents of this directory. * - * @returns {Promise} + * @returns {Promise} */ readdir = async function () { // Don't proceed if this is not a directory, throw error diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index 87bb409ac..459781592 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -26,6 +26,15 @@ import stat from './operations/stat.js'; import upload from './operations/upload/index.js'; import write from './operations/write.js'; +/** + * The Cloud Storage API. Lets you store and manage files and directories in + * the cloud. + * + * Operation implementations live under `operations/` as `this`-context + * functions whose JSDoc (including the per-form `@overload` declarations) is + * the source of truth for the public signatures — `types/` is generated from + * it, never edited by hand. + */ export class PuterJSFileSystemModule extends PuterModule { space = space; @@ -53,7 +62,7 @@ export class PuterJSFileSystemModule extends PuterModule { * Unlike the request-based modules, the socket carries the token from the * moment it connects, so it has to be rebuilt whenever auth state changes. * - * @param {import('../../../types/puter').Puter} puter + * @param {import('../../index.js').Puter} puter */ constructor (puter) { super(puter); @@ -304,3 +313,20 @@ export class PuterJSFileSystemModule extends PuterModule { } } } + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle, the socket plumbing, and the legacy `authToken` accessor + * omitted. + * + * @typedef {import('../../lib/types.js').OmitMembers< + * typeof PuterJSFileSystemModule, + * 'puter' | 'authToken' + * | 'socket' | 'cacheUpdateTimer' + * | 'initializeSocket' | 'shouldUseSocketAutoUnref' | 'bindSocketEvents' + * | 'onAuthStateChanged' | 'invalidateCache' + * | 'startCacheUpdateTimer' | 'stopCacheUpdateTimer' + * >} FSConstructor + */ + +export const FS = /** @type {FSConstructor} */ (PuterJSFileSystemModule); diff --git a/src/puter-js/src/modules/FileSystem/operations/copy.js b/src/puter-js/src/modules/FileSystem/operations/copy.js index 2766b4d75..b42a8cc1a 100644 --- a/src/puter-js/src/modules/FileSystem/operations/copy.js +++ b/src/puter-js/src/modules/FileSystem/operations/copy.js @@ -1,8 +1,8 @@ import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').CopyOptions} CopyOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ +/** @typedef {import('../types.js').CopyOptions} CopyOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * Copies a file or directory to another location. Relative paths resolve diff --git a/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js b/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js index 11396f997..a0ccebb58 100644 --- a/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js +++ b/src/puter-js/src/modules/FileSystem/operations/deleteFSEntry.js @@ -1,7 +1,7 @@ import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').DeleteOptions} DeleteOptions */ +/** @typedef {import('../types.js').DeleteOptions} DeleteOptions */ /** * Deletes one or more files or directories. Relative paths resolve against the diff --git a/src/puter-js/src/modules/FileSystem/operations/mkdir.js b/src/puter-js/src/modules/FileSystem/operations/mkdir.js index ba23c9135..20b105966 100644 --- a/src/puter-js/src/modules/FileSystem/operations/mkdir.js +++ b/src/puter-js/src/modules/FileSystem/operations/mkdir.js @@ -2,8 +2,8 @@ import path from 'path-browserify'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').MkdirOptions} MkdirOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ +/** @typedef {import('../types.js').MkdirOptions} MkdirOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * Creates a directory. Relative paths resolve against the app's root diff --git a/src/puter-js/src/modules/FileSystem/operations/move.js b/src/puter-js/src/modules/FileSystem/operations/move.js index 98e635076..5a2b6f655 100644 --- a/src/puter-js/src/modules/FileSystem/operations/move.js +++ b/src/puter-js/src/modules/FileSystem/operations/move.js @@ -3,8 +3,8 @@ import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; import stat from './stat.js'; -/** @typedef {import('../../../../types/modules/filesystem').MoveOptions} MoveOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ +/** @typedef {import('../types.js').MoveOptions} MoveOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * Moves a file or directory to another location. Relative paths resolve diff --git a/src/puter-js/src/modules/FileSystem/operations/read.js b/src/puter-js/src/modules/FileSystem/operations/read.js index bd3b2d107..8d8b65e56 100644 --- a/src/puter-js/src/modules/FileSystem/operations/read.js +++ b/src/puter-js/src/modules/FileSystem/operations/read.js @@ -1,7 +1,7 @@ import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { defineOperation } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').ReadOptions} ReadOptions */ +/** @typedef {import('../types.js').ReadOptions} ReadOptions */ /** * Reads a file and resolves with its contents as a `Blob`. Relative paths diff --git a/src/puter-js/src/modules/FileSystem/operations/readdir.js b/src/puter-js/src/modules/FileSystem/operations/readdir.js index f59b8cc2a..723ea88cd 100644 --- a/src/puter-js/src/modules/FileSystem/operations/readdir.js +++ b/src/puter-js/src/modules/FileSystem/operations/readdir.js @@ -4,9 +4,9 @@ import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import mapV2EntryToV1 from '../utils/mapV2EntryToV1.js'; import { fsRequest, parseOperationArgs } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').ReaddirOptions} ReaddirOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ -/** @typedef {import('../../../../types/shared').ListPage} FSItemPage */ +/** @typedef {import('../types.js').ReaddirOptions} ReaddirOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ +/** @typedef {import('../../../lib/types.js').ListPage} FSItemPage */ // Listings larger than this are served but never cached. const MAX_CACHE_SIZE = 100 * 1024 * 1024; diff --git a/src/puter-js/src/modules/FileSystem/operations/rename.js b/src/puter-js/src/modules/FileSystem/operations/rename.js index 1271af5f7..422fe446e 100644 --- a/src/puter-js/src/modules/FileSystem/operations/rename.js +++ b/src/puter-js/src/modules/FileSystem/operations/rename.js @@ -1,8 +1,8 @@ import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { defineOperation, firstDefined } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').RenameOptions} RenameOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ +/** @typedef {import('../types.js').RenameOptions} RenameOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * Renames a file or directory. The item can be addressed by `path` (relative diff --git a/src/puter-js/src/modules/FileSystem/operations/scaffold.js b/src/puter-js/src/modules/FileSystem/operations/scaffold.js index 448b95d29..07fdb2bb2 100644 --- a/src/puter-js/src/modules/FileSystem/operations/scaffold.js +++ b/src/puter-js/src/modules/FileSystem/operations/scaffold.js @@ -176,7 +176,7 @@ export async function fsRequest (spec) { * up first. * * The operation is returned as `T`, so each operation declares its own public - * signature (matching `types/modules/filesystem.d.ts`) where it is defined. + * signature where it is defined. * * @template {(...args: any[]) => Promise} [T=(this: FileSystemModule, ...args: unknown[]) => Promise] * @param {{ diff --git a/src/puter-js/src/modules/FileSystem/operations/sign.js b/src/puter-js/src/modules/FileSystem/operations/sign.js index 2b3c57695..cca6f8b81 100644 --- a/src/puter-js/src/modules/FileSystem/operations/sign.js +++ b/src/puter-js/src/modules/FileSystem/operations/sign.js @@ -1,6 +1,6 @@ import { defineOperation, firstDefined } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').SignResult} SignResult */ +/** @typedef {import('../types.js').SignResult} SignResult */ /** * Signs one or more filesystem entries for an app, producing the access URLs diff --git a/src/puter-js/src/modules/FileSystem/operations/space.js b/src/puter-js/src/modules/FileSystem/operations/space.js index 372f35c79..406957bd8 100644 --- a/src/puter-js/src/modules/FileSystem/operations/space.js +++ b/src/puter-js/src/modules/FileSystem/operations/space.js @@ -1,7 +1,7 @@ import { defineOperation } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').SpaceInfo} SpaceInfo */ -/** @typedef {import('../../../../types/shared').RequestCallbacks} SpaceCallbacks */ +/** @typedef {import('../types.js').SpaceInfo} SpaceInfo */ +/** @typedef {import('../../../lib/types.js').RequestCallbacks} SpaceCallbacks */ /** * Returns the storage capacity and usage of the current user, in bytes. diff --git a/src/puter-js/src/modules/FileSystem/operations/stat.js b/src/puter-js/src/modules/FileSystem/operations/stat.js index 0fc7cb500..2446a555e 100644 --- a/src/puter-js/src/modules/FileSystem/operations/stat.js +++ b/src/puter-js/src/modules/FileSystem/operations/stat.js @@ -2,20 +2,11 @@ import { dedupe } from '../../../lib/networkUtils.js'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; import { fsRequest, parseOperationArgs } from './scaffold.js'; -/** @typedef {import('../../../../types/modules/filesystem').StatOptions} StatOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ - -// Results larger than this are served but never cached. -const MAX_CACHE_SIZE = 20 * 1024 * 1024; +/** @typedef {import('../types.js').StatOptions} StatOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** - * Returns information about a file or directory, addressed by `path` - * (relative paths resolve against the app's root directory) or by `uid`. - * - * With `consistency: 'eventual'` a cached entry may be returned instead of - * hitting the backend; the default `'strong'` always revalidates. - * - * @type {{ + * @typedef {{ * (options: StatOptions): Promise, * ( * path: string, @@ -28,9 +19,24 @@ const MAX_CACHE_SIZE = 20 * 1024 * 1024; * success: (value: FSItem) => void, * error?: (reason: unknown) => void, * ): Promise, - * }} + * }} StatOperation */ -const stat = async function (...args) { + +// Results larger than this are served but never cached. +const MAX_CACHE_SIZE = 20 * 1024 * 1024; + +/** + * Returns information about a file or directory, addressed by `path` + * (relative paths resolve against the app's root directory) or by `uid`. + * + * With `consistency: 'eventual'` a cached entry may be returned instead of + * hitting the backend; the default `'strong'` always revalidates. + * + * @this {import('../index.js').PuterJSFileSystemModule} + * @param {...unknown} args + * @returns {Promise} + */ +const statImpl = async function (...args) { const options = parseOperationArgs(args, ['path']); // consistency levels @@ -93,4 +99,6 @@ const stat = async function (...args) { }); }; +const stat = /** @type {StatOperation} */ (statImpl); + export default stat; diff --git a/src/puter-js/src/modules/FileSystem/operations/upload/index.js b/src/puter-js/src/modules/FileSystem/operations/upload/index.js index 631624525..606cf16da 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload/index.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload/index.js @@ -12,9 +12,18 @@ import { generateThumbnails } from './thumbnails.js'; import { performSignedBatchUpload } from './signedBatchUpload.js'; import { performLegacyBatchUpload } from './legacyBatchUpload.js'; -/** @typedef {import('../../../../../types/modules/filesystem').UploadItems} UploadItems */ -/** @typedef {import('../../../../../types/modules/filesystem').UploadOptions} UploadOptions */ -/** @typedef {import('../../../../../types/modules/fs-item').FSItem} FSItem */ +/** @typedef {import('../../types.js').UploadItems} UploadItems */ +/** @typedef {import('../../types.js').UploadOptions} UploadOptions */ +/** @typedef {import('../../../FSItem.js').FSItem} FSItem */ + +/** + * @typedef {( + * this: import('../../index.js').PuterJSFileSystemModule, + * items: UploadItems, + * dirPath?: string, + * options?: UploadOptions, + * ) => Promise} UploadOperation + */ /** * Uploads local items — files, blobs, strings, directory entries, or a @@ -29,7 +38,7 @@ import { performLegacyBatchUpload } from './legacyBatchUpload.js'; * @param {UploadOptions} [options] * @returns {Promise} */ -const upload = async function (items, dirPath, options = {}) { +const uploadImpl = async function (items, dirPath, options = {}) { return new Promise(async (resolve, reject) => { // If auth token is not provided and we are in the web environment, // try to authenticate with Puter @@ -188,4 +197,6 @@ const upload = async function (items, dirPath, options = {}) { }); }; +const upload = /** @type {UploadOperation} */ (uploadImpl); + export default upload; diff --git a/src/puter-js/src/modules/FileSystem/operations/write.js b/src/puter-js/src/modules/FileSystem/operations/write.js index bdf26fd2a..932e06eaa 100644 --- a/src/puter-js/src/modules/FileSystem/operations/write.js +++ b/src/puter-js/src/modules/FileSystem/operations/write.js @@ -1,8 +1,8 @@ import path from 'path-browserify'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; -/** @typedef {import('../../../../types/modules/filesystem').WriteOptions} WriteOptions */ -/** @typedef {import('../../../../types/modules/fs-item').FSItem} FSItem */ +/** @typedef {import('../types.js').WriteOptions} WriteOptions */ +/** @typedef {import('../../FSItem.js').FSItem} FSItem */ /** * @typedef {{ diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js new file mode 100644 index 000000000..4c1324bf7 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -0,0 +1,273 @@ +// Shapes shared across the `puter.fs` operations. JSDoc-only; no runtime exports. + +/** @typedef {import('../FSItem.js').FSItem} FSItem */ +/** + * @template [T=unknown] + * @typedef {import('../../lib/types.js').RequestCallbacks} RequestCallbacks + */ + +/** + * Storage space information for the current user, in bytes. + * + * @typedef {Object} SpaceInfo + * @property {number} capacity Total storage capacity available to the user, in bytes. + * @property {number} used Amount of storage space used by the user, in bytes. + */ + +/** + * @typedef {Object} CopyOptionsOwn + * @property {string} [source] Path to the file or directory to copy. Required when passing options as + * the only argument. + * @property {string} [destination] Path to the destination. Required when passing options as the only + * argument. + * @property {boolean} [overwrite] Whether to overwrite the destination file or directory if it already + * exists. Defaults to `false`. + * @property {string} [newName] The new name to use for the copied file or directory. Defaults to + * `undefined`. + * @property {boolean} [dedupeName] Whether to deduplicate the file or directory name if it already + * exists. Defaults to `false`. + */ + +/** + * Options for the `copy` operation. + * + * @typedef {CopyOptionsOwn & RequestCallbacks} CopyOptions + */ + +/** + * @typedef {Object} MoveOptionsOwn + * @property {string} [source] Path to the file or directory to move. Required when passing options as + * the only argument. + * @property {string} [destination] Path to the destination. Required when passing options as the only + * argument. + * @property {boolean} [overwrite] Whether to overwrite the destination file or directory if it already + * exists. Defaults to `false`. + * @property {string} [newName] The new name to use for the moved file or directory. Defaults to + * `undefined`. + * @property {boolean} [createMissingParents] Whether to create missing parent directories. Defaults to + * `false`. + * @property {Record} [newMetadata] + * @property {string} [excludeSocketID] + * @property {string} [original_client_socket_id] + */ + +/** + * Options for the `move` operation. + * + * @typedef {MoveOptionsOwn & RequestCallbacks} MoveOptions + */ + +/** + * @typedef {Object} MkdirOptionsOwn + * @property {string} [path] The directory path to create if not specified via function parameter. + * @property {boolean} [overwrite] Whether to overwrite the directory if it already exists. Defaults to + * `false`. + * @property {boolean} [dedupeName] Whether to deduplicate the directory name if it already exists. + * Defaults to `false`. + * @property {boolean} [rename] + * @property {boolean} [createMissingParents] Whether to create missing parent directories. Defaults to + * `false`. + * @property {boolean} [recursive] + * @property {string} [shortcutTo] + */ + +/** + * Options for the `mkdir` operation. + * + * @typedef {MkdirOptionsOwn & RequestCallbacks} MkdirOptions + */ + +/** + * @typedef {Object} DeleteOptionsOwn + * @property {string | string[]} [paths] A single path or array of paths to delete. Required when + * passing options as the only argument. + * @property {boolean} [recursive] Whether to delete the directory recursively. Defaults to `true`. + * @property {boolean} [descendantsOnly] Whether to delete only the descendants of the directory and not + * the directory itself. Defaults to `false`. + */ + +/** + * Options for the `delete` operation. + * + * @typedef {DeleteOptionsOwn & RequestCallbacks} DeleteOptions + */ + +/** + * @typedef {Object} ReadOptionsOwn + * @property {string} [path] Path to the file to read. Required when passing options as the only + * argument. + * @property {number} [offset] The offset to start reading from. + * @property {number} [byte_count] The number of bytes to read from the offset. Required if `offset` is + * provided. + */ + +/** + * Options for the `read` operation. + * + * @typedef {ReadOptionsOwn & RequestCallbacks} ReadOptions + */ + +/** + * @typedef {Object} ReaddirOptionsOwn + * @property {string} [path] The path to the directory to read. Required when passing options as the + * only argument. + * @property {string} [uid] The UID of the directory to read. + * @property {boolean} [no_thumbs] + * @property {boolean} [no_assocs] + * @property {'strong' | 'eventual'} [consistency] + * @property {number} [limit] Maximum number of entries to return. + * @property {number} [offset] Skips the given number of entries. Prefer `cursor` for paging through + * large directories. + * @property {string | null} [cursor] Opaque continuation cursor from a previous page. + * @property {boolean} [includeTotal] Include a `total` count of every entry across all pages. + * @property {'name' | 'modified' | 'type' | 'size'} [sortBy] Sort field. Default is `name`. + * @property {'asc' | 'desc'} [sortOrder] Sort direction. Default is `asc`. + * @property {boolean} [recursive] Whether to also list the contents of subdirectories. Defaults to + * `false`. + * @property {number} [depth] How many levels to descend when `recursive` is `true`. Defaults to + * unlimited. + */ + +/** + * Options for the `readdir` operation. + * + * @typedef {ReaddirOptionsOwn & RequestCallbacks} ReaddirOptions + */ + +/** + * @typedef {Object} RenameOptionsOwn + * @property {string} [uid] The UID of the file or directory to rename. Can be used instead of `path`. + * @property {string} [path] Path to the file or directory to rename. Required when passing options as + * the only argument. + * @property {string} [newName] The new name for the file or directory. Required when passing options as + * the only argument. + * @property {string} [excludeSocketID] + * @property {string} [original_client_socket_id] + */ + +/** + * Options for the `rename` operation. + * + * @typedef {RenameOptionsOwn & RequestCallbacks} RenameOptions + */ + +/** + * @typedef {Object} StatOptionsOwn + * @property {string} [path] Path to the file or directory. Required when passing options as the only + * argument. + * @property {string} [uid] The UID of the file or directory. Can be used instead of `path`. + * @property {'strong' | 'eventual'} [consistency] + * @property {boolean} [returnSubdomains] Whether to return subdomain information. Defaults to `false`. + * @property {boolean} [returnWorkers] Whether to return the workers attached to the item. Workers are + * served alongside subdomains, so this is an alias of `returnSubdomains` — setting either one returns + * both. Defaults to `false`. + * @property {boolean} [returnPermissions] Whether to return permission information. Defaults to `false`. + * @property {boolean} [returnVersions] Whether to return version information. Defaults to `false`. + * @property {boolean} [returnSize] Whether to return size information. Defaults to `false`. + */ + +/** + * Options for the `stat` operation. + * + * @typedef {StatOptionsOwn & RequestCallbacks} StatOptions + */ + +/** + * @typedef {Object} UploadOptionsOwn + * @property {boolean} [overwrite] Whether to overwrite the destination file if it already exists. + * Defaults to `false`. + * @property {boolean} [dedupeName] Whether to deduplicate the file name if it already exists. Defaults + * to `true`. Ignored when `overwrite` is `true`. + * @property {string} [name] + * @property {boolean} [parsedDataTransferItems] + * @property {boolean} [createFileParent] + * @property {boolean} [createMissingAncestors] + * @property {boolean} [createMissingParents] Whether to create missing parent directories. Defaults to + * `false`. + * @property {string} [shortcutTo] + * @property {string} [appUID] + * @property {boolean} [strict] + * @property {(operationId: string, xhr: XMLHttpRequest) => void} [init] + * @property {() => void} [start] + * @property {(operationId: string, progress: number) => void} [progress] + * @property {(operationId: string) => void} [abort] + */ + +/** + * Options for the `upload` operation. + * + * @typedef {UploadOptionsOwn & RequestCallbacks} UploadOptions + */ + +/** + * One operation's outcome inside a failed upload: either a failed operation + * (`error: true`, with its own `status`, `message`, and `code`) or the + * `FSItem` a successful operation produced. + * + * @typedef {{ error: true, status?: number, message?: string, code?: string, [key: string]: unknown } + * | FSItem} UploadOperationResult + */ + +/** + * The rejection value of `upload()` when the batch request itself completed + * but one or more of its operations failed. + * + * @typedef {Object} UploadBatchError + * @property {string} message + * @property {'batch_upload_failed' | 'batch_upload_partially_failed' | 'batch_upload_no_results'} code + * `batch_upload_failed` when nothing was written, `batch_upload_partially_failed` when only some + * operations failed, and `batch_upload_no_results` when the server reported success without saying what + * it wrote. + * @property {number} status + * @property {UploadOperationResult[]} results Every operation's result, in the order the operations + * were sent. + * @property {UploadOperationResult[]} failedItems Just the operations that failed. + * @property {number} failedCount + * @property {number} totalCount + */ + +/** + * @typedef {Object} WriteOptionsOwn + * @property {boolean} [overwrite] Whether to overwrite the file if it already exists. Defaults to + * `true`. + * @property {boolean} [dedupeName] Whether to deduplicate the file name if it already exists. Defaults + * to `false`. + * @property {boolean} [createMissingParents] Whether to create missing parent directories. Defaults to + * `false`. + * @property {boolean} [createMissingAncestors] + * @property {(operationId: string, xhr: XMLHttpRequest) => void} [init] + * @property {() => void} [start] + * @property {(operationId: string, progress: number) => void} [progress] + * @property {(operationId: string) => void} [abort] + */ + +/** + * Options for the `write` operation. + * + * @typedef {WriteOptionsOwn & RequestCallbacks} WriteOptions + */ + +/** + * What `sign()` resolves to. + * + * @template [T=Record] + * @typedef {Object} SignResult + * @property {string} token + * @property {T | T[]} items + */ + +/** + * Everything `upload()` accepts as its items argument. + * + * @typedef {DataTransferItemList + * | DataTransferItem + * | FileList + * | File[] + * | Blob[] + * | Blob + * | File + * | string + * | unknown[]} UploadItems + */ + +export {}; diff --git a/src/puter-js/src/modules/Peer.js b/src/puter-js/src/modules/Peer.js index 3fbe6fa4a..924a11c54 100644 --- a/src/puter-js/src/modules/Peer.js +++ b/src/puter-js/src/modules/Peer.js @@ -1,12 +1,51 @@ import { fetchUrl } from '../lib/networkUtils.js'; import { PuterModule } from '../lib/PuterModule.js'; -/** @typedef {import('../../types/modules/peer').PuterPeerMessage} PuterPeerMessage */ -/** @typedef {import('../../types/modules/peer').PuterPeerOptions} PuterPeerOptions */ +/** + * Options for `puter.peer.serve()` and `puter.peer.connect()`. + * + * @typedef {Object} PuterPeerOptions + * @property {RTCIceServer[]} [iceServers] Custom ICE servers (STUN/TURN) to use instead of the + * Puter-managed relays. + * @property {boolean} [forceRelay] Route every candidate through a TURN relay. + * @property {string} [anonToken] Connect without a Puter session, using a token the server issued. + */ -class PuterPeerServerConnectionEvent extends Event { +/** + * Metadata about a peer user. + * + * @typedef {Object} PuterPeerUser + * @property {string} username + * @property {string} uuid + */ + +/** @typedef {string | Blob | ArrayBuffer | ArrayBufferView} PuterPeerMessage */ +/** @typedef {RTCSessionDescription | RTCSessionDescriptionInit} PuterPeerDescription */ +/** @typedef {RTCIceCandidate | RTCIceCandidateInit} PuterPeerIceCandidate */ + +/** + * Dispatched by `PuterPeerServer` for the `'connection'` event when a client + * connects. + */ +export class PuterPeerServerConnectionEvent extends Event { + /** + * The connection to the client. + * + * @type {PuterPeerConnection} + */ conn; + + /** + * Metadata about the connecting user, when available. + * + * @type {PuterPeerUser | undefined} + */ user; + + /** + * @param {PuterPeerConnection} connection + * @param {PuterPeerUser} [user] + */ constructor (connection, user) { super('connection'); this.conn = connection; @@ -14,30 +53,63 @@ class PuterPeerServerConnectionEvent extends Event { } } -class PuterPeerConnectionMessageEvent extends Event { +/** + * Dispatched by `PuterPeerConnection` for the `'message'` event when a message + * is received. + */ +export class PuterPeerConnectionMessageEvent extends Event { + /** + * The received message payload. + * + * @type {ArrayBuffer | string} + */ data; + + /** @param {ArrayBuffer | string} message */ constructor (message) { super('message'); this.data = message; } } -class PuterPeerConnectionOpenEvent extends Event { +/** + * Dispatched by `PuterPeerConnection` for the `'open'` event when the data + * channel is ready. + */ +export class PuterPeerConnectionOpenEvent extends Event { constructor () { super('open'); } } -class PuterPeerConnectionCloseEvent extends Event { +/** + * Dispatched by `PuterPeerConnection` for the `'close'` event when the + * connection closes. + */ +export class PuterPeerConnectionCloseEvent extends Event { + /** + * The reason the connection was closed, if one was provided. + * + * @type {string | undefined} + */ reason; + + /** @param {string} [reason] */ constructor (reason = undefined) { super('close'); this.reason = reason; } } -class PuterPeerConnectionErrorEvent extends Event { +/** + * Dispatched by `PuterPeerConnection` for the `'error'` event when a connection + * error occurs. + */ +export class PuterPeerConnectionErrorEvent extends Event { + /** @type {string} */ error; + + /** @param {string} error */ constructor (error) { super('error'); this.error = error; @@ -203,14 +275,18 @@ export class PuterPeerServer extends EventTarget { } } -class PuterPeerConnection extends EventTarget { +/** + * A WebRTC data-channel connection to a peer. Emits `'open'`, `'message'`, + * `'close'`, and `'error'` events. + */ +export class PuterPeerConnection extends EventTarget { #wsconn; peerconnection; /** * Information about the user who created the server. * - * @type {import('../../types/modules/peer').PuterPeerUser | undefined} + * @type {PuterPeerUser | undefined} */ owner; #peerConfig; @@ -419,7 +495,12 @@ class PuterPeerConnection extends EventTarget { } } -class Peer extends PuterModule { +/** + * The `puter.peer` API. Provides WebRTC data channels with built-in signaling + * and TURN relays for connecting clients directly without your own signaling + * server. Peer connections require authentication. + */ +export class PeerModule extends PuterModule { #signallerUrl; #turnServers; #fallbackIceServers; @@ -532,4 +613,16 @@ class Peer extends PuterModule { } } +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../lib/types.js').OmitMembers< + * typeof PeerModule, + * 'puter' | 'authToken' + * >} PeerConstructor + */ + +export const Peer = /** @type {PeerConstructor} */ (PeerModule); + export default Peer; diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 5a52c8f1a..489e459b7 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -3,19 +3,249 @@ import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js'; import FSItem from './FSItem.js'; import PuterDialog from './PuterDialog.js'; -/** @typedef {import('../../types/modules/ui').AlertButton} AlertButton */ -/** @typedef {import('../../types/modules/ui').AlertOptions} AlertOptions */ -/** @typedef {import('../../types/modules/ui').AppConnection} AppConnection */ -/** @typedef {import('../../types/modules/ui').ColorPickerOptions} ColorPickerOptions */ -/** @typedef {import('../../types/modules/ui').ConnectionEvent} ConnectionEvent */ -/** @typedef {import('../../types/modules/ui').ContextMenuOptions} ContextMenuOptions */ -/** @typedef {import('../../types/modules/ui').FontPickerOptions} FontPickerOptions */ -/** @typedef {import('../../types/modules/ui').LaunchAppOptions} LaunchAppOptions */ -/** @typedef {import('../../types/modules/ui').MenubarOptions} MenubarOptions */ -/** @typedef {import('../../types/modules/ui').ThemeData} ThemeData */ -/** @typedef {import('../../types/modules/ui').NotificationOptions} NotificationOptions */ -/** @typedef {import('../../types/modules/ui').WindowHandle} WindowHandle */ -/** @typedef {import('../../types/modules/ui').WindowOptions} WindowOptions */ + +/** + * A button shown in an `alert()` dialog. + * + * @typedef {Object} AlertButton + * @property {string} label Text displayed on the button. + * @property {string} [value] Value returned when this button is pressed. Defaults to `label` if not set. + * @property {'primary' | 'success' | 'info' | 'warning' | 'danger'} [type] Visual style of the button. + */ + +/** + * Options that configure an `alert()` dialog. + * + * @typedef {Object} AlertOptions + * @property {'primary' | 'success' | 'info' | 'warning' | 'danger'} [type] Visual style of the alert + * dialog. + * @property {string} [body_icon] Icon URL shown in the dialog body. Takes precedence over `icon`. + * @property {string} [icon] Icon URL shown in the dialog body, used when `body_icon` is not set. + */ + +/** + * Options that configure a `prompt()` dialog. + * + * @typedef {Object} PromptOptions + * @property {string} [defaultValue] Value the input is pre-filled with. + */ + +/** + * A single item in a context menu. The string `'-'` may be used in place of an + * item to render a separator. + * + * @typedef {Object} ContextMenuItem + * @property {string} label Text displayed for the menu item. + * @property {() => void} [action] Function executed when the item is clicked. Not required for items + * with submenus. + * @property {string} [icon] Icon shown next to the label. Must be a base64-encoded image data URI + * starting with `data:image`; other strings are ignored. + * @property {string} [icon_active] Icon shown when the item is hovered or active. Must be a + * base64-encoded image data URI starting with `data:image`; other strings are ignored. + * @property {boolean} [disabled] If `true`, the item is disabled and unclickable. Defaults to `false`. + * @property {(ContextMenuItem | '-')[]} [items] Submenu items. Specifying this creates a submenu. + */ + +/** + * A handle to a window created by `createWindow()`. + * + * @typedef {Object} WindowHandle + * @property {string} id Identifier of the window, usable as the `window_id` argument to the + * `setWindow*` methods. + */ + +/** + * Identifies a window: either a window id string or a window handle returned by + * `createWindow()`. + * + * @typedef {string | WindowHandle} WindowIdentifier + */ + +/** + * Options that configure a context menu. + * + * @typedef {Object} ContextMenuOptions + * @property {(ContextMenuItem | '-')[]} items Menu items and separators. Use the string `'-'` to insert + * a separator. + * @property {'dark' | 'light'} [theme] Forces the rendered menu's color theme. Only applies when + * running standalone (`puter.env === 'web'`); ignored inside the Puter desktop (`puter.env === 'app'`). + * When unset, the menu follows the system color-scheme preference. + * @property {number} [x] X position of the menu, in pixels. Defaults to the cursor position. + * Standalone only, with the same caveat as `theme`. + * @property {number} [y] Y position of the menu, in pixels. Defaults to the cursor position. + * Standalone only, with the same caveat as `theme`. + */ + +/** + * Options that configure a window created by `createWindow()`. + * + * @typedef {Object} WindowOptions + * @property {boolean} [center] If `true`, the window is placed at the center of the screen. + * @property {string} [content] Content of the window. + * @property {boolean} [disable_parent_window] If `true`, the parent window is blocked until this window + * is closed. + * @property {boolean} [has_head] If `true`, the window has a head containing the icon and close, + * minimize, and maximize buttons. + * @property {number} [height] Height of the window in pixels. + * @property {boolean} [is_resizable] If `true`, the user can resize the window. + * @property {boolean} [show_in_taskbar] If `true`, the window is represented in the taskbar. + * @property {string} [title] Title of the window. + * @property {number} [width] Width of the window in pixels. + */ + +/** + * Options that configure `launchApp()`. + * + * @typedef {Object} LaunchAppOptions + * @property {string} [name] Name of the app to launch. If not provided, a new instance of the current + * app is launched. + * @property {string} [app_name] Legacy spelling of `name`. + * @property {Record} [args] Arguments to pass to the app. + * @property {string[]} [file_paths] Paths of existing files to open with the launched app. + * @property {FSItem[]} [items] `FSItem` objects to open with the launched app. + * @property {string} [pseudonym] A pseudonym to launch the app under. + * @property {(connection: AppConnection) => void} [callback] + */ + +/** + * Theme data delivered with the `themeChanged` event. + * + * @typedef {Object} ThemeData + * @property {{ + * primaryHue: number, + * primarySaturation: string, + * primaryLightness: string, + * primaryAlpha: number, + * primaryColor: string, + * }} palette `primaryHue` is the hue of the theme color; `primarySaturation` and `primaryLightness` are + * percentage strings including the `%` sign; `primaryAlpha` runs from `0` to `1`; `primaryColor` is a + * CSS color value for text. + */ + +/** + * A single item in a menubar menu. The string `'-'` may be used in place of an + * item to render a separator. + * + * @typedef {Object} MenuItem + * @property {string} label Text displayed for the menu item. + * @property {string} [id] + * @property {() => void} [action] Function executed when the item is clicked. + * @property {(MenuItem | '-')[]} [items] Submenu items. + * @property {string} [icon] URL or data URI of an icon shown next to the label. + * @property {string} [icon_active] URL or data URI of an icon shown when the item is hovered or active. + * Falls back to `icon` if not provided. + * @property {boolean} [checked] If `true`, renders a checkmark next to the item. Use for toggleable + * options. + * @property {boolean} [disabled] If `true`, the item is visible but cannot be clicked. + */ + +/** + * Options that configure the menubar set by `setMenubar()`. + * + * @typedef {Object} MenubarOptions + * @property {(MenuItem | '-')[]} items Menu items and separators. Use the string `'-'` to insert a + * separator. + * @property {'dark' | 'light'} [theme] Forces the rendered menubar's color theme. Only applies when + * running standalone (`puter.env === 'web'`); ignored inside the Puter desktop (`puter.env === 'app'`). + * When unset, the menubar follows the system color-scheme preference. + */ + +/** + * Options that configure `showOpenFilePicker()`. + * + * @typedef {Object} FilePickerOptions + * @property {boolean} [multiple] If `true`, the user can select multiple files. Defaults to `false`. + * @property {string | string[]} [accept] MIME types or file extensions accepted by the picker. Defaults + * to `*​/*`. For example `'image/*'`, or `['.jpg', '.png']`. + * @property {string} [path] Initial directory to open the picker in. Defaults to the user's Desktop. + * The special prefix `%appdata%` resolves to the app's private appdata directory. + */ + +/** + * Options that configure `showColorPicker()`. + * + * @typedef {Object} ColorPickerOptions + * @property {string} [defaultColor] The color initially selected when the picker opens. + */ + +/** + * Options that configure `showFontPicker()`. + * + * @typedef {Object} FontPickerOptions + * @property {string} [defaultFont] The font initially selected when the picker opens. + */ + +/** + * Options that configure `showDirectoryPicker()`. + * + * @typedef {Object} DirectoryPickerOptions + * @property {boolean} [multiple] If `true`, the user can select multiple directories. Defaults to + * `false`. + */ + +/** + * Options that configure a notification shown by `notify()`. + * + * @typedef {Object} NotificationOptions + * @property {string} [title] Title shown in the notification. + * @property {string} [text] Body text shown under the title. + * @property {string} [icon] Icon URL or Puter icon name (for example `bell.svg`). + * @property {'info' | 'success' | 'warning' | 'error' | 'default'} [type] Visual style used to pick a + * default icon and accent color when no `icon` is provided. + * @property {number} [duration] Time in milliseconds before the notification auto-dismisses. Defaults + * to `5000`; set to `0` to keep it until dismissed. + * @property {boolean} [round_icon] If `true`, renders the icon as a circle. + * @property {boolean} [roundIcon] Alias for `round_icon`. + * @property {string} [uid] Optional ID to associate with the notification. + * @property {unknown} [value] Optional value stored on the notification element. + */ + +/** + * Data passed to the `close` handler on an `AppConnection`. + * + * @typedef {Object} AppConnectionCloseEvent + * @property {string} appInstanceID Instance ID of the app that closed. + * @property {number} [statusCode] + */ + +/** + * Data passed to the `connection` event handler when another app requests a + * connection to your app. + * + * @typedef {Object} ConnectionEvent + * @property {AppConnection} conn Connection to the app that initiated the request. + * @property {(value?: unknown) => void} accept Call `accept(value)` to accept the connection; `value` + * is sent back to the requester. + * @property {(value?: unknown) => void} reject Call `reject(value)` to reject the connection; `value` + * is sent back to the requester. + */ + +/** + * The outcome the desktop reports back from a `launchApp()` request. + * + * @typedef {Object} LaunchAppResult + * @property {boolean} launched + * @property {string | null} [requestedAppName] + * @property {string | null} [openedAppName] + * @property {string | null} [appInstanceID] + * @property {string | null} [appUid] + * @property {boolean} [redirectedToFallback] + * @property {boolean} [deniedPrivateAccess] + * @property {{ + * hasAccess: boolean, + * fallbackAppName?: string, + * fallbackArgs?: Record, + * reason?: string, + * }} [privateAccess] + */ + +/** + * A promise from a picker that also exposes `undefinedOnCancel`, which + * resolves to `undefined` instead of staying pending when the user cancels. + * + * @template T + * @typedef {Promise & { undefinedOnCancel?: Promise }} CancelAwarePromise + */ const createDeferred = () => { let resolve; @@ -34,12 +264,17 @@ const FILE_OPEN_CANCELLED = Symbol('FILE_OPEN_CANCELLED'); // them in its URL. const MAX_REQUESTED_PERMISSIONS = 16; -// AppConnection provides an API for interacting with another app. -// It's returned by UI methods, and cannot be constructed directly by user code. -// For basic usage: -// - postMessage(message) Send a message to the target app -// - on('message', callback) Listen to messages from the target app -class AppConnection extends EventListener { +/** + * An interface for interacting with another app. Returned by the UI methods + * that launch or connect to one; it cannot be constructed directly. + * + * - `postMessage(message)` sends a message to the target app. + * - `on('message', handler)` listens for messages from it. + * - `on('close', handler)` fires when it closes. + * + * @extends {EventListener<{ message: unknown, close: AppConnectionCloseEvent }>} + */ +export class AppConnection extends EventListener { // targetOrigin for postMessage() calls to Puter #puterOrigin = '*'; @@ -54,7 +289,7 @@ class AppConnection extends EventListener { * Extra information the target app supplied when the connection was * established. Declared here because `from()` sets it on the instance. * - * @type {(Record & { launchResult?: import('../../types/modules/ui').LaunchAppResult }) | undefined} + * @type {(Record & { launchResult?: LaunchAppResult }) | undefined} */ response; @@ -187,7 +422,18 @@ class AppConnection extends EventListener { } } -class UI extends EventListener { +/** + * The UI API: tools for creating rich user interfaces and interacting with the + * Puter desktop environment, including dialogs, window management, file + * pickers, and desktop integration. + * + * @extends {EventListener<{ + * localeChanged: { language: string }, + * themeChanged: ThemeData, + * connection: ConnectionEvent, + * }>} + */ +export class UIModule extends EventListener { // Used to generate a unique message id for each message sent to the host environment // we start from 1 because 0 is falsy and we want to avoid that for the message id #messageID = 1; @@ -1754,13 +2000,10 @@ class UI extends EventListener { /** * Asynchronously extracts entries from DataTransferItems, like files and directories. * - * @private - * @function - * @async * @param {DataTransferItemList} dataTransferItems - List of data transfer items from a drag-and-drop operation. * @param {Object} [options={}] - Optional settings. * @param {boolean} [options.raw=false] - Determines if the file path should be processed. - * @returns {Promise>} - A promise that resolves to an array of File or Entry objects. + * @returns {Promise>} - A promise that resolves to an array of File or FileSystemEntry objects. * @throws {Error} - Throws an error if there's an EncodingError and provides information about how to solve it. * * @example @@ -2271,19 +2514,19 @@ class UI extends EventListener { * @overload * @param {'localeChanged'} eventName * @param {(data: { language: string }) => void} callback - * @returns {void} + * @returns {undefined} */ /** * @overload * @param {'themeChanged'} eventName * @param {(data: ThemeData) => void} callback - * @returns {void} + * @returns {undefined} */ /** * @overload * @param {'connection'} eventName * @param {(data: ConnectionEvent) => void} callback - * @returns {void} + * @returns {undefined} */ /** * Listens for a broadcast from Puter. A broadcast that already happened is @@ -2295,7 +2538,7 @@ class UI extends EventListener { * * @param {string} eventName * @param {(data: unknown) => void} callback - * @returns {void} + * @returns {undefined} */ on (eventName, callback) { super.on(eventName, callback); @@ -2471,4 +2714,19 @@ class UI extends EventListener { } } +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle, the legacy `authToken` accessor, and the desktop plumbing + * omitted. + * + * @typedef {import('../lib/types.js').OmitMembers< + * typeof UIModule, + * 'puter' | 'authToken' | 'util' | 'messageTarget' + * | 'itemWatchCallbackFunctions' | 'appInstanceID' | 'parentInstanceID' + * | 'mouseX' | 'mouseY' + * >} UIConstructor + */ + +export const UI = /** @type {UIConstructor} */ (UIModule); + export default UI; diff --git a/src/puter-js/src/modules/Util.js b/src/puter-js/src/modules/Util.js index c8140b869..1ddd1acc5 100644 --- a/src/puter-js/src/modules/Util.js +++ b/src/puter-js/src/modules/Util.js @@ -14,7 +14,12 @@ export default class Util { } } -class UtilRPC { +/** + * The lower-level RPC interface used to talk to iframes: it swaps functions in + * a value for callback ids so the value survives `postMessage`, and resolves + * them back on the other side. + */ +export class UtilRPC { constructor () { this.callbackManager = new CallbackManager(); this.callbackManager.attach_to_source(globalThis); diff --git a/src/puter-js/src/modules/Workers.js b/src/puter-js/src/modules/Workers.js index cef2237cf..916ce11a1 100644 --- a/src/puter-js/src/modules/Workers.js +++ b/src/puter-js/src/modules/Workers.js @@ -3,11 +3,28 @@ import { PuterModule } from '../lib/PuterModule.js'; import * as utils from '../lib/utils.js'; import { fetchAllPages, iteratePages } from '../lib/pagination.js'; -/** @typedef {import('../../types/modules/workers').WorkerDeployment} WorkerDeployment */ -/** @typedef {import('../../types/modules/workers').WorkerInfo} WorkerInfo */ -/** @typedef {import('../../types/shared').ListPage} WorkerPage */ -/** @typedef {import('../../types/shared').ListPaginationOptions} ListPaginationOptions */ -/** @typedef {import('../../types/shared').ListStreamOptions} ListStreamOptions */ +/** + * Information about a deployed worker, as returned by `get()` and `list()`. + * + * @typedef {Object} WorkerInfo + * @property {string} name The name of the worker. + * @property {string} url The URL of the worker. + * @property {string} file_path The file path of the worker's source code. + * @property {string} file_uid The unique identifier of the worker file. + * @property {string} created_at The date and time when the worker was created. + */ + +/** + * The result of a worker deployment, as returned by `create()`. + * + * @typedef {Object} WorkerDeployment + * @property {boolean} success Whether the worker deployment was successful. + * @property {string} url The URL of the deployed worker. + * @property {string[]} [errors] Any errors that occurred during deployment. + */ +/** @typedef {import('../lib/types.js').ListPage} WorkerPage */ +/** @typedef {import('../lib/types.js').ListPaginationOptions} ListPaginationOptions */ +/** @typedef {import('../lib/types.js').ListStreamOptions} ListStreamOptions */ /** * The `puter.workers` module: deploy and call serverless workers. @@ -296,3 +313,15 @@ export class WorkersHandler extends PuterModule { } } + +/** + * The public face of the module: derived from the class, with the internal + * `puter` handle and the legacy `authToken` accessor omitted. + * + * @typedef {import('../lib/types.js').OmitMembers< + * typeof WorkersHandler, + * 'puter' | 'authToken' + * >} WorkersConstructor + */ + +export const Workers = /** @type {WorkersConstructor} */ (WorkersHandler); diff --git a/src/puter-js/src/modules/ai/chat.js b/src/puter-js/src/modules/ai/chat.js index 78216f174..0389d5a79 100644 --- a/src/puter-js/src/modules/ai/chat.js +++ b/src/puter-js/src/modules/ai/chat.js @@ -1,11 +1,11 @@ import * as utils from '../../lib/utils.js'; import { hasTestModeFlag, isPlainObject } from './lib/args.js'; -/** @typedef {import('../../../types/modules/ai').ChatMessage} ChatMessage */ -/** @typedef {import('../../../types/modules/ai').ChatOptions} ChatOptions */ -/** @typedef {import('../../../types/modules/ai').ChatResponse} ChatResponse */ -/** @typedef {import('../../../types/modules/ai').ChatResponseChunk} ChatResponseChunk */ -/** @typedef {import('../../../types/modules/ai').StreamingChatOptions} StreamingChatOptions */ +/** @typedef {import('./types.js').ChatMessage} ChatMessage */ +/** @typedef {import('./types.js').ChatOptions} ChatOptions */ +/** @typedef {import('./types.js').ChatResponse} ChatResponse */ +/** @typedef {import('./types.js').ChatResponseChunk} ChatResponseChunk */ +/** @typedef {import('./types.js').StreamingChatOptions} StreamingChatOptions */ // Parameters copied from the caller's options object onto the driver // request. `compaction` (provider-neutral inline-compaction opt-in) and the diff --git a/src/puter-js/src/modules/ai/image.js b/src/puter-js/src/modules/ai/image.js index 427f34123..4f0bc6081 100644 --- a/src/puter-js/src/modules/ai/image.js +++ b/src/puter-js/src/modules/ai/image.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js'; import { toImageElement } from './lib/media.js'; -/** @typedef {import('../../../types/modules/ai').Txt2ImgOptions} Txt2ImgOptions */ +/** @typedef {import('./types.js').Txt2ImgOptions} Txt2ImgOptions */ /** * @overload diff --git a/src/puter-js/src/modules/ai/index.js b/src/puter-js/src/modules/ai/index.js index cd0e5dd61..a66aaf532 100644 --- a/src/puter-js/src/modules/ai/index.js +++ b/src/puter-js/src/modules/ai/index.js @@ -8,7 +8,7 @@ import { speech2txt } from './stt.js'; import { listEngines, listVoices, txt2speech } from './tts.js'; import { txt2vid } from './video.js'; -/** @typedef {import('../../../types/puter').Puter} Puter */ +/** @typedef {import('../../index.js').Puter} Puter */ /** * `txt2speech` is callable directly and carries the engine/voice listers. @@ -23,8 +23,8 @@ import { txt2vid } from './video.js'; * * Method implementations live in the sibling files as `this`-context * functions whose JSDoc (including the per-form `@overload` declarations) is - * the source of truth for the public signatures; types/modules/ai.d.ts - * mirrors them for TypeScript consumers of the published SDK. + * the source of truth for the public signatures — `types/` is generated from + * it, never edited by hand. */ export class AIModule extends PuterModule { /** @type {Txt2Speech} */ diff --git a/src/puter-js/src/modules/ai/ocr.js b/src/puter-js/src/modules/ai/ocr.js index 3d4831ac6..47707fcc2 100644 --- a/src/puter-js/src/modules/ai/ocr.js +++ b/src/puter-js/src/modules/ai/ocr.js @@ -1,7 +1,7 @@ import * as utils from '../../lib/utils.js'; import { dataUriByteLength, isBlobLike, isPlainObject } from './lib/args.js'; -/** @typedef {import('../../../types/modules/ai').Img2TxtOptions} Img2TxtOptions */ +/** @typedef {import('./types.js').Img2TxtOptions} Img2TxtOptions */ /** * The recognition result shapes the OCR drivers return. diff --git a/src/puter-js/src/modules/ai/sts.js b/src/puter-js/src/modules/ai/sts.js index aefb19db1..f8b89aecd 100644 --- a/src/puter-js/src/modules/ai/sts.js +++ b/src/puter-js/src/modules/ai/sts.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import { dataUriByteLength, isPlainObject, toDataUriIfBlob } from './lib/args.js'; import { toAudioElement } from './lib/media.js'; -/** @typedef {import('../../../types/modules/ai').Speech2SpeechOptions} Speech2SpeechOptions */ +/** @typedef {import('./types.js').Speech2SpeechOptions} Speech2SpeechOptions */ const MAX_INPUT_SIZE = 25 * 1024 * 1024; diff --git a/src/puter-js/src/modules/ai/stt.js b/src/puter-js/src/modules/ai/stt.js index 063dfbc86..0bbdf26a8 100644 --- a/src/puter-js/src/modules/ai/stt.js +++ b/src/puter-js/src/modules/ai/stt.js @@ -1,9 +1,9 @@ import * as utils from '../../lib/utils.js'; import { dataUriByteLength, isPlainObject, toDataUriIfBlob } from './lib/args.js'; -/** @typedef {import('../../../types/modules/ai').Speech2TxtOptions} Speech2TxtOptions */ -/** @typedef {import('../../../types/modules/ai').Speech2TxtResult} Speech2TxtResult */ -/** @typedef {import('../../../types/modules/ai').TextFormatSpeech2TxtOptions} TextFormatSpeech2TxtOptions */ +/** @typedef {import('./types.js').Speech2TxtOptions} Speech2TxtOptions */ +/** @typedef {import('./types.js').Speech2TxtResult} Speech2TxtResult */ +/** @typedef {import('./types.js').TextFormatSpeech2TxtOptions} TextFormatSpeech2TxtOptions */ const MAX_INPUT_SIZE = 25 * 1024 * 1024; diff --git a/src/puter-js/src/modules/ai/tts.js b/src/puter-js/src/modules/ai/tts.js index fb5945208..bfa62f5a3 100644 --- a/src/puter-js/src/modules/ai/tts.js +++ b/src/puter-js/src/modules/ai/tts.js @@ -2,11 +2,11 @@ import * as utils from '../../lib/utils.js'; import { hasTestModeFlag } from './lib/args.js'; import { toAudioElement } from './lib/media.js'; -/** @typedef {import('../../../types/modules/ai').ListTTSEnginesOptions} ListTTSEnginesOptions */ -/** @typedef {import('../../../types/modules/ai').ListTTSVoicesOptions} ListTTSVoicesOptions */ -/** @typedef {import('../../../types/modules/ai').TTSEngine} TTSEngine */ -/** @typedef {import('../../../types/modules/ai').TTSVoice} TTSVoice */ -/** @typedef {import('../../../types/modules/ai').Txt2SpeechOptions} Txt2SpeechOptions */ +/** @typedef {import('./types.js').ListTTSEnginesOptions} ListTTSEnginesOptions */ +/** @typedef {import('./types.js').ListTTSVoicesOptions} ListTTSVoicesOptions */ +/** @typedef {import('./types.js').TTSEngine} TTSEngine */ +/** @typedef {import('./types.js').TTSVoice} TTSVoice */ +/** @typedef {import('./types.js').Txt2SpeechOptions} Txt2SpeechOptions */ const MAX_INPUT_SIZE = 3000; diff --git a/src/puter-js/src/modules/ai/types.js b/src/puter-js/src/modules/ai/types.js new file mode 100644 index 000000000..9e7161425 --- /dev/null +++ b/src/puter-js/src/modules/ai/types.js @@ -0,0 +1,398 @@ +// Shapes shared across the `puter.ai` operations. JSDoc-only; no runtime exports. +// +// Provider-specific response bodies stay loosely typed: the SDK does not yet +// publish stable shapes for those payloads. + +/** + * @typedef {string + * | { image_url?: { url: string } } + * | { video_url?: { url: string } } + * | Record} AIMessageContent + */ + +/** + * An image attached to a message. + * + * @typedef {Object} ImageContent + * @property {string} type + * @property {{ url: string }} image_url + */ + +/** + * One tool call the model asked for. + * + * @typedef {Object} ToolCall + * @property {string} id + * @property {{ name: string, arguments: string }} function + */ + +/** + * A function/tool definition the model may call. + * + * @typedef {Object} Tool + * @property {string} type + * @property {{ name: string, description: string, parameters: object, strict?: boolean }} function + */ + +/** + * One message in a chat conversation. + * + * @typedef {Object} ChatMessage + * @property {string} [role] + * @property {AIMessageContent | AIMessageContent[]} content + * @property {ToolCall[]} [tool_calls] + * @property {string} [tool_call_id] + * @property {{ type: string }} [cache_control] + * @property {ImageContent[]} [images] Images attached to the message. Present on responses from + * image-capable models. + */ + +/** + * Options for a chat completion request. + * + * @typedef {Object} ChatOptions + * @property {string} [model] The model to use for the completion. Defaults to `gpt-5-nano` if not + * specified. + * @property {number} [temperature] Sampling temperature between 0 and 2. Lower values are more focused + * and deterministic, higher values more random. Defaults to the model's own default. + * @property {number} [max_tokens] + * @property {boolean} [vision] + * @property {string} [driver] + * @property {string} [provider] The provider to route the request through. + * @property {Tool[]} [tools] Function/tool definitions the model can call. See Function Calling. + * @property {unknown} [response] + * @property {string} [reasoning_effort] Controls how much effort reasoning models spend thinking. Flat + * form. Accepted values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (availability varies by + * model; default `medium` on newer GPT-5.x models). Reasoning models only. + * @property {{ effort: string }} [reasoning] Nested form of `reasoning_effort`. The `effort` value + * accepts the same values as `reasoning_effort`. Reasoning models only. + * @property {string} [verbosity] Controls how long or short responses are. Flat form. Accepted values: + * `low`, `medium`, `high`. Reasoning models only. + * @property {{ verbosity: string }} [text] Nested form of `verbosity` — it lives under `text`. The + * `verbosity` value accepts the same values as `verbosity`. Reasoning models only. + * @property {{ aspect_ratio: string, image_size: string }} [image_config] Controls image output for + * image-capable models. `aspect_ratio` is the aspect ratio of the generated image, e.g. `"16:9"`, + * `"1:1"`, `"9:16"`; `image_size` is the output quality/resolution and must be one of the model's + * supported quality levels. + * @property {boolean | { trigger_tokens?: number }} [compaction] Provider-neutral inline-compaction + * opt-in for long stateless conversations. `true` enables it with provider defaults; an object sets the + * token threshold at which earlier context is summarized. When the upstream compacts, you receive a + * `"compaction"` chunk (streaming) or a `compaction` field on the result (non-streaming) — resend it in + * `messages` on the next turn in place of the summarized history. + * @property {unknown} [context_management] Escape hatch: a provider-native `context_management` + * payload, passed through untouched. Prefer `compaction` for provider portability. + */ + +/** + * `ChatOptions` with streaming turned on, which changes what `chat()` resolves + * to. + * + * @typedef {ChatOptions & { stream: boolean }} StreamingChatOptions + */ + +/** + * What a non-streaming `chat()` resolves to. + * + * @typedef {Object} ChatResponse + * @property {ChatMessage} [message] + * @property {unknown} [choices] + * @property {{ type: 'compaction', id?: string, encrypted_content: string }} [compaction] + * Inline-compaction artifact, present when the upstream compacted earlier context during this + * (non-streaming) response. Carries `type:'compaction'` so you can push it straight into `messages` on + * the next turn in place of the summarized history (same shape as the streaming `compaction` chunk). + */ + +/** + * A single chunk of a streaming chat response. Each chunk has a `type` + * discriminator; which other fields are present depends on that `type`. + * + * @typedef {Object} ChatResponseChunk + * @property {string} type The kind of chunk: `"text"`, `"reasoning"`, `"image"`, `"tool_use"`, + * `"compaction"`, `"extra_content"`, `"usage"`, or `"error"`. + * @property {string} [text] Text delta. Present on `"text"` chunks. + * @property {string} [reasoning] Reasoning/thinking delta. Present on `"reasoning"` chunks. + * @property {ImageContent} [image] A generated image. Present on `"image"` chunks from image-capable + * models. + * @property {string} [id] Tool call id (`"tool_use"`) or compaction item id (`"compaction"`). + * @property {string} [name] Tool/function name. Present on `"tool_use"` chunks. + * @property {unknown} [input] Parsed tool call arguments. Present on `"tool_use"` chunks. + * @property {string} [encrypted_content] Opaque/encrypted compaction summary. Present on + * `"compaction"` chunks — the same shape regardless of which provider served the request. Resend it in + * `messages` on the next turn in place of the summarized history. + * @property {unknown} [extra_content] Provider-specific extra metadata. + * @property {Record} [usage] Token usage totals. Present on the final `"usage"` chunk. + * @property {string} [message] Error description. Present on `"error"` chunks, which end the stream. + */ + +/** + * Options for `img2txt()` (OCR). + * + * @typedef {Object} Img2TxtOptions + * @property {string | File | Blob} [source] + * @property {string} [provider] + * @property {boolean} [testMode] + * @property {boolean} [test_mode] `snake_case` spelling of `testMode`, forwarded to the driver as-is. + * @property {string} [model] + * @property {number[]} [pages] + * @property {boolean} [includeImageBase64] + * @property {number} [imageLimit] + * @property {number} [imageMinSize] + * @property {string} [bboxAnnotationFormat] + * @property {string} [documentAnnotationFormat] + */ + +/** + * Options for `txt2img()`. + * + * @typedef {Object} Txt2ImgOptions + * @property {string} [prompt] Text description of the image to generate. + * @property {string} [model] Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` + * (OpenAI), or `'grok-imagine-image'` when `provider` is `'xai'`. + * @property {string} [quality] Image quality / output size tier. Interpretation is provider- and + * model-specific: OpenAI GPT models take `'high'` | `'medium'` | `'low'` (default `'low'`), and + * `gpt-image-2` also accepts `'auto'`; Gemini takes an output size tier `'512'` | `'1K'` | `'2K'` | + * `'4K'` (availability varies by model). + * @property {string} [input_image] An input image for image-to-image generation. Replicate and xAI + * `grok-imagine-*` accept a URL; Gemini and OpenAI `gpt-image-*` expect a base64-encoded (or data-URI) + * image (xAI also accepts base64/data-URI). + * @property {string[]} [input_images] Multiple input images for image-to-image / multi-image + * generation. Gemini and OpenAI `gpt-image-*` expect base64-encoded (or data-URI) images; Replicate + * expects image URLs; xAI `grok-imagine-*` accepts either (up to 3 images). + * @property {string} [input_image_mime_type] MIME type of the input image(s), e.g. `'image/png'`. Used + * as a fallback when the type cannot be auto-detected (Gemini). + * @property {string} [driver] + * @property {string} [provider] + * @property {string} [service] + * @property {{ w: number, h: number }} [ratio] Aspect ratio as `{ w, h }` (e.g. `{ w: 16, h: 9 }`). + * Supported by OpenAI, Gemini, and Replicate. + * @property {number} [width] Width of the image to generate, in pixels (Together). Default `1024`. + * @property {number} [height] Height of the image to generate, in pixels (Together). Default `1024`. + * @property {string} [aspect_ratio] Alternative way to specify the aspect ratio (Together). + * @property {number} [steps] Number of generation/inference steps (Together, default `20`; Replicate + * `flux-schnell`). + * @property {number} [seed] Seed used for generation; reuse to reproduce results (Together, Replicate). + * @property {string} [negative_prompt] Prompt describing what NOT to guide the image generation toward + * (Together). + * @property {number} [n] Number of image results to generate (Together). Default `1`. + * @property {string} [image_url] URL of an input image for models that support it (Together). + * @property {string} [image_base64] Base64-encoded input image for image-to-image generation (Together). + * @property {string} [mask_image_url] URL of a mask image for inpainting (Together). + * @property {string} [mask_image_base64] Base64-encoded mask image for inpainting (Together). + * @property {number} [prompt_strength] How strongly the prompt influences the output (Together). + * @property {boolean} [disable_safety_checker] When `true`, disables the safety checker (Together, + * Replicate). + * @property {string} [response_format] Format of the image response. Together: `'base64'` | `'url'`. + * Replicate: output format, e.g. `'webp'` | `'jpg'` | `'png'`. + * @property {number} [guidance] Guidance scale (Replicate `flux-2-klein-9b-base`). + * @property {boolean} [go_fast] Use the model's optimized fast mode (Replicate `flux-2-dev`). Defaults + * to `true` for that model, and affects pricing. + * @property {number} [output_quality] Output quality, 0-100 (Replicate, flux family). + * @property {string} [output_megapixels] Approximate output size in megapixels (Replicate, flux + * family), e.g. `'0.25'` | `'0.5'` | `'1'` | `'2'`. + * @property {number} [safety_tolerance] Safety tolerance level (Replicate `flux-2-pro`, + * `flux-1.1-pro`). + * @property {boolean} [prompt_upsampling] Enable prompt upsampling (Replicate `flux-1.1-pro`). + * @property {string} [generation_mode] Generation tier for Replicate Leonardo models, which affects + * pricing: `'standard'` | `'ultra'` (`lucid-origin`), `'fast'` | `'quality'` | `'ultra'` + * (`phoenix-1.0`). + * @property {string} [style] Stylistic preset (Replicate Leonardo models). + * @property {string} [contrast] Contrast preset (Replicate Leonardo models). + * @property {boolean} [prompt_enhance] Server-side prompt enhancement (Replicate Leonardo models). + * @property {boolean} [test_mode] When `true`, returns a sample image without using credits. + * @property {string} [puter_output_path] When set, the generated image is saved to this path on the + * Puter filesystem. Relative paths resolve against the app's data directory (`~/AppData//`) when + * called from an app, or `~/` otherwise. The caller must have write permission to the destination. + */ + +/** + * Options for `txt2vid()`. + * + * @typedef {Object} Txt2VidOptions + * @property {string} [prompt] + * @property {string} [driver] + * @property {string} [model] + * @property {number} [seconds] + * @property {number} [duration] + * @property {boolean} [test_mode] + * @property {string} [size] OpenAI: output size. + * @property {string} [resolution] OpenAI: output resolution. + * @property {File | string} [input_reference] OpenAI: reference clip or image. + * @property {number} [width] TogetherAI. + * @property {number} [height] TogetherAI. + * @property {number} [fps] TogetherAI. + * @property {number} [steps] TogetherAI. + * @property {number} [guidance_scale] TogetherAI. + * @property {number} [seed] TogetherAI. + * @property {string} [output_format] TogetherAI. + * @property {number} [output_quality] TogetherAI. + * @property {string} [negative_prompt] TogetherAI. + * @property {string[]} [reference_images] TogetherAI. + * @property {Array<{ input_image: string, frame: number }>} [frame_images] TogetherAI. + * @property {Record} [metadata] TogetherAI. + * @property {string} [puter_output_path] Save the generated video to this path on the Puter filesystem. + * @property {string} [last_frame] Final frame to guide generation toward. + */ + +/** + * Options for `txt2speech()`. + * + * @typedef {Object} Txt2SpeechOptions + * @property {string} [text] Text to synthesize. Must be less than 3000 characters. + * @property {string} [language] Language code. For AWS Polly defaults to `'en-US'`; for xAI a BCP-47 + * code defaulting to `'en'` (supports `'auto'`). + * @property {string} [voice] Voice ID used for synthesis (provider-specific). Defaults to `'Joanna'` + * (aws-polly), `'alloy'` (openai), `'21m00Tcm4TlvDq8ikWAM'` (elevenlabs), `'Kore'` (gemini), `'eve'` + * (xai), `'geffen_32'` (speechify). + * @property {string} [engine] AWS Polly synthesis engine: `'standard'` (default), `'neural'`, + * `'long-form'`, or `'generative'`. + * @property {string} [provider] TTS provider: `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, + * `'gemini'`, `'xai'`, or `'speechify'`. Common aliases (`'eleven'`, `'google'`, `'grok'`, `'polly'`, + * `'simba'`, …) resolve to these. + * @property {string} [model] Model identifier (provider-specific). + * @property {string} [response_format] OpenAI output format: `'mp3'` (default), `'wav'`, `'opus'`, + * `'aac'`, `'flac'`, or `'pcm'`. + * @property {string} [output_format] Output format for ElevenLabs (defaults to `'mp3_44100_128'`) and + * xAI (`'mp3'` default, `'wav'`, `'pcm'`, `'mulaw'`, `'alaw'`). + * @property {string} [instructions] Natural-language guidance for voice style such as tone, speed, and + * mood (OpenAI and Gemini). + * @property {Record} [voice_settings] ElevenLabs voice tuning options (e.g. stability, + * similarity boost, speed). + * @property {boolean} [ssml] When `true`, AWS Polly treats `text` as SSML markup. + * @property {boolean} [test_mode] When `true`, returns a sample audio without using credits. + */ + +/** + * Options for `txt2speech.listEngines()`. + * + * @typedef {Object} ListTTSEnginesOptions + * @property {string} [provider] TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every + * provider's engines. + */ + +/** + * A TTS engine/model as returned by `txt2speech.listEngines()`. + * + * @typedef {Object} TTSEngine + * @property {string} id Engine/model identifier. + * @property {string} name Human-readable engine name. + * @property {string} provider Provider this engine belongs to. + * @property {number} [pricing_per_million_chars] Cost per million characters (may be absent). + */ + +/** + * Options for `txt2speech.listVoices()`. + * + * @typedef {Object} ListTTSVoicesOptions + * @property {string} [provider] TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every + * provider's voices. + * @property {string} [engine] Engine/model filter (provider-specific, ignored by some providers). + */ + +/** + * A TTS voice as returned by `txt2speech.listVoices()`. + * + * @typedef {Object} TTSVoice + * @property {string} id Voice identifier to pass to `txt2speech()`. + * @property {string} name Human-readable voice name. + * @property {string} provider Provider this voice belongs to. + * @property {{ name: string, code: string }} [language] Language info (may be absent). + * @property {string} [description] Short description of the voice (may be absent). + * @property {string} [category] Voice category, e.g. `'premade'` (may be absent). + * @property {Record} [labels] Provider-specific labels (may be absent). + * @property {string[]} [supported_models] Model IDs this voice works with (may be absent). + * @property {string[]} [supported_engines] Engine types this voice supports (may be absent). + */ + +/** + * One word of a `speech2txt()` transcript. + * + * @typedef {Object} Speech2TxtWord + * @property {string} text + * @property {number} start + * @property {number} end + * @property {string} [speaker] Detected speaker, present when `diarize: true` (xAI). + */ + +/** + * What `speech2txt()` resolves to, unless `response_format` is `"text"`. + * + * @typedef {Object} Speech2TxtResult + * @property {string} text + * @property {string} language + * @property {Record[]} [segments] + * @property {number} [duration] Duration of the audio in seconds (provider-dependent, e.g. xAI). + * @property {Speech2TxtWord[]} [words] Per-word timestamps (provider-dependent, e.g. xAI). + */ + +/** + * The options every `speech2txt()` form shares. + * + * @typedef {Object} BaseSpeech2TxtOptions + * @property {string | File | Blob} [file] + * @property {string | File | Blob} [audio] + * @property {string} [provider] + * @property {string} [model] + * @property {string} [language] + * @property {string} [prompt] + * @property {boolean} [stream] + * @property {boolean} [translate] + * @property {number} [temperature] + * @property {boolean} [logprobs] + * @property {string[]} [timestamp_granularities] + * @property {string} [chunking_strategy] + * @property {string[]} [known_speaker_names] + * @property {string[]} [known_speaker_references] + * @property {Record} [extra_body] + * @property {boolean} [format] + * @property {boolean} [diarize] + * @property {boolean} [multichannel] + * @property {number} [channels] + * @property {string} [audio_format] + * @property {number} [sample_rate] + * @property {boolean} [test_mode] + */ + +/** + * The `response_format: "text"` form, which resolves to a plain string. + * + * @typedef {BaseSpeech2TxtOptions & { response_format: 'text' }} TextFormatSpeech2TxtOptions + */ + +/** + * Any other `speech2txt()` form, which resolves to a `Speech2TxtResult`. + * + * @typedef {BaseSpeech2TxtOptions & { response_format?: Exclude }} Speech2TxtOptions + */ + +/** + * Options for `speech2speech()`. The camelCase aliases are mapped onto the + * snake_case names before the request goes out; the snake_case spelling wins + * when both are given. + * + * @typedef {Object} Speech2SpeechOptions + * @property {string | File | Blob} [audio] + * @property {string | File | Blob} [file] + * @property {string} [provider] + * @property {string} [model] + * @property {string} [model_id] + * @property {string} [voice] + * @property {string} [voice_id] + * @property {string} [output_format] + * @property {Record} [voice_settings] + * @property {number} [seed] + * @property {string} [file_format] + * @property {boolean} [remove_background_noise] + * @property {number} [optimize_streaming_latency] + * @property {boolean} [enable_logging] + * @property {boolean} [test_mode] + * @property {string} [modelId] camelCase alias of `model_id`. + * @property {string} [voiceId] camelCase alias of `voice_id`. + * @property {string} [outputFormat] camelCase alias of `output_format`. + * @property {Record} [voiceSettings] camelCase alias of `voice_settings`. + * @property {string} [fileFormat] camelCase alias of `file_format`. + * @property {boolean} [removeBackgroundNoise] camelCase alias of `remove_background_noise`. + * @property {number} [optimizeStreamingLatency] camelCase alias of `optimize_streaming_latency`. + * @property {boolean} [enableLogging] camelCase alias of `enable_logging`. + */ + +export {}; diff --git a/src/puter-js/src/modules/ai/video.js b/src/puter-js/src/modules/ai/video.js index 317c81432..f5672fe5a 100644 --- a/src/puter-js/src/modules/ai/video.js +++ b/src/puter-js/src/modules/ai/video.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js'; import { toVideoElement } from './lib/media.js'; -/** @typedef {import('../../../types/modules/ai').Txt2VidOptions} Txt2VidOptions */ +/** @typedef {import('./types.js').Txt2VidOptions} Txt2VidOptions */ /** * @overload diff --git a/src/puter-js/src/modules/apps/checkName.js b/src/puter-js/src/modules/apps/checkName.js index 939283c48..753002e5e 100644 --- a/src/puter-js/src/modules/apps/checkName.js +++ b/src/puter-js/src/modules/apps/checkName.js @@ -1,7 +1,7 @@ import { fetchUrl } from '../../lib/networkUtils.js'; import { invalidRequest } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/apps').CheckAppNameResult} CheckAppNameResult */ +/** @typedef {import('./types.js').CheckAppNameResult} CheckAppNameResult */ /** * Checks whether an app name is available to the user. diff --git a/src/puter-js/src/modules/apps/create.js b/src/puter-js/src/modules/apps/create.js index 4079ca7c4..301c58dbe 100644 --- a/src/puter-js/src/modules/apps/create.js +++ b/src/puter-js/src/modules/apps/create.js @@ -3,8 +3,8 @@ import { addUserIteration } from './lib/appUsers.js'; import { toAppObject } from './lib/appObject.js'; import { invalidRequest } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/apps').CreateAppOptions} CreateAppOptions */ -/** @typedef {import('../../../types/modules/apps').CreateAppResult} CreateAppResult */ +/** @typedef {import('./types.js').CreateAppOptions} CreateAppOptions */ +/** @typedef {import('./types.js').CreateAppResult} CreateAppResult */ /** * @overload diff --git a/src/puter-js/src/modules/apps/get.js b/src/puter-js/src/modules/apps/get.js index 14e56e8d4..302349bd8 100644 --- a/src/puter-js/src/modules/apps/get.js +++ b/src/puter-js/src/modules/apps/get.js @@ -1,8 +1,8 @@ import * as utils from '../../lib/utils.js'; import { addUserIteration } from './lib/appUsers.js'; -/** @typedef {import('../../../types/modules/apps').App} App */ -/** @typedef {import('../../../types/modules/apps').AppListOptions} AppListOptions */ +/** @typedef {import('./types.js').App} App */ +/** @typedef {import('./types.js').AppListOptions} AppListOptions */ /** * Returns the app with the given name. Rejects if the app does not exist. diff --git a/src/puter-js/src/modules/apps/getDeveloperProfile.js b/src/puter-js/src/modules/apps/getDeveloperProfile.js index 10b9558f8..255e8dd65 100644 --- a/src/puter-js/src/modules/apps/getDeveloperProfile.js +++ b/src/puter-js/src/modules/apps/getDeveloperProfile.js @@ -1,7 +1,7 @@ import * as utils from '../../lib/utils.js'; /** @typedef {Record} DeveloperProfile */ -/** @typedef {import('../../../types/shared').RequestCallbacks} ProfileCallbacks */ +/** @typedef {import('../../lib/types.js').RequestCallbacks} ProfileCallbacks */ /** * @overload diff --git a/src/puter-js/src/modules/apps/index.js b/src/puter-js/src/modules/apps/index.js index 54ff10de2..48368ca30 100644 --- a/src/puter-js/src/modules/apps/index.js +++ b/src/puter-js/src/modules/apps/index.js @@ -7,15 +7,15 @@ import { getDeveloperProfile } from './getDeveloperProfile.js'; import { list } from './list.js'; import { update } from './update.js'; -/** @typedef {import('../../../types/puter').Puter} Puter */ +/** @typedef {import('../../index.js').Puter} Puter */ /** * The `puter.apps` module. * * Method implementations live in the sibling files as `this`-context * functions whose JSDoc (including the per-form `@overload` declarations) is - * the source of truth for the public signatures; types/modules/apps.d.ts - * mirrors them for TypeScript consumers of the published SDK. + * the source of truth for the public signatures — `types/` is generated from + * it, never edited by hand. */ export class AppsModule extends PuterModule { // The fields hold the unbound functions so they keep the full overloaded diff --git a/src/puter-js/src/modules/apps/lib/appObject.js b/src/puter-js/src/modules/apps/lib/appObject.js index c91035e16..c891a1979 100644 --- a/src/puter-js/src/modules/apps/lib/appObject.js +++ b/src/puter-js/src/modules/apps/lib/appObject.js @@ -4,8 +4,8 @@ // lives in exactly one place. /** - * @typedef {import('../../../../types/modules/apps').CreateAppOptions - * | import('../../../../types/modules/apps').UpdateAppAttributes} AppAttributes + * @typedef {import('../types.js').CreateAppOptions + * | import('../types.js').UpdateAppAttributes} AppAttributes */ /** diff --git a/src/puter-js/src/modules/apps/lib/appUsers.js b/src/puter-js/src/modules/apps/lib/appUsers.js index 53991af18..3f33a4313 100644 --- a/src/puter-js/src/modules/apps/lib/appUsers.js +++ b/src/puter-js/src/modules/apps/lib/appUsers.js @@ -2,8 +2,8 @@ // `users()` async iterator, backed by the app-telemetry driver. Shared by // every method that returns apps (create/update/get/list). -/** @typedef {import('../../../../types/puter').Puter} Puter */ -/** @typedef {import('../../../../types/modules/apps').App} App */ +/** @typedef {import('../../../index.js').Puter} Puter */ +/** @typedef {import('../types.js').App} App */ /** * @param {Puter} puter diff --git a/src/puter-js/src/modules/apps/list.js b/src/puter-js/src/modules/apps/list.js index 660ca5b81..1a8c89659 100644 --- a/src/puter-js/src/modules/apps/list.js +++ b/src/puter-js/src/modules/apps/list.js @@ -3,18 +3,18 @@ import { fetchAllPages, iteratePages } from '../../lib/pagination.js'; import { PuterJSError } from '../../lib/PuterJSError.js'; import { addUserIterationToApps } from './lib/appUsers.js'; -/** @typedef {import('../../../types/modules/apps').App} App */ -/** @typedef {import('../../../types/modules/apps').AppListOptions} AppListOptions */ +/** @typedef {import('./types.js').App} App */ +/** @typedef {import('./types.js').AppListOptions} AppListOptions */ /** * @overload - * @param {AppListOptions & import('../../../types/shared').ListStreamOptions} options - * @returns {AsyncIterableIterator>} + * @param {AppListOptions & import('../../lib/types.js').ListStreamOptions} options + * @returns {AsyncIterableIterator>} */ /** * @overload - * @param {AppListOptions & import('../../../types/shared').ListPaginationOptions & ({ cursor: string | null } | { offset: number } | { includeTotal: true })} options - * @returns {Promise>} + * @param {AppListOptions & import('../../lib/types.js').ListPaginationOptions & ({ cursor: string | null } | { offset: number } | { includeTotal: true })} options + * @returns {Promise>} */ /** * @overload @@ -29,8 +29,8 @@ import { addUserIterationToApps } from './lib/appUsers.js'; * envelope, and `stream: true` returns an async iterator of page envelopes. * * @this {import('./index.js').AppsModule} - * @param {AppListOptions & (import('../../../types/shared').ListPaginationOptions | import('../../../types/shared').ListStreamOptions)} [options] - * @returns {Promise | Promise> | AsyncIterableIterator>} + * @param {AppListOptions & (import('../../lib/types.js').ListPaginationOptions | import('../../lib/types.js').ListStreamOptions)} [options] + * @returns {Promise | Promise> | AsyncIterableIterator>} */ export function list (options) { const { puter } = this; diff --git a/src/puter-js/src/modules/apps/types.js b/src/puter-js/src/modules/apps/types.js new file mode 100644 index 000000000..01231dffc --- /dev/null +++ b/src/puter-js/src/modules/apps/types.js @@ -0,0 +1,121 @@ +// Shapes shared across the `puter.apps` operations. JSDoc-only; no runtime exports. + +/** + * A user of an app, as returned by `App.users()` and `App.getUsers()`. + * + * @typedef {Object} AppUser + * @property {string} username The user's username. + * @property {string} user_uuid The user's unique identifier. + * @property {string | null} [user_email] The user's email address. Only present when the user granted + * this app the `user::email:read` permission (e.g. via `puter.perms.requestEmail()`); omitted + * otherwise. May be `null` if the user granted access but has no email on file. + */ + +/** + * Pagination options for `App.getUsers()`. + * + * @typedef {Object} GetUsersOptions + * @property {number} [limit] The number of users to retrieve. Default is `100`. + * @property {number} [offset] The offset to start retrieving users from. Default is `0`. + */ + +/** + * The `App` object containing Puter app details. + * + * @typedef {Object} App + * @property {string} uid The unique identifier of the app, generated by Puter when the app is created. + * @property {string} name The name of the app. + * @property {string} index_url The URL of the index file of the app, loaded when the app is started. + * @property {string} [title] The title of the app. + * @property {string} [description] The description of the app. + * @property {string} [icon] The Data URL of the icon of the app (a base64 encoded image). + * @property {boolean} [maximize_on_start] Whether the app should be maximized when it is started. + * Default is `false`. + * @property {boolean} [background] Whether the app should run in the background. Default is `false`. + * @property {string[]} [filetype_associations] The file types that the app can open. Each string is in + * the format `"."` or `"mime/type"`, e.g. `[".txt", "image/png"]`. For a directory + * association, the string should be `.directory`. + * @property {Record} [metadata] Custom metadata for the app as arbitrary key-value pairs. + * @property {string} [created_at] The date and time when the app was created, in `YYYY-MM-DDTHH:MM:SSZ` + * format. + * @property {number} [open_count] The number of times the app has been opened. If `stats_period` is set + * to a value other than `all`, this is the count within that period. + * @property {number} [user_count] The number of users that have access to the app. If `stats_period` is + * set to a value other than `all`, this is the count within that period. + * @property {(pageSize?: number) => AsyncIterableIterator} users Iterates over all users of the + * app, fetching them page by page. `pageSize` defaults to 100. + * @property {(params?: GetUsersOptions) => Promise} getUsers Retrieves a list of users one + * page at a time as defined by limit and offset. + */ + +/** + * The result returned by `Apps.create()`. + * + * @typedef {Object} CreateAppResult + * @property {string} uid The unique identifier of the app, generated by Puter when the app is created. + * @property {string} name The name of the app. + * @property {string} title The title of the app. + * @property {string} index_url The URL of the index file of the app, loaded when the app is started. + * @property {string} subdomain The subdomain assigned to the app. + * @property {{ username: string, uuid: string }} owner Information about the owner of the app. + */ + +/** + * Options for `Apps.list()` and `Apps.get()`. + * + * @typedef {Object} AppListOptions + * @property {string} [stats_period] The period for which to get the user and open count. One of + * `today`, `yesterday`, `7d`, `30d`, `this_month`, `last_month`, `this_year`, `last_year`, + * `month_to_date`, `year_to_date`, or `last_12_months`. Default is `all` (all time). + * @property {null | 16 | 32 | 64 | 128 | 256 | 512} [icon_size] The size of the icons to return. + * Default is `null` (the original size). + */ + +/** + * Options for creating an app with `Apps.create()`. + * + * @typedef {Object} CreateAppOptions + * @property {string} name The name of the app to create. Must be unique to the user's apps; if an app + * with this name already exists the promise is rejected. + * @property {string} indexURL The URL of the app's index page, displayed when the app is started. Must + * start with `http://` or `https://`; other protocols are not allowed. + * @property {string} [title] The human-readable title of the app. Defaults to `name` if not provided. + * @property {string} [description] The description of the app aimed at the end user. + * @property {string} [icon] The icon of the app. + * @property {boolean} [maximizeOnStart] Whether the app should be maximized when it is started. + * Defaults to `false`. + * @property {boolean} [background] Whether the app should run in the background. Defaults to `false`. + * @property {string[]} [filetypeAssociations] The filetypes that the app can open. File extensions and + * MIME types are supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`. + * @property {Record} [metadata] Custom metadata for the app as arbitrary key-value pairs. + * @property {boolean} [dedupeName] Whether to deduplicate the app name if it already exists. Defaults to + * `false`. + */ + +/** + * Attributes to update with `Apps.update()`. + * + * @typedef {Object} UpdateAppAttributes + * @property {string} [name] The new name of the app. Must be unique to the user's apps; if an app with + * this name already exists the promise is rejected. + * @property {string} [indexURL] The new URL of the app's index page. Must be accessible to the user. + * @property {string} [title] The new title of the app. + * @property {string} [description] The new description of the app aimed at the end user. + * @property {string} [icon] The new icon of the app. + * @property {boolean} [maximizeOnStart] Whether the app should be maximized when it is started. + * Defaults to `false`. + * @property {boolean} [background] Whether the app should run in the background. Defaults to `false`. + * @property {string[]} [filetypeAssociations] The filetypes that the app can open. File extensions and + * MIME types are supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`. + * @property {Record} [metadata] Custom metadata for the app as arbitrary key-value pairs. + */ + +/** + * The result returned by `Apps.checkName()`. + * + * @typedef {Object} CheckAppNameResult + * @property {string} name The name that was checked. + * @property {boolean} available Whether the name is available. + */ + +export {}; diff --git a/src/puter-js/src/modules/apps/update.js b/src/puter-js/src/modules/apps/update.js index 2991d4788..f757d7a53 100644 --- a/src/puter-js/src/modules/apps/update.js +++ b/src/puter-js/src/modules/apps/update.js @@ -2,8 +2,8 @@ import * as utils from '../../lib/utils.js'; import { addUserIteration } from './lib/appUsers.js'; import { toAppObject } from './lib/appObject.js'; -/** @typedef {import('../../../types/modules/apps').App} App */ -/** @typedef {import('../../../types/modules/apps').UpdateAppAttributes} UpdateAppAttributes */ +/** @typedef {import('./types.js').App} App */ +/** @typedef {import('./types.js').UpdateAppAttributes} UpdateAppAttributes */ /** * Updates attributes of the app with the given name. diff --git a/src/puter-js/src/modules/hosting/create.js b/src/puter-js/src/modules/hosting/create.js index c28801a88..126ae965a 100644 --- a/src/puter-js/src/modules/hosting/create.js +++ b/src/puter-js/src/modules/hosting/create.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js'; import { normalizeSubdomain } from './lib/args.js'; -/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ +/** @typedef {import('./types.js').Subdomain} Subdomain */ /** * @overload diff --git a/src/puter-js/src/modules/hosting/get.js b/src/puter-js/src/modules/hosting/get.js index 018ee4c92..75da0ff1b 100644 --- a/src/puter-js/src/modules/hosting/get.js +++ b/src/puter-js/src/modules/hosting/get.js @@ -1,7 +1,7 @@ import * as utils from '../../lib/utils.js'; import { normalizeSubdomain } from './lib/args.js'; -/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ +/** @typedef {import('./types.js').Subdomain} Subdomain */ /** * Retrieves a subdomain by name. Rejects if the subdomain does not exist. diff --git a/src/puter-js/src/modules/hosting/index.js b/src/puter-js/src/modules/hosting/index.js index e1d181c37..1ad3b53ef 100644 --- a/src/puter-js/src/modules/hosting/index.js +++ b/src/puter-js/src/modules/hosting/index.js @@ -5,15 +5,15 @@ import { get } from './get.js'; import { list } from './list.js'; import { update } from './update.js'; -/** @typedef {import('../../../types/puter').Puter} Puter */ +/** @typedef {import('../../index.js').Puter} Puter */ /** * The `puter.hosting` module. * * Method implementations live in the sibling files as `this`-context * functions whose JSDoc (including the per-form `@overload` declarations) is - * the source of truth for the public signatures; types/modules/hosting.d.ts - * mirrors them for TypeScript consumers of the published SDK. + * the source of truth for the public signatures — `types/` is generated from + * it, never edited by hand. */ export class HostingModule extends PuterModule { // The fields hold the unbound functions so they keep the full overloaded diff --git a/src/puter-js/src/modules/hosting/list.js b/src/puter-js/src/modules/hosting/list.js index 815ff1b57..ecb61d719 100644 --- a/src/puter-js/src/modules/hosting/list.js +++ b/src/puter-js/src/modules/hosting/list.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import { fetchAllPages, iteratePages } from '../../lib/pagination.js'; import { PuterJSError } from '../../lib/PuterJSError.js'; -/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ +/** @typedef {import('./types.js').Subdomain} Subdomain */ // Older backends include worker-backed subdomain rows in select results; // current ones exclude them server-side. Filtering here keeps the SDK's output @@ -12,13 +12,13 @@ const withoutWorkerRows = (items) => /** * @overload - * @param {import('../../../types/shared').ListStreamOptions} options - * @returns {AsyncIterableIterator>} + * @param {import('../../lib/types.js').ListStreamOptions} options + * @returns {AsyncIterableIterator>} */ /** * @overload - * @param {import('../../../types/shared').ListPaginationOptions & ({ cursor: string | null } | { includeTotal: true })} options - * @returns {Promise>} + * @param {import('../../lib/types.js').ListPaginationOptions & ({ cursor: string | null } | { includeTotal: true })} options + * @returns {Promise>} */ /** * @overload @@ -36,7 +36,7 @@ const withoutWorkerRows = (items) => * * @this {import('./index.js').HostingModule} * @param {...unknown} args - * @returns {Promise | Promise> | AsyncIterableIterator>} + * @returns {Promise | Promise> | AsyncIterableIterator>} */ export function list (...args) { const { puter } = this; diff --git a/src/puter-js/src/modules/hosting/types.js b/src/puter-js/src/modules/hosting/types.js new file mode 100644 index 000000000..1325a4c0e --- /dev/null +++ b/src/puter-js/src/modules/hosting/types.js @@ -0,0 +1,14 @@ +// Shapes shared across the `puter.hosting` operations. JSDoc-only; no runtime exports. + +/** + * A subdomain hosted on Puter, containing its details. + * + * @typedef {Object} Subdomain + * @property {string} uid Unique identifier of the subdomain. + * @property {string} subdomain Name of the subdomain, i.e. the part before the main domain + * (e.g. `example` in `example.puter.site`). + * @property {import('../FSItem.js').FSItem} root_dir The root directory of the subdomain, where its + * files are stored. + */ + +export {}; diff --git a/src/puter-js/src/modules/hosting/update.js b/src/puter-js/src/modules/hosting/update.js index 00e9bffec..e1db55c97 100644 --- a/src/puter-js/src/modules/hosting/update.js +++ b/src/puter-js/src/modules/hosting/update.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import getAbsolutePathForApp from '../FileSystem/utils/getAbsolutePathForApp.js'; import { normalizeSubdomain } from './lib/args.js'; -/** @typedef {import('../../../types/modules/hosting').Subdomain} Subdomain */ +/** @typedef {import('./types.js').Subdomain} Subdomain */ /** * Updates a subdomain to point at a new directory. Rejects if the subdomain diff --git a/src/puter-js/src/modules/kv/add.js b/src/puter-js/src/modules/kv/add.js index a992b3a5a..49907592d 100644 --- a/src/puter-js/src/modules/kv/add.js +++ b/src/puter-js/src/modules/kv/add.js @@ -2,9 +2,9 @@ import * as utils from '../../lib/utils.js'; import { isObject, isOptConfigShorthand } from './lib/args.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVAddPath} KVAddPath */ -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ -/** @typedef {import('../../../types/modules/kv').KVValue} KVValue */ +/** @typedef {import('./types.js').KVAddPath} KVAddPath */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVValue} KVValue */ /** * @overload diff --git a/src/puter-js/src/modules/kv/decr.js b/src/puter-js/src/modules/kv/decr.js index 438f79574..a2f1da50c 100644 --- a/src/puter-js/src/modules/kv/decr.js +++ b/src/puter-js/src/modules/kv/decr.js @@ -2,8 +2,8 @@ import * as utils from '../../lib/utils.js'; import { parseCounterArgs } from './lib/args.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVIncrementPath} KVIncrementPath */ -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVIncrementPath} KVIncrementPath */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ /** * @overload diff --git a/src/puter-js/src/modules/kv/del.js b/src/puter-js/src/modules/kv/del.js index 12dcdc28e..b2c03f6a9 100644 --- a/src/puter-js/src/modules/kv/del.js +++ b/src/puter-js/src/modules/kv/del.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import { isObject, parseOptConfigThenCallbacks } from './lib/args.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ const delDriverCall = (puter, args) => utils.makeDriverMethod({ diff --git a/src/puter-js/src/modules/kv/expire.js b/src/puter-js/src/modules/kv/expire.js index ef9f3d8f5..70df42dc1 100644 --- a/src/puter-js/src/modules/kv/expire.js +++ b/src/puter-js/src/modules/kv/expire.js @@ -1,7 +1,7 @@ import * as utils from '../../lib/utils.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ /** * @overload diff --git a/src/puter-js/src/modules/kv/expireAt.js b/src/puter-js/src/modules/kv/expireAt.js index 1eddc46b4..07418c578 100644 --- a/src/puter-js/src/modules/kv/expireAt.js +++ b/src/puter-js/src/modules/kv/expireAt.js @@ -1,7 +1,7 @@ import * as utils from '../../lib/utils.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ /** * @overload diff --git a/src/puter-js/src/modules/kv/flush.js b/src/puter-js/src/modules/kv/flush.js index 7bfde4164..257e30d1c 100644 --- a/src/puter-js/src/modules/kv/flush.js +++ b/src/puter-js/src/modules/kv/flush.js @@ -1,7 +1,7 @@ import * as utils from '../../lib/utils.js'; import { isObject, parseOptConfigThenCallbacks } from './lib/args.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ const flushDriverCall = (puter, args) => utils.makeDriverMethod({ iface: 'puter-kvstore', method: 'flush', puter })(args); diff --git a/src/puter-js/src/modules/kv/get.js b/src/puter-js/src/modules/kv/get.js index 99408745d..796ed1b0d 100644 --- a/src/puter-js/src/modules/kv/get.js +++ b/src/puter-js/src/modules/kv/get.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import { isObject, parseOptConfigThenCallbacks } from './lib/args.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ const getDriverCall = (puter, args) => utils.makeDriverMethod({ diff --git a/src/puter-js/src/modules/kv/incr.js b/src/puter-js/src/modules/kv/incr.js index d3ef8b584..6411a7a28 100644 --- a/src/puter-js/src/modules/kv/incr.js +++ b/src/puter-js/src/modules/kv/incr.js @@ -2,8 +2,8 @@ import * as utils from '../../lib/utils.js'; import { parseCounterArgs } from './lib/args.js'; import { assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVIncrementPath} KVIncrementPath */ -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVIncrementPath} KVIncrementPath */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ /** * @overload diff --git a/src/puter-js/src/modules/kv/index.js b/src/puter-js/src/modules/kv/index.js index 25eff5d8e..697926b77 100644 --- a/src/puter-js/src/modules/kv/index.js +++ b/src/puter-js/src/modules/kv/index.js @@ -14,24 +14,33 @@ import { remove } from './remove.js'; import { set } from './set.js'; import { update } from './update.js'; -/** @typedef {import('../../../types/puter').Puter} Puter */ +/** @typedef {import('../../index.js').Puter} Puter */ /** - * The `puter.kv` module. + * The key-value store. Each app has its own private store within each user's + * account; apps cannot access other apps' stores. * * Method implementations live in the sibling files as `this`-context * functions whose JSDoc (including the per-form `@overload` declarations) is - * the source of truth for the public signatures; types/modules/kv.d.ts - * mirrors them for TypeScript consumers of the published SDK. + * the source of truth for the public signatures — `types/` is generated from + * it, never edited by hand. */ export class KVModule extends PuterModule { /** @type {GuiBootCache} */ guiCache; - /** The maximum allowed key size, in bytes (`1 KB`). */ + /** + * The maximum allowed key size, in bytes (`1 KB`). + * + * @readonly + */ MAX_KEY_SIZE = MAX_KEY_SIZE; - /** The maximum allowed value size, in bytes (`400 KB`). */ + /** + * The maximum allowed value size, in bytes (`400 KB`). + * + * @readonly + */ MAX_VALUE_SIZE = MAX_VALUE_SIZE; // The fields hold the unbound functions so they keep the full overloaded diff --git a/src/puter-js/src/modules/kv/lib/guiCache.js b/src/puter-js/src/modules/kv/lib/guiCache.js index b72105236..d6902e188 100644 --- a/src/puter-js/src/modules/kv/lib/guiCache.js +++ b/src/puter-js/src/modules/kv/lib/guiCache.js @@ -42,7 +42,7 @@ const createDeferred = () => { * within the lifetime window is then served from the one batched response. */ export class GuiBootCache { - /** @param {import('../../../../types/puter').Puter} puter */ + /** @param {import('../../../index.js').Puter} puter */ constructor (puter) { this.puter = puter; this.batch = createDeferred(); diff --git a/src/puter-js/src/modules/kv/list.js b/src/puter-js/src/modules/kv/list.js index 6f11815e7..4fcb52e38 100644 --- a/src/puter-js/src/modules/kv/list.js +++ b/src/puter-js/src/modules/kv/list.js @@ -2,16 +2,16 @@ import * as utils from '../../lib/utils.js'; import { fetchAllPages, iteratePages } from '../../lib/pagination.js'; import { isObject, isOptConfigShorthand } from './lib/args.js'; -/** @typedef {import('../../../types/modules/kv').KVListOptions} KVListOptions */ +/** @typedef {import('./types.js').KVListOptions} KVListOptions */ /** * @template [T=unknown] - * @typedef {import('../../../types/modules/kv').KVListPage} KVListPage + * @typedef {import('./types.js').KVListPage} KVListPage */ -/** @typedef {import('../../../types/modules/kv').KVListPaginationOptions} KVListPaginationOptions */ -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVListPaginationOptions} KVListPaginationOptions */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ /** * @template [T=unknown] - * @typedef {import('../../../types/modules/kv').KVPair} KVPair + * @typedef {import('./types.js').KVPair} KVPair */ // Page size the SDK uses when it pages on the caller's behalf (full listings diff --git a/src/puter-js/src/modules/kv/remove.js b/src/puter-js/src/modules/kv/remove.js index b65cba313..2b568ab68 100644 --- a/src/puter-js/src/modules/kv/remove.js +++ b/src/puter-js/src/modules/kv/remove.js @@ -2,8 +2,8 @@ import * as utils from '../../lib/utils.js'; import { isObject } from './lib/args.js'; import { assertKeyPresent, assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ -/** @typedef {import('../../../types/modules/kv').KVValue} KVValue */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVValue} KVValue */ /** * Removes values from a key by one or more dot-separated paths (e.g. diff --git a/src/puter-js/src/modules/kv/set.js b/src/puter-js/src/modules/kv/set.js index 6995f437b..eb04bce6f 100644 --- a/src/puter-js/src/modules/kv/set.js +++ b/src/puter-js/src/modules/kv/set.js @@ -2,19 +2,19 @@ import * as utils from '../../lib/utils.js'; import { isBatchSetItem, isObject, parseTrailingArgs } from './lib/args.js'; import { assertKeyPresent, assertKeySize, assertValueSize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ -/** @typedef {import('../../../types/modules/kv').KVScalar} KVScalar */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVScalar} KVScalar */ /** * @template [T=KVScalar] - * @typedef {import('../../../types/modules/kv').KVSetBatch} KVSetBatch + * @typedef {import('./types.js').KVSetBatch} KVSetBatch */ /** * @template [T=KVScalar] - * @typedef {import('../../../types/modules/kv').KVSetItem} KVSetItem + * @typedef {import('./types.js').KVSetItem} KVSetItem */ /** * @template [T=KVScalar] - * @typedef {import('../../../types/modules/kv').KVSetObject} KVSetObject + * @typedef {import('./types.js').KVSetObject} KVSetObject */ const setSingle = (puter, args) => diff --git a/src/puter-js/src/modules/kv/types.js b/src/puter-js/src/modules/kv/types.js new file mode 100644 index 000000000..13a522c16 --- /dev/null +++ b/src/puter-js/src/modules/kv/types.js @@ -0,0 +1,149 @@ +// Shapes shared across the `puter.kv` operations. JSDoc-only; no runtime exports. + +/** @typedef {string | number | boolean | object | unknown} KVValue */ +/** @typedef {KVValue | KVValue[]} KVScalar */ + +/** + * A key-value pair as returned by `list()` when `returnValues` is `true`. + * + * @template [T=unknown] + * @typedef {Object} KVPair + * @property {string} key The key name. + * @property {T} value The value associated with the key. Can be of any type. + */ + +/** + * A single item in a batch `set()` operation. + * + * @template [T=KVScalar] + * @typedef {Object} KVSetItem + * @property {string} key The key to create or update. Maximum key size is `1 KB`. + * @property {T} value The value to store. Maximum value size is `400 KB`. + * @property {number} [expireAt] Timestamp, in seconds, at which the key should expire. + */ + +/** + * Object form of the arguments to `set()`. + * + * @template [T=KVScalar] + * @typedef {Object} KVSetObject + * @property {string} key The key to create or update. Maximum key size is `1 KB`. + * @property {T} value The value to store. Maximum value size is `400 KB`. + * @property {number} [expireAt] Timestamp, in seconds, at which the key should expire. + * @property {KVOptConfig} [optConfig] + */ + +/** + * Wrapped batch form of `set()`, setting multiple items in a single request. + * + * @template [T=KVScalar] + * @typedef {Object} KVSetBatch + * @property {KVSetItem[]} items The key-value items to set in a single request. + * @property {KVOptConfig} [optConfig] + */ + +/** + * Maps a dot-separated path to a property within an object value (e.g. + * `"user.score"`) to the amount to increment/decrement it by. + * + * @typedef {Record} KVIncrementPath + */ + +/** + * Maps each dot-separated path (e.g. `"profile.name"`) to the new value for + * that path. + * + * @typedef {Record} KVUpdatePath + */ + +/** + * Object form of the arguments to `update()`. + * + * @typedef {Object} KVUpdateObject + * @property {string} key The key to update. + * @property {KVUpdatePath} pathAndValueMap Maps dot-separated paths to their new values. + * @property {number} [ttl] Time-to-live for the key, in seconds. + * @property {KVOptConfig} [optConfig] + */ + +/** + * Maps each dot-separated path (e.g. `"profile.tags"`) to the value (or values) + * to add at that path. + * + * @typedef {Record} KVAddPath + */ + +/** + * Options object form of the arguments to `list()`. + * + * @typedef {Object} KVListOptions + * @property {string} [pattern] Prefix-based key filter. A trailing `*` is a wildcard; both `abc` and + * `abc*` match keys starting with `abc`. Defaults to `*`, matching all keys. + * @property {boolean} [returnValues] When `true`, results contain `KVPair` objects with `key` and + * `value`; when `false`, results contain only keys. Defaults to `false`. + * @property {number} [limit] Maximum number of items to return in a single call. + * @property {string} [cursor] Pagination cursor from a previous call. + * @property {number} [offset] Skips the given number of items before the page starts. Maximum `5000`, + * and cannot be combined with `cursor`. Prefer `cursor` — requests get slower and more expensive the + * larger the offset. + * @property {boolean} [includeTotal] When `true`, the result includes a `total` count of every item + * matching the query across all pages. The count is metered and its cost grows with the store — + * request it once (on the first page) and avoid it in hot paths; to know whether more pages exist, + * check for `cursor` instead. + * @property {boolean} [fetchUntilFull] A page can come back with fewer than `limit` items even when + * more exist (for example when expired keys are excluded). When `true`, the page is filled up to + * `limit` items when possible. Requires `limit`. + * @property {KVOptConfig} [optConfig] + */ + +/** + * The options that switch `list()` from a flat array to a `KVListPage`. Any + * one of them is enough — they are the same set the runtime treats as a + * paginated request. + * + * @typedef {{ limit: number } + * | { cursor: string } + * | { offset: number } + * | { includeTotal: boolean } + * | { fetchUntilFull: boolean }} KVListPaginationOptions + */ + +/** + * The `stream: true` form of `list()`: returns an async iterator of + * `KVListPage`s for `for await ... of` instead of a promise. Cannot be + * combined with `offset`; pass `cursor` to resume from a position. + * + * @typedef {Object} KVListStreamOptions + * @property {true} stream Stream page envelopes as they are fetched. + */ + +/** + * A page of paginated results from `list()` when `limit` or `cursor` is used. + * + * @template [T=unknown] + * @typedef {Object} KVListPage + * @property {T[]} items The keys (or `KVPair` objects when `returnValues` is `true`) for this page. + * @property {string} [cursor] Pagination cursor for the next page. Present only when there are more + * results to fetch; pass it to the next `list()` call. + * @property {number} [total] Total count of items matching the query across all pages. Present only + * when the page was requested with `includeTotal`. + */ + +/** + * Per-call configuration accepted by every `puter.kv` operation. + * + * @typedef {Object} KVOptConfig + * @property {string} [appUuid] Address another app's namespace instead of this app's own. Requires an + * `app-data::kv:` permission, which `puter.perms.requestAppData()` asks the user for. + * @property {boolean} [disableSharing] Mark the entry private to this app: invisible and untouchable + * to any other app the user later grants access to this namespace. + * + * Honoured by both forms of `set()` — one key, or a batch, where it marks every entry in the batch. + * `set` writes the whole entry, so writing the key again without the flag makes it shareable. + * Rejected when combined with `appUuid`, since only an entry's owner may mark it private. + * + * Use it for anything another app should never read, such as a cached OAuth token, since a user + * granting access cannot see what a namespace holds. + */ + +export {}; diff --git a/src/puter-js/src/modules/kv/update.js b/src/puter-js/src/modules/kv/update.js index 225b58070..635c6a809 100644 --- a/src/puter-js/src/modules/kv/update.js +++ b/src/puter-js/src/modules/kv/update.js @@ -2,10 +2,10 @@ import * as utils from '../../lib/utils.js'; import { isObject, parseTrailingArgs } from './lib/args.js'; import { assertKeyPresent, assertKeySize } from './lib/validate.js'; -/** @typedef {import('../../../types/modules/kv').KVOptConfig} KVOptConfig */ -/** @typedef {import('../../../types/modules/kv').KVUpdateObject} KVUpdateObject */ -/** @typedef {import('../../../types/modules/kv').KVUpdatePath} KVUpdatePath */ -/** @typedef {import('../../../types/modules/kv').KVValue} KVValue */ +/** @typedef {import('./types.js').KVOptConfig} KVOptConfig */ +/** @typedef {import('./types.js').KVUpdateObject} KVUpdateObject */ +/** @typedef {import('./types.js').KVUpdatePath} KVUpdatePath */ +/** @typedef {import('./types.js').KVValue} KVValue */ const updateDriverCall = (puter, args) => utils.makeDriverMethod({ diff --git a/src/puter-js/src/modules/networking/PSocket.js b/src/puter-js/src/modules/networking/PSocket.js index 92e74c081..d33eb8d6b 100644 --- a/src/puter-js/src/modules/networking/PSocket.js +++ b/src/puter-js/src/modules/networking/PSocket.js @@ -10,12 +10,29 @@ export let wispInfo = { handler: undefined, }; -/** @typedef {import('../../../types/modules/networking').SocketEvent} SocketEvent */ +/** @typedef {import('./types.js').SocketEvent} SocketEvent */ + +/** + * The payload each socket event carries. + * + * @typedef {Object} PSocketEventMap + * @property {void} open Fires when the socket is initialized and ready to send data. + * @property {Uint8Array} data Fires when the remote server sends data over the socket. + * @property {Error} error Fires when the socket hits an error; a `close` follows shortly after. The + * human-readable reason is on `error.message`. + * @property {boolean} close Fires when the socket is closed. `true` if it closed due to an error. + * @property {void} drain Fires when the write buffer has been flushed. + * @property {Uint8Array} tlsdata The `PTLSSocket` spelling of `data`. + * @property {void} tlsopen The `PTLSSocket` spelling of `open`. + * @property {boolean} tlsclose The `PTLSSocket` spelling of `close`. + */ /** * A raw TCP socket in the browser, tunnelled over the Wisp relay. Construct it * with `puter.net.Socket(hostname, port)`; the connection is established * asynchronously, so write once `'open'` has fired. + * + * @extends {EventListener} */ export class PSocket extends EventListener { _events = new Map(); @@ -92,7 +109,8 @@ export class PSocket extends EventListener { /** * Registers a handler for a socket event, the same as `on`. * - * @param {...unknown} args a `(event, handler)` pair + * @template {SocketEvent} K + * @param {[event: K, handler: (data: PSocketEventMap[K]) => void]} args * @returns {void} */ addListener (...args) { diff --git a/src/puter-js/src/modules/networking/PTLS.js b/src/puter-js/src/modules/networking/PTLS.js index e550505c9..8c9b0cdb0 100644 --- a/src/puter-js/src/modules/networking/PTLS.js +++ b/src/puter-js/src/modules/networking/PTLS.js @@ -100,9 +100,10 @@ export class PTLSSocket extends PSocket { * `'close'` are accepted as aliases of the `tls`-prefixed events, so the * same handler code works against either socket type. * - * @param {string} event - * @param {(...args: unknown[]) => void} callback - * @returns {void} + * @template {import('./types.js').SocketEvent} K + * @param {K} event + * @param {(data: import('./PSocket.js').PSocketEventMap[K]) => void} callback + * @returns {this | undefined} */ on (event, callback) { if ( event === 'data' || event === 'open' || event === 'close' ) { diff --git a/src/puter-js/src/modules/networking/types.js b/src/puter-js/src/modules/networking/types.js new file mode 100644 index 000000000..34a6a67c0 --- /dev/null +++ b/src/puter-js/src/modules/networking/types.js @@ -0,0 +1,33 @@ +// Shapes shared across the `puter.net` sockets. JSDoc-only; no runtime exports. + +/** + * Names of events emitted by a socket. Plain `PSocket` uses `'open'`, + * `'data'`, `'close'`, `'error'`; `PTLSSocket` uses the `'tls'`-prefixed + * variants. + * + * @typedef {'open' + * | 'data' + * | 'error' + * | 'close' + * | 'drain' + * | 'tlsdata' + * | 'tlsopen' + * | 'tlsclose'} SocketEvent + */ + +/** + * The `puter.net` networking API. Establishes network connections directly + * from the frontend without a server or proxy, and bypasses CORS + * restrictions. + * + * @typedef {Object} Networking + * @property {() => Promise} generateWispV1URL Mints a relay URL (server plus single-use + * token) for speaking the Wisp v1 protocol directly. + * @property {typeof import('./PSocket.js').PSocket} Socket Constructor for a raw TCP `Socket`. + * @property {{ TLSSocket: typeof import('./PTLS.js').PTLSSocket }} tls Constructor for a + * TLS-protected `TLSSocket`. + * @property {(input: RequestInfo | URL, init?: RequestInit) => Promise} fetch + * Fetch an http/https resource without being bound by CORS restrictions. + */ + +export {}; diff --git a/src/puter-js/src/modules/os/index.js b/src/puter-js/src/modules/os/index.js index 5dcc5819e..7c1991f44 100644 --- a/src/puter-js/src/modules/os/index.js +++ b/src/puter-js/src/modules/os/index.js @@ -2,14 +2,14 @@ import { PuterModule } from '../../lib/PuterModule.js'; import { user } from './user.js'; import { version } from './version.js'; -/** @typedef {import('../../../types/puter').Puter} Puter */ +/** @typedef {import('../../index.js').Puter} Puter */ /** * The `puter.os` module. * * Method implementations live in the sibling files as `this`-context - * functions whose JSDoc is the source of truth for the public signatures; - * types/modules/os.d.ts mirrors them for TypeScript consumers of the SDK. + * functions whose JSDoc is the source of truth for the public signatures — + * `types/` is generated from it, never edited by hand. */ export class OSModule extends PuterModule { user = user; diff --git a/src/puter-js/src/modules/os/user.js b/src/puter-js/src/modules/os/user.js index 4d1bfd4ed..33ebf4727 100644 --- a/src/puter-js/src/modules/os/user.js +++ b/src/puter-js/src/modules/os/user.js @@ -11,9 +11,9 @@ import { parseCallbackOptions } from './lib/args.js'; // latency so it catches the boot burst and nothing else. const WHOAMI_DEDUPE_WINDOW_MS = 1000; -/** @typedef {import('../../../types/modules/auth').User} User */ +/** @typedef {import('../Auth.js').User} User */ -/** @typedef {import('../../../types/shared').RequestCallbacks} UserCallbacks */ +/** @typedef {import('../../lib/types.js').RequestCallbacks} UserCallbacks */ /** * @overload diff --git a/src/puter-js/src/modules/os/version.js b/src/puter-js/src/modules/os/version.js index 194c41695..b4eb481aa 100644 --- a/src/puter-js/src/modules/os/version.js +++ b/src/puter-js/src/modules/os/version.js @@ -2,7 +2,7 @@ import * as utils from '../../lib/utils.js'; import { parseCallbackOptions } from './lib/args.js'; /** @typedef {Record} VersionInfo */ -/** @typedef {import('../../../types/shared').RequestCallbacks} VersionCallbacks */ +/** @typedef {import('../../lib/types.js').RequestCallbacks} VersionCallbacks */ /** * @overload diff --git a/src/puter-js/src/modules/perms/appData.js b/src/puter-js/src/modules/perms/appData.js index 2d1ea53e5..9ae848a3d 100644 --- a/src/puter-js/src/modules/perms/appData.js +++ b/src/puter-js/src/modules/perms/appData.js @@ -1,9 +1,9 @@ import { PuterJSError } from '../../lib/PuterJSError.js'; /** @typedef {import('./index.js').PermsModule} PermsModule */ -/** @typedef {import('../../../types/modules/perms').AppDataScopes} AppDataScopes */ -/** @typedef {import('../../../types/modules/perms').AppDataKvScope} AppDataKvScope */ -/** @typedef {import('../../../types/modules/perms').AppDataFsScope} AppDataFsScope */ +/** @typedef {import('./types.js').AppDataScopes} AppDataScopes */ +/** @typedef {import('./types.js').AppDataKvScope} AppDataKvScope */ +/** @typedef {import('./types.js').AppDataFsScope} AppDataFsScope */ // Mirrors `services/permission/appDataScopes.ts`, which stays authoritative. // This copy only turns a typo into a useful error instead of an opaque 403. diff --git a/src/puter-js/src/modules/perms/appRootDir.js b/src/puter-js/src/modules/perms/appRootDir.js index 177191236..d3e5703ce 100644 --- a/src/puter-js/src/modules/perms/appRootDir.js +++ b/src/puter-js/src/modules/perms/appRootDir.js @@ -9,7 +9,7 @@ import { req } from './lib/req.js'; * permission and retries (with a short backoff to ride out server-side cache * invalidation), returning the fs item on success or `undefined` if denied. * - * @param {import('../../../types/puter').Puter} puter + * @param {import('../../index.js').Puter} puter * @param {'read' | 'write'} access * @param {string | { uid: string }} appUidOrObject * @returns {Promise | undefined>} diff --git a/src/puter-js/src/modules/perms/index.js b/src/puter-js/src/modules/perms/index.js index 6fee13c9a..4132831d3 100644 --- a/src/puter-js/src/modules/perms/index.js +++ b/src/puter-js/src/modules/perms/index.js @@ -19,7 +19,7 @@ import { requestPermission, requestReadApps, requestReadSubdomains, } from './permissions.js'; -/** @typedef {import('../../../types/puter').Puter} Puter */ +/** @typedef {import('../../index.js').Puter} Puter */ // Every `this`-context method exposed on the module, rebound in the // constructor so both `puter.perms.grantUser(...)` and destructured @@ -43,8 +43,8 @@ const METHODS = [ * The `puter.perms` module. * * Method implementations live in the sibling files as `this`-context - * functions whose JSDoc is the source of truth for the public signatures; - * types/modules/perms.d.ts mirrors them for TypeScript consumers of the SDK. + * functions whose JSDoc is the source of truth for the public signatures — + * `types/` is generated from it, never edited by hand. */ export class PermsModule extends PuterModule { // Grant / revoke diff --git a/src/puter-js/src/modules/perms/lib/req.js b/src/puter-js/src/modules/perms/lib/req.js index b1c7810f2..635e310bd 100644 --- a/src/puter-js/src/modules/perms/lib/req.js +++ b/src/puter-js/src/modules/perms/lib/req.js @@ -6,7 +6,7 @@ import { fetchUrl } from '../../../lib/networkUtils.js'; * than rejecting — preserved for backward compatibility, so callers keep * inspecting `result.error` instead of catching. * - * @param {import('../../../../types/puter').Puter} puter + * @param {import('../../../index.js').Puter} puter * @param {string} route * @param {Record} [body] - When present the request is a POST. * @returns {Promise>} diff --git a/src/puter-js/src/modules/perms/types.js b/src/puter-js/src/modules/perms/types.js new file mode 100644 index 000000000..edf6c3b9d --- /dev/null +++ b/src/puter-js/src/modules/perms/types.js @@ -0,0 +1,58 @@ +// Shapes shared across the `puter.perms` operations. JSDoc-only; no runtime exports. + +/** + * The stores an `app-data` scope can name. + * + * @typedef {'kv' | 'fs'} AppDataStore + */ + +/** + * The three access classes. `delete` is orthogonal to `write`: neither implies + * the other, so an app that only adds data cannot remove any. + * + * @typedef {'read' | 'write' | 'delete'} AppDataClass + */ + +/** + * A key-value scope: an access class, or one concrete operation. Classes are + * the coarser form — `read` covers `get`/`list`, `write` covers + * `set`/`add`/`incr`/`decr`/`update`, and `delete` covers + * `del`/`remove`/`expire`/`expireAt`. + * + * `flush` is deliberately absent — it empties a whole namespace and no scope + * reaches it. + * + * @typedef {AppDataClass + * | 'get' | 'list' + * | 'set' | 'add' | 'incr' | 'decr' | 'update' + * | 'del' | 'remove' | 'expire' | 'expireAt'} AppDataKvScope + */ + +/** + * A file scope. Classes only, with no per-operation form: ACL checks a mode, + * not an operation, so there is nothing finer to name. + * + * @typedef {AppDataClass} AppDataFsScope + */ + +/** + * One `':'` pair, as the array form takes them. + * + * @typedef {`kv:${AppDataKvScope}` | `fs:${AppDataFsScope}`} AppDataScopePair + */ + +/** + * What `requestAppData` accepts. A bare class applies to both stores; the array + * form spells out the store on every entry; the object form groups by store. + * There is no bare-name array — an entry with no store would be ambiguous + * between the two. + * + * @typedef {AppDataClass + * | AppDataScopePair[] + * | { + * kv?: AppDataKvScope | AppDataKvScope[], + * fs?: AppDataFsScope | AppDataFsScope[], + * }} AppDataScopes + */ + +export {}; diff --git a/src/puter-js/tsconfig.types.json b/src/puter-js/tsconfig.types.json new file mode 100644 index 000000000..38ceda2ae --- /dev/null +++ b/src/puter-js/tsconfig.types.json @@ -0,0 +1,30 @@ +{ + // Generates `types/` from the JSDoc in `src/`. The JSDoc is the source of + // truth for the published type surface; nothing under `types/` is written + // by hand or committed — the SDK build regenerates it and the npm tarball + // ships it. `npm run check:puterjs:types` runs this and type-checks the + // result without skipLibCheck, which is what CI gates on. + "compilerOptions": { + "target": "es2022", + "lib": ["es2022", "dom", "dom.iterable"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowJs": true, + "checkJs": false, + "declaration": true, + "emitDeclarationOnly": true, + "skipLibCheck": true, + "strict": true, + // Members tagged `@internal` are implementation detail and stay out of + // the published surface. + "stripInternal": true, + "rootDir": "src", + "outDir": "types" + }, + "include": ["src/**/*.js"], + "exclude": [ + "src/**/*.test.js", + "src/config.js", + "node_modules" + ] +} diff --git a/src/puter-js/types/modules/ai.d.ts b/src/puter-js/types/modules/ai.d.ts deleted file mode 100644 index 4b179f361..000000000 --- a/src/puter-js/types/modules/ai.d.ts +++ /dev/null @@ -1,519 +0,0 @@ -export type AIMessageContent = string | { image_url?: { url: string } } | { video_url?: { url: string } } | Record; - -export interface ImageContent { - type: string; - image_url: { url: string }; -} - -export interface ChatMessage { - role?: string; - content: AIMessageContent | AIMessageContent[]; - tool_calls?: ToolCall[]; - tool_call_id?: string; - cache_control?: { type: string }; - /** Images attached to the message. Present on responses from image-capable models. */ - images?: ImageContent[]; -} - -export interface ToolCall { - id: string; - function: { name: string, arguments: string }; -} - -export interface Tool { - type: string; - function: { name: string, description: string, parameters: object, strict?: boolean }; -} - -/** - * Options for a chat completion request. - */ -export interface ChatOptions { - /** The model to use for the completion. Defaults to `gpt-5-nano` if not specified. */ - model?: string; - /** Sampling temperature between 0 and 2. Lower values are more focused and deterministic, higher values more random. Defaults to the model's own default. */ - temperature?: number; - max_tokens?: number; - vision?: boolean; - driver?: string; - /** The provider to route the request through. */ - provider?: string; - /** Function/tool definitions the model can call. See Function Calling. */ - tools?: Tool[]; - response?: unknown; - /** - * Controls how much effort reasoning models spend thinking. Flat form. - * Accepted values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` - * (availability varies by model; default `medium` on newer GPT-5.x models). - * Reasoning models only. - */ - reasoning_effort?: string; - /** - * Nested form of `reasoning_effort`. The `effort` value accepts the same - * values as `reasoning_effort`. Reasoning models only. - */ - reasoning?: { effort: string}; - /** - * Controls how long or short responses are. Flat form. Accepted values: - * `low`, `medium`, `high`. Reasoning models only. - */ - verbosity?: string; - /** - * Nested form of `verbosity` — it lives under `text`. The `verbosity` value - * accepts the same values as `verbosity`. Reasoning models only. - */ - text?: { verbosity: string}; - /** - * Controls image output for image-capable models. - * - `aspect_ratio`: aspect ratio of the generated image, e.g. `"16:9"`, `"1:1"`, `"9:16"`. - * - `image_size`: output quality/resolution; must be one of the model's supported quality levels. - */ - image_config?: { aspect_ratio: string, image_size: string }; - /** - * Provider-neutral inline-compaction opt-in for long stateless - * conversations. `true` enables it with provider defaults; an object sets - * the token threshold at which earlier context is summarized. When the - * upstream compacts, you receive a `"compaction"` chunk (streaming) or a - * `compaction` field on the result (non-streaming) — resend it in `messages` - * on the next turn in place of the summarized history. - */ - compaction?: boolean | { trigger_tokens?: number }; - /** - * Escape hatch: a provider-native `context_management` payload, passed - * through untouched. Prefer `compaction` for provider portability. - */ - context_management?: unknown; -} - -export interface StreamingChatOptions extends ChatOptions { - stream: boolean; -} - -export interface ChatResponse { - message?: ChatMessage; - choices?: unknown; - /** - * Inline-compaction artifact, present when the upstream compacted earlier - * context during this (non-streaming) response. Carries `type:'compaction'` - * so you can push it straight into `messages` on the next turn in place of - * the summarized history (same shape as the streaming `compaction` chunk). - */ - compaction?: { type: 'compaction'; id?: string; encrypted_content: string }; -} - -/** - * A single chunk of a streaming chat response. Each chunk has a `type` - * discriminator; which other fields are present depends on that `type`. - */ -export interface ChatResponseChunk { - /** The kind of chunk: `"text"`, `"reasoning"`, `"image"`, `"tool_use"`, `"compaction"`, `"extra_content"`, `"usage"`, or `"error"`. */ - type: string; - /** Text delta. Present on `"text"` chunks. */ - text?: string; - /** Reasoning/thinking delta. Present on `"reasoning"` chunks. */ - reasoning?: string; - /** A generated image. Present on `"image"` chunks from image-capable models. */ - image?: ImageContent; - /** Tool call id (`"tool_use"`) or compaction item id (`"compaction"`). */ - id?: string; - /** Tool/function name. Present on `"tool_use"` chunks. */ - name?: string; - /** Parsed tool call arguments. Present on `"tool_use"` chunks. */ - input?: unknown; - /** - * Opaque/encrypted compaction summary. Present on `"compaction"` chunks — - * the same shape regardless of which provider served the request. Resend it - * in `messages` on the next turn in place of the summarized history. - */ - encrypted_content?: string; - /** Provider-specific extra metadata. */ - extra_content?: unknown; - /** Token usage totals. Present on the final `"usage"` chunk. */ - usage?: Record; - /** Error description. Present on `"error"` chunks, which end the stream. */ - message?: string; -} - -export interface Img2TxtOptions { - source?: string | File | Blob; - provider?: string; - testMode?: boolean; - /** `snake_case` spelling of `testMode`, forwarded to the driver as-is. */ - test_mode?: boolean; - model?: string; - pages?: number[]; - includeImageBase64?: boolean; - imageLimit?: number; - imageMinSize?: number; - bboxAnnotationFormat?: string; - documentAnnotationFormat?: string; -} - -export interface Txt2ImgOptions { - /** Text description of the image to generate. */ - prompt?: string; - /** - * Image model to use (provider-specific). Defaults to `'gpt-image-1-mini'` - * (OpenAI), or `'grok-imagine-image'` when `provider` is `'xai'`. - */ - model?: string; - /** - * Image quality / output size tier. Interpretation is provider- and - * model-specific: - * - OpenAI GPT models: `'high'` | `'medium'` | `'low'` (default `'low'`); - * `gpt-image-2` also accepts `'auto'`. - * - Gemini: output size tier `'512'` | `'1K'` | `'2K'` | `'4K'` - * (availability varies by model). - */ - quality?: string; - /** - * An input image for image-to-image generation. Replicate and xAI - * `grok-imagine-*` accept a URL; Gemini and OpenAI `gpt-image-*` expect a - * base64-encoded (or data-URI) image (xAI also accepts base64/data-URI). - */ - input_image?: string; - /** - * Multiple input images for image-to-image / multi-image generation. - * Gemini and OpenAI `gpt-image-*` expect base64-encoded (or data-URI) - * images; Replicate expects image URLs; xAI `grok-imagine-*` accepts either - * (up to 3 images). - */ - input_images?: string[]; - /** - * MIME type of the input image(s), e.g. `'image/png'`. Used as a fallback - * when the type cannot be auto-detected (Gemini). - */ - input_image_mime_type?: string; - driver?: string; - provider?: string; - service?: string; - /** - * Aspect ratio as `{ w, h }` (e.g. `{ w: 16, h: 9 }`). Supported by OpenAI, - * Gemini, and Replicate. - */ - ratio?: { w: number; h: number }; - /** Width of the image to generate, in pixels (Together). Default `1024`. */ - width?: number; - /** Height of the image to generate, in pixels (Together). Default `1024`. */ - height?: number; - /** Alternative way to specify the aspect ratio (Together). */ - aspect_ratio?: string; - /** - * Number of generation/inference steps (Together, default `20`; Replicate - * `flux-schnell`). - */ - steps?: number; - /** Seed used for generation; reuse to reproduce results (Together, Replicate). */ - seed?: number; - /** Prompt describing what NOT to guide the image generation toward (Together). */ - negative_prompt?: string; - /** Number of image results to generate (Together). Default `1`. */ - n?: number; - /** URL of an input image for models that support it (Together). */ - image_url?: string; - /** Base64-encoded input image for image-to-image generation (Together). */ - image_base64?: string; - /** URL of a mask image for inpainting (Together). */ - mask_image_url?: string; - /** Base64-encoded mask image for inpainting (Together). */ - mask_image_base64?: string; - /** How strongly the prompt influences the output (Together). */ - prompt_strength?: number; - /** When `true`, disables the safety checker (Together, Replicate). */ - disable_safety_checker?: boolean; - /** - * Format of the image response. Together: `'base64'` | `'url'`. Replicate: - * output format, e.g. `'webp'` | `'jpg'` | `'png'`. - */ - response_format?: string; - /** Guidance scale (Replicate `flux-2-klein-9b-base`). */ - guidance?: number; - /** - * Use the model's optimized fast mode (Replicate `flux-2-dev`). Defaults to - * `true` for that model, and affects pricing. - */ - go_fast?: boolean; - /** Output quality, 0-100 (Replicate, flux family). */ - output_quality?: number; - /** - * Approximate output size in megapixels (Replicate, flux family), e.g. - * `'0.25'` | `'0.5'` | `'1'` | `'2'`. - */ - output_megapixels?: string; - /** Safety tolerance level (Replicate `flux-2-pro`, `flux-1.1-pro`). */ - safety_tolerance?: number; - /** Enable prompt upsampling (Replicate `flux-1.1-pro`). */ - prompt_upsampling?: boolean; - /** - * Generation tier for Replicate Leonardo models, which affects pricing: - * `'standard'` | `'ultra'` (`lucid-origin`), `'fast'` | `'quality'` | - * `'ultra'` (`phoenix-1.0`). - */ - generation_mode?: string; - /** Stylistic preset (Replicate Leonardo models). */ - style?: string; - /** Contrast preset (Replicate Leonardo models). */ - contrast?: string; - /** Server-side prompt enhancement (Replicate Leonardo models). */ - prompt_enhance?: boolean; - /** When `true`, returns a sample image without using credits. */ - test_mode?: boolean; - /** - * When set, the generated image is saved to this path on the Puter - * filesystem. Relative paths resolve against the app's data directory - * (`~/AppData//`) when called from an app, or `~/` otherwise. The - * caller must have write permission to the destination. - */ - puter_output_path?: string; -} - -export interface Txt2VidOptions { - prompt?: string; - driver?: string; - model?: string; - seconds?: number; - duration?: number; - test_mode?: boolean; - - // OpenAI options - size?: string; - resolution?: string; - input_reference?: File | string; - - // TogetherAI options - width?: number; - height?: number; - fps?: number; - steps?: number; - guidance_scale?: number; - seed?: number; - output_format?: string; - output_quality?: number; - negative_prompt?: string; - reference_images?: string[]; - frame_images?: Array<{ input_image: string; frame: number }>; - metadata?: Record; - puter_output_path?: string; - - last_frame?: string; -} - -export interface Txt2SpeechOptions { - /** Text to synthesize. Must be less than 3000 characters. */ - text?: string; - /** Language code. For AWS Polly defaults to `'en-US'`; for xAI a BCP-47 code defaulting to `'en'` (supports `'auto'`). */ - language?: string; - /** Voice ID used for synthesis (provider-specific). Defaults to `'Joanna'` (aws-polly), `'alloy'` (openai), `'21m00Tcm4TlvDq8ikWAM'` (elevenlabs), `'Kore'` (gemini), `'eve'` (xai), `'geffen_32'` (speechify). */ - voice?: string; - /** AWS Polly synthesis engine: `'standard'` (default), `'neural'`, `'long-form'`, or `'generative'`. */ - engine?: string; - /** TTS provider: `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'speechify'`. Common aliases (`'eleven'`, `'google'`, `'grok'`, `'polly'`, `'simba'`, …) resolve to these. */ - provider?: string; - /** Model identifier (provider-specific). */ - model?: string; - /** OpenAI output format: `'mp3'` (default), `'wav'`, `'opus'`, `'aac'`, `'flac'`, or `'pcm'`. */ - response_format?: string; - /** Output format for ElevenLabs (defaults to `'mp3_44100_128'`) and xAI (`'mp3'` default, `'wav'`, `'pcm'`, `'mulaw'`, `'alaw'`). */ - output_format?: string; - /** Natural-language guidance for voice style such as tone, speed, and mood (OpenAI and Gemini). */ - instructions?: string; - /** ElevenLabs voice tuning options (e.g. stability, similarity boost, speed). */ - voice_settings?: Record; - /** When `true`, AWS Polly treats `text` as SSML markup. */ - ssml?: boolean; - /** When `true`, returns a sample audio without using credits. */ - test_mode?: boolean; -} - -export interface ListTTSEnginesOptions { - /** TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every provider's engines. */ - provider?: string; -} - -/** A TTS engine/model as returned by `txt2speech.listEngines()`. */ -export interface TTSEngine { - /** Engine/model identifier. */ - id: string; - /** Human-readable engine name. */ - name: string; - /** Provider this engine belongs to. */ - provider: string; - /** Cost per million characters (may be absent). */ - pricing_per_million_chars?: number; -} - -export interface ListTTSVoicesOptions { - /** TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every provider's voices. */ - provider?: string; - /** Engine/model filter (provider-specific, ignored by some providers). */ - engine?: string; -} - -/** A TTS voice as returned by `txt2speech.listVoices()`. */ -export interface TTSVoice { - /** Voice identifier to pass to `txt2speech()`. */ - id: string; - /** Human-readable voice name. */ - name: string; - /** Provider this voice belongs to. */ - provider: string; - /** Language info (may be absent). */ - language?: { name: string; code: string }; - /** Short description of the voice (may be absent). */ - description?: string; - /** Voice category, e.g. `'premade'` (may be absent). */ - category?: string; - /** Provider-specific labels (may be absent). */ - labels?: Record; - /** Model IDs this voice works with (may be absent). */ - supported_models?: string[]; - /** Engine types this voice supports (may be absent). */ - supported_engines?: string[]; -} - -/** - * Converts text to speech. Callable directly, with `listEngines` and - * `listVoices` helpers attached for discovering available engines and voices. - */ -export interface Txt2Speech { - (text: string, testMode?: boolean): Promise; - (text: string, options: Txt2SpeechOptions, testMode?: boolean): Promise; - (text: string, language: string, testMode?: boolean): Promise; - (text: string, language: string, voice: string, testMode?: boolean): Promise; - (text: string, language: string, voice: string, engine: string, testMode?: boolean): Promise; - - /** List available TTS engines/models with pricing information. */ - listEngines (provider?: string): Promise; - listEngines (options?: ListTTSEnginesOptions): Promise; - - /** List available TTS voices, optionally filtered by provider/engine. */ - listVoices (engine?: string): Promise; - listVoices (options?: ListTTSVoicesOptions): Promise; -} - -export interface Speech2TxtWord { - text: string; - start: number; - end: number; - /** Detected speaker, present when `diarize: true` (xAI). */ - speaker?: string; -} - -export interface Speech2TxtResult { - text: string; - language: string; - segments?: Record[]; - /** Duration of the audio in seconds (provider-dependent, e.g. xAI). */ - duration?: number; - /** Per-word timestamps (provider-dependent, e.g. xAI). */ - words?: Speech2TxtWord[]; -} - -interface BaseSpeech2TxtOptions { - file?: string | File | Blob; - audio?: string | File | Blob; - provider?: string; - model?: string; - language?: string; - prompt?: string; - stream?: boolean; - translate?: boolean; - temperature?: number; - logprobs?: boolean; - timestamp_granularities?: string[]; - chunking_strategy?: string; - known_speaker_names?: string[]; - known_speaker_references?: string[]; - extra_body?: Record; - format?: boolean; - diarize?: boolean; - multichannel?: boolean; - channels?: number; - audio_format?: string; - sample_rate?: number; - test_mode?: boolean; -} - -export interface TextFormatSpeech2TxtOptions extends BaseSpeech2TxtOptions { - response_format: "text"; -} - -export interface Speech2TxtOptions extends BaseSpeech2TxtOptions { - response_format?: Exclude; -} - -export interface Speech2SpeechOptions { - audio?: string | File | Blob; - file?: string | File | Blob; - provider?: string; - model?: string; - model_id?: string; - voice?: string; - voice_id?: string; - output_format?: string; - voice_settings?: Record; - seed?: number; - file_format?: string; - remove_background_noise?: boolean; - optimize_streaming_latency?: number; - enable_logging?: boolean; - test_mode?: boolean; - - // camelCase aliases, mapped onto the snake_case names above before the - // request goes out. The snake_case spelling wins when both are given. - modelId?: string; - voiceId?: string; - outputFormat?: string; - voiceSettings?: Record; - fileFormat?: string; - removeBackgroundNoise?: boolean; - optimizeStreamingLatency?: number; - enableLogging?: boolean; -} - -export class AI { - listModels (provider?: string): Promise[]>; - listModelProviders (): Promise; - - chat (prompt: string, testMode?: boolean): Promise; - chat (prompt: string, options: ChatOptions, testMode?: boolean): Promise; - chat (prompt: string, imageURL: string | File, testMode?: boolean): Promise; - chat (prompt: string, imageURLArray: string[], testMode?: boolean): Promise; - chat (prompt: string, imageURL: string | File, options: ChatOptions, testMode?: boolean): Promise; - chat (prompt: string, imageURLArray: string[], options: ChatOptions, testMode?: boolean): Promise; - - chat (prompt: string, options: StreamingChatOptions, testMode?: boolean): Promise>; - chat (prompt: string, imageURL: string | File, options: StreamingChatOptions, testMode?: boolean): Promise>; - chat (prompt: string, imageURLArray: string[], options: StreamingChatOptions, testMode?: boolean): Promise>; - - chat (messages: ChatMessage[], testMode?: boolean): Promise; - chat (messages: ChatMessage[], options: ChatOptions, testMode?: boolean): Promise; - chat (messages: ChatMessage[], options: StreamingChatOptions, testMode?: boolean): Promise>; - - img2txt (source: string | File | Blob, testMode?: boolean): Promise; - img2txt (source: string | File | Blob, options: Img2TxtOptions, testMode?: boolean): Promise; - img2txt (options: Img2TxtOptions, testMode?: boolean): Promise; - - txt2img (prompt: string, testMode?: boolean): Promise; - txt2img (prompt: string, options: Txt2ImgOptions): Promise; - txt2img (options: Txt2ImgOptions, testMode?: boolean): Promise; - - txt2vid (prompt: string, testMode?: boolean): Promise; - txt2vid (prompt: string, options: Txt2VidOptions): Promise; - txt2vid (options: Txt2VidOptions, testMode?: boolean): Promise; - - speech2txt (source: string | File | Blob, testMode?: boolean): Promise; - speech2txt (source: string | File | Blob, options: TextFormatSpeech2TxtOptions, testMode?: boolean): Promise; - speech2txt (source: string | File | Blob, options: Speech2TxtOptions, testMode?: boolean): Promise; - speech2txt (options: TextFormatSpeech2TxtOptions, testMode?: boolean): Promise; - speech2txt (options: Speech2TxtOptions, testMode?: boolean): Promise; - - speech2speech (source: string | File | Blob, testMode?: boolean): Promise; - speech2speech (source: string | File | Blob, options: Speech2SpeechOptions, testMode?: boolean): Promise; - speech2speech (options: Speech2SpeechOptions, testMode?: boolean): Promise; - - txt2speech: Txt2Speech; -} - -// NOTE: AI responses contain provider-specific payloads that are not fully typed here because -// the SDK does not yet publish stable shapes for those fields. diff --git a/src/puter-js/types/modules/apps.d.ts b/src/puter-js/types/modules/apps.d.ts deleted file mode 100644 index 657b163a8..000000000 --- a/src/puter-js/types/modules/apps.d.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { ListPage, ListPaginationOptions, ListStreamOptions, RequestCallbacks } from '../shared.d.ts'; - -/** A user of an app, as returned by `App.users()` and `App.getUsers()`. */ -export interface AppUser { - /** The user's username. */ - username: string; - /** The user's unique identifier. */ - user_uuid: string; - /** - * The user's email address. Only present when the user granted this app - * the `user::email:read` permission (e.g. via - * `puter.perms.requestEmail()`); omitted otherwise. May be `null` if the - * user granted access but has no email on file. - */ - user_email?: string | null; -} - -/** Pagination options for `App.getUsers()`. */ -export interface GetUsersOptions { - /** The number of users to retrieve. Default is `100`. */ - limit?: number; - /** The offset to start retrieving users from. Default is `0`. */ - offset?: number; -} - -/** The `App` object containing Puter app details. */ -export interface App { - /** The unique identifier of the app, generated by Puter when the app is created. */ - uid: string; - /** The name of the app. */ - name: string; - /** The URL of the index file of the app, loaded when the app is started. */ - index_url: string; - /** The title of the app. */ - title?: string; - /** The description of the app. */ - description?: string; - /** The Data URL of the icon of the app (a base64 encoded image). */ - icon?: string; - /** Whether the app should be maximized when it is started. Default is `false`. */ - maximize_on_start?: boolean; - /** Whether the app should run in the background. Default is `false`. */ - background?: boolean; - /** - * The file types that the app can open. Each string is in the format - * `"."` or `"mime/type"`, e.g. `[".txt", "image/png"]`. For a - * directory association, the string should be `.directory`. - */ - filetype_associations?: string[]; - /** Custom metadata for the app as arbitrary key-value pairs. */ - metadata?: Record; - /** The date and time when the app was created, in `YYYY-MM-DDTHH:MM:SSZ` format. */ - created_at?: string; - /** - * The number of times the app has been opened. If `stats_period` is set to - * a value other than `all`, this is the count within that period. - */ - open_count?: number; - /** - * The number of users that have access to the app. If `stats_period` is set - * to a value other than `all`, this is the count within that period. - */ - user_count?: number; - /** - * Iterates over all users of the app, fetching them page by page. - * @param pageSize - The number of users to retrieve per page. Default is 100. - */ - users (pageSize?: number): AsyncIterableIterator; - /** - * Retrieves a list of users one page at a time as defined by limit and offset. - * @param params - Pagination options. - */ - getUsers (params?: GetUsersOptions): Promise; -} - -/** The result returned by `Apps.create()`. */ -export interface CreateAppResult { - /** The unique identifier of the app, generated by Puter when the app is created. */ - uid: string; - /** The name of the app. */ - name: string; - /** The title of the app. */ - title: string; - /** The URL of the index file of the app, loaded when the app is started. */ - index_url: string; - /** The subdomain assigned to the app. */ - subdomain: string; - /** Information about the owner of the app. */ - owner: { - /** The username of the owner. */ - username: string; - /** The unique identifier of the owner. */ - uuid: string; - }; -} - -/** Options for `Apps.list()` and `Apps.get()`. */ -export interface AppListOptions { - /** - * The period for which to get the user and open count. One of `today`, - * `yesterday`, `7d`, `30d`, `this_month`, `last_month`, `this_year`, - * `last_year`, `month_to_date`, `year_to_date`, or `last_12_months`. - * Default is `all` (all time). - */ - stats_period?: string; - /** The size of the icons to return. Default is `null` (the original size). */ - icon_size?: null | 16 | 32 | 64 | 128 | 256 | 512; -} - -/** Options for creating an app with `Apps.create()`. */ -export interface CreateAppOptions { - /** - * The name of the app to create. Must be unique to the user's apps; - * if an app with this name already exists the promise is rejected. - */ - name: string; - /** - * The URL of the app's index page, displayed when the app is started. - * Must start with `http://` or `https://`; other protocols are not allowed. - */ - indexURL: string; - /** The human-readable title of the app. Defaults to `name` if not provided. */ - title?: string; - /** The description of the app aimed at the end user. */ - description?: string; - /** The icon of the app. */ - icon?: string; - /** Whether the app should be maximized when it is started. Defaults to `false`. */ - maximizeOnStart?: boolean; - /** Whether the app should run in the background. Defaults to `false`. */ - background?: boolean; - /** - * The filetypes that the app can open. File extensions and MIME types are - * supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`. - */ - filetypeAssociations?: string[]; - /** Custom metadata for the app as arbitrary key-value pairs. */ - metadata?: Record; - /** Whether to deduplicate the app name if it already exists. Defaults to `false`. */ - dedupeName?: boolean; -} - -/** Attributes to update with `Apps.update()`. */ -export interface UpdateAppAttributes { - /** - * The new name of the app. Must be unique to the user's apps; if an app - * with this name already exists the promise is rejected. - */ - name?: string; - /** The new URL of the app's index page. Must be accessible to the user. */ - indexURL?: string; - /** The new title of the app. */ - title?: string; - /** The new description of the app aimed at the end user. */ - description?: string; - /** The new icon of the app. */ - icon?: string; - /** Whether the app should be maximized when it is started. Defaults to `false`. */ - maximizeOnStart?: boolean; - /** Whether the app should run in the background. Defaults to `false`. */ - background?: boolean; - /** - * The filetypes that the app can open. File extensions and MIME types are - * supported, e.g. `[".txt", ".md", "application/pdf"]`. Defaults to `[]`. - */ - filetypeAssociations?: string[]; - /** Custom metadata for the app as arbitrary key-value pairs. */ - metadata?: Record; -} - -/** The result returned by `Apps.checkName()`. */ -export interface CheckAppNameResult { - /** The name that was checked. */ - name: string; - /** Whether the name is available. */ - available: boolean; -} - -/** Create, manage, and interact with applications in the Puter ecosystem. */ -export class Apps { - /** - * Returns all apps belonging to the user that this app has access to, - * fetching page by page under the hood. Resolves to an empty array if - * the user has no apps. With `stream: true` it instead returns an async - * iterator of pages for `for await ... of`; with `cursor` (even `null`), - * `offset`, or `includeTotal` it resolves to a single page envelope. - */ - list (options: AppListOptions & ListStreamOptions): AsyncIterableIterator>; - list (options: AppListOptions & ListPaginationOptions & ({ cursor: string | null } | { offset: number } | { includeTotal: true })): Promise>; - list (options?: AppListOptions & { limit?: number }): Promise; - /** - * Creates a Puter app with the given name. The app name must be unique to - * the user's apps; if one already exists the promise is rejected. `indexURL` - * must start with `http://` or `https://`. - */ - create (name: string, indexURL: string, title?: string): Promise; - create (options: CreateAppOptions): Promise; - /** Updates attributes of the app with the given name. */ - update (name: string, attributes: UpdateAppAttributes): Promise; - /** Returns the app with the given name. If the app does not exist, the promise is rejected. */ - get (name: string, options?: AppListOptions): Promise; - /** - * Deletes the app with the given name. Resolves to `{ success: true, uid }` - * with the `uid` of the deleted app. - */ - delete (name: string): Promise<{ success: boolean; uid: string }>; - checkName (name: string): Promise; - getDeveloperProfile (options?: RequestCallbacks>): Promise>; - getDeveloperProfile (success: (value: Record) => void, error?: (reason: unknown) => void): Promise>; -} diff --git a/src/puter-js/types/modules/auth.d.ts b/src/puter-js/types/modules/auth.d.ts deleted file mode 100644 index 6a29582a8..000000000 --- a/src/puter-js/types/modules/auth.d.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { RequestCallbacks } from "../shared"; - -/** Puter user details, as returned by `getUser()`. */ -export interface User { - /** Unique identifier of the user. */ - uuid: string; - /** The user's username. */ - username: string; - /** Whether the user's email address has been confirmed. */ - email_confirmed?: boolean | number; - /** The user's free storage. */ - actual_free_storage?: number; - /** The current active app. */ - app_name?: string; - /** - * When the account was created, in unix seconds. Only returned to user - * tokens — apps acting on a user's behalf do not receive it. - */ - created_ts?: number; - feature_flags?: Record; - hasDevAccountAccess?: boolean; - /** Whether the user's account is temporary. */ - is_temp?: boolean; - /** The user's last active timestamp. */ - last_activity_ts?: number; - otp?: boolean; - /** The amount of paid storage. */ - paid_storage?: number; - /** The user's referral code. */ - referral_code?: string; - /** Whether the user's account needs email confirmation. */ - requires_email_confirmation?: boolean | number; - /** Whether the user is subscribed. */ - subscribed?: boolean; -} - -/** Information about the user's resource allowance and consumption. */ -export interface AllowanceInfo { - /** Total resource allowance for the month. */ - monthUsageAllowance: number; - /** The remaining allowance that can be used. */ - remaining: number; -} - -/** Total usage for a single application. */ -export interface AppUsage { - /** Number of Puter API calls for the application. */ - count: number; - /** Total resources consumed by the application. */ - total: number; -} - -/** Usage information for a single API. */ -export interface APIUsage { - /** Total resource consumed by this API. */ - cost: number; - /** Number of times the API is called. */ - count: number; - /** Units of measurement for the API (e.g. tokens for AI calls, bytes for FS operations). */ - units: number; -} - -/** - * The user's monthly resource usage in the Puter ecosystem. - * Resources are measured in microcents (e.g. `$0.01` = `1,000,000`). - */ -export interface MonthlyUsage { - /** The user's resource allowance and consumption. */ - allowanceInfo: AllowanceInfo; - /** Total usage by application, keyed by application id. */ - appTotals: Record; - /** Usage information per API, keyed by API name. */ - usage: Record; -} - -/** - * Detailed resource usage statistics for a specific application. - * Resources are measured in microcents (e.g. `$0.01` = `1,000,000`). - */ -export interface DetailedAppUsage { - /** The application's total resource consumption. */ - total: number; - /** Usage information per API, keyed by API name. */ - [key: string]: APIUsage; -} - -/** The result of a sign-in operation. */ -export interface SignInResult { - /** Whether the sign-in operation was successful. */ - success: boolean; - /** The authentication token. */ - token: string; - /** Unique identifier of the application. */ - app_uid: string; - /** Username of the user who signed in. */ - username: string; - /** Error message if the sign-in operation failed. */ - error?: string; - /** Additional message about the sign-in operation. */ - msg?: string; -} - -/** - * Authenticate users with their Puter accounts. Most Puter methods handle - * authentication automatically; these methods are only needed for custom - * authentication flows. - */ -export class Auth { - /** - * Initiates the sign in process for the user, opening a popup window with the - * appropriate authentication method. Must be triggered by a user action (such - * as a click) because it opens a popup. Resolves once the user has signed in. - * - * Set `attempt_temp_user_creation` to `true` to have Puter automatically create - * a temporary user, useful for onboarding without requiring sign-up. - * - * Set `request_auth` to `true` to have the popup let the user re-pick their - * account even when this site already holds a token for them — Puter - * otherwise skips that prompt for a site it has seen before. - */ - signIn (options?: { attempt_temp_user_creation?: boolean, request_auth?: boolean }): Promise; - /** Signs the user out of the application. */ - signOut (): void; - /** Returns `true` if the user is signed in, `false` otherwise. */ - isSignedIn (): boolean; - /** Returns the user's basic information. */ - getUser (options?: RequestCallbacks): Promise; - getUser (success: (value: User) => void, error?: (reason: unknown) => void): Promise; - /** Returns the user's basic information, without the callback forms. */ - whoami (): Promise; - /** Gets the user's current monthly resource usage. Usage data is scoped to the calling app only. */ - getMonthlyUsage (): Promise; - /** - * Gets detailed resource usage statistics for an application by its `appId`. - * Users can only see usage of applications they have accessed before, and - * usage data is scoped to the calling app only. - */ - getDetailedAppUsage (appId: string): Promise; -} diff --git a/src/puter-js/types/modules/debug.d.ts b/src/puter-js/types/modules/debug.d.ts deleted file mode 100644 index 169b18b94..000000000 --- a/src/puter-js/types/modules/debug.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class Debug { - constructor (context: Record, parameters?: Record); -} diff --git a/src/puter-js/types/modules/drivers.d.ts b/src/puter-js/types/modules/drivers.d.ts deleted file mode 100644 index cdaddf2c0..000000000 --- a/src/puter-js/types/modules/drivers.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class Driver { - readonly iface_name: string; - call (methodName: string, parameters?: Record): Promise; -} - -export class Drivers { - list (): Promise>; - get (ifaceName: string): Promise; - call (ifaceName: string, methodName: string, parameters?: Record): Promise; - call (ifaceName: string, parameters?: Record): Promise; -} diff --git a/src/puter-js/types/modules/email.d.ts b/src/puter-js/types/modules/email.d.ts deleted file mode 100644 index 3b2b820a1..000000000 --- a/src/puter-js/types/modules/email.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * One attachment: either inline base64 `content`, or a Puter FS reference - * (`path`/`uid`) read server-side with the caller's — falling back to the - * authorizing worker's — file permissions. - */ -export interface EmailAttachment { - /** Required with `content`; defaults to the file's name for FS refs. */ - filename?: string; - /** Base64 file body. Mutually exclusive with `path`/`uid`. */ - content?: string; - /** Puter FS path (supports `~/`). Mutually exclusive with `content`. */ - path?: string; - /** Puter FS entry uid. Mutually exclusive with `content`. */ - uid?: string; - contentType?: string; -} - -export interface EmailSendOptions { - /** Recipient address(es). */ - to: string | string[]; - subject: string; - /** Plain-text body. At least one of `text` / `html` is required. */ - text?: string; - /** HTML body. */ - html?: string; - cc?: string | string[]; - bcc?: string | string[]; - replyTo?: string; - /** - * A worker's auth token authorizing the send when the caller is not - * itself a worker (inside a worker: `me.puter.authToken`). The caller - * stays the billed and rate-limited identity. - */ - emailAccessToken?: string; - attachments?: EmailAttachment[]; -} - -export interface EmailSendResult { - /** First transport message id reported for this send, when available. */ - messageId: string | null; - /** Total charge for this send, in microcents. */ - cost: number; - /** Recipients omitted because they opted out of this sender's mail. */ - suppressed: string[]; - /** - * Recipients whose delivery attempt failed. Everyone else got their - * copy — retry with just these addresses. A send where every delivery - * fails rejects instead. - */ - failed: string[]; -} - -/** - * Restricted outbound email. Sending is limited server-side to trusted - * callers (Puter workers owned by allowlisted or permitted users). - */ -export class Email { - /** Sends an email with a plain-text body. */ - send (to: string | string[], subject: string, body: string): Promise; - send (options: EmailSendOptions): Promise; -} diff --git a/src/puter-js/types/modules/filesystem.d.ts b/src/puter-js/types/modules/filesystem.d.ts deleted file mode 100644 index 1c5584450..000000000 --- a/src/puter-js/types/modules/filesystem.d.ts +++ /dev/null @@ -1,347 +0,0 @@ -import type { ListPage, ListStreamOptions, RequestCallbacks } from '../shared.d.ts'; -import type { FSItem } from './fs-item.d.ts'; - -/** - * Storage space information for the current user, in bytes. - */ -export interface SpaceInfo { - /** Total storage capacity available to the user, in bytes. */ - capacity: number; - /** Amount of storage space used by the user, in bytes. */ - used: number; -} - -/** - * Options for the `copy` operation. - */ -export interface CopyOptions extends RequestCallbacks { - /** Path to the file or directory to copy. Required when passing options as the only argument. */ - source?: string; - /** Path to the destination. Required when passing options as the only argument. */ - destination?: string; - /** Whether to overwrite the destination file or directory if it already exists. Defaults to `false`. */ - overwrite?: boolean; - /** The new name to use for the copied file or directory. Defaults to `undefined`. */ - newName?: string; - /** Whether to deduplicate the file or directory name if it already exists. Defaults to `false`. */ - dedupeName?: boolean; -} - -/** - * Options for the `move` operation. - */ -export interface MoveOptions extends RequestCallbacks { - /** Path to the file or directory to move. Required when passing options as the only argument. */ - source?: string; - /** Path to the destination. Required when passing options as the only argument. */ - destination?: string; - /** Whether to overwrite the destination file or directory if it already exists. Defaults to `false`. */ - overwrite?: boolean; - /** The new name to use for the moved file or directory. Defaults to `undefined`. */ - newName?: string; - /** Whether to create missing parent directories. Defaults to `false`. */ - createMissingParents?: boolean; - newMetadata?: Record; - excludeSocketID?: string; - original_client_socket_id?: string; -} - -/** - * Options for the `mkdir` operation. - */ -export interface MkdirOptions extends RequestCallbacks { - /** The directory path to create if not specified via function parameter. */ - path?: string; - /** Whether to overwrite the directory if it already exists. Defaults to `false`. */ - overwrite?: boolean; - /** Whether to deduplicate the directory name if it already exists. Defaults to `false`. */ - dedupeName?: boolean; - rename?: boolean; - /** Whether to create missing parent directories. Defaults to `false`. */ - createMissingParents?: boolean; - recursive?: boolean; - shortcutTo?: string; -} - -/** - * Options for the `delete` operation. - */ -export interface DeleteOptions extends RequestCallbacks { - /** A single path or array of paths to delete. Required when passing options as the only argument. */ - paths?: string | string[]; - /** Whether to delete the directory recursively. Defaults to `true`. */ - recursive?: boolean; - /** Whether to delete only the descendants of the directory and not the directory itself. Defaults to `false`. */ - descendantsOnly?: boolean; -} - -/** - * Options for the `read` operation. - */ -export interface ReadOptions extends RequestCallbacks { - /** Path to the file to read. Required when passing options as the only argument. */ - path?: string; - /** The offset to start reading from. */ - offset?: number; - /** The number of bytes to read from the offset. Required if `offset` is provided. */ - byte_count?: number; -} - -/** - * Options for the `readdir` operation. - */ -export interface ReaddirOptions extends RequestCallbacks { - /** The path to the directory to read. Required when passing options as the only argument. */ - path?: string; - /** The UID of the directory to read. */ - uid?: string; - no_thumbs?: boolean; - no_assocs?: boolean; - consistency?: 'strong' | 'eventual'; - /** Maximum number of entries to return. */ - limit?: number; - /** Skips the given number of entries. Prefer `cursor` for paging through large directories. */ - offset?: number; - /** Sort field. Default is `name`. */ - sortBy?: 'name' | 'modified' | 'type' | 'size'; - /** Sort direction. Default is `asc`. */ - sortOrder?: 'asc' | 'desc'; - /** Whether to also list the contents of subdirectories. Defaults to `false`. */ - recursive?: boolean; - /** How many levels to descend when `recursive` is `true`. Defaults to unlimited. */ - depth?: number; -} - -/** - * Options for the `rename` operation. - */ -export interface RenameOptions extends RequestCallbacks { - /** The UID of the file or directory to rename. Can be used instead of `path`. */ - uid?: string; - /** Path to the file or directory to rename. Required when passing options as the only argument. */ - path?: string; - /** The new name for the file or directory. Required when passing options as the only argument. */ - newName?: string; - excludeSocketID?: string; - original_client_socket_id?: string; -} - -/** - * Options for the `stat` operation. - */ -export interface StatOptions extends RequestCallbacks { - /** Path to the file or directory. Required when passing options as the only argument. */ - path?: string; - /** The UID of the file or directory. Can be used instead of `path`. */ - uid?: string; - consistency?: 'strong' | 'eventual'; - /** Whether to return subdomain information. Defaults to `false`. */ - returnSubdomains?: boolean; - /** - * Whether to return the workers attached to the item. Workers are served - * alongside subdomains, so this is an alias of `returnSubdomains` — setting - * either one returns both. Defaults to `false`. - */ - returnWorkers?: boolean; - /** Whether to return permission information. Defaults to `false`. */ - returnPermissions?: boolean; - /** Whether to return version information. Defaults to `false`. */ - returnVersions?: boolean; - /** Whether to return size information. Defaults to `false`. */ - returnSize?: boolean; -} - -/** - * Options for the `upload` operation. - */ -export interface UploadOptions extends RequestCallbacks { - /** Whether to overwrite the destination file if it already exists. Defaults to `false`. */ - overwrite?: boolean; - /** Whether to deduplicate the file name if it already exists. Defaults to `true`. Ignored when `overwrite` is `true`. */ - dedupeName?: boolean; - name?: string; - parsedDataTransferItems?: boolean; - createFileParent?: boolean; - createMissingAncestors?: boolean; - /** Whether to create missing parent directories. Defaults to `false`. */ - createMissingParents?: boolean; - shortcutTo?: string; - appUID?: string; - strict?: boolean; - init?: (operationId: string, xhr: XMLHttpRequest) => void; - start?: () => void; - progress?: (operationId: string, progress: number) => void; - abort?: (operationId: string) => void; -} - -/** - * One operation's outcome inside a failed upload: either a failed operation - * (`error: true`, with its own `status`, `message`, and `code`) or the - * `FSItem` a successful operation produced. - */ -export type UploadOperationResult = - | { error: true; status?: number; message?: string; code?: string; [key: string]: unknown } - | FSItem; - -/** - * The rejection value of `upload()` when the batch request itself completed - * but one or more of its operations failed. - */ -export interface UploadBatchError { - message: string; - /** - * `batch_upload_failed` when nothing was written, `batch_upload_partially_failed` - * when only some operations failed, and `batch_upload_no_results` when the server - * reported success without saying what it wrote. - */ - code: 'batch_upload_failed' | 'batch_upload_partially_failed' | 'batch_upload_no_results'; - status: number; - /** Every operation's result, in the order the operations were sent. */ - results: UploadOperationResult[]; - /** Just the operations that failed. */ - failedItems: UploadOperationResult[]; - failedCount: number; - totalCount: number; -} - -/** - * Options for the `write` operation. - */ -export interface WriteOptions extends RequestCallbacks { - /** Whether to overwrite the file if it already exists. Defaults to `true`. */ - overwrite?: boolean; - /** Whether to deduplicate the file name if it already exists. Defaults to `false`. */ - dedupeName?: boolean; - /** Whether to create missing parent directories. Defaults to `false`. */ - createMissingParents?: boolean; - createMissingAncestors?: boolean; - init?: (operationId: string, xhr: XMLHttpRequest) => void; - start?: () => void; - progress?: (operationId: string, progress: number) => void; - abort?: (operationId: string) => void; -} - -export interface SignResult> { - token: string; - items: T | T[]; -} - -export type UploadItems = DataTransferItemList | DataTransferItem | FileList | File[] | Blob[] | Blob | File | string | unknown[]; - -/** - * The Cloud Storage API. Lets you store and manage files and directories in the cloud. - */ -export class FS { - /** - * Returns the storage space capacity and usage for the current user, in bytes. - * Requires permission to access the user's storage space. - */ - space (): Promise; - space (options: RequestCallbacks): Promise; - space (success: (value: SpaceInfo) => void, error?: (reason: unknown) => void): Promise; - - /** - * Creates a directory. Resolves to the `FSItem` of the created directory. - * If `path` is not absolute, it is resolved relative to the app's root directory. - */ - mkdir (options: MkdirOptions): Promise; - mkdir (path: string, options?: MkdirOptions): Promise; - mkdir (path: string, options: MkdirOptions, success: (value: FSItem) => void, error?: (reason: unknown) => void): Promise; - mkdir (path: string, success: (value: FSItem) => void, error?: (reason: unknown) => void): Promise; - - /** - * Copies a file or directory from one location to another. Resolves to the `FSItem` - * of the copied file or directory. If the source does not exist, the promise is rejected. - * If `destination` is a directory, the item is copied into it using the same name. - */ - copy (options: CopyOptions): Promise; - copy (source: string, destination: string, options?: CopyOptions): Promise; - copy (source: string, destination: string, options: CopyOptions | undefined, success: (value: FSItem) => void, error?: (reason: unknown) => void): Promise; - - /** - * Moves a file or directory from one location to another. Resolves to the `FSItem` - * of the moved file or directory. If the source does not exist, the promise is rejected. - * If `destination` is a directory, the item is moved into it using the same name. - */ - move (options: MoveOptions): Promise; - move (source: string, destination: string, options?: MoveOptions): Promise; - - /** - * Renames a file or directory to a new name. Resolves to the `FSItem` of the renamed item. - * If `path` is not absolute, it is resolved relative to the app's root directory. - */ - rename (options: RenameOptions): Promise; - rename (path: string, newName: string, success?: (value: FSItem) => void, error?: (reason: unknown) => void): Promise; - - /** - * Reads data from a file. Resolves to a `Blob` containing the file's contents. - * If `path` is not absolute, it is resolved relative to the app's root directory. - */ - read (options: ReadOptions): Promise; - read (path: string, options?: ReadOptions): Promise; - read (path: string, success: (value: Blob) => void, error?: (reason: unknown) => void): Promise; - - /** - * Reads the contents of a directory. Resolves to an array of `FSItem` objects - * (files and directories) within the specified directory. - * If `path` is not absolute, it is resolved relative to the app's root directory. - */ - readdir (options: ReaddirOptions & ListStreamOptions): AsyncIterableIterator>; - readdir (options: ReaddirOptions & { includeTotal?: boolean } & ({ cursor: string | null } | { includeTotal: true })): Promise>; - readdir (options: ReaddirOptions): Promise; - readdir (path: string, options: ReaddirOptions, success?: (value: FSItem[]) => void, error?: (reason: unknown) => void): Promise; - readdir (path: string, success?: (value: FSItem[]) => void, error?: (reason: unknown) => void): Promise; - - /** - * Gets information about a file or directory. Resolves to the `FSItem` of the item. - * If `path` is not absolute, it is resolved relative to the app's root directory. - */ - stat (options: StatOptions): Promise; - stat (path: string, options?: StatOptions): Promise; - stat (path: string, options: StatOptions, success: (value: FSItem) => void, error?: (reason: unknown) => void): Promise; - stat (path: string, success: (value: FSItem) => void, error?: (reason: unknown) => void): Promise; - - /** - * Deletes a file or directory. Accepts a single path or an array of paths. - * Resolves when the file(s) or directory(ies) are deleted. - * If a path is not absolute, it is resolved relative to the app's root directory. - */ - delete (options: DeleteOptions): Promise; - delete (paths: string | string[], options?: DeleteOptions): Promise; - - /** - * Uploads local items to the Puter filesystem. Resolves to a single `FSItem` if `items` - * contains one item, or an array of `FSItem` objects if it contains multiple items. - * If `dirPath` is not set, items are uploaded to the app's root directory. - * Rejects if any part of the upload failed — the promise never resolves to a mix - * of items and errors. On the batch-endpoint path (`nodejs`, `workers`) the - * rejection value is an `UploadBatchError`. - */ - upload (items: UploadItems, dirPath?: string, options?: UploadOptions): Promise; - - /** - * Writes data to a file, creating it if it does not exist. Resolves to the `FSItem` - * of the written file. If `path` is not absolute, it is resolved relative to the app's - * root directory. A `File` may be written directly, in which case its path is derived - * from the file's name. Omitting `data` creates an empty file. - */ - write (file: File): Promise; - write (path: string, data?: string | File | Blob | ArrayBuffer | ArrayBufferView, options?: WriteOptions): Promise; - - sign (appUid: string, items: unknown | unknown[], success?: (result: SignResult) => void, error?: (reason: unknown) => void): Promise; - - /** - * Generates a URL that can be used to read a file. Resolves to the URL string. - * `expiresIn` controls how long the URL stays valid, as a - * [jsonwebtoken](https://github.com/auth0/node-jsonwebtoken#usage) duration string - * (e.g. `'24h'`, `'30d'`; units `s`, `m`, `h`, `d`, `w`, `y`) or a number of seconds. - * Defaults to `'24h'`. - */ - getReadURL (path: string, expiresIn?: string | number): Promise; - - /** - * Revokes a URL produced by `getReadURL`, or the access token / token UUID - * behind it. Resolves once the URL no longer grants read access. - */ - revokeReadURL (urlOrTokenOrUuid: string): Promise; -} diff --git a/src/puter-js/types/modules/fs-item.d.ts b/src/puter-js/types/modules/fs-item.d.ts deleted file mode 100644 index a95976d34..000000000 --- a/src/puter-js/types/modules/fs-item.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -export interface FileSignatureInfo { - read_url?: string; - write_url?: string; - metadata_url?: string; - fsentry_accessed?: number; - fsentry_modified?: number; - fsentry_created?: number; - fsentry_is_dir?: boolean; - fsentry_size?: number | null; - fsentry_name?: string; - path?: string; - uid?: string; -} - -export interface InternalFSProperties { - signature?: string | null; - expires?: string | null; - file_signature: FileSignatureInfo; -} - -/** - * Represents a file or a directory in the Puter file system. - */ -export class FSItem { - constructor (options: Record); - - readURL?: string; - writeURL?: string; - metadataURL?: string; - /** The name of the item. */ - name: string; - uid: string; - /** The unique identifier of the item, generated by Puter when the item is created. */ - id: string; - uuid: string; - /** The path of the item, relative to the root directory of the file system. */ - path: string; - /** The size of the item in bytes. `null` if the item is a directory. */ - size: number | null; - /** Unix timestamp of when the item was last accessed. */ - accessed?: number; - /** Unix timestamp of when the item was last modified. */ - modified?: number; - /** Unix timestamp of when the item was created. */ - created?: number; - /** Whether the item is a directory. `true` for a directory, `false` for a file. */ - isDir: boolean; - /** Alias of `isDir`, kept for backward compatibility. */ - isDirectory: boolean; - _internalProperties?: InternalFSProperties; - - /** Writes data to the file, overwriting its existing contents. Resolves to the written `FSItem`. */ - write (data: Blob | File | ArrayBuffer | ArrayBufferView | string): Promise; - /** Renames the item. Resolves to the renamed `FSItem`. */ - rename (newName: string): Promise; - /** Moves the item to `destination`, which is either the directory to move it into or the item's new path. Resolves to the moved `FSItem`. */ - move (destination: string, overwrite?: boolean, newName?: string): Promise; - /** Copies the item into `destinationDirectory`. With `autoRename`, a free name is picked instead of conflicting. Resolves to the copied `FSItem`. */ - copy (destinationDirectory: string, autoRename?: boolean, overwrite?: boolean): Promise; - /** Deletes the item. Resolves once the item has been deleted. */ - delete (): Promise; - /** Creates a new subdirectory inside the item. The item must be a directory, otherwise an error is thrown. Resolves to the created `FSItem`. */ - mkdir (name: string, autoRename?: boolean): Promise; - /** Lists the contents of the item. The item must be a directory, otherwise an error is thrown. Resolves to an array of `FSItem` objects. */ - readdir (): Promise; - /** Reads the contents of the file. Resolves to a `Blob` containing the file's contents. */ - read (): Promise; - - // Placeholders that are not implemented in the runtime SDK yet. - watch (callback: (item: FSItem) => void): void; - open (callback: (item: FSItem) => void): void; - setAsWallpaper (options?: Record, callback?: () => void): void; - versions (): Promise; - trash (): void; - metadata (): Promise; -} diff --git a/src/puter-js/types/modules/hosting.d.ts b/src/puter-js/types/modules/hosting.d.ts deleted file mode 100644 index a5c00e1b9..000000000 --- a/src/puter-js/types/modules/hosting.d.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { FSItem } from './fs-item.d.ts'; -import type { ListPage, ListPaginationOptions, ListStreamOptions } from '../shared.d.ts'; - -/** A subdomain hosted on Puter, containing its details. */ -export interface Subdomain { - /** Unique identifier of the subdomain. */ - uid: string; - /** Name of the subdomain, i.e. the part before the main domain (e.g. `example` in `example.puter.site`). */ - subdomain: string; - /** The root directory of the subdomain, where its files are stored. */ - root_dir: FSItem; -} - -/** Deploy and manage websites on Puter by hosting directories under subdomains. */ -export class Hosting { - /** - * Lists all subdomains belonging to the user that this app has access - * to, fetching page by page under the hood. Resolves to an empty array - * if the user has no subdomains. With `stream: true` it instead returns - * an async iterator of pages for `for await ... of`; with `cursor` (even - * `null`) or `includeTotal` it resolves to a single page envelope. - */ - list (options: ListStreamOptions): AsyncIterableIterator>; - list (options: ListPaginationOptions & ({ cursor: string | null } | { includeTotal: true })): Promise>; - list (options?: { limit?: number; offset?: number }): Promise; - - /** - * Creates a new subdomain served by the hosting service from the given directory. - * Rejects if a subdomain with the given name already exists or if the path does not exist. - */ - create (subdomain: string, dirPath: string): Promise; - create (options: { subdomain: string; root_dir: string }): Promise; - - /** - * Updates a subdomain to point to a new directory. - * Rejects if the subdomain does not exist or if the path does not exist. - */ - update (subdomain: string, dirPath: string): Promise; - - /** Retrieves a subdomain by name. Rejects if the subdomain does not exist. */ - get (subdomain: string): Promise; - - /** - * Deletes a subdomain from the account; it will no longer be served. The associated - * directory is disconnected but not deleted. Rejects if the subdomain does not exist. - */ - delete (subdomain: string): Promise<{ success: boolean; uid: string }>; -} diff --git a/src/puter-js/types/modules/kv.d.ts b/src/puter-js/types/modules/kv.d.ts deleted file mode 100644 index 56c5f9cc8..000000000 --- a/src/puter-js/types/modules/kv.d.ts +++ /dev/null @@ -1,287 +0,0 @@ -/* eslint-disable no-unused-vars */ -export type KVValue = string | number | boolean | object | unknown; -export type KVScalar = KVValue | KVValue[]; - -/** A key-value pair as returned by `list()` when `returnValues` is `true`. */ -export interface KVPair { - /** The key name. */ - key: string; - /** The value associated with the key. Can be of any type. */ - value: T; -} - -/** A single item in a batch `set()` operation. */ -export interface KVSetItem { - /** The key to create or update. Maximum key size is `1 KB`. */ - key: string; - /** The value to store. Maximum value size is `400 KB`. */ - value: T; - /** Timestamp, in seconds, at which the key should expire. */ - expireAt?: number; -} - -/** Object form of the arguments to `set()`. */ -export interface KVSetObject { - /** The key to create or update. Maximum key size is `1 KB`. */ - key: string; - /** The value to store. Maximum value size is `400 KB`. */ - value: T; - /** Timestamp, in seconds, at which the key should expire. */ - expireAt?: number; - optConfig?: KVOptConfig; -} - -/** Wrapped batch form of `set()`, setting multiple items in a single request. */ -export interface KVSetBatch { - /** The key-value items to set in a single request. */ - items: KVSetItem[]; - optConfig?: KVOptConfig; -} - -/** - * Maps a dot-separated path to a property within an object value (e.g. - * `"user.score"`) to the amount to increment/decrement it by. - */ -export interface KVIncrementPath { - [path: string]: number; -} - -/** - * Maps each dot-separated path (e.g. `"profile.name"`) to the new value for - * that path. - */ -export interface KVUpdatePath { - [path: string]: KVValue; -} - -/** Object form of the arguments to `update()`. */ -export interface KVUpdateObject { - /** The key to update. */ - key: string; - /** Maps dot-separated paths to their new values. */ - pathAndValueMap: KVUpdatePath; - /** Time-to-live for the key, in seconds. */ - ttl?: number; - optConfig?: KVOptConfig; -} - -/** - * Maps each dot-separated path (e.g. `"profile.tags"`) to the value (or values) - * to add at that path. - */ -export interface KVAddPath { - [path: string]: KVValue | KVValue[]; -} - -/** Options object form of the arguments to `list()`. */ -export interface KVListOptions { - /** - * Prefix-based key filter. A trailing `*` is a wildcard; both `abc` and - * `abc*` match keys starting with `abc`. Defaults to `*`, matching all keys. - */ - pattern?: string; - /** - * When `true`, results contain `KVPair` objects with `key` and `value`; - * when `false`, results contain only keys. Defaults to `false`. - */ - returnValues?: boolean; - /** Maximum number of items to return in a single call. */ - limit?: number; - /** Pagination cursor from a previous call. */ - cursor?: string; - /** - * Skips the given number of items before the page starts. Maximum `5000`, - * and cannot be combined with `cursor`. Prefer `cursor` — requests get - * slower and more expensive the larger the offset. - */ - offset?: number; - /** - * When `true`, the result includes a `total` count of every item matching - * the query across all pages. The count is metered and its cost grows - * with the store — request it once (on the first page) and avoid it in - * hot paths; to know whether more pages exist, check for `cursor` - * instead. - */ - includeTotal?: boolean; - /** - * A page can come back with fewer than `limit` items even when more exist - * (for example when expired keys are excluded). When `true`, the page is - * filled up to `limit` items when possible. Requires `limit`. - */ - fetchUntilFull?: boolean; - optConfig?: KVOptConfig; -} - -/** - * The options that switch `list()` from a flat array to a `KVListPage`. Any - * one of them is enough — they are the same set the runtime treats as a - * paginated request. - */ -export type KVListPaginationOptions = - | { limit: number } - | { cursor: string } - | { offset: number } - | { includeTotal: boolean } - | { fetchUntilFull: boolean }; - -/** - * The `stream: true` form of `list()`: returns an async iterator of - * `KVListPage`s for `for await ... of` instead of a promise. Cannot be - * combined with `offset`; pass `cursor` to resume from a position. - */ -export interface KVListStreamOptions { - /** Stream page envelopes as they are fetched. */ - stream: true; -} - -/** A page of paginated results from `list()` when `limit` or `cursor` is used. */ -export interface KVListPage { - /** The keys (or `KVPair` objects when `returnValues` is `true`) for this page. */ - items: T[]; - /** - * Pagination cursor for the next page. Present only when there are more - * results to fetch; pass it to the next `list()` call. - */ - cursor?: string; - /** - * Total count of items matching the query across all pages. Present only - * when the page was requested with `includeTotal`. - */ - total?: number; -} - -export interface KVOptConfig { - /** - * Address another app's namespace instead of this app's own. Requires an - * `app-data::kv:` permission, which `puter.perms.requestAppData()` - * asks the user for. - */ - appUuid?: string; - /** - * Mark the entry private to this app: invisible and untouchable to any other - * app the user later grants access to this namespace. - * - * Honoured by both forms of `set()` — one key, or a batch, where it marks - * every entry in the batch. `set` writes the whole entry, so writing the - * key again without the flag makes it shareable. Rejected when combined - * with `appUuid`, since only an entry's owner may mark it private. - * - * Use it for anything another app should never read, such as a cached OAuth - * token, since a user granting access cannot see what a namespace holds. - */ - disableSharing?: boolean; -} - -/** - * The key-value store. Each app has its own private store within each user's - * account; apps cannot access other apps' stores. - */ -export class KV { - /** The maximum allowed key size, in bytes (`1 KB`). */ - readonly MAX_KEY_SIZE: number; - /** The maximum allowed value size, in bytes (`400 KB`). */ - readonly MAX_VALUE_SIZE: number; - - /** - * Creates a key-value pair, or updates the value if the key already exists. - * Can also set multiple pairs at once via an array or batch object. - * @param key - Key name. Maximum key size is `1 KB`. - * @param value - Value to store. Maximum value size is `400 KB`. - * @returns `true` once the pair has been created or updated. - */ - set(key: string, value: T, optConfig: KVOptConfig): Promise; - /** @param expireAt - Timestamp, in seconds, at which the key should expire. */ - set(key: string, value: T, expireAt?: number, optConfig?: KVOptConfig): Promise; - set(item: KVSetObject): Promise; - set(items: KVSetItem[], optConfig?: KVOptConfig): Promise; - set(batch: KVSetBatch): Promise; - /** Returns the key's value, or `undefined` if the key does not exist. */ - get(key: string, optConfig?: KVOptConfig): Promise; - /** - * Removes a key. Does nothing if the key does not exist. - * @returns `true` once the key has been removed. - */ - del (key: string, optConfig?: KVOptConfig): Promise; - /** - * Increments the value of a key, returning the new value. If the key does - * not exist it is initialized to `0` first. Limited to 64-bit signed - * integers; errors if the value is not a valid integer. - * @param amount - Amount to increment by (defaults to `1`), or an object - * mapping a path within an object value to the amount to increment it by. - */ - incr (key: string, optConfig: KVOptConfig): Promise; - incr (key: string, amount?: number | KVIncrementPath, optConfig?: KVOptConfig): Promise; - /** - * Decrements the value of a key, returning the new value. If the key does - * not exist it is initialized to `0` first. Errors if the value is not a - * valid integer. - * @param amount - Amount to decrement by (defaults to `1`), or an object - * mapping a path within an object value to the amount to decrement it by. - */ - decr (key: string, optConfig: KVOptConfig): Promise; - decr (key: string, amount?: number | KVIncrementPath, optConfig?: KVOptConfig): Promise; - /** - * Adds values to an existing key, returning the updated value. - * @param value - The value to add (defaults to `1` when omitted), or an - * object mapping dot-separated paths to the value(s) to add at each path. - */ - add (key: string, optConfig: KVOptConfig): Promise; - add (key: string, value?: KVValue | KVAddPath, optConfig?: KVOptConfig): Promise; - /** - * Removes values from a key by one or more dot-separated paths, returning - * the updated value. - * @param paths - One or more dot-separated paths to remove (e.g. `"profile.bio"`). - */ - remove (key: string, ...paths: Array): Promise; - /** - * Updates one or more paths within the value stored at a key without - * overwriting the entire value, returning the updated value. - * @param pathAndValueMap - Maps dot-separated paths to their new values. - * @param ttl - Time-to-live for the key, in seconds. - */ - update (key: string, pathAndValueMap: KVUpdatePath, optConfig: KVOptConfig): Promise; - update (key: string, pathAndValueMap: KVUpdatePath, ttl?: number, optConfig?: KVOptConfig): Promise; - update (item: KVUpdateObject): Promise; - /** - * Sets the time-to-live for a key, in seconds. - * @param ttlSeconds - Number of seconds until the key is removed. - * @returns `true` once the expiration has been set. - */ - expire (key: string, ttlSeconds: number, optConfig?: KVOptConfig): Promise; - /** - * Sets the expiration timestamp for a key. - * @param timestampSeconds - Unix timestamp, in seconds, at which the key is removed. - * @returns `true` once the expiry time has been set. - */ - expireAt (key: string, timestampSeconds: number, optConfig?: KVOptConfig): Promise; - /** - * Lists keys in the store for the current app, sorted lexicographically by - * key. Returns just the keys, an array of `KVPair` objects when - * `returnValues` is `true`, or a `KVListPage` when `limit`/`cursor` is used. - * With `stream: true` it instead returns an async iterator of - * `KVListPage`s for `for await ... of`. Full (non-paginated) listings are - * fetched page by page under the hood, but still read the entire store — - * every page is metered, so prefer `stream`/`limit` with a narrow - * `pattern` on large stores. - * @param pattern - Prefix-based key filter with an optional trailing `*` - * wildcard. Defaults to `*`, matching all keys. - */ - list (pattern?: string, returnValues?: false): Promise; - list(pattern: string, returnValues: true): Promise[]>; - list(returnValues: true): Promise[]>; - list (pattern: string, returnValues: boolean, optConfig: KVOptConfig): Promise[]>; - list (pattern: string, optConfig: KVOptConfig): Promise; - list(returnValues: true, optConfig: KVOptConfig): Promise[]>; - list (options: KVListOptions & KVListStreamOptions & { returnValues?: false }): AsyncIterableIterator>; - list(options: KVListOptions & KVListStreamOptions & { returnValues: true }): AsyncIterableIterator>>; - list (options: KVListOptions & KVListPaginationOptions & { returnValues?: false }): Promise>; - list(options: KVListOptions & KVListPaginationOptions & { returnValues: true }): Promise>>; - list (options: KVListOptions & { returnValues?: false }): Promise; - list(options: KVListOptions & { returnValues: true }): Promise[]>; - /** - * Removes all key-value pairs from the store for the current app. - * @returns `true` once the store has been flushed. - */ - flush (optConfig?: KVOptConfig): Promise; - clear (optConfig?: KVOptConfig): Promise; -} diff --git a/src/puter-js/types/modules/networking.d.ts b/src/puter-js/types/modules/networking.d.ts deleted file mode 100644 index cff8c087d..000000000 --- a/src/puter-js/types/modules/networking.d.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** Names of events emitted by a socket. Plain `PSocket` uses `'open'`, `'data'`, `'close'`, `'error'`; `PTLSSocket` uses the `'tls'`-prefixed variants. */ -export type SocketEvent = - | 'open' - | 'data' - | 'error' - | 'close' - | 'drain' - | 'tlsdata' - | 'tlsopen' - | 'tlsclose'; - -/** - * A raw TCP socket usable directly in the browser. - * Construct via `puter.net.Socket(hostname, port)`. - */ -export class PSocket { - /** - * @param host The hostname of the server to connect to (an IP address or domain name). - * @param port The port number to connect to on the server. - */ - constructor (host: string, port: number); - /** Write data to the socket. */ - write (data: ArrayBuffer | ArrayBufferView | string, callback?: () => void): void; - /** Voluntarily close the TCP socket. */ - close (): void; - /** `'open'` fires when the socket is initialized and ready to send data. */ - on (event: 'open', handler: () => void): void; - /** `'data'` fires when the remote server sends data over the socket; `buffer` is the received data. */ - on (event: 'data', handler: (buffer: Uint8Array) => void): void; - /** `'error'` fires when the socket encounters an error (a `'close'` event follows shortly after). The human-readable reason is on `error.message`. */ - on (event: 'error', handler: (error: Error) => void): void; - /** `'close'` fires when the socket is closed; `hadError` is `true` if it closed due to an error. */ - on (event: 'close', handler: (hadError: boolean) => void): void; - /** Register a handler for a socket event by name. */ - addListener (event: SocketEvent, handler: (...args: unknown[]) => void): void; -} - -/** - * A TLS-protected TCP socket usable directly in the browser. The interface is - * the same as `PSocket` but the connection is encrypted. Its events are - * `'tls'`-prefixed. Construct via `puter.net.tls.TLSSocket(hostname, port)`. - */ -export class PTLSSocket extends PSocket { - /** - * @param host The hostname of the server to connect to (an IP address or domain name). - * @param port The port number to connect to on the server. - */ - constructor (host: string, port: number); - /** `'tlsopen'` fires when the socket is initialized and ready to send data. */ - on (event: 'tlsopen', handler: () => void): void; - /** `'tlsdata'` fires when the remote server sends data over the socket; `buffer` is the received data. */ - on (event: 'tlsdata', handler: (buffer: Uint8Array) => void): void; - /** `'tlsclose'` fires when the socket is closed; `hadError` is `true` if it closed due to an error. */ - on (event: 'tlsclose', handler: (hadError: boolean) => void): void; -} - -/** - * The `puter.net` networking API. Establishes network connections directly from - * the frontend without a server or proxy, and bypasses CORS restrictions. - */ -export interface Networking { - generateWispV1URL(): Promise; - /** Constructor for a raw TCP `Socket`. */ - Socket: typeof PSocket; - tls: { - /** Constructor for a TLS-protected `TLSSocket`. */ - TLSSocket: typeof PTLSSocket; - }; - /** - * Fetch an http/https resource without being bound by CORS restrictions. - * @param init A standard `RequestInit` object. - * @returns A `Promise` that resolves to a `Response`. - */ - fetch(input: RequestInfo | URL, init?: RequestInit): Promise; -} diff --git a/src/puter-js/types/modules/os.d.ts b/src/puter-js/types/modules/os.d.ts deleted file mode 100644 index 146d5ee4b..000000000 --- a/src/puter-js/types/modules/os.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { RequestCallbacks } from '../shared.d.ts'; -import type { User } from './auth.d.ts'; - -export class OS { - user (options?: RequestCallbacks & { query?: Record }): Promise; - user (success: (value: User) => void, error?: (reason: unknown) => void): Promise; - version (options?: RequestCallbacks>): Promise>; - version (success: (value: Record) => void, error?: (reason: unknown) => void): Promise>; -} diff --git a/src/puter-js/types/modules/peer.d.ts b/src/puter-js/types/modules/peer.d.ts deleted file mode 100644 index f22516a67..000000000 --- a/src/puter-js/types/modules/peer.d.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** Options for `puter.peer.serve()` and `puter.peer.connect()`. */ -export interface PuterPeerOptions { - /** Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays. */ - iceServers?: RTCIceServer[]; - forceRelay?: boolean; -} - -/** Metadata about a peer user. */ -export interface PuterPeerUser { - username: string; - uuid: string; -} - -export type PuterPeerMessage = string | Blob | ArrayBuffer | ArrayBufferView; -export type PuterPeerDescription = RTCSessionDescription | RTCSessionDescriptionInit; -export type PuterPeerIceCandidate = RTCIceCandidate | RTCIceCandidateInit; - -/** Dispatched by `PuterPeerServer` for the `'connection'` event when a client connects. */ -export class PuterPeerServerConnectionEvent extends Event { - /** The connection to the client. */ - readonly conn: PuterPeerConnection; - /** Metadata about the connecting user (if available). */ - readonly user: PuterPeerUser; -} - -/** Dispatched by `PuterPeerConnection` for the `'message'` event when a message is received. */ -export class PuterPeerConnectionMessageEvent extends Event { - /** The received message payload. */ - readonly data: ArrayBuffer | string; -} - -/** Dispatched by `PuterPeerConnection` for the `'open'` event when the data channel is ready. */ -export class PuterPeerConnectionOpenEvent extends Event {} - -/** Dispatched by `PuterPeerConnection` for the `'close'` event when the connection closes. */ -export class PuterPeerConnectionCloseEvent extends Event { - /** The reason the connection was closed, if one was provided. */ - readonly reason?: string; -} - -/** Dispatched by `PuterPeerConnection` for the `'error'` event when a connection error occurs. */ -export class PuterPeerConnectionErrorEvent extends Event { - readonly error: string; -} - -export interface PuterPeerServerEventMap { - connection: PuterPeerServerConnectionEvent; -} - -export interface PuterPeerConnectionEventMap { - open: PuterPeerConnectionOpenEvent; - message: PuterPeerConnectionMessageEvent; - close: PuterPeerConnectionCloseEvent; - error: PuterPeerConnectionErrorEvent; -} - -/** - * A peer server created by `puter.peer.serve()`. Emits a `'connection'` event - * when a client connects. - */ -export class PuterPeerServer extends EventTarget { - /** The invite code to share with other clients so they can connect. */ - inviteCode?: string; - /** Map of all connected clients, keyed by id. */ - connections: Map; - - /** - * Opens the signalling connection and registers the server. Resolves to - * the invite code, which is also kept on `inviteCode`. `serve()` calls - * this for you. - */ - start (options?: PuterPeerOptions): Promise; - /** Closes every client connection and the signalling connection. */ - close (): void; - - addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void; - addEventListener( - type: K, - listener: (this: PuterPeerServer, ev: PuterPeerServerEventMap[K]) => unknown, - options?: boolean | AddEventListenerOptions, - ): void; - removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void; - removeEventListener( - type: K, - listener: (this: PuterPeerServer, ev: PuterPeerServerEventMap[K]) => unknown, - options?: boolean | EventListenerOptions, - ): void; -} - -/** - * A WebRTC data-channel connection to a peer. Emits `'open'`, `'message'`, - * `'close'`, and `'error'` events. - */ -export class PuterPeerConnection extends EventTarget { - peerconnection: RTCPeerConnection; - /** Information about the user who created the server. */ - owner?: PuterPeerUser; - connected: boolean; - closed: boolean; - - /** Connect to the server that issued `invitecode`. `puter.peer.connect()` calls this for you. */ - connect (invitecode: string, options?: PuterPeerOptions): Promise; - /** Close the connection, optionally providing a reason. */ - close (reason?: string): void; - createOffer (): Promise; - createAnswer (): Promise; - setRemoteDescription (description: PuterPeerDescription): Promise; - addIceCandidate (candidate: PuterPeerIceCandidate): Promise; - /** Send a message to the peer. Supports `string`, `Blob`, `ArrayBuffer`, or `ArrayBufferView`. */ - send (message: PuterPeerMessage): void; - - addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void; - addEventListener( - type: K, - listener: (this: PuterPeerConnection, ev: PuterPeerConnectionEventMap[K]) => unknown, - options?: boolean | AddEventListenerOptions, - ): void; - removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void; - removeEventListener( - type: K, - listener: (this: PuterPeerConnection, ev: PuterPeerConnectionEventMap[K]) => unknown, - options?: boolean | EventListenerOptions, - ): void; -} - -/** - * The `puter.peer` API. Provides WebRTC data channels with built-in signaling - * and TURN relays for connecting clients directly without your own signaling - * server. Peer connections require authentication. - */ -export default class Peer { - readonly authToken?: string | null; - readonly APIOrigin: string; - readonly appID?: string; - - /** - * Fetches TURN relay credentials ahead of time so peer connections start - * faster. Optional, since `serve()` and `connect()` call it automatically - * when needed. Resolves once relay details are cached; if relays cannot be - * loaded, Puter.js falls back to default ICE servers when connecting. - */ - ensureTurnRelays (): Promise; - /** - * Create a peer server that generates an invite code other clients can use - * to connect. - * @returns A `Promise` that resolves to a `PuterPeerServer`. - */ - serve (options?: PuterPeerOptions): Promise; - /** - * Connect to a peer server using an invite code created by `serve()`. - * @returns A `Promise` that resolves to a `PuterPeerConnection`. - */ - connect (invitecode: string, options?: PuterPeerOptions): Promise; -} diff --git a/src/puter-js/types/modules/perms.d.ts b/src/puter-js/types/modules/perms.d.ts deleted file mode 100644 index 7d2fbb50b..000000000 --- a/src/puter-js/types/modules/perms.d.ts +++ /dev/null @@ -1,173 +0,0 @@ -export class Perms { - grantUser (username: string, permission: string): Promise>; - grantGroup (groupUid: string, permission: string): Promise>; - grantApp (appUid: string, permission: string): Promise>; - grantAppAnyUser (appUid: string, permission: string): Promise>; - grantOrigin (origin: string, permission: string): Promise>; - - revokeUser (username: string, permission: string): Promise>; - revokeGroup (groupUid: string, permission: string): Promise>; - revokeApp (appUid: string, permission: string): Promise>; - revokeAppAnyUser (appUid: string, permission: string): Promise>; - revokeOrigin (origin: string, permission: string): Promise>; - - createGroup (metadata?: Record, extra?: Record): Promise>; - addUsersToGroup (uid: string, usernames: string[]): Promise>; - removeUsersFromGroup (uid: string, usernames: string[]): Promise>; - listGroups (): Promise>; - - /** - * Request a specific permission string to be granted. Note that some - * permission strings are not supported and will be denied silently. - * @param permission - The permission string to request. - * @returns `true` if the permission was granted, `false` otherwise. - */ - request (permission: string): Promise; - - /** - * Request to see a user's email. If the permission has already been granted - * the user will not be prompted and their email address will be returned. - * @returns The user's email address if granted, `null` if granted but the - * user has no email address, or `undefined` if access is denied. - */ - requestEmail (): Promise; - - /** - * Request read access to the user's Desktop folder. - * @returns The Desktop folder path if granted, or `undefined` if denied. - */ - requestReadDesktop (): Promise; - - /** - * Request write access to the user's Desktop folder. - * @returns The Desktop folder path if granted, or `undefined` if denied. - */ - requestWriteDesktop (): Promise; - - /** - * Request read access to the user's Documents folder. - * @returns The Documents folder path if granted, or `undefined` if denied. - */ - requestReadDocuments (): Promise; - - /** - * Request write access to the user's Documents folder. - * @returns The Documents folder path if granted, or `undefined` if denied. - */ - requestWriteDocuments (): Promise; - - /** - * Request read access to the user's Pictures folder. - * @returns The Pictures folder path if granted, or `undefined` if denied. - */ - requestReadPictures (): Promise; - - /** - * Request write access to the user's Pictures folder. - * @returns The Pictures folder path if granted, or `undefined` if denied. - */ - requestWritePictures (): Promise; - - /** - * Request read access to the user's Videos folder. - * @returns The Videos folder path if granted, or `undefined` if denied. - */ - requestReadVideos (): Promise; - - /** - * Request write access to the user's Videos folder. - * @returns The Videos folder path if granted, or `undefined` if denied. - */ - requestWriteVideos (): Promise; - - /** - * Request read access to the user's apps. - * @returns `true` if read access was granted, `false` otherwise. - */ - requestReadApps (): Promise; - - /** - * Request write (manage) access to the user's apps. - * @returns `true` if manage access was granted, `false` otherwise. - */ - requestManageApps (): Promise; - - /** - * Request read access to the user's subdomains. - * @returns `true` if read access was granted, `false` otherwise. - */ - requestReadSubdomains (): Promise; - - /** - * Request write (manage) access to the user's subdomains. - * @returns `true` if manage access was granted, `false` otherwise. - */ - requestManageSubdomains (): Promise; - - /** - * Request permission to use another app's data: its key-value namespace and - * its AppData directory, both scoped to the current user. - * - * Deleting entries is a separate scope from writing them, so request - * `delete` explicitly when the app needs to remove data it did not write. - * - * @param appIdentifier - The target app's uid, registered name, or an object - * carrying either. - * @param scopes - An access class applied to both stores, an array of - * `':'` pairs, or a per-store object. - * @returns `true` if the app may now use that data, `false` if denied. - */ - requestAppData ( - appIdentifier: string | { uid: string } | { name: string }, - scopes: AppDataScopes, - ): Promise; -} - -/** The stores an `app-data` scope can name. */ -export type AppDataStore = 'kv' | 'fs'; - -/** - * The three access classes. `delete` is orthogonal to `write`: neither implies - * the other, so an app that only adds data cannot remove any. - */ -export type AppDataClass = 'read' | 'write' | 'delete'; - -/** - * A key-value scope: an access class, or one concrete operation. Classes are - * the coarser form — `read` covers `get`/`list`, `write` covers - * `set`/`add`/`incr`/`decr`/`update`, and `delete` covers - * `del`/`remove`/`expire`/`expireAt`. - * - * `flush` is deliberately absent — it empties a whole namespace and no scope - * reaches it. - */ -export type AppDataKvScope = - | AppDataClass - | 'get' | 'list' - | 'set' | 'add' | 'incr' | 'decr' | 'update' - | 'del' | 'remove' | 'expire' | 'expireAt'; - -/** - * A file scope. Classes only, with no per-operation form: ACL checks a mode, - * not an operation, so there is nothing finer to name. - */ -export type AppDataFsScope = AppDataClass; - -/** One `':'` pair, as the array form takes them. */ -export type AppDataScopePair = - | `kv:${AppDataKvScope}` - | `fs:${AppDataFsScope}`; - -/** - * What `requestAppData` accepts. A bare class applies to both stores; the array - * form spells out the store on every entry; the object form groups by store. - * There is no bare-name array — an entry with no store would be ambiguous - * between the two. - */ -export type AppDataScopes = - | AppDataClass - | AppDataScopePair[] - | { - kv?: AppDataKvScope | AppDataKvScope[], - fs?: AppDataFsScope | AppDataFsScope[], - }; diff --git a/src/puter-js/types/modules/ui.d.ts b/src/puter-js/types/modules/ui.d.ts deleted file mode 100644 index ec304ec6f..000000000 --- a/src/puter-js/types/modules/ui.d.ts +++ /dev/null @@ -1,429 +0,0 @@ -import type { FSItem } from './fs-item.d.ts'; - -/** A button shown in an `alert()` dialog. */ -export interface AlertButton { - /** Text displayed on the button. */ - label: string; - /** Value returned when this button is pressed. Defaults to `label` if not set. */ - value?: string; - /** Visual style of the button. */ - type?: 'primary' | 'success' | 'info' | 'warning' | 'danger'; -} - -/** Options that configure an `alert()` dialog. */ -export interface AlertOptions { - /** Visual style of the alert dialog. */ - type?: 'primary' | 'success' | 'info' | 'warning' | 'danger'; - /** Icon URL shown in the dialog body. Takes precedence over `icon`. */ - body_icon?: string; - /** Icon URL shown in the dialog body, used when `body_icon` is not set. */ - icon?: string; -} - -export interface PromptOptions { - defaultValue?: string; -} - -/** A single item in a context menu. The string `'-'` may be used in place of an item to render a separator. */ -export interface ContextMenuItem { - /** Text displayed for the menu item. */ - label: string; - /** Function executed when the item is clicked. Not required for items with submenus. */ - action?: () => void; - /** Icon shown next to the label. Must be a base64-encoded image data URI starting with `data:image`; other strings are ignored. */ - icon?: string; - /** Icon shown when the item is hovered or active. Must be a base64-encoded image data URI starting with `data:image`; other strings are ignored. */ - icon_active?: string; - /** If `true`, the item is disabled and unclickable. Defaults to `false`. */ - disabled?: boolean; - /** Submenu items. Specifying this creates a submenu. */ - items?: (ContextMenuItem | '-')[]; -} - -/** A handle to a window created by `createWindow()`. */ -export interface WindowHandle { - /** Identifier of the window, usable as the `window_id` argument to the `setWindow*` methods. */ - id: string; -} - -/** Identifies a window: either a window id string or a window handle returned by `createWindow()`. */ -export type WindowIdentifier = string | WindowHandle; - -/** Options that configure a context menu. */ -export interface ContextMenuOptions { - /** Menu items and separators. Use the string `'-'` to insert a separator. */ - items: (ContextMenuItem | '-')[]; - /** - * Forces the rendered menu's color theme. Only applies when running standalone - * (`puter.env === 'web'`); ignored inside the Puter desktop (`puter.env === 'app'`). - * When unset, the menu follows the system color-scheme preference. - */ - theme?: 'dark' | 'light'; - /** - * X position of the menu, in pixels. Defaults to the cursor position. - * Standalone only, with the same caveat as `theme`. - */ - x?: number; - /** - * Y position of the menu, in pixels. Defaults to the cursor position. - * Standalone only, with the same caveat as `theme`. - */ - y?: number; -} - -/** Options that configure a window created by `createWindow()`. */ -export interface WindowOptions { - /** If `true`, the window is placed at the center of the screen. */ - center?: boolean; - /** Content of the window. */ - content?: string; - /** If `true`, the parent window is blocked until this window is closed. */ - disable_parent_window?: boolean; - /** If `true`, the window has a head containing the icon and close, minimize, and maximize buttons. */ - has_head?: boolean; - /** Height of the window in pixels. */ - height?: number; - /** If `true`, the user can resize the window. */ - is_resizable?: boolean; - /** If `true`, the window is represented in the taskbar. */ - show_in_taskbar?: boolean; - /** Title of the window. */ - title?: string; - /** Width of the window in pixels. */ - width?: number; -} - -/** Options that configure `launchApp()`. */ -export interface LaunchAppOptions { - /** Name of the app to launch. If not provided, a new instance of the current app is launched. */ - name?: string; - app_name?: string; - /** Arguments to pass to the app. */ - args?: Record; - /** Paths of existing files to open with the launched app. */ - file_paths?: string[]; - /** `FSItem` objects to open with the launched app. */ - items?: FSItem[]; - /** A pseudonym to launch the app under. */ - pseudonym?: string; - callback?: (connection: AppConnection) => void; -} - -/** Theme data delivered with the `themeChanged` event. */ -export interface ThemeData { - palette: { - /** Hue of the theme color. */ - primaryHue: number; - /** Saturation of the theme color as a percentage string, including the `%` sign. */ - primarySaturation: string; - /** Lightness of the theme color as a percentage string, including the `%` sign. */ - primaryLightness: string; - /** Opacity of the theme color, from `0` to `1`. */ - primaryAlpha: number; - /** CSS color value for text. */ - primaryColor: string; - }; -} - -/** Options that configure the menubar set by `setMenubar()`. */ -export interface MenubarOptions { - /** Menu items and separators. Use the string `'-'` to insert a separator. */ - items: (MenuItem | '-')[]; - /** - * Forces the rendered menubar's color theme. Only applies when running standalone - * (`puter.env === 'web'`); ignored inside the Puter desktop (`puter.env === 'app'`). - * When unset, the menubar follows the system color-scheme preference. - */ - theme?: 'dark' | 'light'; -} - -/** A single item in a menubar menu. The string `'-'` may be used in place of an item to render a separator. */ -export interface MenuItem { - /** Text displayed for the menu item. */ - label: string; - id?: string; - /** Function executed when the item is clicked. */ - action?: () => void; - /** Submenu items. */ - items?: (MenuItem | '-')[]; - /** URL or data URI of an icon shown next to the label. */ - icon?: string; - /** URL or data URI of an icon shown when the item is hovered or active. Falls back to `icon` if not provided. */ - icon_active?: string; - /** If `true`, renders a checkmark next to the item. Use for toggleable options. */ - checked?: boolean; - /** If `true`, the item is visible but cannot be clicked. */ - disabled?: boolean; -} - -/** Options that configure `showOpenFilePicker()`. */ -export interface FilePickerOptions { - /** If `true`, the user can select multiple files. Defaults to `false`. */ - multiple?: boolean; - /** - * MIME types or file extensions accepted by the picker. Defaults to `*\/*`. - * For example `'image/*'`, or `['.jpg', '.png']`. - */ - accept?: string | string[]; - /** - * Initial directory to open the picker in. Defaults to the user's Desktop. - * The special prefix `%appdata%` resolves to the app's private appdata directory. - */ - path?: string; -} - -/** Options that configure `showColorPicker()`. */ -export interface ColorPickerOptions { - /** The color initially selected when the picker opens. */ - defaultColor?: string; -} - -/** Options that configure `showFontPicker()`. */ -export interface FontPickerOptions { - /** The font initially selected when the picker opens. */ - defaultFont?: string; -} - -/** Options that configure `showDirectoryPicker()`. */ -export interface DirectoryPickerOptions { - /** If `true`, the user can select multiple directories. Defaults to `false`. */ - multiple?: boolean; -} - -/** Options that configure a notification shown by `notify()`. */ -export interface NotificationOptions { - /** Title shown in the notification. */ - title?: string; - /** Body text shown under the title. */ - text?: string; - /** Icon URL or Puter icon name (for example `bell.svg`). */ - icon?: string; - /** Visual style used to pick a default icon and accent color when no `icon` is provided. */ - type?: 'info' | 'success' | 'warning' | 'error' | 'default'; - /** Time in milliseconds before the notification auto-dismisses. Defaults to `5000`; set to `0` to keep it until dismissed. */ - duration?: number; - /** If `true`, renders the icon as a circle. */ - round_icon?: boolean; - /** Alias for `round_icon`. */ - roundIcon?: boolean; - /** Optional ID to associate with the notification. */ - uid?: string; - /** Optional value stored on the notification element. */ - value?: unknown; -} - -/** Data passed to the `close` handler on an `AppConnection`. */ -export interface AppConnectionCloseEvent { - /** Instance ID of the app that closed. */ - appInstanceID: string; - statusCode?: number; -} - -/** Data passed to the `connection` event handler when another app requests a connection to your app. */ -export interface ConnectionEvent { - /** Connection to the app that initiated the request. */ - conn: AppConnection; - /** Call `accept(value)` to accept the connection; `value` is sent back to the requester. */ - accept: (value?: unknown) => void; - /** Call `reject(value)` to reject the connection; `value` is sent back to the requester. */ - reject: (value?: unknown) => void; -} - -export interface LaunchAppResult { - launched: boolean; - requestedAppName?: string | null; - openedAppName?: string | null; - appInstanceID?: string | null; - appUid?: string | null; - redirectedToFallback?: boolean; - deniedPrivateAccess?: boolean; - privateAccess?: { - hasAccess: boolean; - fallbackAppName?: string; - fallbackArgs?: Record; - reason?: string; - }; -} - -export type CancelAwarePromise = Promise & { undefinedOnCancel?: Promise }; - -/** Provides an interface for interaction with another app. */ -export class AppConnection { - /** Whether the target app is using Puter.js. If not, some features of `AppConnection` are unavailable. */ - readonly usesSDK: boolean; - readonly response?: Record & { - launchResult?: LaunchAppResult; - }; - - /** - * Listen to an event from the target app. - * - `message`: the target app sent a message with `postMessage()`; the handler receives the message. - * - `close`: the target app closed; the handler receives an object with the closed app's `appInstanceID`. - */ - on (eventName: 'message', handler: (message: unknown) => void): void; - on (eventName: 'close', handler: (data: AppConnectionCloseEvent) => void): void; - /** Remove an event listener added with `on(eventName, handler)`. */ - off (eventName: string, handler: (...args: unknown[]) => void): void; - /** Send a message to the target app. Does nothing if the target app is not using the SDK or the connection is not open. */ - postMessage (message: unknown): void; - /** Attempt to close the target app. An app may close apps it launched with `launchApp()`. Does nothing without permission or if already closed. */ - close (): void; -} - -/** - * The UI API: tools for creating rich user interfaces and interacting with the - * Puter desktop environment, including dialogs, window management, file pickers, - * and desktop integration. - */ -export class UI { - /** - * Displays an alert dialog. Blocks the parent window until the user presses a button. - * Resolves to the pressed button's `value` (or its `label` if `value` is unset). - */ - alert (message?: string, buttons?: AlertButton[], options?: AlertOptions): Promise; - /** - * Displays a prompt dialog. Blocks the parent window until the user responds. - * Resolves to the input value on OK, or `false` if the user cancels. - */ - prompt (message?: string, placeholder?: string, options?: PromptOptions): Promise; - /** Displays a desktop notification. Resolves to the notification UID. */ - notify (options?: NotificationOptions): Promise; - /** Presents a dialog for the user to authenticate with their Puter account. Resolves once authenticated; rejects if the user cancels. */ - authenticateWithPuter (): Promise; - /** Displays a context menu at the current cursor position. Menu item actions run when clicked. */ - contextMenu (options: ContextMenuOptions): void; - /** Creates and displays a window. Resolves to a window handle whose `id` can be passed to the `setWindow*` methods. */ - createWindow (options?: WindowOptions): Promise; - /** Retrieves the current language/locale code from the Puter environment (e.g. `en`, `fr`, `es`, `de`). */ - getLanguage (): Promise; - /** Hides the active spinner instance. */ - hideSpinner (): void; - /** Hides the window of the application. */ - hideWindow (): void; - /** - * Shows an overlay with a spinner in the center of the screen. If called - * multiple times, only one spinner is shown until all instances are hidden. - * @param html Custom message rendered under the spinner; accepts plain text or HTML. Defaults to `"Working..."`. - */ - showSpinner (html?: string): void; - /** Shows the window of the application. */ - showWindow (): void; - /** Presents a color picker dialog and resolves to the selected color. */ - showColorPicker (defaultColor?: string): Promise; - showColorPicker (options?: ColorPickerOptions): Promise; - /** - * Asks the user to grant a permission to this app. Inside the Puter GUI the - * request is shown on the desktop; on the web it opens a popup on the Puter - * origin. Resolves to `true` only if the permission was granted. - */ - requestPermission (options: { permission: string }): Promise; - /** - * Presents a directory picker for the user's Puter cloud storage. Resolves to - * one `FSItem` or an array of `FSItem` objects depending on selection count. - */ - showDirectoryPicker (options?: DirectoryPickerOptions): Promise; - /** Presents a font picker for previewing and selecting a font. */ - showFontPicker (defaultFont?: string): Promise<{ fontFamily: string }>; - showFontPicker (options?: FontPickerOptions): Promise<{ fontFamily: string }>; - /** - * Presents a file picker for the user's Puter cloud storage. Resolves to one - * `FSItem` or an array of `FSItem` objects depending on selection count. - */ - showOpenFilePicker (options?: FilePickerOptions): CancelAwarePromise; - /** - * Presents a file picker for choosing where and with what name to save a file. - * Resolves to an `FSItem` for the saved file. If the user cancels, the promise stays pending. - * @param content Data to write. When `type` is `'url'`, a URL whose contents are saved; when `'move'` or `'copy'`, the source path of an existing file. - * @param suggestedName Default file name to pre-fill in the dialog. - * @param type How `content` is interpreted: `'url'`, `'move'`, or `'copy'`. Auto-detected as `'url'` when `content` is a `URL` object. - */ - showSaveFilePicker ( - content?: unknown, - suggestedName?: string, - type?: 'url' | 'move' | 'copy', - ): CancelAwarePromise; - /** - * Presents a dialog for sharing a link on various social media platforms. - * @param url The URL to share. - * @param message Message to prefill in the post. Only supported by some platforms. - * @param options Dialog position; `left` and `top` both default to `0`. - */ - socialShare (url: string, message?: string, options?: { left?: number; top?: number }): void; - /** Creates a menubar, a horizontal bar at the top of the window containing menus. */ - setMenubar (options: MenubarOptions): void; - setMenuItemIcon (itemId: string, icon: string): void; - setMenuItemIconActive (itemId: string, icon: string): void; - setMenuItemChecked (itemId: string, checked: boolean): void; - /** - * Dynamically sets the window height. Minimum is `200`; smaller values are clamped to `200`. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowHeight (height: number, window_id?: WindowIdentifier): void; - /** - * Sets the window position. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowPosition (x: number, y: number, window_id?: WindowIdentifier): void; - /** - * Dynamically sets the window width and height. Minimum for each is `200`; smaller values are clamped to `200`. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowSize (width: number, height: number, window_id?: WindowIdentifier): void; - /** - * Dynamically sets the window title. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowTitle (title: string, window_id?: WindowIdentifier): void; - /** - * Dynamically sets the window width. Minimum is `200`; smaller values are clamped to `200`. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowWidth (width: number, window_id?: WindowIdentifier): void; - /** - * Sets the window X position. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowX (x: number, window_id?: WindowIdentifier): void; - /** - * Sets the window Y position. - * @param window_id Targets a specific window; accepts a window id string or a handle from `createWindow()`. Defaults to the app's main window. - */ - setWindowY (y: number, window_id?: WindowIdentifier): void; - /** Returns whether the app was launched to open one or more items (via double-clicking, the 'Open With...' menu, etc.). */ - wasLaunchedWithItems (): boolean; - /** @deprecated Also fires when items are dropped on the app; new code should handle the `drop` event instead. */ - onItemsOpened (handler: (items: FSItem[]) => void): void; - /** - * Registers a callback invoked when the app is launched with items (via - * double-clicking or the 'Open With...' menu). The handler receives an array - * of items, each a file or directory. - */ - onLaunchedWithItems (handler: (items: FSItem[]) => void): void; - /** Registers a function run when the window is about to close. Not called when the app exits via `puter.exit()`. */ - onWindowClose (handler: () => void): void; - /** - * Listen to a broadcast event from Puter. If the broadcast was received before - * the handler was attached, the handler is called immediately with the most recent value. - * - `localeChanged`: sent on startup and when the user's locale changes. - * - `themeChanged`: sent on startup and when the user's desktop theme changes. - * - `connection`: sent when another app requests a connection to your app. - */ - on (eventName: 'localeChanged', handler: (data: { language: string }) => void): void; - on (eventName: 'themeChanged', handler: (data: ThemeData) => void): void; - on (eventName: 'connection', handler: (data: ConnectionEvent) => void): void; - /** Obtains a connection to the app that launched this app, or `null` if there is no parent app. */ - parentApp (): AppConnection | null; - /** - * Dynamically launches another app. If no app name is given, a new instance of - * the current app is launched. Resolves to an `AppConnection` once launched. - */ - launchApp (appName?: string, args?: Record, callback?: (connection: AppConnection) => void): Promise; - launchApp (options: LaunchAppOptions): Promise; - - getEntriesFromDataTransferItems (dataTransferItems: DataTransferItemList, options?: { raw?: boolean }): Promise>; - - requestUpgrade (): Promise; -} - -// NOTE: UI contains additional internal helpers that are not surfaced here because they are not -// part of the stable app-facing API. diff --git a/src/puter-js/types/modules/util.d.ts b/src/puter-js/types/modules/util.d.ts deleted file mode 100644 index 072048fea..000000000 --- a/src/puter-js/types/modules/util.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class UtilRPC { - callbackManager: unknown; - getDehydrator (): { dehydrate(value: unknown): unknown }; - getHydrator (config: { target: Window | Worker | MessagePort }): { hydrate(value: unknown): unknown }; - registerCallback (resolve: (value: unknown) => void): string; - send (target: Window | Worker | MessagePort, id: string, ...args: unknown[]): void; -} - -export default class Util { - rpc: UtilRPC; -} diff --git a/src/puter-js/types/modules/workers.d.ts b/src/puter-js/types/modules/workers.d.ts deleted file mode 100644 index f05c64142..000000000 --- a/src/puter-js/types/modules/workers.d.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { ListPage, ListPaginationOptions, ListStreamOptions } from '../shared.d.ts'; - -/** Information about a deployed worker, as returned by `get()` and `list()`. */ -export interface WorkerInfo { - /** The name of the worker. */ - name: string; - /** The URL of the worker. */ - url: string; - /** The file path of the worker's source code. */ - file_path: string; - /** The unique identifier of the worker file. */ - file_uid: string; - /** The date and time when the worker was created. */ - created_at: string; -} - -/** The result of a worker deployment, as returned by `create()`. */ -export interface WorkerDeployment { - /** Whether the worker deployment was successful. */ - success: boolean; - /** The URL of the deployed worker. */ - url: string; - /** Any errors that occurred during deployment. */ - errors?: string[]; -} - -export class WorkersHandler { - /** - * Creates and deploys a new worker from a JavaScript file containing router code. - * A worker is tied to its name: create it once, then deploy changes by overwriting - * its source file rather than calling `create()` again. Workers cannot be larger - * than 10MB. Requires a Puter account with a verified email address. - * - * @param workerName The name for the worker. May contain letters, numbers, hyphens, and underscores. - * @param filePath The path to a JavaScript file in your Puter account that contains the router code. - * @param appName The name of an existing app to bind the worker to, which becomes the identity - * the worker runs as. An app may only name an app it created; a user token may name any app in - * the account. - */ - create (workerName: string, filePath: string, appName?: string): Promise; - /** - * @param options Controls the worker's sandbox. When `sandbox` is `true`, a dedicated - * `sandbox-` app is created (or reused) to own the worker, giving it its own - * KV and AppData namespace. Defaults to `true` for user tokens and `false` when an app - * deploys the worker — an app's workers run as the app itself unless you opt in, and so - * share one namespace with each other and with the app. - */ - create (workerName: string, filePath: string, options?: { sandbox?: boolean }): Promise; - /** Deletes an existing worker and stops its execution. Resolves to `true` if successful. */ - delete (workerName: string): Promise; - /** - * Sends a request to a worker endpoint, automatically including the user's session - * so the worker gets user context (`user.puter`) for the User-Pays model. Accepts the - * same input as the Fetch API; resolves to a `Response`. - */ - exec (request: RequestInfo | URL, init?: RequestInit): Promise; - /** Gets information for a specific worker, or `undefined` if it does not exist. */ - get (workerName: string): Promise; - /** - * Lists all workers in your account with their details, fetching page by - * page under the hood. With `stream: true` it instead returns an async - * iterator of pages for `for await ... of`; with any pagination option - * it resolves to a single page envelope. - */ - list (options: ListStreamOptions): AsyncIterableIterator>; - list (options: ListPaginationOptions & ({ limit: number } | { offset: number } | { cursor: string | null } | { includeTotal: true })): Promise>; - list (): Promise; - getLoggingHandle (workerName: string): Promise void; - onLog: (event: MessageEvent) => void; - }>; -} diff --git a/src/puter-js/types/puter.d.ts b/src/puter-js/types/puter.d.ts deleted file mode 100644 index d84994989..000000000 --- a/src/puter-js/types/puter.d.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { AI } from './modules/ai.d.ts'; -import type { Apps } from './modules/apps.d.ts'; -import type { Auth } from './modules/auth.d.ts'; -import type { Debug } from './modules/debug.d.ts'; -import type { Drivers } from './modules/drivers.d.ts'; -import type { Email } from './modules/email.d.ts'; -import type { FS } from './modules/filesystem.d.ts'; -import type { FSItem } from './modules/fs-item.d.ts'; -import type { Hosting } from './modules/hosting.d.ts'; -import type { KV } from './modules/kv.d.ts'; -import type { Networking } from './modules/networking.d.ts'; -import type { OS } from './modules/os.d.ts'; -import type Peer from './modules/peer.d.ts'; -import type { Perms } from './modules/perms.d.ts'; -import type { UI } from './modules/ui.d.ts'; -import type Util from './modules/util.d.ts'; -import type { WorkersHandler } from './modules/workers.d.ts'; -import type { APICallLogger, APILoggingConfig, PuterEnvironment, ToolSchema } from './shared.d.ts'; - -export interface PuterArgs { - [key: string]: unknown; -} - -export interface PuterUser extends Record { - username?: string; -} - -export class Puter { - env: PuterEnvironment; - appID?: string; - appName?: string; - appDataPath?: string; - appInstanceID?: string; - parentInstanceID?: string; - args: PuterArgs; - onAuth?: (user: PuterUser) => void; - authToken?: string | null; - APIOrigin: string; - logger: unknown; - apiCallLogger?: APICallLogger; - puterAuthState: { - isPromptOpen: boolean; - authGranted: boolean | null; - resolver: { resolve: () => void; reject: (reason?: unknown) => void } | null; - }; - - // Core modules - util: Util; - ai: AI; - apps: Apps; - auth: Auth; - os: OS; - fs: FS; - ui: UI; - hosting: Hosting; - kv: KV; - email: Email; - perms: Perms; - drivers: Drivers; - debug: Debug; - peer: Peer | null; - path: { - join: (...parts: string[]) => string; - dirname: (p: string) => string; - basename: (p: string) => string; - normalize?: (p: string) => string; - [key: string]: unknown; - }; - - net: Networking; - workers: WorkersHandler; - - static FSItem: typeof FSItem; - - setAuthToken(authToken: string): void; - resetAuthToken(): void; - setAPIOrigin(APIOrigin: string): void; - setAppID(appID: string): void; - - /** - * Subscribes to token / API origin changes. Modules read both live off - * the instance, so this is only for the ones holding a connection open - * that has to be rebuilt. Returns an unsubscribe function. - */ - onAuthStateChanged(listener: () => void): () => void; - - get defaultAPIOrigin(): string; - set defaultAPIOrigin(value: string); - get defaultGUIOrigin(): string; - set defaultGUIOrigin(value: string); - - print(text: string, options?: { code?: boolean; escapeHTML?: boolean }): void; - randName(separator?: string): string; - exit(statusCode?: number): void; - - getUser(options?: { success?: (user: PuterUser) => void; error?: (reason: unknown) => void }): Promise; - configureAPILogging(config?: APILoggingConfig): this; - enableAPILogging(config?: APILoggingConfig): this; - disableAPILogging(): this; - - // Utilities for caches and network; exposed but not all internals are typed. - checkAndUpdateGUIFScache(): void; - initNetworkMonitoring(): void; - - tools: ToolSchema[]; -} diff --git a/src/puter-js/types/shared.d.ts b/src/puter-js/types/shared.d.ts deleted file mode 100644 index 686ff49fc..000000000 --- a/src/puter-js/types/shared.d.ts +++ /dev/null @@ -1,90 +0,0 @@ -export type PuterEnvironment = 'app' | 'gui' | 'web' | 'web-worker' | 'service-worker' | 'nodejs'; - -export interface RequestCallbacks { - success?: (value: T) => void; - error?: (reason: unknown) => void; -} - -export interface APILoggingConfig { - enabled?: boolean; - [key: string]: unknown; -} - -export interface APICallLogger { - isEnabled(): boolean; - logRequest(entry: Record): void; - updateConfig(config: APILoggingConfig): void; - disable(): void; -} - -export interface PaginationOptions { - page?: number; - per_page?: number; -} - -/** - * Standard pagination request params shared by list APIs - * (`puter.apps.list()`, `puter.hosting.list()`, `puter.workers.list()`, - * `puter.fs.readdir()`). - */ -export interface ListPaginationOptions { - /** Maximum items per page. Each endpoint documents its cap and default. */ - limit?: number; - /** - * Skips the given number of items. Cannot be combined with `cursor` or - * `stream`; prefer `cursor` — requests get slower and more expensive the - * larger the offset. - */ - offset?: number; - /** - * Opaque continuation cursor. Pass `null` for the first page, then each - * page's `cursor` to fetch the next one. - */ - cursor?: string | null; - /** - * When `true`, the result includes a `total` count of every item across - * all pages. - */ - includeTotal?: boolean; -} - -/** One page of a paginated listing. */ -export interface ListPage { - /** The items on this page. A page may hold fewer than `limit` items while more pages exist. */ - items: T[]; - /** Present only while more pages exist; pass it to the next call to resume. */ - cursor?: string; - /** Total item count across all pages; present when requested via `includeTotal`. */ - total?: number; -} - -/** - * The `stream: true` form of list methods: returns an async iterator of - * pages for `for await ... of` instead of a promise. - */ -export interface ListStreamOptions { - /** Stream page envelopes as they are fetched. */ - stream: true; - /** Maximum items per page. Defaults to the endpoint's page size. */ - limit?: number; - /** Start streaming from a previous page's `cursor` instead of the beginning. */ - cursor?: string | null; - /** Include a `total` count on the first streamed page. */ - includeTotal?: boolean; -} - -export interface PaginatedResult { - data: T[]; - page?: number; - pages?: number; -} - -export interface ToolSchema { - function: { - name: string; - description: string; - parameters: Record; - strict?: boolean; - }; - exec: (parameters: Record) => unknown | Promise; -} diff --git a/src/worker-types/package.json b/src/worker-types/package.json index 189463de8..68fcb4c2c 100644 --- a/src/worker-types/package.json +++ b/src/worker-types/package.json @@ -39,6 +39,7 @@ "@heyputer/puter.js": "^2.5.0" }, "scripts": { + "pretypecheck": "cd ../puter-js && npm run build:types", "typecheck": "tsc --noEmit -p tsconfig.json" } } diff --git a/tools/checkPuterjsTypes.mjs b/tools/checkPuterjsTypes.mjs new file mode 100644 index 000000000..f85f3b519 --- /dev/null +++ b/tools/checkPuterjsTypes.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * Type-checks the declarations puter.js publishes. + * + * The JSDoc in src/puter-js/src is the source of truth for the SDK's types; + * src/puter-js/types is `tsc --emitDeclarationOnly` output, generated by the + * SDK build and shipped in the npm tarball but never committed. So this + * generates it and checks the result, rather than diffing against something + * checked in. + * + * The check runs *without* `skipLibCheck`, which is the point: that flag is on + * everywhere else, and it is how the hand-maintained declarations used to hide + * broken re-exports — the root index.d.ts named types no module exported, and + * nothing ever looked. + * + * Usage: + * node tools/checkPuterjsTypes.mjs + */ + +import { execFileSync } from 'node:child_process'; +import { dirname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SDK = join(ROOT, 'src', 'puter-js'); + +const tsc = (args) => { + try { + execFileSync('npx', ['tsc', ...args], { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { ok: true, output: '' }; + } catch (e) { + if (e.stdout === undefined && e.stderr === undefined) throw e; + return { ok: false, output: `${e.stdout ?? ''}${e.stderr ?? ''}` }; + } +}; + +const generated = tsc(['-p', join(SDK, 'tsconfig.types.json')]); +if (!generated.ok) { + console.error('Generating declarations from the puter.js JSDoc failed:\n'); + console.error(generated.output); + process.exit(1); +} + +const checked = tsc([ + '--noEmit', + '--strict', + '--target', 'es2022', + '--module', 'nodenext', + '--moduleResolution', 'nodenext', + join(SDK, 'index.d.ts'), +]); + +// Errors inside third-party `@types` packages are not this project's to fix. +const errors = checked.output + .split('\n') + .filter((line) => /error TS\d+/.test(line)) + .filter((line) => !line.includes(`node_modules${sep}@types`)); + +if (errors.length) { + console.error( + 'The declarations generated from the puter.js JSDoc do not type-check.' + + ' Fix the JSDoc in src/puter-js/src — src/puter-js/types is generated' + + ' output and editing it there would be overwritten by the next build.\n', + ); + for (const line of errors) console.error(` ${line}`); + process.exit(1); +} + +console.log('puter.js declarations generate and type-check cleanly.');