@astrale-os/adapter-cloudflare 0.5.0-beta.176 → 0.5.0-beta.178

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.
Files changed (100) hide show
  1. package/README.md +119 -78
  2. package/dist/adapter.d.ts +6 -5
  3. package/dist/adapter.js +8 -8
  4. package/dist/build/index.d.ts +1 -0
  5. package/dist/build/index.js +1 -0
  6. package/dist/hosting/activation.d.ts +37 -0
  7. package/dist/hosting/activation.js +108 -0
  8. package/dist/hosting/assets.d.ts +14 -0
  9. package/dist/hosting/assets.js +53 -0
  10. package/dist/hosting/bounded.d.ts +10 -0
  11. package/dist/hosting/bounded.js +32 -0
  12. package/dist/hosting/bytes.d.ts +6 -0
  13. package/dist/hosting/bytes.js +25 -0
  14. package/dist/hosting/client.d.ts +144 -0
  15. package/dist/hosting/client.js +491 -0
  16. package/dist/hosting/dispatcher/cron.d.mts +10 -0
  17. package/dist/hosting/dispatcher/cron.mjs +135 -0
  18. package/dist/hosting/dispatcher/deployments.d.mts +12 -0
  19. package/dist/hosting/dispatcher/deployments.mjs +164 -0
  20. package/dist/hosting/dispatcher/dispatcher.d.mts +43 -0
  21. package/dist/hosting/dispatcher/dispatcher.mjs +96 -0
  22. package/dist/hosting/dispatcher/env.d.mts +51 -0
  23. package/dist/hosting/dispatcher/failure.d.mts +4 -0
  24. package/dist/hosting/dispatcher/failure.mjs +15 -0
  25. package/dist/hosting/dispatcher/index.d.mts +4 -0
  26. package/dist/hosting/dispatcher/index.mjs +10 -0
  27. package/dist/hosting/dispatcher/modules.d.ts +25 -0
  28. package/dist/hosting/dispatcher/modules.js +56 -0
  29. package/dist/hosting/dispatcher/services.d.mts +28 -0
  30. package/dist/hosting/dispatcher/services.mjs +231 -0
  31. package/dist/hosting/identity.d.ts +15 -0
  32. package/dist/hosting/identity.js +36 -0
  33. package/dist/hosting/index.d.ts +34 -0
  34. package/dist/hosting/index.js +32 -0
  35. package/dist/hosting/kv.d.ts +89 -0
  36. package/dist/hosting/kv.js +206 -0
  37. package/dist/hosting/listing.d.ts +34 -0
  38. package/dist/hosting/listing.js +50 -0
  39. package/dist/hosting/names.d.ts +16 -0
  40. package/dist/hosting/names.js +28 -0
  41. package/dist/hosting/provision/account.d.ts +11 -0
  42. package/dist/hosting/provision/account.js +86 -0
  43. package/dist/hosting/provision/dispatcher.d.ts +44 -0
  44. package/dist/hosting/provision/dispatcher.js +101 -0
  45. package/dist/hosting/provision/ingress.d.ts +12 -0
  46. package/dist/hosting/provision/ingress.js +31 -0
  47. package/dist/hosting/provision/plan.d.ts +143 -0
  48. package/dist/hosting/provision/plan.js +246 -0
  49. package/dist/hosting/provision/routing.d.ts +82 -0
  50. package/dist/hosting/provision/routing.js +226 -0
  51. package/dist/hosting/readiness.d.ts +53 -0
  52. package/dist/hosting/readiness.js +82 -0
  53. package/dist/hosting/refusal.d.ts +28 -0
  54. package/dist/hosting/refusal.js +13 -0
  55. package/dist/hosting/retirement.d.ts +69 -0
  56. package/dist/hosting/retirement.js +106 -0
  57. package/dist/hosting/retry-after.d.ts +6 -0
  58. package/dist/hosting/retry-after.js +55 -0
  59. package/dist/hosting/script.d.ts +186 -0
  60. package/dist/hosting/script.js +324 -0
  61. package/dist/hosting/testing/fake-cloudflare.d.ts +211 -0
  62. package/dist/hosting/testing/fake-cloudflare.js +756 -0
  63. package/dist/hosting/testing/index.d.ts +6 -0
  64. package/dist/hosting/testing/index.js +6 -0
  65. package/dist/hosting/time.d.ts +4 -0
  66. package/dist/hosting/time.js +20 -0
  67. package/dist/identity/material.d.ts +0 -1
  68. package/dist/identity/material.js +1 -10
  69. package/dist/legacy/direct/index.d.ts +5 -9
  70. package/dist/legacy/direct/index.js +5 -9
  71. package/dist/namespace/configure.d.ts +2 -6
  72. package/dist/namespace/configure.js +15 -12
  73. package/dist/namespace/coordinates.d.ts +3 -26
  74. package/dist/namespace/coordinates.js +13 -45
  75. package/dist/namespace/deploy-release.d.ts +23 -30
  76. package/dist/namespace/deploy-release.js +123 -153
  77. package/dist/namespace/hosting.d.ts +29 -0
  78. package/dist/namespace/hosting.js +120 -0
  79. package/dist/namespace/index.d.ts +7 -10
  80. package/dist/namespace/index.js +7 -10
  81. package/dist/namespace/list.d.ts +11 -26
  82. package/dist/namespace/list.js +49 -125
  83. package/dist/namespace/readiness.d.ts +10 -20
  84. package/dist/namespace/readiness.js +15 -65
  85. package/dist/namespace/record.d.ts +21 -0
  86. package/dist/namespace/record.js +53 -0
  87. package/dist/release/index.d.ts +0 -1
  88. package/dist/release/index.js +0 -1
  89. package/package.json +11 -7
  90. package/template/astrale.config.ts +10 -7
  91. package/dist/namespace/api.d.ts +0 -71
  92. package/dist/namespace/api.js +0 -265
  93. package/dist/namespace/calls.d.ts +0 -29
  94. package/dist/namespace/calls.js +0 -104
  95. package/dist/namespace/routing.d.ts +0 -55
  96. package/dist/namespace/routing.js +0 -165
  97. package/dist/namespace/script.d.ts +0 -106
  98. package/dist/namespace/script.js +0 -278
  99. package/dist/release/staged-worker-name.d.ts +0 -3
  100. package/dist/release/staged-worker-name.js +0 -10
