@pithy-sh/cloudflare 0.4.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/ai/aiManager.d.ts +15 -1
- package/dist/ai/aiManager.d.ts.map +1 -1
- package/dist/ai/aiManager.js +39 -6
- package/dist/analytics/analyticsManager.d.ts +78 -0
- package/dist/analytics/analyticsManager.d.ts.map +1 -0
- package/dist/analytics/analyticsManager.js +206 -0
- package/dist/analytics/datasets.d.ts +26 -0
- package/dist/analytics/datasets.d.ts.map +1 -0
- package/dist/analytics/datasets.js +26 -0
- package/dist/analytics/window.d.ts +50 -0
- package/dist/analytics/window.d.ts.map +1 -0
- package/dist/analytics/window.js +67 -0
- package/dist/artifacts/artifactsManager.d.ts +52 -0
- package/dist/artifacts/artifactsManager.d.ts.map +1 -0
- package/dist/artifacts/artifactsManager.js +150 -0
- package/dist/client/clients.d.ts +13 -1
- package/dist/client/clients.d.ts.map +1 -1
- package/dist/client/clients.js +22 -1
- package/dist/client/manager.d.ts +13 -2
- package/dist/client/manager.d.ts.map +1 -1
- package/dist/client/manager.js +18 -5
- package/dist/d1/d1Manager.d.ts.map +1 -1
- package/dist/d1/d1Manager.js +3 -3
- package/dist/d1/d1PreparedStatement.d.ts +12 -0
- package/dist/d1/d1PreparedStatement.d.ts.map +1 -1
- package/dist/d1/d1PreparedStatement.js +13 -3
- package/dist/media/imageManager.d.ts +25 -0
- package/dist/media/imageManager.d.ts.map +1 -1
- package/dist/media/imageManager.js +58 -2
- package/dist/media/streamManager.d.ts +15 -0
- package/dist/media/streamManager.d.ts.map +1 -1
- package/dist/media/streamManager.js +28 -1
- package/dist/oauth/callback.d.ts +32 -0
- package/dist/oauth/callback.d.ts.map +1 -0
- package/dist/oauth/callback.js +58 -0
- package/dist/oauth/catalog.d.ts +26 -0
- package/dist/oauth/catalog.d.ts.map +1 -0
- package/dist/oauth/catalog.js +30 -0
- package/dist/oauth/client.d.ts +126 -0
- package/dist/oauth/client.d.ts.map +1 -0
- package/dist/oauth/client.js +200 -0
- package/dist/oauth/scopes.d.ts +62 -0
- package/dist/oauth/scopes.d.ts.map +1 -0
- package/dist/oauth/scopes.js +79 -0
- package/dist/queue/queueProvisioner.d.ts +30 -0
- package/dist/queue/queueProvisioner.d.ts.map +1 -0
- package/dist/queue/queueProvisioner.js +72 -0
- package/dist/r2/r2Manager.d.ts +11 -1
- package/dist/r2/r2Manager.d.ts.map +1 -1
- package/dist/r2/r2Manager.js +18 -1
- package/dist/secrets/secretsStoreManager.d.ts.map +1 -1
- package/dist/secrets/secretsStoreManager.js +3 -2
- package/dist/test-utils/settle.d.ts +79 -0
- package/dist/test-utils/settle.d.ts.map +1 -0
- package/dist/test-utils/settle.js +105 -0
- package/dist/tokens/accountTokensManager.d.ts +8 -7
- package/dist/tokens/accountTokensManager.d.ts.map +1 -1
- package/dist/tokens/accountTokensManager.js +2 -1
- package/dist/tokens/permissions.d.ts +49 -0
- package/dist/tokens/permissions.d.ts.map +1 -1
- package/dist/tokens/permissions.js +56 -2
- package/dist/tokens/profiles.d.ts +10 -30
- package/dist/tokens/profiles.d.ts.map +1 -1
- package/dist/tokens/profiles.js +48 -36
- package/dist/tokens/runtime.d.ts +16 -0
- package/dist/tokens/runtime.d.ts.map +1 -0
- package/dist/tokens/runtime.js +18 -0
- package/dist/workers/buildsManager.js +1 -1
- package/dist/workflows/stepFailure.js +1 -1
- package/dist/workflows/workflowsClient.js +1 -1
- package/package.json +3 -3
- package/src/ai/aiManager.ts +44 -5
- package/src/analytics/analyticsManager.ts +311 -0
- package/src/analytics/datasets.ts +30 -0
- package/src/analytics/window.ts +101 -0
- package/src/artifacts/artifactsManager.ts +199 -0
- package/src/client/clients.ts +28 -1
- package/src/client/manager.ts +24 -6
- package/src/d1/d1Manager.ts +7 -3
- package/src/d1/d1PreparedStatement.ts +21 -2
- package/src/media/imageManager.ts +93 -1
- package/src/media/streamManager.ts +34 -0
- package/src/oauth/callback.ts +70 -0
- package/src/oauth/catalog.ts +39 -0
- package/src/oauth/client.ts +265 -0
- package/src/oauth/scopes.ts +91 -0
- package/src/queue/queueProvisioner.ts +79 -0
- package/src/r2/r2Manager.ts +29 -2
- package/src/secrets/secretsStoreManager.ts +2 -1
- package/src/test-utils/settle.ts +139 -0
- package/src/tokens/accountTokensManager.ts +12 -0
- package/src/tokens/permissions.ts +81 -0
- package/src/tokens/profiles.ts +58 -70
- package/src/tokens/runtime.ts +18 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ _Everything on the adopter side is on the site. `pithy.sh/docs` is canonical —
|
|
|
16
16
|
|
|
17
17
|
## Live integration tests
|
|
18
18
|
|
|
19
|
-
Mocks prove our call *shapes*. They cannot prove the request, the response decoding, and the error handling are functionally correct against real Cloudflare — that only surfaces live. So every manager that makes real CF calls also has a `*.integration.test.ts` that creates a throwaway resource, exercises the manager against it, and tears it down. These are excluded from the default suite and run via `
|
|
19
|
+
Mocks prove our call *shapes*. They cannot prove the request, the response decoding, and the error handling are functionally correct against real Cloudflare — that only surfaces live. So every manager that makes real CF calls also has a `*.integration.test.ts` that creates a throwaway resource, exercises the manager against it, and tears it down. These are excluded from the default suite and run via turbo's `test:integration` (the `vitest.integration.config.ts` project), gated on credentials so they skip cleanly without them. Turbo builds `@pithy-sh/core` first, because the suites import it through its `exports` map into `dist/`; the package's own `bun run test:integration` skips that build, so it fails on a fresh checkout until something has built core.
|
|
20
20
|
|
|
21
21
|
Credentials come from `packages/cloudflare/.dev.vars`, with `process.env` overlaid per key for anything the file does not set (`loadCloudflareEnv`). **Nothing creates that file.** It used to be a symlink to the root's, wired by a `vars:local` task; #154 removed both, and `pithy dev` and `pithy seed` generate `apps/<worker>/.dev.vars` in an adopter's project — `apps/` is the registry, so nothing regenerates a file in a kit package. Write it, or export the variables:
|
|
22
22
|
|
|
@@ -25,10 +25,10 @@ cat > .dev.vars <<'EOF' # a real file, in this package, git-ignored
|
|
|
25
25
|
CLOUDFLARE_ACCOUNT_ID=…
|
|
26
26
|
CLOUDFLARE_API_TOKEN=…
|
|
27
27
|
EOF
|
|
28
|
-
|
|
28
|
+
bunx turbo run test:integration --filter=@pithy-sh/cloudflare # builds core first, then runs against those creds
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
Exporting them instead works identically and is how CI runs — the workflow sets `CLOUDFLARE_*` and `SECRETS_STORE_ID` from the `E2E Integration Testing` environment with no `.dev.vars` present. Set a key in both places and the file wins.
|
|
31
|
+
Exporting them instead works identically and is how CI runs — the workflow sets `CLOUDFLARE_*` and `SECRETS_STORE_ID` from the `E2E Integration Testing` environment with no `.dev.vars` present, and runs the suites through turbo, which builds first and hands a task only the variables `test:integration` lists in `passThroughEnv` (`turbo.jsonc`). Set a key in both places and the file wins.
|
|
32
32
|
|
|
33
33
|
Point them at a **dedicated test account** — they create and delete real resources.
|
|
34
34
|
|
package/dist/ai/aiManager.d.ts
CHANGED
|
@@ -64,6 +64,17 @@ export interface AnalyzeImageOptions {
|
|
|
64
64
|
/** Upper bound on generated tokens. */
|
|
65
65
|
maxTokens?: number;
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The REST path that runs `model`: `/accounts/<id>/ai/run/@cf/baai/bge-m3`, the model's slashes literal.
|
|
69
|
+
*
|
|
70
|
+
* **Not the SDK's `ai.run` (#709).** cloudflare 7.0.0 builds that path with its `path` tag, which
|
|
71
|
+
* percent-encodes every path parameter, the `/`s inside a model name included. Cloudflare routes on the
|
|
72
|
+
* literal slashes, so `/ai/run/@cf%2Fbaai%2Fbge-m3` answers 7000 "No route for that URI" for every model.
|
|
73
|
+
* The path is built here instead, and a name that could change its shape is refused by {@link AI_MODEL_NAME}.
|
|
74
|
+
*
|
|
75
|
+
* @throws ValidationError (`validation/invalid_input`) when `model` is not a Workers AI model name.
|
|
76
|
+
*/
|
|
77
|
+
export declare function aiRunPath(accountId: string, model: string): string;
|
|
67
78
|
/**
|
|
68
79
|
* Out-of-Worker Workers AI access over the REST API: run any model, generate text, generate
|
|
69
80
|
* embeddings, analyze an image, and list models from a CLI/CI/provisioning context. Inside a
|
|
@@ -74,7 +85,10 @@ export interface AnalyzeImageOptions {
|
|
|
74
85
|
export declare class CloudflareAIManager extends CloudflareManager {
|
|
75
86
|
/**
|
|
76
87
|
* Run a Cloudflare AI model by name with an arbitrary JSON input. The single seam every typed
|
|
77
|
-
* helper builds on. The account
|
|
88
|
+
* helper builds on. The account is addressed for you; `input` is the model's body.
|
|
89
|
+
*
|
|
90
|
+
* Through the SDK's raw `post` to {@link aiRunPath}, not `ai.run`, whose path encoding Cloudflare cannot
|
|
91
|
+
* route. A malformed model name throws `validation/invalid_input` before any request is sent.
|
|
78
92
|
*/
|
|
79
93
|
runModel(model: string, input: Record<string, unknown>): Promise<AIRunResponse>;
|
|
80
94
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"aiManager.d.ts","sourceRoot":"","sources":["../../src/ai/aiManager.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"aiManager.d.ts","sourceRoot":"","sources":["../../src/ai/aiManager.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uCAAuC,CAAC;AAE/E,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAiBtD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB;;;iBAKgE,CAAC;AAC9F,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAuChE,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;GAckE,CAAC;AAC9F,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,cAAc,CAAC,CAAC;AAE7D;;GAEG;AACH,eAAO,MAAM,WAAW;;iBAI8D,CAAC;AACvF,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,kCAAkC;AAClC,MAAM,WAAW,mBAAmB;IAClC,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,+DAA+D;AAC/D,MAAM,WAAW,WAAW;IAC1B,+BAA+B;IAC/B,IAAI,EAAE,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;IACtC,2BAA2B;IAC3B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,kCAAkC;AAClC,MAAM,WAAW,mBAAmB;IAClC,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sFAAsF;IACtF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD;;;;;;;;;GASG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CASlE;AAED;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,iBAAiB;IACxD;;;;;;OAMG;IACG,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAMpF;IAED;;;OAGG;IACG,YAAY,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,cAAc,CAAC,CAclG;IAED;;;OAGG;IACG,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAU5F;IAED;;;OAGG;IACG,YAAY,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,WAAW,CAAC,CAezF;IAED,kGAAkG;IAC5F,UAAU,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAK/C;IAED,cAAc,IAAI,MAAM,CAEvB;IAED,oDAAoD;IAC9C,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;CACF"}
|
package/dist/ai/aiManager.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
3
|
import { CloudflareInvalidResponseError, cloudflareRequest } from "../client/errors.js";
|
|
4
4
|
import { CloudflareManager } from "../client/manager.js";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
6
|
import { fromUint8Array } from "js-base64";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
//#region src/ai/aiManager.ts
|
|
@@ -62,6 +63,35 @@ const TextGeneration = z.union([FlatTextGeneration, ChatCompletionText]).describ
|
|
|
62
63
|
const ImageToText = z.object({ description: z.string().describe("The model's textual description of the image.") }).describe("A Cloudflare image-to-text result: a description of the supplied image.");
|
|
63
64
|
const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable for alt text.";
|
|
64
65
|
/**
|
|
66
|
+
* What a Workers AI model name may look like: `@<namespace>/` and then one to three segments, each made of
|
|
67
|
+
* letters, digits, `.`, `_` and `-` and starting with a letter or digit — `@cf/baai/bge-m3`,
|
|
68
|
+
* `@cf/llava-hf/llava-1.5-7b-hf`, `@hf/thebloke/deepseek-coder-6.7b-instruct-awq`.
|
|
69
|
+
*
|
|
70
|
+
* **The name goes into the request path unencoded, so this pattern is the whole of the defense.** A segment
|
|
71
|
+
* cannot be `.` or `..` (it must start with a letter or digit), and nothing that ends a path, opens a query
|
|
72
|
+
* or a fragment, or escapes a character (`?`, `#`, `%`, `\`, whitespace) is in the alphabet. A name outside
|
|
73
|
+
* it is refused before a request exists.
|
|
74
|
+
*/
|
|
75
|
+
const AI_MODEL_NAME = /^@[a-z0-9][a-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*){1,3}$/;
|
|
76
|
+
/**
|
|
77
|
+
* The REST path that runs `model`: `/accounts/<id>/ai/run/@cf/baai/bge-m3`, the model's slashes literal.
|
|
78
|
+
*
|
|
79
|
+
* **Not the SDK's `ai.run` (#709).** cloudflare 7.0.0 builds that path with its `path` tag, which
|
|
80
|
+
* percent-encodes every path parameter, the `/`s inside a model name included. Cloudflare routes on the
|
|
81
|
+
* literal slashes, so `/ai/run/@cf%2Fbaai%2Fbge-m3` answers 7000 "No route for that URI" for every model.
|
|
82
|
+
* The path is built here instead, and a name that could change its shape is refused by {@link AI_MODEL_NAME}.
|
|
83
|
+
*
|
|
84
|
+
* @throws ValidationError (`validation/invalid_input`) when `model` is not a Workers AI model name.
|
|
85
|
+
*/
|
|
86
|
+
function aiRunPath(accountId, model) {
|
|
87
|
+
if (!AI_MODEL_NAME.test(model)) throw new ValidationError({
|
|
88
|
+
message: `"${model.slice(0, 120)}" is not a Workers AI model name.`,
|
|
89
|
+
action: "Pass a model name such as @cf/baai/bge-m3. The Workers AI model catalog lists them.",
|
|
90
|
+
detail: `model name ${JSON.stringify(model)} does not match ${AI_MODEL_NAME}`
|
|
91
|
+
});
|
|
92
|
+
return `/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
65
95
|
* Out-of-Worker Workers AI access over the REST API: run any model, generate text, generate
|
|
66
96
|
* embeddings, analyze an image, and list models from a CLI/CI/provisioning context. Inside a
|
|
67
97
|
* Worker you use the `Ai` binding directly — this manager is the REST counterpart, addressed by
|
|
@@ -71,13 +101,16 @@ const DEFAULT_IMAGE_PROMPT = "Describe this image in a concise sentence suitable
|
|
|
71
101
|
var CloudflareAIManager = class extends CloudflareManager {
|
|
72
102
|
/**
|
|
73
103
|
* Run a Cloudflare AI model by name with an arbitrary JSON input. The single seam every typed
|
|
74
|
-
* helper builds on. The account
|
|
104
|
+
* helper builds on. The account is addressed for you; `input` is the model's body.
|
|
105
|
+
*
|
|
106
|
+
* Through the SDK's raw `post` to {@link aiRunPath}, not `ai.run`, whose path encoding Cloudflare cannot
|
|
107
|
+
* route. A malformed model name throws `validation/invalid_input` before any request is sent.
|
|
75
108
|
*/
|
|
76
109
|
async runModel(model, input) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
})
|
|
110
|
+
const path = aiRunPath(this.accountId, model);
|
|
111
|
+
return cloudflareRequest(`AI run model '${model}'`, async () => {
|
|
112
|
+
return (await this.getClient().post(path, { body: input })).result;
|
|
113
|
+
});
|
|
81
114
|
}
|
|
82
115
|
/**
|
|
83
116
|
* Generate text from a chat prompt. Returns the validated `{ response }` payload. A response that
|
|
@@ -147,4 +180,4 @@ var CloudflareAIManager = class extends CloudflareManager {
|
|
|
147
180
|
}
|
|
148
181
|
};
|
|
149
182
|
//#endregion
|
|
150
|
-
export { CloudflareAIManager, ImageToText, TextGeneration, VectorEmbeddings };
|
|
183
|
+
export { CloudflareAIManager, ImageToText, TextGeneration, VectorEmbeddings, aiRunPath };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CloudflareManager } from "../client/manager";
|
|
5
|
+
import { type AnalyticsDataset } from "./datasets";
|
|
6
|
+
/**
|
|
7
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
|
|
8
|
+
*
|
|
9
|
+
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
10
|
+
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
11
|
+
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
12
|
+
* refusal in Cloudflare's own words.
|
|
13
|
+
*
|
|
14
|
+
* The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
|
|
15
|
+
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
16
|
+
* the caller decides which keys those are.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The most groups one Turnstile read asks for. A group is one sitekey and one event type, and Cloudflare
|
|
20
|
+
* documents ten event types, so a project's one or two widgets need a few dozen. An answer that fills the
|
|
21
|
+
* limit is refused rather than read short.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TURNSTILE_EVENT_GROUP_LIMIT = 1000;
|
|
24
|
+
/**
|
|
25
|
+
* How many keys one query filters on. The answer is one group per key, so this is also the `limit`, and
|
|
26
|
+
* it sits far under the 10,000 `maxPageSize` the datasets advertise — a large library is several bounded
|
|
27
|
+
* queries, never one unbounded filter.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ANALYTICS_KEY_CHUNK = 500;
|
|
30
|
+
/** One dataset's limits, as `viewer.accounts.settings` reports them. Every duration is in seconds. */
|
|
31
|
+
export declare const DatasetLimits: z.ZodObject<{
|
|
32
|
+
enabled: z.ZodBoolean;
|
|
33
|
+
maxDuration: z.ZodNumber;
|
|
34
|
+
notOlderThan: z.ZodNumber;
|
|
35
|
+
maxPageSize: z.ZodNumber;
|
|
36
|
+
}, z.core.$strip>;
|
|
37
|
+
export type DatasetLimits = z.infer<typeof DatasetLimits>;
|
|
38
|
+
/** A window and the keys to read it for. */
|
|
39
|
+
export interface WindowQuery {
|
|
40
|
+
/** The window's start, inclusive. */
|
|
41
|
+
since: Date;
|
|
42
|
+
/** The window's end, exclusive. */
|
|
43
|
+
until: Date;
|
|
44
|
+
}
|
|
45
|
+
/** Out-of-band reads of the account's delivery analytics. */
|
|
46
|
+
export declare class CloudflareAnalyticsManager extends CloudflareManager {
|
|
47
|
+
getServiceType(): string;
|
|
48
|
+
/** Whether the token can read the account's analytics settings. Never throws. */
|
|
49
|
+
validateServiceAccess(): Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Run one query and hand back its `data`, refusing in Cloudflare's own words when `errors` is non-empty.
|
|
52
|
+
* The status is a 200 either way, so the envelope is the only place a refusal can be read.
|
|
53
|
+
*/
|
|
54
|
+
private query;
|
|
55
|
+
/** The one account in an answer, refused when the token's viewer cannot see it. */
|
|
56
|
+
private onlyAccount;
|
|
57
|
+
/**
|
|
58
|
+
* Each dataset's limits for this account — whether it is served at all, its retention, and the widest
|
|
59
|
+
* window one query may ask for. Read, never assumed: the numbers differ per dataset and per plan.
|
|
60
|
+
*/
|
|
61
|
+
datasetLimits(datasets: readonly AnalyticsDataset[]): Promise<Partial<Record<AnalyticsDataset, DatasetLimits>>>;
|
|
62
|
+
/** Minutes of video delivered per Stream uid inside the window, for the uids given. Absent means none. */
|
|
63
|
+
streamMinutesViewed(window: WindowQuery & {
|
|
64
|
+
uids: readonly string[];
|
|
65
|
+
}): Promise<Map<string, number>>;
|
|
66
|
+
/** Delivery requests per hosted image id inside the window, for the ids given. Absent means none. */
|
|
67
|
+
imageRequests(window: WindowQuery & {
|
|
68
|
+
imageIds: readonly string[];
|
|
69
|
+
}): Promise<Map<string, number>>;
|
|
70
|
+
/**
|
|
71
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
|
|
72
|
+
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
73
|
+
*/
|
|
74
|
+
turnstileEvents(window: WindowQuery & {
|
|
75
|
+
siteKeys: readonly string[];
|
|
76
|
+
}): Promise<Map<string, Map<string, number>>>;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=analyticsManager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analyticsManager.d.ts","sourceRoot":"","sources":["../../src/analytics/analyticsManager.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,EAAE,KAAK,gBAAgB,EAAoD,MAAM,YAAY,CAAC;AAErG;;;;;;;;;;;GAWG;AAEH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC,sGAAsG;AACtG,eAAO,MAAM,aAAa;;;;;iBAOiF,CAAC;AAC5G,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAgE1D,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,qCAAqC;IACrC,KAAK,EAAE,IAAI,CAAC;IACZ,mCAAmC;IACnC,KAAK,EAAE,IAAI,CAAC;CACb;AASD,6DAA6D;AAC7D,qBAAa,0BAA2B,SAAQ,iBAAiB;IAC/D,cAAc,IAAI,MAAM,CAEvB;IAED,iFAAiF;IAC3E,qBAAqB,IAAI,OAAO,CAAC,OAAO,CAAC,CAO9C;IAED;;;OAGG;YACW,KAAK;IAsBnB,mFAAmF;IACnF,OAAO,CAAC,WAAW;IAWnB;;;OAGG;IACG,aAAa,CACjB,QAAQ,EAAE,SAAS,gBAAgB,EAAE,GACpC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,gBAAgB,EAAE,aAAa,CAAC,CAAC,CAAC,CA2B3D;IAED,0GAA0G;IACpG,mBAAmB,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAuBzG;IAED,qGAAqG;IAC/F,aAAa,CAAC,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CA2BvG;IAED;;;OAGG;IACG,eAAe,CACnB,MAAM,EAAE,WAAW,GAAG;QAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;KAAE,GACpD,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAmC3C;CACF"}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { CloudflareInvalidResponseError, cloudflareRefusal, cloudflareRequest, decodeResponse } from "../client/errors.js";
|
|
4
|
+
import { CloudflareManager } from "../client/manager.js";
|
|
5
|
+
import { IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS } from "./datasets.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/analytics/analyticsManager.ts
|
|
8
|
+
/**
|
|
9
|
+
* **Cloudflare's GraphQL Analytics API, for the account-level datasets the kit reads (#728).**
|
|
10
|
+
*
|
|
11
|
+
* One endpoint, `POST /graphql`, reached through the SDK's raw `post` like every other call the typed SDK
|
|
12
|
+
* does not cover — never a hand-rolled `fetch` (CLAUDE.md §Cloudflare access). It answers HTTP 200 with
|
|
13
|
+
* `{ data, errors }` even when it refuses, so `errors` is read on every answer and a non-empty one is a
|
|
14
|
+
* refusal in Cloudflare's own words.
|
|
15
|
+
*
|
|
16
|
+
* The token it runs on is the application's one `runtime` token, carrying "Account Analytics Read", whose grain is
|
|
17
|
+
* the whole account. This manager narrows nothing on its own: every query takes the keys it is about, and
|
|
18
|
+
* the caller decides which keys those are.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The most groups one Turnstile read asks for. A group is one sitekey and one event type, and Cloudflare
|
|
22
|
+
* documents ten event types, so a project's one or two widgets need a few dozen. An answer that fills the
|
|
23
|
+
* limit is refused rather than read short.
|
|
24
|
+
*/
|
|
25
|
+
const TURNSTILE_EVENT_GROUP_LIMIT = 1e3;
|
|
26
|
+
/**
|
|
27
|
+
* How many keys one query filters on. The answer is one group per key, so this is also the `limit`, and
|
|
28
|
+
* it sits far under the 10,000 `maxPageSize` the datasets advertise — a large library is several bounded
|
|
29
|
+
* queries, never one unbounded filter.
|
|
30
|
+
*/
|
|
31
|
+
const ANALYTICS_KEY_CHUNK = 500;
|
|
32
|
+
/** One dataset's limits, as `viewer.accounts.settings` reports them. Every duration is in seconds. */
|
|
33
|
+
const DatasetLimits = z.object({
|
|
34
|
+
enabled: z.boolean().describe("Whether Cloudflare serves this dataset to this account at all."),
|
|
35
|
+
maxDuration: z.number().describe("The widest window one query may ask for, in seconds."),
|
|
36
|
+
notOlderThan: z.number().describe("How far back the dataset reaches, in seconds — its retention."),
|
|
37
|
+
maxPageSize: z.number().describe("The largest `limit` one query may pass.")
|
|
38
|
+
}).describe("One analytics dataset's limits for this account, as Cloudflare's settings node reports them.");
|
|
39
|
+
/** The GraphQL error code for a spent rate budget — the one refusal a caller may retry. */
|
|
40
|
+
const RATE_BUDGET_CODE = "budget";
|
|
41
|
+
/** One entry of a GraphQL `errors` array. */
|
|
42
|
+
const GraphQLError = z.object({
|
|
43
|
+
message: z.string().optional().describe("Cloudflare's own sentence for the failure."),
|
|
44
|
+
extensions: z.object({ code: z.string().optional().describe("Cloudflare's machine code for the failure.") }).partial().optional().describe("Cloudflare's structured extras.")
|
|
45
|
+
}).describe("One error the GraphQL endpoint answered.");
|
|
46
|
+
/** The envelope every GraphQL answer arrives in. `data` is decoded per query. */
|
|
47
|
+
const GraphQLAnswer = z.object({
|
|
48
|
+
data: z.unknown().optional().describe("The query's result, when it ran."),
|
|
49
|
+
errors: z.array(GraphQLError).nullish().describe("Why the query did not run, when it did not.")
|
|
50
|
+
}).describe("The GraphQL endpoint's answer envelope.");
|
|
51
|
+
/** The one account a query is about, from `viewer.accounts(filter: { accountTag })`. */
|
|
52
|
+
function accountOf(account) {
|
|
53
|
+
return z.object({ viewer: z.object({ accounts: z.array(account).describe("The accounts the filter matched.") }).describe("The authenticated viewer.") }).describe("A GraphQL answer about one account.");
|
|
54
|
+
}
|
|
55
|
+
const StreamMinutesGroup = z.object({
|
|
56
|
+
sum: z.object({ minutesViewed: z.number().describe("Minutes of video delivered.") }).describe("The summed metrics."),
|
|
57
|
+
dimensions: z.object({ uid: z.string().describe("The Stream video uid.") }).describe("The group's key.")
|
|
58
|
+
}).describe("Minutes delivered for one video.");
|
|
59
|
+
const ImageRequestsGroup = z.object({
|
|
60
|
+
count: z.number().describe("Delivery requests for the image."),
|
|
61
|
+
dimensions: z.object({ imageId: z.string().describe("The Cloudflare Images id.") }).describe("The group's key.")
|
|
62
|
+
}).describe("Delivery requests for one hosted image.");
|
|
63
|
+
const TurnstileEventGroup = z.object({
|
|
64
|
+
count: z.number().describe("Events of this type for this sitekey."),
|
|
65
|
+
dimensions: z.object({
|
|
66
|
+
siteKey: z.string().describe("The widget's public sitekey."),
|
|
67
|
+
eventType: z.string().describe("The Turnstile event type, as Cloudflare names it.")
|
|
68
|
+
}).describe("The group's key.")
|
|
69
|
+
}).describe("Events of one type for one Turnstile widget.");
|
|
70
|
+
/** Split keys into bounded chunks. */
|
|
71
|
+
function chunks(items, size) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Out-of-band reads of the account's delivery analytics. */
|
|
77
|
+
var CloudflareAnalyticsManager = class extends CloudflareManager {
|
|
78
|
+
getServiceType() {
|
|
79
|
+
return "Analytics";
|
|
80
|
+
}
|
|
81
|
+
/** Whether the token can read the account's analytics settings. Never throws. */
|
|
82
|
+
async validateServiceAccess() {
|
|
83
|
+
try {
|
|
84
|
+
await this.datasetLimits([STREAM_MINUTES]);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Run one query and hand back its `data`, refusing in Cloudflare's own words when `errors` is non-empty.
|
|
92
|
+
* The status is a 200 either way, so the envelope is the only place a refusal can be read.
|
|
93
|
+
*/
|
|
94
|
+
async query(operation, query, variables) {
|
|
95
|
+
const raw = await cloudflareRequest(operation, () => this.getClient().post("/graphql", { body: {
|
|
96
|
+
query,
|
|
97
|
+
variables
|
|
98
|
+
} }), { permission: "Account Analytics Read" });
|
|
99
|
+
const answer = decodeResponse(GraphQLAnswer, raw, operation);
|
|
100
|
+
const errors = answer.errors ?? [];
|
|
101
|
+
if (errors.length > 0) throw cloudflareRefusal({
|
|
102
|
+
problem: `Cloudflare request failed: ${operation}.`,
|
|
103
|
+
apiErrors: errors.map((error) => ({ ...error.message === void 0 ? {} : { message: error.message } })),
|
|
104
|
+
permission: "Account Analytics Read",
|
|
105
|
+
...errors.some((error) => error.extensions?.code === RATE_BUDGET_CODE) ? { status: 429 } : {},
|
|
106
|
+
detail: JSON.stringify(errors)
|
|
107
|
+
});
|
|
108
|
+
return answer.data;
|
|
109
|
+
}
|
|
110
|
+
/** The one account in an answer, refused when the token's viewer cannot see it. */
|
|
111
|
+
onlyAccount(schema, data, operation) {
|
|
112
|
+
const [account] = decodeResponse(accountOf(schema), data, operation).viewer.accounts;
|
|
113
|
+
if (account === void 0) throw new CloudflareInvalidResponseError({
|
|
114
|
+
message: `Cloudflare answered no account for ${operation}. The token cannot see this account.`,
|
|
115
|
+
detail: `viewer.accounts was empty for account ${this.accountId}`
|
|
116
|
+
});
|
|
117
|
+
return account;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Each dataset's limits for this account — whether it is served at all, its retention, and the widest
|
|
121
|
+
* window one query may ask for. Read, never assumed: the numbers differ per dataset and per plan.
|
|
122
|
+
*/
|
|
123
|
+
async datasetLimits(datasets) {
|
|
124
|
+
const fields = datasets.map((dataset) => `${dataset} { enabled maxDuration notOlderThan maxPageSize }`).join(" ");
|
|
125
|
+
const operation = "read analytics dataset limits";
|
|
126
|
+
const data = await this.query(operation, `query ($account: string!) { viewer { accounts(filter: { accountTag: $account }) { settings { ${fields} } } } }`, { account: this.accountId });
|
|
127
|
+
const shape = z.object({ settings: z.object(Object.fromEntries(datasets.map((dataset) => [dataset, DatasetLimits.nullish()]))).describe("The datasets asked about. A dataset this account is not served is null or absent.") }).describe("The account's analytics settings.");
|
|
128
|
+
const settings = this.onlyAccount(shape, data, operation).settings;
|
|
129
|
+
const limits = {};
|
|
130
|
+
for (const dataset of datasets) {
|
|
131
|
+
const entry = settings[dataset];
|
|
132
|
+
if (entry !== null && entry !== void 0) limits[dataset] = entry;
|
|
133
|
+
}
|
|
134
|
+
return limits;
|
|
135
|
+
}
|
|
136
|
+
/** Minutes of video delivered per Stream uid inside the window, for the uids given. Absent means none. */
|
|
137
|
+
async streamMinutesViewed(window) {
|
|
138
|
+
const totals = /* @__PURE__ */ new Map();
|
|
139
|
+
const operation = "read Stream minutes viewed";
|
|
140
|
+
for (const uids of chunks(window.uids, 500)) {
|
|
141
|
+
const data = await this.query(operation, `query ($account: string!, $filter: AccountStreamMinutesViewedAdaptiveGroupsFilter_InputObject, $limit: uint64!) { viewer { accounts(filter: { accountTag: $account }) { ${STREAM_MINUTES}(filter: $filter, limit: $limit) { sum { minutesViewed } dimensions { uid } } } } }`, {
|
|
142
|
+
account: this.accountId,
|
|
143
|
+
filter: {
|
|
144
|
+
datetime_geq: window.since.toISOString(),
|
|
145
|
+
datetime_lt: window.until.toISOString(),
|
|
146
|
+
uid_in: uids
|
|
147
|
+
},
|
|
148
|
+
limit: uids.length
|
|
149
|
+
});
|
|
150
|
+
const groups = this.onlyAccount(z.object({ [STREAM_MINUTES]: z.array(StreamMinutesGroup).describe("Minutes per video.") }), data, operation)[STREAM_MINUTES];
|
|
151
|
+
for (const group of groups) totals.set(group.dimensions.uid, (totals.get(group.dimensions.uid) ?? 0) + group.sum.minutesViewed);
|
|
152
|
+
}
|
|
153
|
+
return totals;
|
|
154
|
+
}
|
|
155
|
+
/** Delivery requests per hosted image id inside the window, for the ids given. Absent means none. */
|
|
156
|
+
async imageRequests(window) {
|
|
157
|
+
const totals = /* @__PURE__ */ new Map();
|
|
158
|
+
const operation = "read Images delivery requests";
|
|
159
|
+
for (const imageIds of chunks(window.imageIds, 500)) {
|
|
160
|
+
const data = await this.query(operation, `query ($account: string!, $filter: AccountImagesHostedRequestsAdaptiveGroupsFilter_InputObject, $limit: uint64!) { viewer { accounts(filter: { accountTag: $account }) { ${IMAGE_REQUESTS}(filter: $filter, limit: $limit) { count dimensions { imageId } } } } }`, {
|
|
161
|
+
account: this.accountId,
|
|
162
|
+
filter: {
|
|
163
|
+
datetime_geq: window.since.toISOString(),
|
|
164
|
+
datetime_lt: window.until.toISOString(),
|
|
165
|
+
imageId_in: imageIds
|
|
166
|
+
},
|
|
167
|
+
limit: imageIds.length
|
|
168
|
+
});
|
|
169
|
+
const groups = this.onlyAccount(z.object({ [IMAGE_REQUESTS]: z.array(ImageRequestsGroup).describe("Requests per image.") }), data, operation)[IMAGE_REQUESTS];
|
|
170
|
+
for (const group of groups) totals.set(group.dimensions.imageId, (totals.get(group.dimensions.imageId) ?? 0) + group.count);
|
|
171
|
+
}
|
|
172
|
+
return totals;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Turnstile events per sitekey and event type inside the window, for the sitekeys given (#731). The sitekey
|
|
176
|
+
* filter is what scopes the account-wide dataset to the widgets the caller is about. Absent means none.
|
|
177
|
+
*/
|
|
178
|
+
async turnstileEvents(window) {
|
|
179
|
+
const totals = /* @__PURE__ */ new Map();
|
|
180
|
+
if (window.siteKeys.length === 0) return totals;
|
|
181
|
+
const operation = "read Turnstile events";
|
|
182
|
+
const data = await this.query(operation, `query ($account: string!, $filter: AccountTurnstileAdaptiveGroupsFilter_InputObject!, $limit: uint64!) { viewer { accounts(filter: { accountTag: $account }) { ${TURNSTILE_EVENTS}(filter: $filter, limit: $limit) { count dimensions { siteKey eventType } } } } }`, {
|
|
183
|
+
account: this.accountId,
|
|
184
|
+
filter: {
|
|
185
|
+
datetime_geq: window.since.toISOString(),
|
|
186
|
+
datetime_lt: window.until.toISOString(),
|
|
187
|
+
siteKey_in: [...window.siteKeys]
|
|
188
|
+
},
|
|
189
|
+
limit: TURNSTILE_EVENT_GROUP_LIMIT
|
|
190
|
+
});
|
|
191
|
+
const groups = this.onlyAccount(z.object({ [TURNSTILE_EVENTS]: z.array(TurnstileEventGroup).describe("Events per sitekey and type.") }), data, operation)[TURNSTILE_EVENTS];
|
|
192
|
+
if (groups.length >= 1e3) throw new CloudflareInvalidResponseError({
|
|
193
|
+
message: `Cloudflare answered ${groups.length} Turnstile event groups, the most one read asks for. The counts past them are missing.`,
|
|
194
|
+
detail: `${TURNSTILE_EVENTS} filled limit ${TURNSTILE_EVENT_GROUP_LIMIT} for ${window.siteKeys.length} sitekeys`
|
|
195
|
+
});
|
|
196
|
+
for (const group of groups) {
|
|
197
|
+
const { siteKey, eventType } = group.dimensions;
|
|
198
|
+
const events = totals.get(siteKey) ?? /* @__PURE__ */ new Map();
|
|
199
|
+
events.set(eventType, (events.get(eventType) ?? 0) + group.count);
|
|
200
|
+
totals.set(siteKey, events);
|
|
201
|
+
}
|
|
202
|
+
return totals;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
206
|
+
export { ANALYTICS_KEY_CHUNK, CloudflareAnalyticsManager, DatasetLimits, TURNSTILE_EVENT_GROUP_LIMIT };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* **The analytics datasets the kit reads, by their GraphQL names**, in a module of their own. A Worker
|
|
5
|
+
* names a dataset to read its limits and to word a refusal, and importing the manager to learn a string
|
|
6
|
+
* would put the Cloudflare SDK on every module that does (#731). Nothing here imports anything.
|
|
7
|
+
*/
|
|
8
|
+
/** Minutes of Stream video delivered, groupable by video `uid`. Account-level, US-only under a metadata boundary. */
|
|
9
|
+
export declare const STREAM_MINUTES = "streamMinutesViewedAdaptiveGroups";
|
|
10
|
+
/**
|
|
11
|
+
* Hosted Images delivery requests, groupable by `imageId` — the id Cloudflare Images assigns at upload.
|
|
12
|
+
*
|
|
13
|
+
* Not `imagesRequestsAdaptiveGroups`: that dataset groups by time and nothing else, so it can only ever
|
|
14
|
+
* answer an account total, which cannot be scoped to an environment. This one carries the image id,
|
|
15
|
+
* found by schema introspection on 2026-10-17.
|
|
16
|
+
*/
|
|
17
|
+
export declare const IMAGE_REQUESTS = "imagesHostedRequestsAdaptiveGroups";
|
|
18
|
+
/**
|
|
19
|
+
* Turnstile challenge events, groupable by `siteKey` and `eventType` (#731). Account-level, and served under
|
|
20
|
+
* a US and an EU metadata boundary alike, so a boundary does not rule it out. Found by schema introspection
|
|
21
|
+
* on 2026-10-18; Cloudflare marks it Beta.
|
|
22
|
+
*/
|
|
23
|
+
export declare const TURNSTILE_EVENTS = "turnstileAdaptiveGroups";
|
|
24
|
+
/** The datasets this manager queries. */
|
|
25
|
+
export type AnalyticsDataset = typeof STREAM_MINUTES | typeof IMAGE_REQUESTS | typeof TURNSTILE_EVENTS;
|
|
26
|
+
//# sourceMappingURL=datasets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"datasets.d.ts","sourceRoot":"","sources":["../../src/analytics/datasets.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AAEH,qHAAqH;AACrH,eAAO,MAAM,cAAc,sCAAsC,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,uCAAuC,CAAC;AAEnE;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,4BAA4B,CAAC;AAE1D,yCAAyC;AACzC,MAAM,MAAM,gBAAgB,GAAG,OAAO,cAAc,GAAG,OAAO,cAAc,GAAG,OAAO,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/analytics/datasets.ts
|
|
4
|
+
/**
|
|
5
|
+
* **The analytics datasets the kit reads, by their GraphQL names**, in a module of their own. A Worker
|
|
6
|
+
* names a dataset to read its limits and to word a refusal, and importing the manager to learn a string
|
|
7
|
+
* would put the Cloudflare SDK on every module that does (#731). Nothing here imports anything.
|
|
8
|
+
*/
|
|
9
|
+
/** Minutes of Stream video delivered, groupable by video `uid`. Account-level, US-only under a metadata boundary. */
|
|
10
|
+
const STREAM_MINUTES = "streamMinutesViewedAdaptiveGroups";
|
|
11
|
+
/**
|
|
12
|
+
* Hosted Images delivery requests, groupable by `imageId` — the id Cloudflare Images assigns at upload.
|
|
13
|
+
*
|
|
14
|
+
* Not `imagesRequestsAdaptiveGroups`: that dataset groups by time and nothing else, so it can only ever
|
|
15
|
+
* answer an account total, which cannot be scoped to an environment. This one carries the image id,
|
|
16
|
+
* found by schema introspection on 2026-10-17.
|
|
17
|
+
*/
|
|
18
|
+
const IMAGE_REQUESTS = "imagesHostedRequestsAdaptiveGroups";
|
|
19
|
+
/**
|
|
20
|
+
* Turnstile challenge events, groupable by `siteKey` and `eventType` (#731). Account-level, and served under
|
|
21
|
+
* a US and an EU metadata boundary alike, so a boundary does not rule it out. Found by schema introspection
|
|
22
|
+
* on 2026-10-18; Cloudflare marks it Beta.
|
|
23
|
+
*/
|
|
24
|
+
const TURNSTILE_EVENTS = "turnstileAdaptiveGroups";
|
|
25
|
+
//#endregion
|
|
26
|
+
export { IMAGE_REQUESTS, STREAM_MINUTES, TURNSTILE_EVENTS };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
4
|
+
import type { DatasetLimits } from "./analyticsManager";
|
|
5
|
+
/**
|
|
6
|
+
* **The window an analytics read covers, and the limits it is held to (#728, #731).**
|
|
7
|
+
*
|
|
8
|
+
* Written once for every capability that reads Cloudflare's GraphQL Analytics. The rules are the same for
|
|
9
|
+
* each: the last {@link DEFAULT_WINDOW_DAYS} days by default, a window that ends before it starts refused,
|
|
10
|
+
* and a window past the dataset's retention or wider than one read may ask for refused, naming the cap in
|
|
11
|
+
* days. The refusal is each caller's own code, so it is handed to a factory rather than thrown here.
|
|
12
|
+
*/
|
|
13
|
+
/** The default window, in days, ending now. Fits every dataset the kit reads. */
|
|
14
|
+
export declare const DEFAULT_WINDOW_DAYS = 30;
|
|
15
|
+
/** A resolved window: `since` inclusive, `until` exclusive. */
|
|
16
|
+
export interface AnalyticsWindow {
|
|
17
|
+
since: Date;
|
|
18
|
+
until: Date;
|
|
19
|
+
}
|
|
20
|
+
/** Why a window was refused, in the fields a caller's error class takes. */
|
|
21
|
+
export interface WindowRefusal {
|
|
22
|
+
/** The sentence the caller reads. */
|
|
23
|
+
message: string;
|
|
24
|
+
/** The cap and the dataset, for a client that translates. */
|
|
25
|
+
params?: MessageParams;
|
|
26
|
+
/** The numbers behind it, for the log. */
|
|
27
|
+
detail: string;
|
|
28
|
+
}
|
|
29
|
+
/** Builds the caller's own error for a refused window. */
|
|
30
|
+
export type RefuseWindow = (refusal: WindowRefusal) => Error;
|
|
31
|
+
/** One dataset as a read names it: the GraphQL node, and how an operator would say it. */
|
|
32
|
+
export interface MeasuredDataset {
|
|
33
|
+
dataset: string;
|
|
34
|
+
label: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The window a request asks for: the last {@link DEFAULT_WINDOW_DAYS} days by default, a `since` alone
|
|
38
|
+
* running to now, and an `until` alone reaching back the default span. A window that ends before it
|
|
39
|
+
* starts is refused.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveAnalyticsWindow(input: {
|
|
42
|
+
since?: Date;
|
|
43
|
+
until?: Date;
|
|
44
|
+
}, now: Date, refuse: RefuseWindow): AnalyticsWindow;
|
|
45
|
+
/**
|
|
46
|
+
* Hold a window to one dataset's limits, read from the account's own settings rather than assumed. The
|
|
47
|
+
* refusal names the cap in days, so a caller can pick a window that works.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkAnalyticsWindow(window: AnalyticsWindow, limits: DatasetLimits, measured: MeasuredDataset, now: Date, refuse: RefuseWindow): void;
|
|
50
|
+
//# sourceMappingURL=window.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"window.d.ts","sourceRoot":"","sources":["../../src/analytics/window.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAExD;;;;;;;GAOG;AAEH,iFAAiF;AACjF,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAItC,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,IAAI,CAAC;IACZ,KAAK,EAAE,IAAI,CAAC;CACb;AAED,4EAA4E;AAC5E,MAAM,WAAW,aAAa;IAC5B,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,0DAA0D;AAC1D,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,aAAa,KAAK,KAAK,CAAC;AAE7D,0FAA0F;AAC1F,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAOD;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,IAAI,CAAA;CAAE,EACrC,GAAG,EAAE,IAAI,EACT,MAAM,EAAE,YAAY,GACnB,eAAe,CAUjB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,eAAe,EACvB,MAAM,EAAE,aAAa,EACrB,QAAQ,EAAE,eAAe,EACzB,GAAG,EAAE,IAAI,EACT,MAAM,EAAE,YAAY,GACnB,IAAI,CAmBN"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//#region src/analytics/window.ts
|
|
4
|
+
/**
|
|
5
|
+
* **The window an analytics read covers, and the limits it is held to (#728, #731).**
|
|
6
|
+
*
|
|
7
|
+
* Written once for every capability that reads Cloudflare's GraphQL Analytics. The rules are the same for
|
|
8
|
+
* each: the last {@link DEFAULT_WINDOW_DAYS} days by default, a window that ends before it starts refused,
|
|
9
|
+
* and a window past the dataset's retention or wider than one read may ask for refused, naming the cap in
|
|
10
|
+
* days. The refusal is each caller's own code, so it is handed to a factory rather than thrown here.
|
|
11
|
+
*/
|
|
12
|
+
/** The default window, in days, ending now. Fits every dataset the kit reads. */
|
|
13
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
14
|
+
const DAY_MS = 864e5;
|
|
15
|
+
/** Whole days in a span of seconds, for a cap stated to a person. */
|
|
16
|
+
function days(seconds) {
|
|
17
|
+
return Math.floor(seconds / 86400);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The window a request asks for: the last {@link DEFAULT_WINDOW_DAYS} days by default, a `since` alone
|
|
21
|
+
* running to now, and an `until` alone reaching back the default span. A window that ends before it
|
|
22
|
+
* starts is refused.
|
|
23
|
+
*/
|
|
24
|
+
function resolveAnalyticsWindow(input, now, refuse) {
|
|
25
|
+
const until = input.until ?? now;
|
|
26
|
+
const since = input.since ?? /* @__PURE__ */ new Date(until.getTime() - 30 * DAY_MS);
|
|
27
|
+
if (since.getTime() >= until.getTime()) throw refuse({
|
|
28
|
+
message: "That window ends before it starts. Put since before until.",
|
|
29
|
+
detail: `since ${since.toISOString()} is not before until ${until.toISOString()}`
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
since,
|
|
33
|
+
until
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Hold a window to one dataset's limits, read from the account's own settings rather than assumed. The
|
|
38
|
+
* refusal names the cap in days, so a caller can pick a window that works.
|
|
39
|
+
*/
|
|
40
|
+
function checkAnalyticsWindow(window, limits, measured, now, refuse) {
|
|
41
|
+
const reach = (now.getTime() - window.since.getTime()) / 1e3;
|
|
42
|
+
if (reach > limits.notOlderThan) {
|
|
43
|
+
const maxDays = days(limits.notOlderThan);
|
|
44
|
+
throw refuse({
|
|
45
|
+
message: `That window starts ${days(reach)} days ago. Cloudflare keeps ${measured.label} for ${maxDays} days.`,
|
|
46
|
+
params: {
|
|
47
|
+
maxDays,
|
|
48
|
+
dataset: measured.dataset
|
|
49
|
+
},
|
|
50
|
+
detail: `${measured.dataset} notOlderThan ${limits.notOlderThan}s; window reaches ${Math.round(reach)}s`
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const span = (window.until.getTime() - window.since.getTime()) / 1e3;
|
|
54
|
+
if (span > limits.maxDuration) {
|
|
55
|
+
const maxDays = days(limits.maxDuration);
|
|
56
|
+
throw refuse({
|
|
57
|
+
message: `That window spans ${days(span)} days. Cloudflare answers at most ${maxDays} days of ${measured.label} at once.`,
|
|
58
|
+
params: {
|
|
59
|
+
maxDays,
|
|
60
|
+
dataset: measured.dataset
|
|
61
|
+
},
|
|
62
|
+
detail: `${measured.dataset} maxDuration ${limits.maxDuration}s; window spans ${Math.round(span)}s`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { DEFAULT_WINDOW_DAYS, checkAnalyticsWindow, resolveAnalyticsWindow };
|