@mistriwork/cloudflare 0.0.0 → 1.0.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -1
- package/dist/container.d.ts +87 -0
- package/dist/container.js +200 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.js +20 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/instance.d.ts +7 -0
- package/dist/instance.js +10 -0
- package/dist/mail.d.ts +52 -0
- package/dist/mail.js +66 -0
- package/dist/options.d.ts +28 -0
- package/dist/options.js +7 -0
- package/dist/restart.d.ts +11 -0
- package/dist/restart.js +33 -0
- package/dist/worker.d.ts +27 -0
- package/dist/worker.js +42 -0
- package/mark.svg +1 -0
- package/package.json +44 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Baljinder Randhawa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
<img src="mark.svg" alt="mistri" width="48" height="48" />
|
|
2
|
+
|
|
1
3
|
# @mistriwork/cloudflare
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
A [mistri](https://mistriwork.dev) App on Cloudflare Containers: the container class and the Worker handlers that
|
|
6
|
+
`mistri deploy init cloudflare` writes a Worker against, so a fix to the glue is a package upgrade rather than a diff
|
|
7
|
+
in every project.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { MistriContainer, mistriWorker } from '@mistriwork/cloudflare';
|
|
11
|
+
|
|
12
|
+
export class App extends MistriContainer {
|
|
13
|
+
static override options = { vars: ['MISTRI_PUBLIC_URL'], secrets: ['BETTER_AUTH_SECRET'] };
|
|
14
|
+
}
|
|
15
|
+
export default mistriWorker({ binding: 'APP', instance: 'app-1', location: 'enam' });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`MistriContainer` is always on, waits out mistri's startup checks before it answers 500, hands the Worker's named
|
|
19
|
+
vars and secrets to the container, and logs its start and exit. It also upgrades itself: `wrangler deploy` rolls
|
|
20
|
+
nothing out under the `durable_object` scheduling policy and a running container keeps its startup image and env, so
|
|
21
|
+
the Durable Object records what it started the container with (image, `MISTRI_DEPLOY_ID`, instance type) and, on a
|
|
22
|
+
request or the Worker's tick, replaces a healthy container that differs from what its own Worker version wants (never
|
|
23
|
+
one still starting, at most once a minute). The platform moves a running Durable Object onto a new Worker version
|
|
24
|
+
only after about five minutes, so `mistriWorker` stamps its version on the status probe and the tick, and an object
|
|
25
|
+
behind a newer Worker resets itself (at most once per 30 seconds) and starts again on it. `GET /__mistri/status` with `MISTRI_RESTART_TOKEN` in `x-mistri-restart`
|
|
26
|
+
reports the Worker version (`version_metadata` binding `CF_VERSION_METADATA`), what is desired and what the container
|
|
27
|
+
started with, which `mistri deploy` polls; a `POST` with the token stops the container, the manual fallback. Its `options` are `port`, `instanceType` and `image` (pinned at each start, for an account
|
|
28
|
+
whose container applications are created by the Durable Object), `startupTimeoutMs`, `vars`, `secrets`,
|
|
29
|
+
`restartTokenEnv`, `sleepAfter` and `alwaysOn`.
|
|
30
|
+
|
|
31
|
+
`mistriWorker` returns `fetch` (every request to the one named instance, as it is, so WebSockets and streams pass)
|
|
32
|
+
and `scheduled` (a once-a-minute `/readyz` that starts the instance if a host restart or a rollout stopped it).
|
|
33
|
+
|
|
34
|
+
`forwardEmail(message, env, { binding, instance, secretEnv, parse })` signs an inbound message (HMAC-SHA-256 over
|
|
35
|
+
`<timestamp>.<body>`) and posts it to the App's `/api/mail/inbound` through the Durable Object. Hand it the parser:
|
|
36
|
+
`parse: (raw) => PostalMime.parse(raw, { attachmentEncoding: 'base64' })`; this package carries none. A 2xx or a replayed delivery (409) is done; a 404, 413, 400 or 422 rejects the message with
|
|
37
|
+
a reason; anything else (401, 403, 429, 5xx) throws, so a signing or configuration fault is retried and logged rather
|
|
38
|
+
than finishing as if the mail were forwarded.
|
|
39
|
+
|
|
40
|
+
`@cloudflare/containers` is a peer dependency. mistri itself depends on nothing here.
|
|
41
|
+
[Source](https://github.com/rbaljinder/mistri/tree/main/packages/cloudflare).
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** The Container class a mistri App runs in on Cloudflare (MistriContainer). */
|
|
2
|
+
import { Container } from '@cloudflare/containers';
|
|
3
|
+
import { type MistriContainerOptions } from './options.js';
|
|
4
|
+
/** The runtime's handle on the container, as far as the pin reads it. */
|
|
5
|
+
interface Runtime {
|
|
6
|
+
images?: Record<string, unknown>;
|
|
7
|
+
running?: boolean;
|
|
8
|
+
inspect?(): Promise<{
|
|
9
|
+
image?: string;
|
|
10
|
+
}>;
|
|
11
|
+
start(options: Record<string, unknown>): unknown;
|
|
12
|
+
}
|
|
13
|
+
interface Storage {
|
|
14
|
+
get(key: string): Promise<unknown>;
|
|
15
|
+
put(key: string, value: unknown): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
interface State {
|
|
18
|
+
container?: Runtime;
|
|
19
|
+
storage?: Storage;
|
|
20
|
+
abort?(reason?: string): void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The Container class a mistri App runs in, with what a field deployment learned the hard way:
|
|
24
|
+
*
|
|
25
|
+
* - always on: the instance never sleeps on inactivity (jobs, effects, mail and live sync run between requests);
|
|
26
|
+
* - patient startup: mistri listens only after its startup checks, and the class waits 20 s for the port by default,
|
|
27
|
+
* which turned every request during a start into a 500;
|
|
28
|
+
* - an optional pin of the image and instance type at each start, for an account whose container applications are
|
|
29
|
+
* created by the Durable Object;
|
|
30
|
+
* - a self-upgrade: under the `durable_object` scheduling policy `wrangler deploy` rolls nothing out and a running
|
|
31
|
+
* container keeps its startup image and env, so the Durable Object records what it started the container with and, on
|
|
32
|
+
* a request or the Worker's tick, replaces a healthy container whose image, deploy id or instance type is not what
|
|
33
|
+
* this Worker version wants (at most one replacement per minute; a container still starting is left alone);
|
|
34
|
+
* - a self-reset: the platform moves a running Durable Object onto a new Worker version eventually (about five minutes
|
|
35
|
+
* here), so when the Worker's probe or tick names a newer version than this object's own, the object aborts (at most
|
|
36
|
+
* once per 30 seconds) and the platform starts it again on the version that is live;
|
|
37
|
+
* - a token-gated status probe (isProbe) and restart (isRestart), the manual fallback;
|
|
38
|
+
* - the Worker's named vars and secrets handed to the container, and its start, stop and error logged.
|
|
39
|
+
*
|
|
40
|
+
* Configure by overriding the static `options`:
|
|
41
|
+
*
|
|
42
|
+
* export class App extends MistriContainer {
|
|
43
|
+
* static override options = { vars: ['MISTRI_PUBLIC_URL'], secrets: ['BETTER_AUTH_SECRET'] };
|
|
44
|
+
* }
|
|
45
|
+
*/
|
|
46
|
+
export declare class MistriContainer extends Container<Record<string, unknown>> {
|
|
47
|
+
static options: MistriContainerOptions;
|
|
48
|
+
defaultPort: number;
|
|
49
|
+
sleepAfter: string;
|
|
50
|
+
enableInternet: boolean;
|
|
51
|
+
pingEndpoint: string;
|
|
52
|
+
private readonly settings;
|
|
53
|
+
private readonly workerEnv;
|
|
54
|
+
private readonly runtime;
|
|
55
|
+
private readonly storage;
|
|
56
|
+
private readonly object;
|
|
57
|
+
private recorded;
|
|
58
|
+
private replacing;
|
|
59
|
+
private lastReplace;
|
|
60
|
+
constructor(ctx: State, env: Record<string, unknown>);
|
|
61
|
+
onActivityExpired(): Promise<void>;
|
|
62
|
+
/** What this Worker version wants the container to be started with. */
|
|
63
|
+
private desired;
|
|
64
|
+
private startedWith;
|
|
65
|
+
/**
|
|
66
|
+
* Replace a healthy container that was started with something else than this Worker version wants. The container is
|
|
67
|
+
* not restarted here: after the stop, the request in hand (or the next one) starts it with this version's image and
|
|
68
|
+
* env. A container with no record was started before the Durable Object kept one, so it is replaced once.
|
|
69
|
+
*/
|
|
70
|
+
private upgrade;
|
|
71
|
+
private report;
|
|
72
|
+
/**
|
|
73
|
+
* Abort this Durable Object when the Worker in front of it runs a newer version: the next request finds it started
|
|
74
|
+
* again on the live version, whose upgrade() then replaces the container. The time of the reset is stored first, so
|
|
75
|
+
* an object the platform starts again on its old version is reset at most once per RESET_GAP_MS.
|
|
76
|
+
*/
|
|
77
|
+
private resetIfBehind;
|
|
78
|
+
fetch(request: Request): Promise<Response>;
|
|
79
|
+
startAndWaitForPorts(...args: Parameters<Container['startAndWaitForPorts']>): Promise<void>;
|
|
80
|
+
onStart(): Promise<void>;
|
|
81
|
+
onStop(params: {
|
|
82
|
+
exitCode?: number;
|
|
83
|
+
reason?: string;
|
|
84
|
+
}): void;
|
|
85
|
+
onError(error: unknown): unknown;
|
|
86
|
+
}
|
|
87
|
+
export {};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/** The Container class a mistri App runs in on Cloudflare (MistriContainer). */
|
|
2
|
+
import { Container } from '@cloudflare/containers';
|
|
3
|
+
import { containerEnv } from './env.js';
|
|
4
|
+
import { DEFAULT_RESTART_TOKEN_ENV, DEFAULT_STARTUP_MS, DEFAULT_PORT, WORKER_VERSION_HEADER, } from './options.js';
|
|
5
|
+
import { isProbe, isRestart } from './restart.js';
|
|
6
|
+
const STARTED_KEY = 'mistri:started';
|
|
7
|
+
const RESET_KEY = 'mistri:reset-at';
|
|
8
|
+
/** The least between two self-resets: an object the platform starts again on its old version is not reset in a loop. */
|
|
9
|
+
const RESET_GAP_MS = 30_000;
|
|
10
|
+
/** The least between two replacements: a start that does not take is retried by the next tick, not in a loop. */
|
|
11
|
+
const REPLACE_GAP_MS = 60_000;
|
|
12
|
+
/**
|
|
13
|
+
* The Container class a mistri App runs in, with what a field deployment learned the hard way:
|
|
14
|
+
*
|
|
15
|
+
* - always on: the instance never sleeps on inactivity (jobs, effects, mail and live sync run between requests);
|
|
16
|
+
* - patient startup: mistri listens only after its startup checks, and the class waits 20 s for the port by default,
|
|
17
|
+
* which turned every request during a start into a 500;
|
|
18
|
+
* - an optional pin of the image and instance type at each start, for an account whose container applications are
|
|
19
|
+
* created by the Durable Object;
|
|
20
|
+
* - a self-upgrade: under the `durable_object` scheduling policy `wrangler deploy` rolls nothing out and a running
|
|
21
|
+
* container keeps its startup image and env, so the Durable Object records what it started the container with and, on
|
|
22
|
+
* a request or the Worker's tick, replaces a healthy container whose image, deploy id or instance type is not what
|
|
23
|
+
* this Worker version wants (at most one replacement per minute; a container still starting is left alone);
|
|
24
|
+
* - a self-reset: the platform moves a running Durable Object onto a new Worker version eventually (about five minutes
|
|
25
|
+
* here), so when the Worker's probe or tick names a newer version than this object's own, the object aborts (at most
|
|
26
|
+
* once per 30 seconds) and the platform starts it again on the version that is live;
|
|
27
|
+
* - a token-gated status probe (isProbe) and restart (isRestart), the manual fallback;
|
|
28
|
+
* - the Worker's named vars and secrets handed to the container, and its start, stop and error logged.
|
|
29
|
+
*
|
|
30
|
+
* Configure by overriding the static `options`:
|
|
31
|
+
*
|
|
32
|
+
* export class App extends MistriContainer {
|
|
33
|
+
* static override options = { vars: ['MISTRI_PUBLIC_URL'], secrets: ['BETTER_AUTH_SECRET'] };
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
export class MistriContainer extends Container {
|
|
37
|
+
static options = {};
|
|
38
|
+
defaultPort = DEFAULT_PORT;
|
|
39
|
+
sleepAfter = '5m';
|
|
40
|
+
/* PostgreSQL, SMTP and a model API are outbound. */
|
|
41
|
+
enableInternet = true;
|
|
42
|
+
/* Startup only waits for the port to answer; /healthz opens no database. */
|
|
43
|
+
pingEndpoint = 'localhost/healthz';
|
|
44
|
+
settings;
|
|
45
|
+
workerEnv;
|
|
46
|
+
runtime;
|
|
47
|
+
storage;
|
|
48
|
+
object;
|
|
49
|
+
recorded;
|
|
50
|
+
replacing = false;
|
|
51
|
+
lastReplace = Number.NEGATIVE_INFINITY;
|
|
52
|
+
constructor(ctx, env) {
|
|
53
|
+
super(ctx, env);
|
|
54
|
+
const settings = new.target.options;
|
|
55
|
+
this.settings = settings;
|
|
56
|
+
this.workerEnv = env;
|
|
57
|
+
this.runtime = ctx.container;
|
|
58
|
+
this.storage = ctx.storage;
|
|
59
|
+
this.object = ctx;
|
|
60
|
+
this.defaultPort = settings.port ?? DEFAULT_PORT;
|
|
61
|
+
if (settings.sleepAfter)
|
|
62
|
+
this.sleepAfter = settings.sleepAfter;
|
|
63
|
+
const runtime = ctx.container;
|
|
64
|
+
if (runtime && settings.image && settings.instanceType) {
|
|
65
|
+
const { image, instanceType } = settings;
|
|
66
|
+
/* `container` is the class's own handle on ctx.container; every use but start passes through unchanged. */
|
|
67
|
+
this.container = new Proxy(runtime, {
|
|
68
|
+
get(target, key) {
|
|
69
|
+
if (key === 'start')
|
|
70
|
+
return (options) => target.start({ ...options, image: target.images?.[image], instance: instanceType });
|
|
71
|
+
const value = Reflect.get(target, key);
|
|
72
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
this.envVars = containerEnv(settings, env);
|
|
77
|
+
}
|
|
78
|
+
async onActivityExpired() {
|
|
79
|
+
if (this.settings.alwaysOn === false)
|
|
80
|
+
await super.onActivityExpired();
|
|
81
|
+
}
|
|
82
|
+
/** What this Worker version wants the container to be started with. */
|
|
83
|
+
desired() {
|
|
84
|
+
const image = this.settings.image ? this.runtime?.images?.[this.settings.image] : undefined;
|
|
85
|
+
const deployId = this.workerEnv.MISTRI_DEPLOY_ID;
|
|
86
|
+
return {
|
|
87
|
+
image: image == null ? null : String(image),
|
|
88
|
+
deployId: typeof deployId === 'string' && deployId !== '' ? deployId : null,
|
|
89
|
+
instanceType: this.settings.instanceType ?? null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
async startedWith() {
|
|
93
|
+
if (this.recorded === undefined)
|
|
94
|
+
this.recorded = (await this.storage?.get(STARTED_KEY)) ?? null;
|
|
95
|
+
return this.recorded;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Replace a healthy container that was started with something else than this Worker version wants. The container is
|
|
99
|
+
* not restarted here: after the stop, the request in hand (or the next one) starts it with this version's image and
|
|
100
|
+
* env. A container with no record was started before the Durable Object kept one, so it is replaced once.
|
|
101
|
+
*/
|
|
102
|
+
async upgrade() {
|
|
103
|
+
if (this.replacing || !this.runtime?.running || Date.now() - this.lastReplace < REPLACE_GAP_MS)
|
|
104
|
+
return;
|
|
105
|
+
const want = this.desired();
|
|
106
|
+
if (want.image === null && want.deployId === null)
|
|
107
|
+
return;
|
|
108
|
+
const had = await this.startedWith();
|
|
109
|
+
const differs = had === null ||
|
|
110
|
+
(want.deployId !== null && had.deployId !== want.deployId) ||
|
|
111
|
+
(want.image !== null && had.image !== want.image) ||
|
|
112
|
+
(want.instanceType !== null && had.instanceType !== want.instanceType);
|
|
113
|
+
if (!differs || (await this.getState()).status !== 'healthy')
|
|
114
|
+
return;
|
|
115
|
+
this.replacing = true;
|
|
116
|
+
this.lastReplace = Date.now();
|
|
117
|
+
try {
|
|
118
|
+
console.log(`mistri container replaced: started with ${had?.deployId ?? 'unrecorded'}, wanted ${want.deployId ?? want.image}`);
|
|
119
|
+
await this.destroy();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.replacing = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async report() {
|
|
126
|
+
const running = this.runtime?.running === true;
|
|
127
|
+
let image = null;
|
|
128
|
+
try {
|
|
129
|
+
image = (await this.runtime?.inspect?.())?.image || null;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* A runtime without inspect, or one that refuses while the container starts, reports no image. */
|
|
133
|
+
}
|
|
134
|
+
const had = await this.startedWith();
|
|
135
|
+
const { image: wantImage, deployId } = this.desired();
|
|
136
|
+
const version = this.workerEnv.CF_VERSION_METADATA?.id;
|
|
137
|
+
return Response.json({
|
|
138
|
+
workerVersion: version ?? null,
|
|
139
|
+
desired: { image: wantImage, deployId },
|
|
140
|
+
startedWith: had ? { image: had.image, deployId: had.deployId, at: had.at } : null,
|
|
141
|
+
container: { running, image },
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Abort this Durable Object when the Worker in front of it runs a newer version: the next request finds it started
|
|
146
|
+
* again on the live version, whose upgrade() then replaces the container. The time of the reset is stored first, so
|
|
147
|
+
* an object the platform starts again on its old version is reset at most once per RESET_GAP_MS.
|
|
148
|
+
*/
|
|
149
|
+
async resetIfBehind(request) {
|
|
150
|
+
const [live, liveAt] = (request.headers.get(WORKER_VERSION_HEADER) ?? '').split(' ');
|
|
151
|
+
const own = this.workerEnv.CF_VERSION_METADATA;
|
|
152
|
+
if (!live || !liveAt || !own?.id || !own.timestamp || live === own.id || typeof this.object.abort !== 'function')
|
|
153
|
+
return;
|
|
154
|
+
/* Only a newer Worker: during a split rollout an older one also stamps, and must not reset the newer object. */
|
|
155
|
+
if (!(Date.parse(liveAt) > Date.parse(own.timestamp)))
|
|
156
|
+
return;
|
|
157
|
+
const last = Number((await this.storage?.get(RESET_KEY)) ?? 0);
|
|
158
|
+
if (Date.now() - last < RESET_GAP_MS)
|
|
159
|
+
return;
|
|
160
|
+
await this.storage?.put(RESET_KEY, Date.now());
|
|
161
|
+
console.log(`mistri durable object reset: running Worker version ${own.id}, the Worker in front runs ${live}`);
|
|
162
|
+
this.object.abort(`Worker version ${live} is live`);
|
|
163
|
+
}
|
|
164
|
+
async fetch(request) {
|
|
165
|
+
await this.resetIfBehind(request);
|
|
166
|
+
const tokenEnv = this.settings.restartTokenEnv ?? DEFAULT_RESTART_TOKEN_ENV;
|
|
167
|
+
if (isRestart(request, this.workerEnv, tokenEnv)) {
|
|
168
|
+
await this.destroy();
|
|
169
|
+
return new Response('stopped; the next request starts it again\n');
|
|
170
|
+
}
|
|
171
|
+
const probe = isProbe(request, this.workerEnv, tokenEnv);
|
|
172
|
+
try {
|
|
173
|
+
await this.upgrade();
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.error('mistri container upgrade failed', error);
|
|
177
|
+
}
|
|
178
|
+
return probe ? this.report() : super.fetch(request);
|
|
179
|
+
}
|
|
180
|
+
startAndWaitForPorts(...args) {
|
|
181
|
+
const [portsOrArgs, cancellation, start] = args;
|
|
182
|
+
const patient = { instanceGetTimeoutMS: 60_000, portReadyTimeoutMS: this.settings.startupTimeoutMs ?? DEFAULT_STARTUP_MS };
|
|
183
|
+
if (portsOrArgs !== undefined && typeof portsOrArgs === 'object' && !Array.isArray(portsOrArgs))
|
|
184
|
+
return super.startAndWaitForPorts({ ...portsOrArgs, cancellationOptions: { ...patient, ...portsOrArgs.cancellationOptions } });
|
|
185
|
+
return super.startAndWaitForPorts(portsOrArgs, { ...patient, ...cancellation }, start);
|
|
186
|
+
}
|
|
187
|
+
async onStart() {
|
|
188
|
+
console.log('mistri container started');
|
|
189
|
+
const want = this.desired();
|
|
190
|
+
this.recorded = { ...want, at: new Date().toISOString() };
|
|
191
|
+
await this.storage?.put(STARTED_KEY, this.recorded);
|
|
192
|
+
}
|
|
193
|
+
onStop(params) {
|
|
194
|
+
console.warn(`mistri container stopped: exit ${params.exitCode ?? '?'} (${params.reason ?? 'no reason'})`);
|
|
195
|
+
}
|
|
196
|
+
onError(error) {
|
|
197
|
+
console.error('mistri container error', error);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The environment a mistri container starts with, from the Worker's own. */
|
|
2
|
+
import { type MistriContainerOptions } from './options.js';
|
|
3
|
+
/**
|
|
4
|
+
* The environment the container starts with: the image's own ENV said again (a start's environment is not guaranteed
|
|
5
|
+
* to be merged into the image's), what a Cloudflare front end needs, then every named var and secret the Worker holds.
|
|
6
|
+
* A name that is unset or empty is left out, so the container's default applies.
|
|
7
|
+
*/
|
|
8
|
+
export declare function containerEnv(options: MistriContainerOptions, env: Record<string, unknown>): Record<string, string>;
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The environment a mistri container starts with, from the Worker's own. */
|
|
2
|
+
import { DEFAULT_PORT } from './options.js';
|
|
3
|
+
/**
|
|
4
|
+
* The environment the container starts with: the image's own ENV said again (a start's environment is not guaranteed
|
|
5
|
+
* to be merged into the image's), what a Cloudflare front end needs, then every named var and secret the Worker holds.
|
|
6
|
+
* A name that is unset or empty is left out, so the container's default applies.
|
|
7
|
+
*/
|
|
8
|
+
export function containerEnv(options, env) {
|
|
9
|
+
const names = [...new Set(['MISTRI_DEPLOY_ID', ...(options.vars ?? []), ...(options.secrets ?? [])])];
|
|
10
|
+
return {
|
|
11
|
+
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
|
12
|
+
PORT: String(options.port ?? DEFAULT_PORT),
|
|
13
|
+
NODE_OPTIONS: '--enable-source-maps',
|
|
14
|
+
NODE_COMPILE_CACHE: '/tmp/v8',
|
|
15
|
+
NODE_ENV: 'production',
|
|
16
|
+
/* The client address is Cloudflare's CF-Connecting-IP; without this every visitor shares one rate limit. */
|
|
17
|
+
MISTRI_TRUST_PROXY_CLOUDFLARE: '1',
|
|
18
|
+
...Object.fromEntries(names.flatMap((name) => (typeof env[name] === 'string' && env[name] !== '' ? [[name, env[name]]] : []))),
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mistriwork/cloudflare: a mistri App on Cloudflare Containers. `mistri deploy init cloudflare` writes a Worker
|
|
3
|
+
* against this; a fix to the glue is a package upgrade, not a diff in every project.
|
|
4
|
+
*/
|
|
5
|
+
export { MistriContainer } from './container.js';
|
|
6
|
+
export { mistriWorker, type MistriWorkerOptions } from './worker.js';
|
|
7
|
+
export { forwardEmail, signMail, type ForwardEmailOptions, type InboundMessage, type ParsedMail } from './mail.js';
|
|
8
|
+
export { isProbe, isRestart } from './restart.js';
|
|
9
|
+
export { containerEnv } from './env.js';
|
|
10
|
+
export { PROBE_PATH, RESTART_HEADER, type MistriContainerOptions } from './options.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mistriwork/cloudflare: a mistri App on Cloudflare Containers. `mistri deploy init cloudflare` writes a Worker
|
|
3
|
+
* against this; a fix to the glue is a package upgrade, not a diff in every project.
|
|
4
|
+
*/
|
|
5
|
+
export { MistriContainer } from './container.js';
|
|
6
|
+
export { mistriWorker } from './worker.js';
|
|
7
|
+
export { forwardEmail, signMail } from './mail.js';
|
|
8
|
+
export { isProbe, isRestart } from './restart.js';
|
|
9
|
+
export { containerEnv } from './env.js';
|
|
10
|
+
export { PROBE_PATH, RESTART_HEADER } from './options.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one named instance. Its Durable Object, and so its container, is placed where the name is first used, near the
|
|
3
|
+
* database rather than the visitor: a page is several round trips to it.
|
|
4
|
+
*/
|
|
5
|
+
export declare function instanceOf(env: Record<string, unknown>, binding: string, name: string, location?: string): {
|
|
6
|
+
fetch(request: Request): Promise<Response>;
|
|
7
|
+
};
|
package/dist/instance.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one named instance. Its Durable Object, and so its container, is placed where the name is first used, near the
|
|
3
|
+
* database rather than the visitor: a page is several round trips to it.
|
|
4
|
+
*/
|
|
5
|
+
export function instanceOf(env, binding, name, location) {
|
|
6
|
+
const namespace = env[binding];
|
|
7
|
+
if (!namespace || typeof namespace.idFromName !== 'function')
|
|
8
|
+
throw new Error(`the Worker has no Durable Object binding "${binding}"`);
|
|
9
|
+
return namespace.get(namespace.idFromName(name), location ? { locationHint: location } : undefined);
|
|
10
|
+
}
|
package/dist/mail.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface ParsedMail {
|
|
2
|
+
from?: unknown;
|
|
3
|
+
to?: unknown[];
|
|
4
|
+
cc?: unknown[];
|
|
5
|
+
subject?: string;
|
|
6
|
+
messageId?: string;
|
|
7
|
+
inReplyTo?: string;
|
|
8
|
+
references?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
html?: string;
|
|
11
|
+
headers: Array<{
|
|
12
|
+
key: string;
|
|
13
|
+
value: string;
|
|
14
|
+
}>;
|
|
15
|
+
attachments: Array<{
|
|
16
|
+
filename?: string | null;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
content: unknown;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
/** The part of an email message the forwarder reads (Cloudflare's ForwardableEmailMessage). */
|
|
22
|
+
export interface InboundMessage {
|
|
23
|
+
to: string;
|
|
24
|
+
raw: ReadableStream<Uint8Array>;
|
|
25
|
+
setReject(reason: string): void;
|
|
26
|
+
}
|
|
27
|
+
export interface ForwardEmailOptions {
|
|
28
|
+
/** The Durable Object binding, and the instance and location hint the Worker's `fetch` uses (mistriWorker). */
|
|
29
|
+
binding: string;
|
|
30
|
+
instance: string;
|
|
31
|
+
location?: string;
|
|
32
|
+
/** The Worker secret holding the signing key (the App's mail secret). */
|
|
33
|
+
secretEnv: string;
|
|
34
|
+
/** Parses the raw message. */
|
|
35
|
+
parse(raw: ReadableStream<Uint8Array>): Promise<ParsedMail>;
|
|
36
|
+
/** The App's inbound endpoint. Default `/api/mail/inbound`. */
|
|
37
|
+
path?: string;
|
|
38
|
+
/** The public address, so the Host header passes MISTRI_ALLOWED_HOSTS. Default the Worker's MISTRI_PUBLIC_URL. */
|
|
39
|
+
publicUrl?: string;
|
|
40
|
+
/** The signature headers are `x-<header>-timestamp` and `x-<header>-signature`. Default `mistri`. */
|
|
41
|
+
header?: string;
|
|
42
|
+
}
|
|
43
|
+
/** `v1=<hex>`: HMAC-SHA-256 over `<timestamp>.<body>`. */
|
|
44
|
+
export declare function signMail(secret: string, timestamp: string, body: string): Promise<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Forward one message, and let nothing finish quietly that the App did not take. A 2xx (204 taken, 202 dropped and
|
|
47
|
+
* logged) and the replay 409 are done. A 404 rejects it ("No such mailbox"), a 413 as too large, and a 400 or 422 as
|
|
48
|
+
* unreadable: those are the message's own fault and a retry cannot change them. Everything else — 401 or 403 (the
|
|
49
|
+
* signature or the Worker's clock or secret), 429, a 5xx, a redirect — is the deployment's problem, not the sender's, so
|
|
50
|
+
* it throws, for Email Routing to retry and for the Worker's logs to show. The error names the status, never the message.
|
|
51
|
+
*/
|
|
52
|
+
export declare function forwardEmail(message: InboundMessage, env: Record<string, unknown>, options: ForwardEmailOptions): Promise<void>;
|
package/dist/mail.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound mail for a tenant App: Cloudflare Email Routing hands a message to the Worker's `email()`; this signs it
|
|
3
|
+
* and posts it to the App's inbound endpoint through the Durable Object, never over the public Internet.
|
|
4
|
+
* The message is parsed by a function the project supplies (postal-mime's `PostalMime.parse` is the usual one), so
|
|
5
|
+
* this package carries no parser.
|
|
6
|
+
*/
|
|
7
|
+
import { instanceOf } from './instance.js';
|
|
8
|
+
const hex = (bytes) => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
9
|
+
/** `v1=<hex>`: HMAC-SHA-256 over `<timestamp>.<body>`. */
|
|
10
|
+
export async function signMail(secret, timestamp, body) {
|
|
11
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
12
|
+
return `v1=${hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`)))}`;
|
|
13
|
+
}
|
|
14
|
+
function mailBody(message, mail) {
|
|
15
|
+
return JSON.stringify({
|
|
16
|
+
recipient: message.to,
|
|
17
|
+
from: mail.from,
|
|
18
|
+
to: mail.to ?? [],
|
|
19
|
+
cc: mail.cc ?? [],
|
|
20
|
+
subject: mail.subject ?? '',
|
|
21
|
+
messageId: mail.messageId,
|
|
22
|
+
inReplyTo: mail.inReplyTo,
|
|
23
|
+
references: mail.references,
|
|
24
|
+
text: mail.text,
|
|
25
|
+
html: mail.html,
|
|
26
|
+
headers: Object.fromEntries(mail.headers.map((h) => [h.key, h.value])),
|
|
27
|
+
attachments: mail.attachments.map((a) => ({ name: a.filename ?? 'attachment', type: a.mimeType, content: a.content })),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Forward one message, and let nothing finish quietly that the App did not take. A 2xx (204 taken, 202 dropped and
|
|
32
|
+
* logged) and the replay 409 are done. A 404 rejects it ("No such mailbox"), a 413 as too large, and a 400 or 422 as
|
|
33
|
+
* unreadable: those are the message's own fault and a retry cannot change them. Everything else — 401 or 403 (the
|
|
34
|
+
* signature or the Worker's clock or secret), 429, a 5xx, a redirect — is the deployment's problem, not the sender's, so
|
|
35
|
+
* it throws, for Email Routing to retry and for the Worker's logs to show. The error names the status, never the message.
|
|
36
|
+
*/
|
|
37
|
+
export async function forwardEmail(message, env, options) {
|
|
38
|
+
const secret = env[options.secretEnv];
|
|
39
|
+
if (typeof secret !== 'string' || !secret)
|
|
40
|
+
throw new Error(`${options.secretEnv} is not set on the Worker`);
|
|
41
|
+
const base = options.publicUrl ?? env.MISTRI_PUBLIC_URL;
|
|
42
|
+
if (!base)
|
|
43
|
+
throw new Error('forwardEmail needs publicUrl or MISTRI_PUBLIC_URL');
|
|
44
|
+
const body = mailBody(message, await options.parse(message.raw));
|
|
45
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
46
|
+
const name = options.header ?? 'mistri';
|
|
47
|
+
const answer = await instanceOf(env, options.binding, options.instance, options.location).fetch(new Request(new URL(options.path ?? '/api/mail/inbound', base), {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
body,
|
|
50
|
+
headers: {
|
|
51
|
+
'content-type': 'application/json',
|
|
52
|
+
[`x-${name}-timestamp`]: timestamp,
|
|
53
|
+
[`x-${name}-signature`]: await signMail(secret, timestamp, body),
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
if (answer.ok || answer.status === 409)
|
|
57
|
+
return;
|
|
58
|
+
if (answer.status === 404)
|
|
59
|
+
message.setReject('No such mailbox');
|
|
60
|
+
else if (answer.status === 413)
|
|
61
|
+
message.setReject('Message too large');
|
|
62
|
+
else if (answer.status === 400 || answer.status === 422)
|
|
63
|
+
message.setReject('Message could not be processed');
|
|
64
|
+
else
|
|
65
|
+
throw new Error(`the App answered ${answer.status}${answer.status === 401 ? ' (check the signing secret and the Worker clock)' : ''}`);
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** What a project says about its container: every field has the default a mistri App wants. */
|
|
2
|
+
export interface MistriContainerOptions {
|
|
3
|
+
/** The port the App listens on in the image (PORT). Default 4111. */
|
|
4
|
+
port?: number;
|
|
5
|
+
/** Pin the instance type at each start (`standard-1`), for an account whose applications are created by the Durable Object. */
|
|
6
|
+
instanceType?: string;
|
|
7
|
+
/** The image's key in wrangler.jsonc, pinned at each start with `instanceType`. Unset leaves both to wrangler.jsonc. */
|
|
8
|
+
image?: string;
|
|
9
|
+
/** How long a request waits for a starting container to listen. mistri listens only after its startup checks, so minutes. Default 240000. */
|
|
10
|
+
startupTimeoutMs?: number;
|
|
11
|
+
/** Worker vars handed to the container by name (not secret). MISTRI_DEPLOY_ID is always among them. */
|
|
12
|
+
vars?: readonly string[];
|
|
13
|
+
/** Worker secrets handed to the container by name. */
|
|
14
|
+
secrets?: readonly string[];
|
|
15
|
+
/** The secret holding the restart token; a POST with its value in `x-mistri-restart` stops the container, and a GET of `/__mistri/status` with it reports the roll. Default MISTRI_RESTART_TOKEN. */
|
|
16
|
+
restartTokenEnv?: string;
|
|
17
|
+
/** The Durable Object's sleep timer. Default '5m'; it never stops an always-on container. */
|
|
18
|
+
sleepAfter?: string;
|
|
19
|
+
/** Keep the container running when idle: jobs, effects and mail run between requests. Default true. */
|
|
20
|
+
alwaysOn?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare const RESTART_HEADER = "x-mistri-restart";
|
|
23
|
+
export declare const PROBE_PATH = "/__mistri/status";
|
|
24
|
+
/** `<version id> <upload time>` of the Worker, stamped by mistriWorker on the probe and the tick; a client's copy is dropped. */
|
|
25
|
+
export declare const WORKER_VERSION_HEADER = "x-mistri-worker-version";
|
|
26
|
+
export declare const DEFAULT_RESTART_TOKEN_ENV = "MISTRI_RESTART_TOKEN";
|
|
27
|
+
export declare const DEFAULT_STARTUP_MS = 240000;
|
|
28
|
+
export declare const DEFAULT_PORT = 4111;
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const RESTART_HEADER = 'x-mistri-restart';
|
|
2
|
+
export const PROBE_PATH = '/__mistri/status';
|
|
3
|
+
/** `<version id> <upload time>` of the Worker, stamped by mistriWorker on the probe and the tick; a client's copy is dropped. */
|
|
4
|
+
export const WORKER_VERSION_HEADER = 'x-mistri-worker-version';
|
|
5
|
+
export const DEFAULT_RESTART_TOKEN_ENV = 'MISTRI_RESTART_TOKEN';
|
|
6
|
+
export const DEFAULT_STARTUP_MS = 240_000;
|
|
7
|
+
export const DEFAULT_PORT = 4111;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this request is an operator's restart: a POST carrying the restart secret (32 characters or more, so a
|
|
3
|
+
* short guessable one never opens it) in `x-mistri-restart`. The container normally replaces itself after a deploy;
|
|
4
|
+
* this is the manual fallback, and what `mistri deploy` sends when it does not.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isRestart(request: Request, env: Record<string, unknown>, tokenEnv?: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Whether this request is the status probe: a GET of PROBE_PATH with the same token header. A path of its own (not `/`)
|
|
9
|
+
* so a stray header never turns a page view into the probe; an older Worker proxies it to the App, which is not JSON.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isProbe(request: Request, env: Record<string, unknown>, tokenEnv?: string): boolean;
|
package/dist/restart.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The token-gated restart and status probe a deploy sends to a container that a code update left running. */
|
|
2
|
+
import { DEFAULT_RESTART_TOKEN_ENV, PROBE_PATH, RESTART_HEADER } from './options.js';
|
|
3
|
+
/** Equal in time for equal lengths, so the token is not read off response timing. */
|
|
4
|
+
function same(a, b) {
|
|
5
|
+
if (a.length !== b.length)
|
|
6
|
+
return false;
|
|
7
|
+
let diff = 0;
|
|
8
|
+
for (let i = 0; i < a.length; i++)
|
|
9
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
10
|
+
return diff === 0;
|
|
11
|
+
}
|
|
12
|
+
function carriesToken(request, env, tokenEnv) {
|
|
13
|
+
const token = env[tokenEnv];
|
|
14
|
+
if (typeof token !== 'string' || token.length < 32)
|
|
15
|
+
return false;
|
|
16
|
+
const given = request.headers.get(RESTART_HEADER);
|
|
17
|
+
return given !== null && same(given, token);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether this request is an operator's restart: a POST carrying the restart secret (32 characters or more, so a
|
|
21
|
+
* short guessable one never opens it) in `x-mistri-restart`. The container normally replaces itself after a deploy;
|
|
22
|
+
* this is the manual fallback, and what `mistri deploy` sends when it does not.
|
|
23
|
+
*/
|
|
24
|
+
export function isRestart(request, env, tokenEnv = DEFAULT_RESTART_TOKEN_ENV) {
|
|
25
|
+
return request.method === 'POST' && carriesToken(request, env, tokenEnv);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether this request is the status probe: a GET of PROBE_PATH with the same token header. A path of its own (not `/`)
|
|
29
|
+
* so a stray header never turns a page view into the probe; an older Worker proxies it to the App, which is not JSON.
|
|
30
|
+
*/
|
|
31
|
+
export function isProbe(request, env, tokenEnv = DEFAULT_RESTART_TOKEN_ENV) {
|
|
32
|
+
return request.method === 'GET' && new URL(request.url).pathname === PROBE_PATH && carriesToken(request, env, tokenEnv);
|
|
33
|
+
}
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface MistriWorkerOptions {
|
|
2
|
+
/** The Durable Object binding in wrangler.jsonc (`durable_objects.bindings[].name`). */
|
|
3
|
+
binding: string;
|
|
4
|
+
/** The instance's name. One instance: a second needs a shared cache and health-aware routing first. */
|
|
5
|
+
instance: string;
|
|
6
|
+
/** Where the instance runs, fixed the first time the name is used: `enam`, `wnam`, `weur`... Near the database. */
|
|
7
|
+
location?: string;
|
|
8
|
+
/** The secret holding the restart token, as the container's `restartTokenEnv`. Default MISTRI_RESTART_TOKEN. */
|
|
9
|
+
restartTokenEnv?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `fetch` and `scheduled` for a Worker in front of one always-on mistri container.
|
|
13
|
+
*
|
|
14
|
+
* fetch every request goes to the instance as it is; a WebSocket or a streamed response passes through
|
|
15
|
+
* scheduled once a minute: starts the instance if a host restart or a rollout stopped it, asking for nothing but
|
|
16
|
+
* the process (/readyz answers before any App code)
|
|
17
|
+
*
|
|
18
|
+
* The status probe and the tick carry this Worker's version id (the `version_metadata` binding), so a Durable Object
|
|
19
|
+
* still on the previous version learns a newer one is live and resets itself (MistriContainer) instead of waiting out
|
|
20
|
+
* the platform's rollout, which took about five minutes. The probe is stamped only when it carries the restart token,
|
|
21
|
+
* so no visitor can make the object reset; the tick is the Worker's own. Every other request has a client's copy of
|
|
22
|
+
* the header removed, so a stamp the object reads was always put there here.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mistriWorker(options: MistriWorkerOptions): {
|
|
25
|
+
fetch(request: Request, env: Record<string, unknown>): Promise<Response>;
|
|
26
|
+
scheduled(_controller: unknown, env: Record<string, unknown>): Promise<void>;
|
|
27
|
+
};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** The Worker's `fetch` and `scheduled`: every request to one named, always-on instance. */
|
|
2
|
+
import { instanceOf } from './instance.js';
|
|
3
|
+
import { DEFAULT_RESTART_TOKEN_ENV, WORKER_VERSION_HEADER } from './options.js';
|
|
4
|
+
import { isProbe } from './restart.js';
|
|
5
|
+
/**
|
|
6
|
+
* `fetch` and `scheduled` for a Worker in front of one always-on mistri container.
|
|
7
|
+
*
|
|
8
|
+
* fetch every request goes to the instance as it is; a WebSocket or a streamed response passes through
|
|
9
|
+
* scheduled once a minute: starts the instance if a host restart or a rollout stopped it, asking for nothing but
|
|
10
|
+
* the process (/readyz answers before any App code)
|
|
11
|
+
*
|
|
12
|
+
* The status probe and the tick carry this Worker's version id (the `version_metadata` binding), so a Durable Object
|
|
13
|
+
* still on the previous version learns a newer one is live and resets itself (MistriContainer) instead of waiting out
|
|
14
|
+
* the platform's rollout, which took about five minutes. The probe is stamped only when it carries the restart token,
|
|
15
|
+
* so no visitor can make the object reset; the tick is the Worker's own. Every other request has a client's copy of
|
|
16
|
+
* the header removed, so a stamp the object reads was always put there here.
|
|
17
|
+
*/
|
|
18
|
+
export function mistriWorker(options) {
|
|
19
|
+
const instance = (env) => instanceOf(env, options.binding, options.instance, options.location);
|
|
20
|
+
return {
|
|
21
|
+
fetch(request, env) {
|
|
22
|
+
const stamp = isProbe(request, env, options.restartTokenEnv ?? DEFAULT_RESTART_TOKEN_ENV);
|
|
23
|
+
return instance(env).fetch(stamp || request.headers.has(WORKER_VERSION_HEADER) ? stamped(request, env, stamp) : request);
|
|
24
|
+
},
|
|
25
|
+
async scheduled(_controller, env) {
|
|
26
|
+
const base = env.MISTRI_PUBLIC_URL ?? 'http://container';
|
|
27
|
+
const answer = await instance(env).fetch(stamped(new Request(new URL('/readyz', base)), env, true));
|
|
28
|
+
if (!answer.ok)
|
|
29
|
+
console.warn(`/readyz answered ${answer.status}`);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** The request with this Worker's version id and upload time in WORKER_VERSION_HEADER when `stamp`, and with no such header otherwise. */
|
|
34
|
+
function stamped(request, env, stamp) {
|
|
35
|
+
const headers = new Headers(request.headers);
|
|
36
|
+
const version = env.CF_VERSION_METADATA;
|
|
37
|
+
if (stamp && version?.id && version.timestamp)
|
|
38
|
+
headers.set(WORKER_VERSION_HEADER, `${version.id} ${version.timestamp}`);
|
|
39
|
+
else
|
|
40
|
+
headers.delete(WORKER_VERSION_HEADER);
|
|
41
|
+
return new Request(request, { headers });
|
|
42
|
+
}
|
package/mark.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>.i{fill:#1c1a17}.a{fill:#6b3fd4}@media (prefers-color-scheme:dark){.i{fill:#f2f0ec}.a{fill:#a88bff}}</style><path class="i" d="M2 4H26V18H22V10H18V28H12V10H8V28H2Z"/><rect class="a" x="20" y="20" width="10" height="8"/></svg>
|
package/package.json
CHANGED
|
@@ -1,4 +1,45 @@
|
|
|
1
|
-
{
|
|
2
|
-
"
|
|
1
|
+
{
|
|
2
|
+
"name": "@mistriwork/cloudflare",
|
|
3
|
+
"version": "1.0.0-rc.10",
|
|
4
|
+
"description": "A mistri App on Cloudflare Containers: the Container class and Worker handlers `mistri deploy init cloudflare` writes against, so a fix to the glue is a package upgrade.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"containers",
|
|
8
|
+
"deploy",
|
|
9
|
+
"mistri",
|
|
10
|
+
"workers"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://mistriwork.dev",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rbaljinder/mistri/issues"
|
|
15
|
+
},
|
|
3
16
|
"license": "MIT",
|
|
4
|
-
"
|
|
17
|
+
"author": "Baljinder Randhawa",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/rbaljinder/mistri.git",
|
|
21
|
+
"directory": "packages/cloudflare"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"mark.svg",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@cloudflare/containers": "^0.3.7"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@cloudflare/containers": "^0.3.7"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=24.22.1"
|
|
44
|
+
}
|
|
45
|
+
}
|