@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.
- package/README.md +119 -78
- package/dist/adapter.d.ts +6 -5
- package/dist/adapter.js +8 -8
- package/dist/build/index.d.ts +1 -0
- package/dist/build/index.js +1 -0
- package/dist/hosting/activation.d.ts +37 -0
- package/dist/hosting/activation.js +108 -0
- package/dist/hosting/assets.d.ts +14 -0
- package/dist/hosting/assets.js +53 -0
- package/dist/hosting/bounded.d.ts +10 -0
- package/dist/hosting/bounded.js +32 -0
- package/dist/hosting/bytes.d.ts +6 -0
- package/dist/hosting/bytes.js +25 -0
- package/dist/hosting/client.d.ts +144 -0
- package/dist/hosting/client.js +491 -0
- package/dist/hosting/dispatcher/cron.d.mts +10 -0
- package/dist/hosting/dispatcher/cron.mjs +135 -0
- package/dist/hosting/dispatcher/deployments.d.mts +12 -0
- package/dist/hosting/dispatcher/deployments.mjs +164 -0
- package/dist/hosting/dispatcher/dispatcher.d.mts +43 -0
- package/dist/hosting/dispatcher/dispatcher.mjs +96 -0
- package/dist/hosting/dispatcher/env.d.mts +51 -0
- package/dist/hosting/dispatcher/failure.d.mts +4 -0
- package/dist/hosting/dispatcher/failure.mjs +15 -0
- package/dist/hosting/dispatcher/index.d.mts +4 -0
- package/dist/hosting/dispatcher/index.mjs +10 -0
- package/dist/hosting/dispatcher/modules.d.ts +25 -0
- package/dist/hosting/dispatcher/modules.js +56 -0
- package/dist/hosting/dispatcher/services.d.mts +28 -0
- package/dist/hosting/dispatcher/services.mjs +231 -0
- package/dist/hosting/identity.d.ts +15 -0
- package/dist/hosting/identity.js +36 -0
- package/dist/hosting/index.d.ts +34 -0
- package/dist/hosting/index.js +32 -0
- package/dist/hosting/kv.d.ts +89 -0
- package/dist/hosting/kv.js +206 -0
- package/dist/hosting/listing.d.ts +34 -0
- package/dist/hosting/listing.js +50 -0
- package/dist/hosting/names.d.ts +16 -0
- package/dist/hosting/names.js +28 -0
- package/dist/hosting/provision/account.d.ts +11 -0
- package/dist/hosting/provision/account.js +86 -0
- package/dist/hosting/provision/dispatcher.d.ts +44 -0
- package/dist/hosting/provision/dispatcher.js +101 -0
- package/dist/hosting/provision/ingress.d.ts +12 -0
- package/dist/hosting/provision/ingress.js +31 -0
- package/dist/hosting/provision/plan.d.ts +143 -0
- package/dist/hosting/provision/plan.js +246 -0
- package/dist/hosting/provision/routing.d.ts +82 -0
- package/dist/hosting/provision/routing.js +226 -0
- package/dist/hosting/readiness.d.ts +53 -0
- package/dist/hosting/readiness.js +82 -0
- package/dist/hosting/refusal.d.ts +28 -0
- package/dist/hosting/refusal.js +13 -0
- package/dist/hosting/retirement.d.ts +69 -0
- package/dist/hosting/retirement.js +106 -0
- package/dist/hosting/retry-after.d.ts +6 -0
- package/dist/hosting/retry-after.js +55 -0
- package/dist/hosting/script.d.ts +186 -0
- package/dist/hosting/script.js +324 -0
- package/dist/hosting/testing/fake-cloudflare.d.ts +211 -0
- package/dist/hosting/testing/fake-cloudflare.js +756 -0
- package/dist/hosting/testing/index.d.ts +6 -0
- package/dist/hosting/testing/index.js +6 -0
- package/dist/hosting/time.d.ts +4 -0
- package/dist/hosting/time.js +20 -0
- package/dist/identity/material.d.ts +0 -1
- package/dist/identity/material.js +1 -10
- package/dist/legacy/direct/index.d.ts +5 -9
- package/dist/legacy/direct/index.js +5 -9
- package/dist/namespace/configure.d.ts +2 -6
- package/dist/namespace/configure.js +15 -12
- package/dist/namespace/coordinates.d.ts +3 -26
- package/dist/namespace/coordinates.js +13 -45
- package/dist/namespace/deploy-release.d.ts +23 -30
- package/dist/namespace/deploy-release.js +123 -153
- package/dist/namespace/hosting.d.ts +29 -0
- package/dist/namespace/hosting.js +120 -0
- package/dist/namespace/index.d.ts +7 -10
- package/dist/namespace/index.js +7 -10
- package/dist/namespace/list.d.ts +11 -26
- package/dist/namespace/list.js +49 -125
- package/dist/namespace/readiness.d.ts +10 -20
- package/dist/namespace/readiness.js +15 -65
- package/dist/namespace/record.d.ts +21 -0
- package/dist/namespace/record.js +53 -0
- package/dist/release/index.d.ts +0 -1
- package/dist/release/index.js +0 -1
- package/package.json +11 -7
- package/template/astrale.config.ts +10 -7
- package/dist/namespace/api.d.ts +0 -71
- package/dist/namespace/api.js +0 -265
- package/dist/namespace/calls.d.ts +0 -29
- package/dist/namespace/calls.js +0 -104
- package/dist/namespace/routing.d.ts +0 -55
- package/dist/namespace/routing.js +0 -165
- package/dist/namespace/script.d.ts +0 -106
- package/dist/namespace/script.js +0 -278
- package/dist/release/staged-worker-name.d.ts +0 -3
- 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,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,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>
|