@zeyolabs/kit 0.0.0-stage → 2026.11.5
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 +37 -2
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/client.ts +77 -0
- package/cloudflare/email/dev.ts +10 -0
- package/cloudflare/email/testing.ts +13 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +150 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +211 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalendarDate,
|
|
3
|
+
DateFormatter,
|
|
4
|
+
fromDate,
|
|
5
|
+
getDayOfWeek,
|
|
6
|
+
getLocalTimeZone,
|
|
7
|
+
now,
|
|
8
|
+
parseDate,
|
|
9
|
+
type Time,
|
|
10
|
+
toCalendarDate,
|
|
11
|
+
today,
|
|
12
|
+
toTime,
|
|
13
|
+
toZoned,
|
|
14
|
+
type ZonedDateTime,
|
|
15
|
+
} from '@internationalized/date';
|
|
16
|
+
|
|
17
|
+
const DEFAULT_LOCALE = 'en-GB';
|
|
18
|
+
|
|
19
|
+
// Day of the week
|
|
20
|
+
|
|
21
|
+
export const Days = [
|
|
22
|
+
'Monday',
|
|
23
|
+
'Tuesday',
|
|
24
|
+
'Wednesday',
|
|
25
|
+
'Thursday',
|
|
26
|
+
'Friday',
|
|
27
|
+
'Saturday',
|
|
28
|
+
'Sunday',
|
|
29
|
+
] as const;
|
|
30
|
+
export type Day = (typeof Days)[number];
|
|
31
|
+
|
|
32
|
+
export const DayIndex: Record<Day, number> = Object.fromEntries(
|
|
33
|
+
Days.map((day, index) => [day, index])
|
|
34
|
+
) as Record<Day, number>;
|
|
35
|
+
|
|
36
|
+
// Date functions local to a timezone
|
|
37
|
+
export class LocalDateF {
|
|
38
|
+
constructor(private readonly timezone: string) {}
|
|
39
|
+
|
|
40
|
+
/** @returns The current date and time. */
|
|
41
|
+
now(): ZonedDateTime {
|
|
42
|
+
return now(this.timezone);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @returns The current date. */
|
|
46
|
+
today(): CalendarDate {
|
|
47
|
+
return today(this.timezone);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const SERVER_TIME_ZONE = 'Europe/London';
|
|
52
|
+
export const SERVER_LOCALE = 'en-GB';
|
|
53
|
+
|
|
54
|
+
// Date functions local to server time zone
|
|
55
|
+
export const ServerDt = new LocalDateF(SERVER_TIME_ZONE);
|
|
56
|
+
|
|
57
|
+
/** Gets the day index of the date */
|
|
58
|
+
function getDayIndexOfDate(date: CalendarDate): number {
|
|
59
|
+
// Always start 0 on Monday
|
|
60
|
+
return getDayOfWeek(date, DEFAULT_LOCALE, 'mon');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Gets the day of the week for a given date.
|
|
65
|
+
* @param date - The date to get the day of the week for.
|
|
66
|
+
* @returns The day of the week
|
|
67
|
+
*/
|
|
68
|
+
function getDayOfDate(date: CalendarDate): Day {
|
|
69
|
+
return Days[getDayIndexOfDate(date)];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Formatting */
|
|
73
|
+
|
|
74
|
+
/* Day of the week */
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Formats a day of the week.
|
|
78
|
+
* @param day - The day of the week to format.
|
|
79
|
+
* @example "Monday" -> "Mon"
|
|
80
|
+
* @returns Formatted string of the day of the week.
|
|
81
|
+
*/
|
|
82
|
+
function formatDayShort(day: Day): string {
|
|
83
|
+
// Use the first three letters of the day
|
|
84
|
+
return day.slice(0, 3);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Calendar Dates */
|
|
88
|
+
|
|
89
|
+
const FullDateFormatter = new DateFormatter(DEFAULT_LOCALE, {
|
|
90
|
+
day: 'numeric',
|
|
91
|
+
month: 'short',
|
|
92
|
+
year: 'numeric',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const ShortDateFormatter = new DateFormatter(DEFAULT_LOCALE, {
|
|
96
|
+
day: 'numeric',
|
|
97
|
+
month: 'short',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const NumFormatter = new DateFormatter(DEFAULT_LOCALE, {
|
|
101
|
+
day: '2-digit',
|
|
102
|
+
month: '2-digit',
|
|
103
|
+
year: 'numeric',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param date The CalendarDate object to format.
|
|
108
|
+
* @returns The formatted date string in dd/mm/yyyy format.
|
|
109
|
+
* @example "05/10/2023"
|
|
110
|
+
*/
|
|
111
|
+
function formatDateNum(date: CalendarDate): string {
|
|
112
|
+
return formatDate(date, NumFormatter);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Formats a full date and time.
|
|
117
|
+
* @param datetime The ZonedDateTime object to format.
|
|
118
|
+
* @returns The formatted date and time string.
|
|
119
|
+
* @example "05/10/2023 14:30:00"
|
|
120
|
+
*/
|
|
121
|
+
function formatAbsolute(datetime: ZonedDateTime): string {
|
|
122
|
+
const date = toCalendarDate(datetime);
|
|
123
|
+
const time = toTime(datetime);
|
|
124
|
+
return `${formatDateNum(date)} ${formatTimeFull(time)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param date The CalendarDate object to format.
|
|
129
|
+
* @returns The formatted date string.
|
|
130
|
+
* @example "5 Oct 2023"
|
|
131
|
+
*/
|
|
132
|
+
function formatDateFull(date: CalendarDate) {
|
|
133
|
+
return formatDate(date, FullDateFormatter);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* ISO format
|
|
138
|
+
* @param date The CalendarDate object to format.
|
|
139
|
+
* @returns The formatted date string in YYYY-MM-DD format.
|
|
140
|
+
* @example "2023-10-05"
|
|
141
|
+
*/
|
|
142
|
+
function formatDateISO(date: CalendarDate): string {
|
|
143
|
+
return date.toString();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param date The CalendarDate object to format.
|
|
148
|
+
* @returns string of date in shortened format
|
|
149
|
+
* @example "Oct 5"
|
|
150
|
+
*/
|
|
151
|
+
function formatDateShort(date: CalendarDate) {
|
|
152
|
+
return formatDate(date, ShortDateFormatter);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Times */
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Gives time in HH:MM format
|
|
159
|
+
* @param time
|
|
160
|
+
* @returns string of time in that format
|
|
161
|
+
*/
|
|
162
|
+
function formatTime(time: Time): string {
|
|
163
|
+
const hours = padNum(time.hour, 2);
|
|
164
|
+
const minutes = padNum(time.minute, 2);
|
|
165
|
+
return `${hours}:${minutes}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Gives time in HH:MM:SS format
|
|
170
|
+
* @param time
|
|
171
|
+
* @returns string of time in that format
|
|
172
|
+
*/
|
|
173
|
+
function formatTimeFull(time: Time): string {
|
|
174
|
+
const hours = padNum(time.hour, 2);
|
|
175
|
+
const minutes = padNum(time.minute, 2);
|
|
176
|
+
const seconds = padNum(time.second, 2);
|
|
177
|
+
return `${hours}:${minutes}:${seconds}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Helpers */
|
|
181
|
+
|
|
182
|
+
function formatDate(date: CalendarDate, formatter: DateFormatter): string {
|
|
183
|
+
const nativeDate = date.toDate(getLocalTimeZone());
|
|
184
|
+
return formatter.format(nativeDate);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function padNum(num: number, len: number): string {
|
|
188
|
+
if (isNaN(num)) return '0'.repeat(len);
|
|
189
|
+
|
|
190
|
+
return num.toString().padStart(len, '0');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// An absolute instant re-zoned into a viewer's timezone. Without `timezone`, call from client
|
|
194
|
+
// code only: `getLocalTimeZone()` reads the current runtime's zone, so server-side it resolves to
|
|
195
|
+
// the server zone. The apps that display these are `ssr: false`, so that default always runs in
|
|
196
|
+
// the browser. Server-rendered content with no browser to detect from (e.g. an email) must pass
|
|
197
|
+
// the viewer's stored `user.timezone` explicitly.
|
|
198
|
+
const inViewerZone = (zdt: ZonedDateTime, timezone?: string): ZonedDateTime =>
|
|
199
|
+
fromDate(zdt.toDate(), timezone ?? getLocalTimeZone());
|
|
200
|
+
|
|
201
|
+
/** HH:MM in the viewer's timezone (stored `timezone`, else the browser's local zone). */
|
|
202
|
+
const formatLocalTime = (zdt: ZonedDateTime, timezone?: string): string =>
|
|
203
|
+
formatTime(toTime(inViewerZone(zdt, timezone)));
|
|
204
|
+
|
|
205
|
+
/** Full date (e.g. "5 Oct 2023") in the viewer's timezone (stored `timezone`, else the browser's local zone). */
|
|
206
|
+
const formatLocalDateFull = (zdt: ZonedDateTime, timezone?: string): string =>
|
|
207
|
+
formatDateFull(toCalendarDate(inViewerZone(zdt, timezone)));
|
|
208
|
+
|
|
209
|
+
/** Numeric date (dd/mm/yyyy) in the viewer's local timezone. */
|
|
210
|
+
const formatLocalDateNum = (zdt: ZonedDateTime): string =>
|
|
211
|
+
formatDateNum(toCalendarDate(inViewerZone(zdt)));
|
|
212
|
+
|
|
213
|
+
/** Numeric date + HH:MM:SS (e.g. "05/10/2023 14:30:00") in the viewer's timezone (stored
|
|
214
|
+
* `timezone`, else the browser's local zone). */
|
|
215
|
+
const formatLocalDateTime = (zdt: ZonedDateTime, timezone?: string): string =>
|
|
216
|
+
formatAbsolute(inViewerZone(zdt, timezone));
|
|
217
|
+
|
|
218
|
+
/** Whole days from `zdt` (a past instant) to today, in the viewer's timezone (clamped ≥ 0). */
|
|
219
|
+
const daysSince = (zdt: ZonedDateTime): number =>
|
|
220
|
+
Math.max(0, today(getLocalTimeZone()).compare(toCalendarDate(inViewerZone(zdt))));
|
|
221
|
+
|
|
222
|
+
/** Whole days from today to `zdt` (a future instant), in the viewer's timezone (clamped ≥ 0). */
|
|
223
|
+
const daysUntil = (zdt: ZonedDateTime): number =>
|
|
224
|
+
Math.max(0, toCalendarDate(inViewerZone(zdt)).compare(today(getLocalTimeZone())));
|
|
225
|
+
|
|
226
|
+
/** Full local date + whole days since the instant, for a "started/created on {date} · {daysAgo}d ago" UI. */
|
|
227
|
+
const formatDateWithDaysAgo = (zdt: ZonedDateTime): { date: string; daysAgo: number } => ({
|
|
228
|
+
date: formatLocalDateFull(zdt),
|
|
229
|
+
daysAgo: daysSince(zdt),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/** `from`, pulled forward so the inclusive `from`→`to` range spans at most `maxDays` days.
|
|
233
|
+
* A range already within the limit comes back unchanged. */
|
|
234
|
+
const clampSpanStart = (from: CalendarDate, to: CalendarDate, maxDays: number): CalendarDate =>
|
|
235
|
+
to.compare(from) > maxDays ? to.subtract({ days: maxDays }) : from;
|
|
236
|
+
|
|
237
|
+
/** Short weekday label for a date, e.g. "Mon". */
|
|
238
|
+
const weekdayShort = (date: CalendarDate): string => formatDayShort(getDayOfDate(date));
|
|
239
|
+
|
|
240
|
+
/** A CalendarDate as a short weekday + day-of-month, e.g. "Mon 31". */
|
|
241
|
+
const formatWeekdayDay = (date: CalendarDate): string => `${weekdayShort(date)} ${date.day}`;
|
|
242
|
+
|
|
243
|
+
/** A ClickHouse DateTime, returned as epoch seconds (the `unix_timestamp` output format), as a ZonedDateTime. */
|
|
244
|
+
const zonedFromUnixSeconds = (seconds: number): ZonedDateTime =>
|
|
245
|
+
fromDate(new Date(seconds * 1000), SERVER_TIME_ZONE);
|
|
246
|
+
|
|
247
|
+
/** A raw Postgres `timestamptz` string, as returned by a raw `ctx.db.execute(sql\`...\`)` select
|
|
248
|
+
* (bypassing the `absoluteDateTime` custom type's own driver conversion), as a ZonedDateTime. */
|
|
249
|
+
const zonedFromPgTimestamp = (s: string): ZonedDateTime => fromDate(new Date(s), SERVER_TIME_ZONE);
|
|
250
|
+
|
|
251
|
+
/** Domain → wire: a ZonedDateTime as epoch seconds, for CH inserts / query params only. */
|
|
252
|
+
const toUnixSeconds = (zdt: ZonedDateTime): number => Math.floor(zdt.toDate().getTime() / 1000);
|
|
253
|
+
|
|
254
|
+
/** Domain → wire: a CalendarDate at server-tz start-of-day, as epoch seconds (CH DateTime query param). */
|
|
255
|
+
const dayStartUnix = (date: CalendarDate): number => toUnixSeconds(toZoned(date, SERVER_TIME_ZONE));
|
|
256
|
+
|
|
257
|
+
/** Epoch milliseconds (e.g. Gmail `internalDate`) as a ZonedDateTime. */
|
|
258
|
+
const zonedFromUnixMillis = (millis: number): ZonedDateTime =>
|
|
259
|
+
fromDate(new Date(millis), SERVER_TIME_ZONE);
|
|
260
|
+
|
|
261
|
+
/** Domain → wire: a ZonedDateTime as epoch milliseconds (e.g. an HTTP JSON boundary). */
|
|
262
|
+
const toUnixMillis = (zdt: ZonedDateTime): number => zdt.toDate().getTime();
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A ClickHouse `Date` column ("YYYY-MM-DD") as a CalendarDate. Null/empty and the `1970-01-01`
|
|
266
|
+
* zero-date sentinel (CH's null-Date placeholder) map to `null`.
|
|
267
|
+
*/
|
|
268
|
+
const calendarFromChDate = (s: null | string | undefined): CalendarDate | null =>
|
|
269
|
+
s && s !== '1970-01-01' ? parseDate(s) : null;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Compact, human relative time for a **past** instant, rendered in the viewer's timezone:
|
|
273
|
+
* "Just now" (< 1 min), "5m" (< 1 h), "3h" (< 1 day), "Yesterday" (exactly 1 day), then "4d".
|
|
274
|
+
* Elapsed time is the absolute now→`zdt` gap, so the result is timezone-independent.
|
|
275
|
+
*/
|
|
276
|
+
const formatRelativeShort = (zdt: ZonedDateTime): string => {
|
|
277
|
+
const minutes = Math.floor(now(getLocalTimeZone()).compare(zdt) / 60_000);
|
|
278
|
+
if (minutes < 1) return 'Just now';
|
|
279
|
+
if (minutes < 60) return `${minutes}m`;
|
|
280
|
+
const hours = Math.floor(minutes / 60);
|
|
281
|
+
if (hours < 24) return `${hours}h`;
|
|
282
|
+
const days = Math.floor(hours / 24);
|
|
283
|
+
if (days === 1) return 'Yesterday';
|
|
284
|
+
return `${days}d`;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Full-word relative time for a **past** instant: "Just now" (< 1 min), "5 minutes ago",
|
|
289
|
+
* "1 hour ago", "10 hours ago", "3 days ago"; 30 days or more falls back to the full local date.
|
|
290
|
+
* Elapsed time is the absolute gap, so the result is timezone-independent. `from` is the "now"
|
|
291
|
+
* to measure against (injectable for tests).
|
|
292
|
+
*/
|
|
293
|
+
const formatRelativeAgo = (
|
|
294
|
+
zdt: ZonedDateTime,
|
|
295
|
+
from: ZonedDateTime = now(getLocalTimeZone())
|
|
296
|
+
): string => {
|
|
297
|
+
const minutes = Math.floor(from.compare(zdt) / 60_000);
|
|
298
|
+
if (minutes < 1) return 'Just now';
|
|
299
|
+
if (minutes < 60) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
|
|
300
|
+
const hours = Math.floor(minutes / 60);
|
|
301
|
+
if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
|
|
302
|
+
const days = Math.floor(hours / 24);
|
|
303
|
+
if (days < 30) return `${days} ${days === 1 ? 'day' : 'days'} ago`;
|
|
304
|
+
return formatLocalDateFull(zdt);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** Date/time formatting + wire-format conversion — a plain object of functions (a Library, same
|
|
308
|
+
* pattern as `Crypto`/`Schema`/`Normalize`), not a service: pure functions, no I/O. `ServerDt`
|
|
309
|
+
* (above) is a tiny library of its own already — `now()`/`today()` in the server timezone —
|
|
310
|
+
* so it stays a standalone export rather than folding in here. */
|
|
311
|
+
export const Datetime = {
|
|
312
|
+
calendarFromChDate,
|
|
313
|
+
clampSpanStart,
|
|
314
|
+
daysSince,
|
|
315
|
+
dayStartUnix,
|
|
316
|
+
daysUntil,
|
|
317
|
+
formatAbsolute,
|
|
318
|
+
formatDateFull,
|
|
319
|
+
formatDateISO,
|
|
320
|
+
formatDateShort,
|
|
321
|
+
formatDateWithDaysAgo,
|
|
322
|
+
formatDayShort,
|
|
323
|
+
formatLocalDateFull,
|
|
324
|
+
formatLocalDateNum,
|
|
325
|
+
formatLocalDateTime,
|
|
326
|
+
formatLocalTime,
|
|
327
|
+
formatRelativeAgo,
|
|
328
|
+
formatRelativeShort,
|
|
329
|
+
formatTime,
|
|
330
|
+
formatWeekdayDay,
|
|
331
|
+
getDayOfDate,
|
|
332
|
+
toUnixMillis,
|
|
333
|
+
toUnixSeconds,
|
|
334
|
+
weekdayShort,
|
|
335
|
+
zonedFromPgTimestamp,
|
|
336
|
+
zonedFromUnixMillis,
|
|
337
|
+
zonedFromUnixSeconds,
|
|
338
|
+
};
|
package/utils/email.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Format } from './strings/format';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transactional email layout, table-based with every style
|
|
5
|
+
* inlined (the only markup that renders consistently across mail clients), a hidden preheader line,
|
|
6
|
+
* an accent strip, a heading, label/value rows, one call-to-action button and a footer. The header
|
|
7
|
+
* is a styled text wordmark, not an image or inline SVG, which Outlook, Gmail and Yahoo strip.
|
|
8
|
+
*
|
|
9
|
+
* Brand-agnostic: callers pass an `EmailBrand`. Colours are hex strings because mail clients have no
|
|
10
|
+
* CSS variables. The same params also render a plain-text alternative, so the two never drift.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type EmailBrand = {
|
|
14
|
+
/** Strip, badge and links. */
|
|
15
|
+
accent: string;
|
|
16
|
+
/** Text on a white card that must stay readable: wordmark, button fill. */
|
|
17
|
+
accentDark: string;
|
|
18
|
+
/** Badge background. */
|
|
19
|
+
accentSoft: string;
|
|
20
|
+
/** Printed in the footer, e.g. a postal address. */
|
|
21
|
+
address?: string;
|
|
22
|
+
name: string;
|
|
23
|
+
supportEmail: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type EmailRow = {
|
|
27
|
+
label: string;
|
|
28
|
+
strong?: boolean;
|
|
29
|
+
/** Smaller line under the label, e.g. "2 × £18.00". */
|
|
30
|
+
sub?: string;
|
|
31
|
+
value: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type EmailSection =
|
|
35
|
+
{ lines: string[]; title: string } | { rows: EmailRow[]; title?: string };
|
|
36
|
+
|
|
37
|
+
export type EmailParams = {
|
|
38
|
+
badge?: string;
|
|
39
|
+
brand: EmailBrand;
|
|
40
|
+
cta?: { href: string; label: string };
|
|
41
|
+
eyebrow: string;
|
|
42
|
+
footnote?: string;
|
|
43
|
+
heading: string;
|
|
44
|
+
/** Paragraphs under the heading. */
|
|
45
|
+
intro: string[];
|
|
46
|
+
/** Inbox-preview line. */
|
|
47
|
+
preheader: string;
|
|
48
|
+
sections: EmailSection[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const FONT =
|
|
52
|
+
"'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
|
|
53
|
+
const INK = '#1f2937';
|
|
54
|
+
const MUTED = '#6b7280';
|
|
55
|
+
const RULE = '#e5e7eb';
|
|
56
|
+
|
|
57
|
+
const e = Format.escapeHtml;
|
|
58
|
+
|
|
59
|
+
function renderRows(rows: EmailRow[]): string {
|
|
60
|
+
return rows
|
|
61
|
+
.map(
|
|
62
|
+
(row, i) => `
|
|
63
|
+
${i > 0 ? `<tr><td colspan="2" style="border-top:1px solid ${RULE};height:1px;font-size:0;line-height:0;"> </td></tr>` : ''}
|
|
64
|
+
<tr>
|
|
65
|
+
<td style="font-family:${FONT};font-size:14px;line-height:1.4;color:${INK};padding:12px 12px 12px 0;font-weight:${row.strong ? 700 : 400};">
|
|
66
|
+
${e(row.label)}${row.sub ? `<br /><span style="font-size:12.5px;color:${MUTED};font-weight:400;">${e(row.sub)}</span>` : ''}
|
|
67
|
+
</td>
|
|
68
|
+
<td align="right" valign="top" style="font-family:${FONT};font-size:14px;line-height:1.4;color:${INK};padding:12px 0;white-space:nowrap;font-weight:${row.strong ? 700 : 500};">${e(row.value)}</td>
|
|
69
|
+
</tr>`
|
|
70
|
+
)
|
|
71
|
+
.join('');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderSection(section: EmailSection): string {
|
|
75
|
+
const title = section.title
|
|
76
|
+
? `<p style="font-family:${FONT};font-size:11px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:${MUTED};margin:0 0 4px;">${e(section.title)}</p>`
|
|
77
|
+
: '';
|
|
78
|
+
const body =
|
|
79
|
+
'rows' in section
|
|
80
|
+
? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">${renderRows(section.rows)}</table>`
|
|
81
|
+
: `<p style="font-family:${FONT};font-size:14px;line-height:1.6;color:${INK};margin:0;">${section.lines.map(e).join('<br />')}</p>`;
|
|
82
|
+
return `
|
|
83
|
+
<tr><td style="padding:0 36px 24px;">
|
|
84
|
+
${title}
|
|
85
|
+
${body}
|
|
86
|
+
</td></tr>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderEmail(params: EmailParams): string {
|
|
90
|
+
const { brand } = params;
|
|
91
|
+
return `<!DOCTYPE html>
|
|
92
|
+
<html lang="en">
|
|
93
|
+
<head>
|
|
94
|
+
<meta charset="utf-8" />
|
|
95
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
96
|
+
<meta name="color-scheme" content="light" />
|
|
97
|
+
<title>${e(params.heading)}</title>
|
|
98
|
+
</head>
|
|
99
|
+
<body style="background:#f4f5f0;margin:0;padding:0;">
|
|
100
|
+
<div style="display:none;max-height:0;overflow:hidden;">${e(params.preheader)}͏‌ ͏‌ ͏‌ </div>
|
|
101
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:#f4f5f0;">
|
|
102
|
+
<tr><td align="center" style="padding:40px 16px 48px;">
|
|
103
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="max-width:600px;width:100%;">
|
|
104
|
+
|
|
105
|
+
<tr><td style="padding:0 4px 20px;">
|
|
106
|
+
<span style="font-family:${FONT};font-size:22px;font-weight:700;letter-spacing:-0.02em;color:${brand.accentDark};">${e(brand.name)}</span>
|
|
107
|
+
</td></tr>
|
|
108
|
+
|
|
109
|
+
<tr><td style="background:#ffffff;border:1px solid ${RULE};border-radius:12px;">
|
|
110
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
111
|
+
<tr><td style="background:${brand.accent};height:4px;border-radius:12px 12px 0 0;font-size:0;line-height:0;"> </td></tr>
|
|
112
|
+
|
|
113
|
+
<tr><td style="padding:30px 36px 8px;">
|
|
114
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
|
115
|
+
<tr>
|
|
116
|
+
<td><span style="font-family:${FONT};font-size:11px;font-weight:600;color:${MUTED};text-transform:uppercase;letter-spacing:0.08em;">${e(params.eyebrow)}</span></td>
|
|
117
|
+
${params.badge ? `<td align="right"><span style="display:inline-block;background:${brand.accentSoft};color:${brand.accentDark};font-family:${FONT};font-size:11px;font-weight:600;padding:4px 10px;border-radius:99px;">${e(params.badge)}</span></td>` : ''}
|
|
118
|
+
</tr>
|
|
119
|
+
</table>
|
|
120
|
+
<h1 style="font-family:${FONT};font-size:26px;font-weight:700;color:${INK};letter-spacing:-0.03em;line-height:1.25;margin:16px 0 12px;">${e(params.heading)}</h1>
|
|
121
|
+
${params.intro.map((p) => `<p style="font-family:${FONT};font-size:14.5px;line-height:1.6;color:#4b5563;margin:0 0 12px;">${e(p)}</p>`).join('\n\t\t\t\t\t\t\t\t')}
|
|
122
|
+
</td></tr>
|
|
123
|
+
|
|
124
|
+
<tr><td style="padding:12px 36px 8px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="border-top:1px solid ${RULE};height:1px;font-size:0;line-height:0;"> </td></tr></table></td></tr>
|
|
125
|
+
${params.sections.map(renderSection).join('')}
|
|
126
|
+
|
|
127
|
+
${
|
|
128
|
+
params.cta
|
|
129
|
+
? `<tr><td style="padding:0 36px 32px;">
|
|
130
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
|
131
|
+
<tr><td style="background:${brand.accentDark};border-radius:8px;">
|
|
132
|
+
<a href="${e(params.cta.href)}" style="display:inline-block;font-family:${FONT};font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;padding:12px 24px;">${e(params.cta.label)}</a>
|
|
133
|
+
</td></tr>
|
|
134
|
+
</table>
|
|
135
|
+
</td></tr>`
|
|
136
|
+
: ''
|
|
137
|
+
}
|
|
138
|
+
</table>
|
|
139
|
+
</td></tr>
|
|
140
|
+
|
|
141
|
+
<tr><td align="center" style="padding-top:28px;">
|
|
142
|
+
<p style="font-family:${FONT};font-size:12.5px;color:${MUTED};line-height:1.7;margin:0;text-align:center;">
|
|
143
|
+
${params.footnote ? `${e(params.footnote)}<br />` : ''}
|
|
144
|
+
Questions? Reply to this email or write to <a href="mailto:${e(brand.supportEmail)}" style="color:${MUTED};text-decoration:underline;">${e(brand.supportEmail)}</a>.${brand.address ? `<br />${e(brand.name)}, ${e(brand.address)}` : ''}
|
|
145
|
+
</p>
|
|
146
|
+
</td></tr>
|
|
147
|
+
|
|
148
|
+
</table>
|
|
149
|
+
</td></tr>
|
|
150
|
+
</table>
|
|
151
|
+
</body>
|
|
152
|
+
</html>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The same content as plain text, for clients that don't show HTML and as the multipart fallback. */
|
|
156
|
+
export function renderEmailText(params: EmailParams): string {
|
|
157
|
+
const out: string[] = [params.heading, '', ...params.intro.flatMap((p) => [p, ''])];
|
|
158
|
+
for (const section of params.sections) {
|
|
159
|
+
if (section.title) out.push(section.title.toUpperCase());
|
|
160
|
+
if ('rows' in section) {
|
|
161
|
+
for (const row of section.rows) {
|
|
162
|
+
out.push(`${row.label}${row.sub ? ` (${row.sub})` : ''}: ${row.value}`);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
out.push(...section.lines);
|
|
166
|
+
}
|
|
167
|
+
out.push('');
|
|
168
|
+
}
|
|
169
|
+
if (params.cta) out.push(`${params.cta.label}: ${params.cta.href}`, '');
|
|
170
|
+
if (params.footnote) out.push(params.footnote, '');
|
|
171
|
+
out.push(`Questions? Reply to this email or write to ${params.brand.supportEmail}.`);
|
|
172
|
+
return out.join('\n') + '\n';
|
|
173
|
+
}
|
package/utils/env.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/** Config vars (`vars` in wrangler.jsonc) AND secrets (Secrets Store bindings in prod, plain
|
|
2
|
+
* strings in local `.dev.vars`/`.env`) — one store for both. Construct directly in tests
|
|
3
|
+
* (`new EnvStore({ AUTH_URL: '...' })`) — real validation logic, no fake method stubs to keep in
|
|
4
|
+
* sync. Production gets the one real instance via `resolve()`/`envStore()` below. */
|
|
5
|
+
export class EnvStore {
|
|
6
|
+
private static instance: EnvStore | null = null;
|
|
7
|
+
|
|
8
|
+
private readonly secrets: Record<string, string> = {};
|
|
9
|
+
|
|
10
|
+
constructor(private readonly env: Record<string, unknown>) {}
|
|
11
|
+
|
|
12
|
+
static current(): EnvStore {
|
|
13
|
+
if (!EnvStore.instance) throw new Error('EnvStore.resolve() has not run yet');
|
|
14
|
+
return EnvStore.instance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Test-only: clears the cached instance so the next `resolve()` call rebuilds against
|
|
18
|
+
* whatever env is current. A real isolate never needs this — the binding is isolate-stable
|
|
19
|
+
* for its whole lifetime; only a test process resolving many different envs does. */
|
|
20
|
+
static resetForTests(): void {
|
|
21
|
+
EnvStore.instance = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Builds the one real instance for this isolate — idempotent. Called from `runWithContext`,
|
|
25
|
+
* or directly (a Durable Object, say) if that never runs. Takes `unknown` so callers never
|
|
26
|
+
* need their own cast.
|
|
27
|
+
*
|
|
28
|
+
* Eagerly resolves every binding named in `secretNames` so `secret()` can stay sync afterward.
|
|
29
|
+
* Gated on the name, not the binding's shape: Secrets Store, Service Bindings, and VPC services
|
|
30
|
+
* are all backed by the identical `Fetcher` RPC stub (same `constructor.name`, same prototype,
|
|
31
|
+
* zero own keys — verified live), so a prior attempt at excluding fetch-shaped bindings from
|
|
32
|
+
* this scan (#1262) instead excluded every real secret too and broke them all in prod (reverted
|
|
33
|
+
* in #1263). Filtering by name is the only signal that's actually reliable, and as a side
|
|
34
|
+
* effect it also means `.get()` is never called on a Service/VPC binding, so it stops causing a
|
|
35
|
+
* spurious cross-worker RPC exception log too. `secretNames` itself is app config
|
|
36
|
+
* (the names of the Secrets Store bindings), not here — this package stays account-agnostic, the actual
|
|
37
|
+
* list of real secret names is per-app config.
|
|
38
|
+
*
|
|
39
|
+
* Local resolution is unreliable in two distinct ways (see the wiki): `.get()` can throw (an
|
|
40
|
+
* unseeded binding), or it can resolve cleanly to something that isn't a real secret at all —
|
|
41
|
+
* `null`, or even a non-string value. Either case falls back to a same-named `process.env`
|
|
42
|
+
* value (populated from `.env` by the SvelteKit/Vite dev server) so `secret()` works the same
|
|
43
|
+
* locally as in prod without every app re-implementing this fallback itself; if that's unset
|
|
44
|
+
* too, the secret is left unresolved, surfaced only if something actually reads it via
|
|
45
|
+
* `secret()`. `process` is absent in the real Workers runtime (no `nodejs_compat` polyfill
|
|
46
|
+
* covers it here), so this fallback only ever fires under local dev. */
|
|
47
|
+
static async resolve(env: unknown, secretNames: ReadonlySet<string>): Promise<void> {
|
|
48
|
+
if (EnvStore.instance) return;
|
|
49
|
+
const raw = env as Record<string, unknown>;
|
|
50
|
+
const instance = new EnvStore(raw);
|
|
51
|
+
const nodeEnv = typeof process === 'undefined' ? undefined : process.env;
|
|
52
|
+
await Promise.all(
|
|
53
|
+
Object.entries(raw).map(async ([name, value]) => {
|
|
54
|
+
if (
|
|
55
|
+
secretNames.has(name) &&
|
|
56
|
+
value != null &&
|
|
57
|
+
typeof value === 'object' &&
|
|
58
|
+
typeof (value as { get?: unknown }).get === 'function'
|
|
59
|
+
) {
|
|
60
|
+
let resolved: string | undefined;
|
|
61
|
+
try {
|
|
62
|
+
const got = await (value as { get(): Promise<unknown> }).get();
|
|
63
|
+
resolved = typeof got === 'string' && got.length > 0 ? got : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
resolved = undefined;
|
|
66
|
+
}
|
|
67
|
+
const fallback = resolved ?? nodeEnv?.[name];
|
|
68
|
+
if (fallback) instance.secrets[name] = fallback;
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
EnvStore.instance = instance;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Any other raw binding (Workers AI, R2, a Durable Object namespace, …) that isn't a plain
|
|
76
|
+
* var/VPC-fetch-shaped/secret — just existence-checked, then handed back as `T`. */
|
|
77
|
+
binding<T>(name: string): T {
|
|
78
|
+
const value = this.env[name];
|
|
79
|
+
if (!value) throw new Error(`EnvStore: "${name}" binding is not configured`);
|
|
80
|
+
return value as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A Cloudflare Hyperdrive binding. */
|
|
84
|
+
hyperdrive(name: string): Hyperdrive {
|
|
85
|
+
return this.binding<Hyperdrive>(name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A numeric `vars` entry (e.g. `SENDER_BATCH_LIMIT: "500"`) — still a string in
|
|
89
|
+
* wrangler.jsonc, parsed and validated here instead of a bare `Number(...)` at the call site,
|
|
90
|
+
* which silently produces `NaN` on a bad value rather than failing. */
|
|
91
|
+
int(name: string): number {
|
|
92
|
+
const value = this.string(name);
|
|
93
|
+
const parsed = Number(value);
|
|
94
|
+
if (!Number.isInteger(parsed))
|
|
95
|
+
throw new Error(`EnvStore: "${name}" ("${value}") is not an integer`);
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A secret — a Secrets Store binding in prod (resolved once in `resolve()` for names in
|
|
100
|
+
* `SECRET_STORE_BINDING_NAMES`), or a plain string in local `.dev.vars`/`.env`. A name missing
|
|
101
|
+
* from that set just fails loudly here at first call instead of at compile time, same tradeoff
|
|
102
|
+
* `string()` already makes. */
|
|
103
|
+
secret(name: string): string {
|
|
104
|
+
const resolved = this.secrets[name];
|
|
105
|
+
if (resolved != null) return resolved;
|
|
106
|
+
const value = this.env[name];
|
|
107
|
+
if (typeof value === 'string') return value;
|
|
108
|
+
throw new Error(`EnvStore: "${name}" secret is not configured in this env`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Same as `secret()` but for a secret that's genuinely optional — `null` instead of throwing
|
|
112
|
+
* when unresolved (unset in this env, or an unseeded local Secrets Store binding with no
|
|
113
|
+
* `.env` fallback either). */
|
|
114
|
+
secretNull(name: string): null | string {
|
|
115
|
+
const resolved = this.secrets[name];
|
|
116
|
+
if (resolved != null) return resolved;
|
|
117
|
+
const value = this.env[name];
|
|
118
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** No enumerated key list: this env's own vars vary too widely per app/worker to maintain one,
|
|
122
|
+
* and a typo'd name still fails loudly, just at first call instead of at compile time. */
|
|
123
|
+
string(name: string): string {
|
|
124
|
+
const value = this.lookup(name);
|
|
125
|
+
if (typeof value !== 'string') throw new Error(`EnvStore: "${name}" is not a string`);
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Same as `string()` but for a var that's genuinely optional (some apps/workers set it,
|
|
130
|
+
* others don't) — `null` instead of throwing when unset. */
|
|
131
|
+
stringNull(name: string): null | string {
|
|
132
|
+
const value = this.env[name];
|
|
133
|
+
if (value == null || value === '') return null;
|
|
134
|
+
if (typeof value !== 'string') throw new Error(`EnvStore: "${name}" is not a string`);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A Workers VPC Service binding (`fetch`/`connect`, real Cloudflare shape — no cast needed at
|
|
139
|
+
* the call site). */
|
|
140
|
+
vpc(name: string): Fetcher {
|
|
141
|
+
const value = this.env[name];
|
|
142
|
+
if (!value) throw new Error(`EnvStore: "${name}" VPC binding is not configured`);
|
|
143
|
+
return value as Fetcher;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Same as `vpc()` but for a VPC binding that's genuinely optional (e.g. local dev has no VPC
|
|
147
|
+
* services at all) — `null` instead of throwing when unset. */
|
|
148
|
+
vpcNull(name: string): Fetcher | null {
|
|
149
|
+
const value = this.env[name];
|
|
150
|
+
return value ? (value as Fetcher) : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private lookup(name: string): unknown {
|
|
154
|
+
const value = this.env[name];
|
|
155
|
+
if (
|
|
156
|
+
value != null &&
|
|
157
|
+
typeof value === 'object' &&
|
|
158
|
+
typeof (value as { get?: unknown }).get === 'function'
|
|
159
|
+
) {
|
|
160
|
+
throw new Error(`EnvStore: "${name}" is a Secrets Store binding — use secret() instead`);
|
|
161
|
+
}
|
|
162
|
+
if (value == null || value === '') {
|
|
163
|
+
throw new Error(`EnvStore: "${name}" is not configured in this env`);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function envStore(): EnvStore {
|
|
170
|
+
return EnvStore.current();
|
|
171
|
+
}
|