@@ -0,0 +1,135 @@
1
+ // @ts-check
2
+
3
+ /*
4
+ * Dependency-free five-field UTC crons: what `cron:<name>` holds and what the dispatcher fires.
5
+ * Uploaded as the dispatcher module `cron.mjs`; the kit validates schedules with the same code.
6
+ */
7
+
8
+ const MONTHS = {
9
+ jan: 1,
10
+ feb: 2,
11
+ mar: 3,
12
+ apr: 4,
13
+ may: 5,
14
+ jun: 6,
15
+ jul: 7,
16
+ aug: 8,
17
+ sep: 9,
18
+ oct: 10,
19
+ nov: 11,
20
+ dec: 12,
21
+ }
22
+ const DAYS = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }
23
+
24
+ /** Admit one dependency-free five-field UTC cron expression. */
25
+ /** @param {unknown} expression */
26
+ export function isUtcCron(expression) {
27
+ if (typeof expression !== 'string') return false
28
+ const fields = expression.trim().split(/\s+/u)
29
+ return (
30
+ fields.length === 5 &&
31
+ fieldValid(fields[0], 0, 59) &&
32
+ fieldValid(fields[1], 0, 23) &&
33
+ fieldValid(fields[2], 1, 31) &&
34
+ fieldValid(fields[3], 1, 12, MONTHS) &&
35
+ fieldValid(fields[4], 0, 7, DAYS)
36
+ )
37
+ }
38
+
39
+ /** Admit one duplicate-free canonical UTC cron array after a representation boundary. */
40
+ /** @param {unknown} input */
41
+ export function canonicalUtcCrons(input) {
42
+ if (!Array.isArray(input) || input.some((value) => typeof value !== 'string')) return undefined
43
+ const canonical = input.map((value) => value.trim().toLowerCase())
44
+ if (!canonical.every(isUtcCron) || new Set(canonical).size !== canonical.length) return undefined
45
+ return Object.freeze(canonical)
46
+ }
47
+
48
+ /** Match one admitted five-field UTC cron expression. */
49
+ /** @param {string} expression @param {Date} date */
50
+ export function matches(expression, date) {
51
+ if (!isUtcCron(expression) || !Number.isFinite(date.getTime())) return false
52
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.trim().split(/\s+/u)
53
+ if (!fieldMatches(minute, date.getUTCMinutes(), 0, 59)) return false
54
+ if (!fieldMatches(hour, date.getUTCHours(), 0, 23)) return false
55
+ if (!fieldMatches(month, date.getUTCMonth() + 1, 1, 12, MONTHS)) return false
56
+ const monthDayMatches = fieldMatches(dayOfMonth, date.getUTCDate(), 1, 31)
57
+ const weekDayMatches =
58
+ fieldMatches(dayOfWeek, date.getUTCDay(), 0, 7, DAYS) ||
59
+ (date.getUTCDay() === 0 && fieldMatches(dayOfWeek, 7, 0, 7, DAYS))
60
+ // Vixie marks a day field as wildcard by its leading character, including */n.
61
+ return dayOfMonth.startsWith('*') || dayOfWeek.startsWith('*')
62
+ ? monthDayMatches && weekDayMatches
63
+ : monthDayMatches || weekDayMatches
64
+ }
65
+
66
+ /** @param {string} field @param {number} value @param {number} minimum @param {number} maximum @param {Readonly<Record<string, number>>} [names] */
67
+ export function fieldMatches(field, value, minimum, maximum, names) {
68
+ for (const segment of field.split(',')) {
69
+ const parsed = parseSegment(segment, minimum, maximum, names)
70
+ if (parsed === undefined) continue
71
+ if (
72
+ value >= parsed.lower &&
73
+ value <= parsed.upper &&
74
+ (value - parsed.lower) % parsed.step === 0
75
+ ) {
76
+ return true
77
+ }
78
+ }
79
+ return false
80
+ }
81
+
82
+ /** @param {string} field @param {number} minimum @param {number} maximum @param {Readonly<Record<string, number>>} [names] */
83
+ function fieldValid(field, minimum, maximum, names) {
84
+ const segments = field.split(',')
85
+ return (
86
+ segments.length > 0 &&
87
+ segments.every((segment) => parseSegment(segment, minimum, maximum, names) !== undefined)
88
+ )
89
+ }
90
+
91
+ /** @param {string} segment @param {number} minimum @param {number} maximum @param {Readonly<Record<string, number>>} [names] */
92
+ function parseSegment(segment, minimum, maximum, names) {
93
+ const parts = segment.split('/')
94
+ if (parts.length > 2 || parts[0] === '') return undefined
95
+ const basis = parts[0]
96
+ const rawStep = parts[1]
97
+ const step = rawStep === undefined ? 1 : Number(rawStep)
98
+ if (!Number.isSafeInteger(step) || step < 1) return undefined
99
+ let lower
100
+ let upper
101
+ if (basis === '*') {
102
+ lower = minimum
103
+ upper = maximum
104
+ } else if (basis.includes('-')) {
105
+ const range = basis.split('-')
106
+ if (range.length !== 2) return undefined
107
+ lower = resolve(range[0], names)
108
+ upper = resolve(range[1], names)
109
+ } else {
110
+ if (rawStep !== undefined) return undefined
111
+ lower = resolve(basis, names)
112
+ upper = lower
113
+ }
114
+ if (
115
+ lower === undefined ||
116
+ upper === undefined ||
117
+ lower < minimum ||
118
+ upper > maximum ||
119
+ lower > upper
120
+ ) {
121
+ return undefined
122
+ }
123
+ return { lower, upper, step }
124
+ }
125
+
126
+ /** @param {string} token @param {Readonly<Record<string, number>>} [names] */
127
+ function resolve(token, names) {
128
+ if (token.length === 0) return undefined
129
+ const key = token.toLowerCase()
130
+ const named = names !== undefined && Object.hasOwn(names, key) ? names[key] : undefined
131
+ if (named !== undefined) return named
132
+ if (!/^\d+$/u.test(token)) return undefined
133
+ const value = Number(token)
134
+ return Number.isSafeInteger(value) ? value : undefined
135
+ }
@@ -0,0 +1,12 @@
1
+ import type { DispatcherEnvironment } from './env.mjs'
2
+
3
+ /** The probe path the dispatcher never counts as a call (`retirementVisible`). */
4
+ export const RETIREMENT_PROBE_PATH: '/__services_retirement_probe__'
5
+ /** Whether a host label is a deployment label, as the dispatcher admits it (≤ 57 characters). */
6
+ export function isDeploymentLabel(label: string): boolean
7
+ /** Route one request to one deployment label: record endpoint, tombstone, activation, call. */
8
+ export function dispatchDeployment(
9
+ request: Request,
10
+ environment: DispatcherEnvironment,
11
+ label: string,
12
+ ): Promise<Response>
@@ -0,0 +1,164 @@
1
+ import { scriptMissing } from './failure.mjs'
2
+
3
+ // @ts-check
4
+
5
+ /** @typedef {import('./env.mjs').DispatcherEnvironment} DispatcherEnvironment */
6
+ /** @typedef {{ body?: ArrayBuffer, contentType?: string }} PublishedRecord */
7
+
8
+ /**
9
+ * Every deployment label, as `@astrale-os/sdk/deployment/address` declares `DEPLOYMENT_LABEL`: an
10
+ * optional readable part, the 16-character line fingerprint and the 16-character content, in
11
+ * lower-case base32. The dispatcher is uploaded as plain modules, so it holds its own copy.
12
+ */
13
+ const DEPLOYMENT_LABEL = /^(?:[a-z0-9]+(?:-[a-z0-9]+)*-)?[a-z2-7]{16}-[a-z2-7]{16}$/u
14
+ /** The longest label a deploy gives: a line of 40 characters, a dash and the content. */
15
+ const MAXIMUM_LABEL_LENGTH = 57
16
+
17
+ /**
18
+ * Where the dispatcher serves a deployment's record, which the deployment then never sees. A
19
+ * deployment that publishes no record answers the path itself.
20
+ */
21
+ const DEPLOYMENT_RECORD_PATH = '/.well-known/astrale/deployment.json'
22
+ /** The media type of a record written before records were opaque: a `DeploymentRecordV1`. */
23
+ const DEPLOYMENT_RECORD_TYPE = 'application/vnd.astrale.deployment-record+json;v=1'
24
+ const ENVELOPE_TYPE = 'application/vnd.astrale+json'
25
+ /** The retirement probe (`retirementVisible`); it is never a call of the deployment. */
26
+ export const RETIREMENT_PROBE_PATH = '/__services_retirement_probe__'
27
+ const ACTIVATION_RETRY_AFTER_SECONDS = '30'
28
+
29
+ /** @param {string} label */
30
+ export function isDeploymentLabel(label) {
31
+ return label.length <= MAXIMUM_LABEL_LENGTH && DEPLOYMENT_LABEL.test(label)
32
+ }
33
+
34
+ /**
35
+ * Route one request to one deployment: the one its host names, or the one a name points at
36
+ * (`./services.mjs`). A deployer writes `record:<label>` to activate the label, and its owner
37
+ * `retirement:<label>` to retire it; the record outlives the retirement. Every call of a retired
38
+ * deployment answers `DEPLOYMENT_RETIRED`, whatever its path. The record endpoint is not a call:
39
+ * the dispatcher answers it itself with the record the deployer published, its bytes and its media
40
+ * type as written, and the record it serves immutable stays readable after retirement. A
41
+ * deployment without its record is not active yet (KV is eventually consistent, so a caller
42
+ * retries), and only an active deployment runs, as one call point when `CALLS` is bound.
43
+ *
44
+ * @param {Request} request
45
+ * @param {DispatcherEnvironment} environment
46
+ * @param {string} label
47
+ */
48
+ export async function dispatchDeployment(request, environment, label) {
49
+ const url = new URL(request.url)
50
+ try {
51
+ const [retirement, record] = await Promise.all([
52
+ environment.SCHEDULES.get(`retirement:${label}`),
53
+ // Only the record endpoint reads the record's bytes; a call only needs its presence.
54
+ url.pathname === DEPLOYMENT_RECORD_PATH
55
+ ? publishedRecord(environment, label)
56
+ : environment.SCHEDULES.get(`record:${label}`).then((value) =>
57
+ value === null ? null : /** @type {PublishedRecord} */ ({}),
58
+ ),
59
+ ])
60
+ if (record?.contentType !== undefined) return deploymentRecordResponse(request, record)
61
+ if (retirement !== null) return deploymentRetiredResponse(url.origin)
62
+ if (record === null) return inactiveDeploymentResponse(url.origin)
63
+ if (url.pathname !== RETIREMENT_PROBE_PATH) {
64
+ environment.CALLS?.writeDataPoint({ indexes: [label] })
65
+ }
66
+ return await environment.DEPLOYMENTS.get(label).fetch(request)
67
+ } catch (cause) {
68
+ if (scriptMissing(cause)) return inactiveDeploymentResponse(url.origin)
69
+ return new Response('dispatch error', { status: 502 })
70
+ }
71
+ }
72
+
73
+ /**
74
+ * What `record:<label>` holds, as the record endpoint serves it: `null` when the label is not
75
+ * activated, the record's bytes and media type, or no `contentType` when the deployment publishes
76
+ * no record. A value written without metadata, before records were opaque, is a
77
+ * `DeploymentRecordV1`.
78
+ *
79
+ * @param {DispatcherEnvironment} environment
80
+ * @param {string} label
81
+ * @returns {Promise<PublishedRecord | null>}
82
+ */
83
+ async function publishedRecord(environment, label) {
84
+ const stored = await environment.SCHEDULES.getWithMetadata(`record:${label}`, 'arrayBuffer')
85
+ if (stored.value === null) return null
86
+ if (stored.metadata === null || stored.metadata === undefined) {
87
+ return { body: stored.value, contentType: DEPLOYMENT_RECORD_TYPE }
88
+ }
89
+ const contentType = /** @type {{ contentType?: unknown }} */ (stored.metadata).contentType
90
+ return typeof contentType === 'string'
91
+ ? { body: stored.value, contentType }
92
+ : { body: stored.value }
93
+ }
94
+
95
+ /**
96
+ * The record a deployer published for this label. It never changes: the label is derived from the
97
+ * build and the configuration of the deployment it describes.
98
+ *
99
+ * @param {Request} request
100
+ * @param {PublishedRecord} record
101
+ */
102
+ function deploymentRecordResponse(request, record) {
103
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
104
+ return new Response('method not allowed', {
105
+ status: 405,
106
+ headers: { allow: 'GET, HEAD', 'cache-control': 'no-store' },
107
+ })
108
+ }
109
+ return new Response(record.body, {
110
+ status: 200,
111
+ headers: {
112
+ 'content-type': /** @type {string} */ (record.contentType),
113
+ 'cache-control': 'public, max-age=31536000, immutable',
114
+ },
115
+ })
116
+ }
117
+
118
+ /**
119
+ * `DEPLOYMENT_RETIRED` in the one HTTP form every emitter answers, attributed to the deployment,
120
+ * whose issuer is its origin: the bytes of kernel-protocol `routes.deploymentRetiredBody`, in its
121
+ * key order, and `routes.deploymentRetiredHeaders`.
122
+ *
123
+ * @param {string} deployment
124
+ */
125
+ function deploymentRetiredResponse(deployment) {
126
+ return new Response(
127
+ JSON.stringify({
128
+ error: {
129
+ code: 3002,
130
+ message: 'This deployment is retired.',
131
+ data: { code: 'DEPLOYMENT_RETIRED', details: { deployment } },
132
+ },
133
+ requestId: null,
134
+ invocation: { source: deployment, id: 'unavailable' },
135
+ }),
136
+ {
137
+ status: 410,
138
+ headers: {
139
+ 'x-astrale-deployment-retired': '1',
140
+ 'cache-control': 'no-store',
141
+ 'content-type': ENVELOPE_TYPE,
142
+ },
143
+ },
144
+ )
145
+ }
146
+
147
+ /** @param {string} deployment */
148
+ function inactiveDeploymentResponse(deployment) {
149
+ return new Response(
150
+ JSON.stringify({
151
+ requestId: null,
152
+ invocation: { source: deployment, id: 'unavailable' },
153
+ error: { code: 5001, message: 'This deployment is not active yet.' },
154
+ }),
155
+ {
156
+ status: 503,
157
+ headers: {
158
+ 'cache-control': 'no-store',
159
+ 'content-type': ENVELOPE_TYPE,
160
+ 'retry-after': ACTIVATION_RETRY_AFTER_SECONDS,
161
+ },
162
+ },
163
+ )
164
+ }
@@ -0,0 +1,43 @@
1
+ import type { DispatchFetcher, DispatcherEnvironment, ScheduleController } from './env.mjs'
2
+
3
+ /** Where a cron fires: a fetcher, and the deployment it reaches when there is one (for logs). */
4
+ export interface ScheduleTarget {
5
+ readonly fetcher: DispatchFetcher
6
+ readonly deployment?: string
7
+ }
8
+
9
+ /**
10
+ * What a consumer prepends to the kit's dispatcher without forking it (see `createDispatcher`).
11
+ */
12
+ export interface DispatcherExtension<
13
+ Environment extends DispatcherEnvironment = DispatcherEnvironment,
14
+ > {
15
+ /** Runs first; a `Response` is the answer, `undefined` hands the request to the kit. */
16
+ route?(
17
+ request: Request,
18
+ environment: Environment,
19
+ ): Promise<Response | undefined> | Response | undefined
20
+ /** Admits a `cron:<name>` the kit's name grammar does not. */
21
+ scheduleName?(name: string, environment: Environment): boolean
22
+ /** Where to fire a due cron: a target, `null` for nothing, `undefined` for the kit's default. */
23
+ scheduleTarget?(
24
+ name: string,
25
+ environment: Environment,
26
+ ): Promise<ScheduleTarget | null | undefined>
27
+ }
28
+
29
+ export interface Dispatcher<Environment extends DispatcherEnvironment = DispatcherEnvironment> {
30
+ fetch(request: Request, environment: Environment): Promise<Response>
31
+ scheduled(controller: ScheduleController, environment: Environment): Promise<void>
32
+ }
33
+
34
+ /**
35
+ * Whether a public path names the schedule endpoint in any spelling a router behind the dispatcher
36
+ * could normalize to it: percent-encoded, with repeated or trailing slashes, in any case.
37
+ */
38
+ export function isSchedulePath(pathname: string): boolean
39
+
40
+ /** Create one public dispatcher, extended or not (`index.mjs` is the unextended one). */
41
+ export function createDispatcher<Environment extends DispatcherEnvironment = DispatcherEnvironment>(
42
+ extension?: DispatcherExtension<Environment>,
43
+ ): Dispatcher<Environment>
@@ -0,0 +1,96 @@
1
+ import { dispatchDeployment, isDeploymentLabel } from './deployments.mjs'
2
+ import {
3
+ dispatchService,
4
+ fanOutSchedules,
5
+ isServiceName,
6
+ missingServiceResponse,
7
+ } from './services.mjs'
8
+
9
+ // @ts-check
10
+
11
+ /** @typedef {import('./env.mjs').DispatcherEnvironment} DispatcherEnvironment */
12
+
13
+ /**
14
+ * Whether a public path names the schedule endpoint in any spelling a router behind the dispatcher
15
+ * could normalize to it: percent-encoded, with repeated or trailing slashes, in any case.
16
+ *
17
+ * @param {string} pathname
18
+ * @returns {boolean}
19
+ */
20
+ export function isSchedulePath(pathname) {
21
+ let decoded = pathname
22
+ try {
23
+ decoded = decodeURIComponent(pathname)
24
+ } catch {
25
+ // A malformed escape is compared as written.
26
+ }
27
+ return (
28
+ decoded
29
+ .replace(/\/{2,}/gu, '/')
30
+ .replace(/\/+$/u, '')
31
+ .toLowerCase() === '/__scheduled'
32
+ )
33
+ }
34
+
35
+ /**
36
+ * Create one public dispatcher of a dispatch namespace. A deployment label
37
+ * (`https://<label>.<routing domain>`) reaches its own script; a name
38
+ * (`https://<name>.<routing domain>`) reaches the deployment `current:<name>` names, through the
39
+ * same deployment routing; anything else answers `no such service: <name>`. The public never
40
+ * reaches a deployment's schedule endpoint, `/__scheduled`: only the cron fan-out calls it.
41
+ *
42
+ * An extension lets a consumer keep its own routing and schedule targets ahead of the kit's
43
+ * without forking it:
44
+ *
45
+ * - `route(request, environment)` runs first, after the `/__scheduled` refusal: a `Response` it
46
+ * answers is the answer, `undefined` hands the request to the kit's routing. It owns its own
47
+ * failures.
48
+ * - `scheduleName(name, environment)` admits a `cron:<name>` the kit's name grammar does not.
49
+ * - `scheduleTarget(name, environment)` is asked, for each name with a cron due, where to fire it:
50
+ * `{ fetcher, deployment? }` fires there, `null` fires nothing, `undefined` lets the kit fire
51
+ * the name's current deployment (and nothing for a name only `scheduleName` admits).
52
+ *
53
+ * The dispatcher is given no custom limits: a dispatcher's `limits.subRequests` would also count
54
+ * the deployments' own binding calls.
55
+ *
56
+ * @template {DispatcherEnvironment} Environment
57
+ * @param {{
58
+ * route?: (request: Request, environment: Environment) => Promise<Response | undefined> | Response | undefined,
59
+ * scheduleName?: (name: string, environment: Environment) => boolean,
60
+ * scheduleTarget?: (
61
+ * name: string,
62
+ * environment: Environment,
63
+ * ) => Promise<{ fetcher: import('./env.mjs').DispatchFetcher, deployment?: string } | null | undefined>,
64
+ * }} [extension]
65
+ */
66
+ export function createDispatcher(extension = {}) {
67
+ return Object.freeze({
68
+ /**
69
+ * @param {Request} request
70
+ * @param {Environment} environment
71
+ * @returns {Promise<Response>}
72
+ */
73
+ async fetch(request, environment) {
74
+ const url = new URL(request.url)
75
+ // Only the cron fan-out reaches a deployment's schedule endpoint, never the public.
76
+ if (isSchedulePath(url.pathname)) return new Response('not found', { status: 404 })
77
+ if (extension.route !== undefined) {
78
+ const routed = await extension.route(request, environment)
79
+ if (routed !== undefined) return routed
80
+ }
81
+ const name = url.hostname.split('.')[0] ?? ''
82
+ if (isDeploymentLabel(name)) return dispatchDeployment(request, environment, name)
83
+ if (isServiceName(name)) return dispatchService(request, environment, name)
84
+ return missingServiceResponse(request, name)
85
+ },
86
+
87
+ /**
88
+ * @param {{ scheduledTime?: number }} controller
89
+ * @param {Environment} environment
90
+ * @returns {Promise<void>}
91
+ */
92
+ async scheduled(controller, environment) {
93
+ await fanOutSchedules(controller, environment, extension)
94
+ },
95
+ })
96
+ }
@@ -0,0 +1,51 @@
1
+ /** What a dispatch namespace binding's `get(name)` answers: the script's fetcher. */
2
+ export interface DispatchFetcher {
3
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
4
+ }
5
+
6
+ /** A dispatch namespace binding (`DEPLOYMENTS`, or one a consumer binds beside it). */
7
+ export interface DispatchNamespaceBinding {
8
+ get(name: string): DispatchFetcher
9
+ }
10
+
11
+ export interface ScheduleKey {
12
+ readonly name: string
13
+ }
14
+
15
+ export type SchedulePage =
16
+ | { readonly keys: readonly ScheduleKey[]; readonly list_complete: true }
17
+ | {
18
+ readonly keys: readonly ScheduleKey[]
19
+ readonly list_complete: false
20
+ readonly cursor: string
21
+ }
22
+
23
+ /** The routing KV binding (`SCHEDULES`): the dispatcher only reads it. */
24
+ export interface RoutingKvBinding {
25
+ get(key: string): Promise<string | null>
26
+ /** Read for the record endpoint only: a record's bytes and the metadata it was written with. */
27
+ getWithMetadata(
28
+ key: string,
29
+ type: 'arrayBuffer',
30
+ ): Promise<{ readonly value: ArrayBuffer | null; readonly metadata: unknown }>
31
+ list(options: { readonly prefix: string; readonly cursor?: string }): Promise<SchedulePage>
32
+ }
33
+
34
+ /** The calls dataset binding (`CALLS`), optional: one data point `{ indexes: [label] }` per call. */
35
+ export interface CallsBinding {
36
+ writeDataPoint(point: { readonly indexes: readonly string[] }): void
37
+ }
38
+
39
+ /** The bindings the kit's dispatcher reads; a consumer's extension may read more. */
40
+ export interface DispatcherEnvironment {
41
+ readonly DEPLOYMENTS: DispatchNamespaceBinding
42
+ readonly SCHEDULES: RoutingKvBinding
43
+ readonly CALLS?: CallsBinding
44
+ }
45
+
46
+ /** The bindings the cron fan-out reads. */
47
+ export type ScheduleEnvironment = Pick<DispatcherEnvironment, 'DEPLOYMENTS' | 'SCHEDULES'>
48
+
49
+ export interface ScheduleController {
50
+ readonly scheduledTime?: number
51
+ }
@@ -0,0 +1,4 @@
1
+ /** The message of a failure, as the dispatcher logs it. */
2
+ export function errorText(cause: unknown): string
3
+ /** Whether a dispatch namespace refused a script it does not hold (`Worker not found.`). */
4
+ export function scriptMissing(cause: unknown): boolean
@@ -0,0 +1,15 @@
1
+ // @ts-check
2
+
3
+ /** @param {unknown} cause */
4
+ export function errorText(cause) {
5
+ return cause instanceof Error ? cause.message : String(cause)
6
+ }
7
+
8
+ /**
9
+ * Whether a dispatch namespace refused a script it does not hold. Cloudflare rejects
10
+ * `get(<absent>).fetch()` with `Error: Worker not found.`
11
+ * @param {unknown} cause
12
+ */
13
+ export function scriptMissing(cause) {
14
+ return /not found|could not find|no such/iu.test(errorText(cause))
15
+ }
@@ -0,0 +1,4 @@
1
+ import type { Dispatcher } from './dispatcher.mjs'
2
+
3
+ declare const dispatcher: Dispatcher
4
+ export default dispatcher
@@ -0,0 +1,10 @@
1
+ import { createDispatcher } from './dispatcher.mjs'
2
+
3
+ // @ts-check
4
+
5
+ /**
6
+ * The kit's dispatcher with no extension: the main module (`index.mjs`) `dispatcherModules()`
7
+ * uploads. A consumer with routing of its own uploads its own `index.mjs` instead, which imports
8
+ * `./dispatcher.mjs` and exports `createDispatcher(extension)` as its default.
9
+ */
10
+ export default createDispatcher()
@@ -0,0 +1,25 @@
1
+ /** One module of the dispatcher Worker upload. */
2
+ export interface DispatcherModule {
3
+ /** The module name the Worker imports it by (`index.mjs` is the main module). */
4
+ readonly name: string;
5
+ readonly bytes: Uint8Array;
6
+ /** The multipart type Cloudflare reads the module as. */
7
+ readonly type: 'application/javascript+module';
8
+ }
9
+ /** The main module every dispatcher upload names. */
10
+ export declare const DISPATCHER_MAIN_MODULE = "index.mjs";
11
+ /**
12
+ * The kit's dispatcher modules, under the names they import each other by. They are plain ES
13
+ * modules shipped with the package (`dist/hosting/dispatcher/*.mjs`) and uploaded as they are.
14
+ */
15
+ export declare const DISPATCHER_MODULE_NAMES: readonly ["index.mjs", "dispatcher.mjs", "deployments.mjs", "services.mjs", "failure.mjs", "cron.mjs"];
16
+ /**
17
+ * The modules of one dispatcher upload: the kit's, read from the package, with `extra` added or
18
+ * replacing a kit module of the same name. A consumer with routing of its own supplies its own
19
+ * `index.mjs`, which imports `./dispatcher.mjs` by that relative name and exports
20
+ * `createDispatcher(extension)` as its default, plus any module that one imports.
21
+ */
22
+ export declare function dispatcherModules(extra?: readonly {
23
+ readonly name: string;
24
+ readonly bytes: Uint8Array;
25
+ }[]): Promise<readonly DispatcherModule[]>;
@@ -0,0 +1,56 @@
1
+ /** The main module every dispatcher upload names. */
2
+ export const DISPATCHER_MAIN_MODULE = 'index.mjs';
3
+ /**
4
+ * The kit's dispatcher modules, under the names they import each other by. They are plain ES
5
+ * modules shipped with the package (`dist/hosting/dispatcher/*.mjs`) and uploaded as they are.
6
+ */
7
+ export const DISPATCHER_MODULE_NAMES = Object.freeze([
8
+ 'index.mjs',
9
+ 'dispatcher.mjs',
10
+ 'deployments.mjs',
11
+ 'services.mjs',
12
+ 'failure.mjs',
13
+ 'cron.mjs',
14
+ ]);
15
+ /**
16
+ * The modules of one dispatcher upload: the kit's, read from the package, with `extra` added or
17
+ * replacing a kit module of the same name. A consumer with routing of its own supplies its own
18
+ * `index.mjs`, which imports `./dispatcher.mjs` by that relative name and exports
19
+ * `createDispatcher(extension)` as its default, plus any module that one imports.
20
+ */
21
+ export async function dispatcherModules(extra = []) {
22
+ const names = new Set();
23
+ for (const module of extra) {
24
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_./-]*\.mjs$/u.test(module.name) || module.name.includes('..')) {
25
+ throw new TypeError(`Dispatcher module name ${module.name} is invalid.`);
26
+ }
27
+ if (names.has(module.name))
28
+ throw new TypeError(`Dispatcher module ${module.name} repeats.`);
29
+ names.add(module.name);
30
+ }
31
+ const kit = await Promise.all(DISPATCHER_MODULE_NAMES.filter((name) => !names.has(name)).map(async (name) => ({
32
+ name,
33
+ bytes: await readModule(name),
34
+ })));
35
+ const all = [...kit, ...extra];
36
+ // The main module first, as Cloudflare's own uploads list it.
37
+ all.sort((left, right) => Number(right.name === DISPATCHER_MAIN_MODULE) - Number(left.name === DISPATCHER_MAIN_MODULE));
38
+ return Object.freeze(all.map((module) => Object.freeze({
39
+ name: module.name,
40
+ bytes: module.bytes,
41
+ type: 'application/javascript+module',
42
+ })));
43
+ }
44
+ /**
45
+ * Read one kit module beside this one. Node's file system is reached through
46
+ * `process.getBuiltinModule`, never an import: the rest of the kit runs in a Worker, and a bundler
47
+ * must not see a `node:` module through it.
48
+ */
49
+ async function readModule(name) {
50
+ const host = globalThis.process;
51
+ const fs = host?.getBuiltinModule?.('node:fs/promises');
52
+ if (fs === undefined) {
53
+ throw new TypeError('Reading the dispatcher modules needs Node.js (provisioning runs in Node).');
54
+ }
55
+ return new Uint8Array(await fs.readFile(new URL(`./${name}`, import.meta.url)));
56
+ }
@@ -0,0 +1,28 @@
1
+ import type { DispatcherExtension } from './dispatcher.mjs'
2
+ import type { DispatcherEnvironment, ScheduleController, ScheduleEnvironment } from './env.mjs'
3
+
4
+ /** Whether a host label is a line name (≤ 40 characters, never a deployment label). */
5
+ export function isServiceName(name: string): boolean
6
+ /** Route one request to the deployment `current:<name>` names. */
7
+ export function dispatchService(
8
+ request: Request,
9
+ environment: DispatcherEnvironment,
10
+ service: string,
11
+ ): Promise<Response>
12
+ /** 404 `no such service: <name>` (no-store), or the 503 5001 envelope on `/invoke`. */
13
+ export function missingServiceResponse(request: Request, service: string): Response
14
+ /** Fire every due `cron:<name>` on its current deployment, or on an extension's target. */
15
+ export function fanOutSchedules<Environment extends ScheduleEnvironment>(
16
+ controller: ScheduleController,
17
+ environment: Environment,
18
+ extension?: Pick<
19
+ DispatcherExtension<Environment & DispatcherEnvironment>,
20
+ 'scheduleName' | 'scheduleTarget'
21
+ >,
22
+ ): Promise<void>
23
+ /** Run one bounded sliding-window fan-out. */
24
+ export function pool<Value>(
25
+ items: readonly Value[],
26
+ limit: number,
27
+ operation: (value: Value) => void | Promise<void>,
28
+ ): Promise<void>