@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
package/package.json
CHANGED
|
@@ -1,6 +1,151 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@zeyolabs/kit",
|
|
3
|
+
"version": "2026.11.5",
|
|
4
|
+
"description": "Shared SvelteKit-on-Cloudflare server toolkit: remote-function wrappers, Cloudflare context, monitoring, storage clients, utils and the ESLint config.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zeyolabs/kit.git"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"provenance": true
|
|
13
|
+
},
|
|
14
|
+
"packageManager": "pnpm@11.5.0",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24.0.0",
|
|
17
|
+
"pnpm": "^11.0.0"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"eslint-config.js",
|
|
21
|
+
"cloudflare",
|
|
22
|
+
"monitoring",
|
|
23
|
+
"server",
|
|
24
|
+
"storage",
|
|
25
|
+
"utils",
|
|
26
|
+
"!**/*.test.ts",
|
|
27
|
+
"!**/*.test-d.ts",
|
|
28
|
+
"!**/*.integration.test.ts",
|
|
29
|
+
"!**/__fixtures__",
|
|
30
|
+
"!**/*.proto",
|
|
31
|
+
"!**/buf*.yaml"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"generate:proto": "cd monitoring/prometheus && buf generate",
|
|
35
|
+
"lint": "eslint . --cache --cache-location .cache/.eslintcache --cache-strategy content",
|
|
36
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:integration": "vitest run --config vitest.config.integration.ts",
|
|
39
|
+
"check": "prettier --check . && pnpm lint && pnpm typecheck && pnpm test",
|
|
40
|
+
"fmt": "prettier --write .",
|
|
41
|
+
"release": "node scripts/release.mjs"
|
|
42
|
+
},
|
|
43
|
+
"exports": {
|
|
44
|
+
"./eslint-config": "./eslint-config.js",
|
|
45
|
+
"./utils/*": "./utils/*.ts",
|
|
46
|
+
"./server/*": "./server/*.ts",
|
|
47
|
+
"./cloudflare/*": "./cloudflare/*.ts",
|
|
48
|
+
"./cloudflare/context": "./cloudflare/context.ts",
|
|
49
|
+
"./cloudflare/amqp": "./cloudflare/amqp/client.ts",
|
|
50
|
+
"./cloudflare/dispatch": "./cloudflare/dispatch/client.ts",
|
|
51
|
+
"./cloudflare/email": "./cloudflare/email/client.ts",
|
|
52
|
+
"./cloudflare/email/dev": "./cloudflare/email/dev.ts",
|
|
53
|
+
"./cloudflare/email/testing": "./cloudflare/email/testing.ts",
|
|
54
|
+
"./cloudflare/r2": "./cloudflare/r2/client.ts",
|
|
55
|
+
"./cloudflare/r2/testing": "./cloudflare/r2/testing.ts",
|
|
56
|
+
"./cloudflare/wait-until": "./cloudflare/wait-until.ts",
|
|
57
|
+
"./cloudflare/workers": "./cloudflare/workers/index.ts",
|
|
58
|
+
"./cloudflare/workers-ai": "./cloudflare/workers-ai/client.ts",
|
|
59
|
+
"./cloudflare/workers-ai/testing": "./cloudflare/workers-ai/testing.ts",
|
|
60
|
+
"./cloudflare/workers/testing": "./cloudflare/workers/testing.ts",
|
|
61
|
+
"./cloudflare/workers/ticking-do": "./cloudflare/workers/ticking-do.ts",
|
|
62
|
+
"./monitoring/alerts": "./monitoring/alerts/client.ts",
|
|
63
|
+
"./monitoring/alerts/event-config": "./monitoring/alerts/event-config.ts",
|
|
64
|
+
"./monitoring/alerts/policy": "./monitoring/alerts/policy.ts",
|
|
65
|
+
"./monitoring/alerts/registry": "./monitoring/alerts/registry.ts",
|
|
66
|
+
"./monitoring/gates": "./monitoring/gates/client.ts",
|
|
67
|
+
"./monitoring/gates/dev": "./monitoring/gates/dev.ts",
|
|
68
|
+
"./monitoring/gates/testing": "./monitoring/gates/testing.ts",
|
|
69
|
+
"./monitoring/logger": "./monitoring/logger/client.ts",
|
|
70
|
+
"./monitoring/prometheus": "./monitoring/prometheus/client.ts",
|
|
71
|
+
"./monitoring/prometheus/dev": "./monitoring/prometheus/dev.ts",
|
|
72
|
+
"./monitoring/prometheus/metrics": "./monitoring/prometheus/metrics.ts",
|
|
73
|
+
"./monitoring/prometheus/testing": "./monitoring/prometheus/testing.ts",
|
|
74
|
+
"./monitoring/slack": "./monitoring/slack/client.ts",
|
|
75
|
+
"./monitoring/slack/api": "./monitoring/slack/api.ts",
|
|
76
|
+
"./monitoring/slack/events": "./monitoring/slack/events.ts",
|
|
77
|
+
"./storage/clickhouse": "./storage/clickhouse/index.ts",
|
|
78
|
+
"./storage/clickhouse/dev": "./storage/clickhouse/dev.ts",
|
|
79
|
+
"./storage/clickhouse/testing": "./storage/clickhouse/testing.ts",
|
|
80
|
+
"./storage/manticore": "./storage/manticore/client.ts",
|
|
81
|
+
"./storage/postgres": "./storage/postgres/client.ts",
|
|
82
|
+
"./storage/postgres/testing": "./storage/postgres/testing.ts",
|
|
83
|
+
"./storage/redis": "./storage/redis/client.ts",
|
|
84
|
+
"./storage/redis/testing": "./storage/redis/testing.ts"
|
|
85
|
+
},
|
|
86
|
+
"dependencies": {
|
|
87
|
+
"@bufbuild/protobuf": "2.16.0",
|
|
88
|
+
"@clickhouse/client-web": "1.24.0",
|
|
89
|
+
"@clickhouse/rowbinary": "0.1.1",
|
|
90
|
+
"@eslint/compat": "2.1.1",
|
|
91
|
+
"@eslint/js": "9.39.4",
|
|
92
|
+
"@internationalized/date": "3.12.4",
|
|
93
|
+
"@oslojs/crypto": "1.0.1",
|
|
94
|
+
"@oslojs/encoding": "1.1.0",
|
|
95
|
+
"@sentry/cloudflare": "11.4.0",
|
|
96
|
+
"aws4fetch": "1.0.20",
|
|
97
|
+
"eslint-config-prettier": "10.1.8",
|
|
98
|
+
"eslint-plugin-drizzle": "0.2.3",
|
|
99
|
+
"eslint-plugin-perfectionist": "5.9.1",
|
|
100
|
+
"eslint-plugin-svelte": "3.24.0",
|
|
101
|
+
"globals": "17.13.0",
|
|
102
|
+
"mimetext": "3.0.28",
|
|
103
|
+
"mysql2": "3.24.5",
|
|
104
|
+
"pg": "8.23.1",
|
|
105
|
+
"snappyjs": "0.7.0",
|
|
106
|
+
"svelte-eslint-parser": "1.8.1",
|
|
107
|
+
"tldts": "7.4.16",
|
|
108
|
+
"typescript-eslint": "8.71.0"
|
|
109
|
+
},
|
|
110
|
+
"peerDependencies": {
|
|
111
|
+
"@cloudflare/workers-types": "*",
|
|
112
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
113
|
+
"@sentry/sveltekit": "^11.4.0",
|
|
114
|
+
"@sveltejs/kit": "^3.1.1",
|
|
115
|
+
"drizzle-orm": "^0.45.3",
|
|
116
|
+
"eslint": "^9.39.4",
|
|
117
|
+
"vitest": "^4.1.11",
|
|
118
|
+
"zod": "^4.6.5"
|
|
119
|
+
},
|
|
120
|
+
"peerDependenciesMeta": {
|
|
121
|
+
"@cloudflare/workers-types": {
|
|
122
|
+
"optional": true
|
|
123
|
+
},
|
|
124
|
+
"@electric-sql/pglite": {
|
|
125
|
+
"optional": true
|
|
126
|
+
},
|
|
127
|
+
"eslint": {
|
|
128
|
+
"optional": true
|
|
129
|
+
},
|
|
130
|
+
"vitest": {
|
|
131
|
+
"optional": true
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"devDependencies": {
|
|
135
|
+
"@bufbuild/buf": "1.73.0",
|
|
136
|
+
"@bufbuild/protoc-gen-es": "2.16.0",
|
|
137
|
+
"@cloudflare/workers-types": "5.20261005.1",
|
|
138
|
+
"@electric-sql/pglite": "0.5.8",
|
|
139
|
+
"@sentry/sveltekit": "11.4.0",
|
|
140
|
+
"@sveltejs/kit": "3.1.1",
|
|
141
|
+
"@types/pg": "8.23.1",
|
|
142
|
+
"@types/snappyjs": "0.7.1",
|
|
143
|
+
"drizzle-orm": "0.45.3",
|
|
144
|
+
"eslint": "9.39.4",
|
|
145
|
+
"prettier": "3.9.9",
|
|
146
|
+
"svelte": "5.57.3",
|
|
147
|
+
"typescript": "6.0.3",
|
|
148
|
+
"vitest": "4.1.11",
|
|
149
|
+
"zod": "4.6.5"
|
|
150
|
+
}
|
|
151
|
+
}
|
package/server/logger.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { captureException } from '@sentry/sveltekit';
|
|
2
|
+
|
|
3
|
+
// Drizzle (and other wrappers) bury the real failure in `error.cause`. Surface the whole chain so
|
|
4
|
+
// logs show the actual cause, not just "Failed query".
|
|
5
|
+
export function causeChain(error: unknown): string {
|
|
6
|
+
const parts: string[] = [];
|
|
7
|
+
let current: unknown = error;
|
|
8
|
+
for (
|
|
9
|
+
let depth = 0;
|
|
10
|
+
depth < 5 && current && typeof current === 'object' && 'cause' in current;
|
|
11
|
+
depth++
|
|
12
|
+
) {
|
|
13
|
+
current = (current as { cause: unknown }).cause;
|
|
14
|
+
if (!current || typeof current !== 'object') break;
|
|
15
|
+
const info = current as { code?: unknown; detail?: unknown; message?: unknown };
|
|
16
|
+
const code = info.code ? `[${String(info.code)}] ` : '';
|
|
17
|
+
const detail = info.detail ? ` | detail: ${String(info.detail)}` : '';
|
|
18
|
+
parts.push(`${code}${String(info.message ?? current)}${detail}`);
|
|
19
|
+
}
|
|
20
|
+
return parts.join(' <- ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `console.error(error)` serializes lossily in Workers log capture: pull message/name/code/cause
|
|
24
|
+
// into the logged context explicitly so they survive.
|
|
25
|
+
function errorDetails(error: unknown): Record<string, unknown> {
|
|
26
|
+
if (!(error instanceof Error)) return { message: String(error) };
|
|
27
|
+
const code = 'code' in error ? (error as { code: unknown }).code : undefined;
|
|
28
|
+
const cause = causeChain(error);
|
|
29
|
+
return {
|
|
30
|
+
message: error.message,
|
|
31
|
+
name: error.name,
|
|
32
|
+
...(code !== undefined && { code }),
|
|
33
|
+
...(cause && { cause }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A `context.tags` key is Sentry tag data (grouping/filtering); the rest becomes extra data. */
|
|
38
|
+
function reportToSentry(error: unknown, context?: Record<string, unknown>): void {
|
|
39
|
+
const { tags, ...extra } = context ?? {};
|
|
40
|
+
captureException(error, { extra, tags: tags as Record<string, string> | undefined });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `warn`/`error` always fire: both are reserved for genuinely abnormal conditions, so volume stays
|
|
45
|
+
* near zero normally and rises only during a real incident. `debug` is for temporary investigation:
|
|
46
|
+
* add a call while chasing a bug and delete it once resolved.
|
|
47
|
+
*/
|
|
48
|
+
export class LoggerClient {
|
|
49
|
+
debug(message: string, context?: Record<string, unknown>): void {
|
|
50
|
+
console.debug(`[DEBUG] ${message}`, ...(context ? [context] : []));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
error(error: unknown, context?: Record<string, unknown>): void {
|
|
54
|
+
console.error('[ERROR]', error, { ...errorDetails(error), ...context });
|
|
55
|
+
reportToSentry(error, context);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
warn(message: string, context?: Record<string, unknown>): void {
|
|
59
|
+
console.warn(`[WARN] ${message}`, ...(context ? [context] : []));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const logger = new LoggerClient();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { LoggerClient } from './logger';
|
|
2
|
+
|
|
3
|
+
export type RateLimiterBinding = {
|
|
4
|
+
get(id: unknown): {
|
|
5
|
+
fetch(
|
|
6
|
+
input: string,
|
|
7
|
+
init?: { body?: string; method?: string; signal?: AbortSignal }
|
|
8
|
+
): Promise<LimiterResponse>;
|
|
9
|
+
};
|
|
10
|
+
idFromName(name: string): unknown;
|
|
11
|
+
};
|
|
12
|
+
// Structural shape of the RATE_LIMITER durable object binding, so this file doesn't depend on
|
|
13
|
+
// @cloudflare/workers-types.
|
|
14
|
+
type LimiterResponse = { json<T>(): Promise<T>; ok: boolean; status: number };
|
|
15
|
+
|
|
16
|
+
export type RateLimitConfig = { requests: number; window: number };
|
|
17
|
+
|
|
18
|
+
export type RateLimitResult = {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
remaining?: number;
|
|
21
|
+
resetAt?: number;
|
|
22
|
+
retryAfter?: number;
|
|
23
|
+
/**
|
|
24
|
+
* True when the limiter binding was missing or unreachable, so `allowed` is a fail-open default,
|
|
25
|
+
* not a real verdict. Callers that need fail-CLOSED behaviour (abuse-sensitive public endpoints)
|
|
26
|
+
* inspect this to reject with a 503 instead of silently passing.
|
|
27
|
+
*/
|
|
28
|
+
unavailable?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shared rate-limit logic. The RATE_LIMITER durable-object binding is injected;
|
|
33
|
+
* returns a result and the caller decides how to reject (e.g. a 429). Fails open: if the binding is
|
|
34
|
+
* missing or unreachable the request passes, flagged `unavailable`.
|
|
35
|
+
*/
|
|
36
|
+
export class RateLimitClient {
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly limiter: RateLimiterBinding | undefined,
|
|
39
|
+
private readonly logger: LoggerClient,
|
|
40
|
+
private readonly configFor: (category: string) => RateLimitConfig
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
async check(identifier: string, category: string): Promise<RateLimitResult> {
|
|
44
|
+
if (!this.limiter) return { allowed: true, unavailable: true };
|
|
45
|
+
|
|
46
|
+
const config = this.configFor(category);
|
|
47
|
+
const stub = this.limiter.get(this.limiter.idFromName(identifier));
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Timeout so a hung DO doesn't block the whole request; 5s clears ordinary
|
|
51
|
+
// Worker-to-DO subrequest latency.
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
54
|
+
|
|
55
|
+
const response = await stub.fetch('https://fake-host', {
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
category,
|
|
58
|
+
limit: config.requests,
|
|
59
|
+
userId: identifier,
|
|
60
|
+
window: config.window,
|
|
61
|
+
}),
|
|
62
|
+
method: 'POST',
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
|
|
68
|
+
// 429 is the DO's real verdict (over the limit), with retryAfter in the body.
|
|
69
|
+
if (response.status === 429) return await response.json<RateLimitResult>();
|
|
70
|
+
// Anything else non-2xx: DO unavailable, so allow.
|
|
71
|
+
if (!response.ok) return { allowed: true, unavailable: true };
|
|
72
|
+
|
|
73
|
+
return await response.json<RateLimitResult>();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// DO timeout or failure: fail open.
|
|
76
|
+
this.logger.error(err, { op: 'rateLimitCheck' });
|
|
77
|
+
return { allowed: true, unavailable: true };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stands in for the binding when absent (local dev): always allows and is not `unavailable`. */
|
|
83
|
+
export const localDevLimiter: RateLimiterBinding = {
|
|
84
|
+
get: () => ({
|
|
85
|
+
fetch: async () => ({ json: async <T>() => ({ allowed: true }) as T, ok: true, status: 200 }),
|
|
86
|
+
}),
|
|
87
|
+
idFromName: (name: string) => name,
|
|
88
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isHttpError } from '@sveltejs/kit';
|
|
2
|
+
|
|
3
|
+
import { FormResult } from '../utils/form';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gate a form handler behind a rate-limit guard. A guard throws SvelteKit's raw `error(429/503, ...)`,
|
|
7
|
+
* which is meant for a `+page.server.ts` load: inside a `Remote.Form` handler it would hit the
|
|
8
|
+
* catch-all, get logged as unexpected (Sentry noise on every throttled request) and show a generic
|
|
9
|
+
* message instead of the real "try again in Ns" one. Convert it to an inline `FormResult.err` here.
|
|
10
|
+
*/
|
|
11
|
+
export function withRateLimit<TData, TResult>(
|
|
12
|
+
guard: () => Promise<void>,
|
|
13
|
+
handler: (data: TData) => Promise<FormResult<TResult | void>>
|
|
14
|
+
): (data: TData) => Promise<FormResult<TResult | void>> {
|
|
15
|
+
return async (data: TData): Promise<FormResult<TResult | void>> => {
|
|
16
|
+
try {
|
|
17
|
+
await guard();
|
|
18
|
+
} catch (e) {
|
|
19
|
+
if (isHttpError(e)) return FormResult.err(e.body.message);
|
|
20
|
+
throw e;
|
|
21
|
+
}
|
|
22
|
+
return handler(data);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
command,
|
|
5
|
+
form,
|
|
6
|
+
query,
|
|
7
|
+
type RemoteCommand,
|
|
8
|
+
type RemoteForm,
|
|
9
|
+
type RemoteFormInput,
|
|
10
|
+
type RemoteQueryFunction,
|
|
11
|
+
} from '$app/server';
|
|
12
|
+
import { error, isHttpError, type RequestEvent } from '@sveltejs/kit';
|
|
13
|
+
|
|
14
|
+
import { ServiceError, toHttpError } from '../utils/errors';
|
|
15
|
+
import { FormResult } from '../utils/form';
|
|
16
|
+
import { HttpStatus } from '../utils/http-status';
|
|
17
|
+
import { logger } from './logger';
|
|
18
|
+
import { AppResult } from './result';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Remote-function wrappers, the `$app/server` counterpart to a plain API route. They mirror the
|
|
22
|
+
* `$app/server` `query`/`command`/`form` signatures (optional input `schema` first, `run` second)
|
|
23
|
+
* and add shared error handling:
|
|
24
|
+
*
|
|
25
|
+
* export const getTags = Remote.Query(() => listTags());
|
|
26
|
+
* export const find = Remote.Query(FooSchema, (data) => find(data));
|
|
27
|
+
* export const saveForm = Remote.Form(SaveSchema, (data) => save(data));
|
|
28
|
+
*
|
|
29
|
+
* Failures are funnelled into each kind's error channel:
|
|
30
|
+
* - a thrown `ServiceError` (`Error400`/`Error404`/...) or a raw SvelteKit `error(status, message)`
|
|
31
|
+
* is a deliberate, client-facing failure: it keeps its status and is not reported;
|
|
32
|
+
* - a thrown `Fail` (`AppResult` with `ok: false`, a soft business failure) surfaces its message
|
|
33
|
+
* (a `Query`/`Cmd` reports it at 400; a `Form` shows it inline);
|
|
34
|
+
* - anything else is unexpected and logged; a `Query`/`Cmd` collapses it to a generic 500 (its
|
|
35
|
+
* message is never surfaced), a `Form` still returns an inline `FormResult.err(...)` with a
|
|
36
|
+
* generic message (throwing would break `<Form>`).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** A thrown `Fail` (`AppResult` with `ok: false`) carries a client-safe message: surface it. */
|
|
40
|
+
function isFail(e: unknown): e is AppResult {
|
|
41
|
+
return e instanceof AppResult && !e.ok;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Map a thrown value to a SvelteKit HttpError: Fail at 400, ServiceError/HttpError keep their status, else 500. */
|
|
45
|
+
function throwAsHttp(e: unknown): never {
|
|
46
|
+
if (isFail(e)) throw error(HttpStatus.BadRequest, e.message ?? 'Request failed');
|
|
47
|
+
if (isHttpError(e)) throw e;
|
|
48
|
+
const { expected, message, status } = toHttpError(e);
|
|
49
|
+
if (!expected) logger.error(e, { op: 'remote' });
|
|
50
|
+
throw error(status, message);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Wrap a run fn so any thrown value funnels through `throwAsHttp` (Query/Cmd error channel). */
|
|
54
|
+
function errorGuard(run: (input?: unknown) => Promise<unknown>) {
|
|
55
|
+
return async (input?: unknown) => {
|
|
56
|
+
try {
|
|
57
|
+
return await run(input);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throwAsHttp(e);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function remoteQuery<O>(run: () => Promise<O>): RemoteQueryFunction<void, O>;
|
|
65
|
+
function remoteQuery<S extends z.ZodType, O>(
|
|
66
|
+
schema: S,
|
|
67
|
+
run: (input: z.infer<S>) => Promise<O>
|
|
68
|
+
): RemoteQueryFunction<z.input<S>, O, z.infer<S>>;
|
|
69
|
+
function remoteQuery(
|
|
70
|
+
schemaOrRun: ((input?: unknown) => Promise<unknown>) | z.ZodType,
|
|
71
|
+
maybeRun?: (input?: unknown) => Promise<unknown>
|
|
72
|
+
) {
|
|
73
|
+
const schema = maybeRun ? (schemaOrRun as z.ZodType) : undefined;
|
|
74
|
+
const run = errorGuard((maybeRun ?? schemaOrRun) as (input?: unknown) => Promise<unknown>);
|
|
75
|
+
return schema ? query(schema, run) : query(run);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function remoteCommand<O>(run: () => Promise<O>): RemoteCommand<void, O>;
|
|
79
|
+
function remoteCommand<S extends z.ZodType, O>(
|
|
80
|
+
schema: S,
|
|
81
|
+
run: (input: z.infer<S>) => Promise<O>
|
|
82
|
+
): RemoteCommand<z.input<S>, O>;
|
|
83
|
+
function remoteCommand(
|
|
84
|
+
schemaOrRun: ((input?: unknown) => Promise<unknown>) | z.ZodType,
|
|
85
|
+
maybeRun?: (input?: unknown) => Promise<unknown>
|
|
86
|
+
) {
|
|
87
|
+
const schema = maybeRun ? (schemaOrRun as z.ZodType) : undefined;
|
|
88
|
+
const run = errorGuard((maybeRun ?? schemaOrRun) as (input?: unknown) => Promise<unknown>);
|
|
89
|
+
return schema ? command(schema, run) : command(run);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function remoteForm<S extends z.ZodType, O>(
|
|
93
|
+
schema: S,
|
|
94
|
+
run: (input: z.infer<S>) => Promise<O>
|
|
95
|
+
): RemoteForm<RemoteFormInput, O> {
|
|
96
|
+
const wrapped = async (data: z.infer<S>) => {
|
|
97
|
+
try {
|
|
98
|
+
return await run(data);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// A ServiceError (wrong password, email in use, ...), a soft Fail, or a directly-thrown
|
|
101
|
+
// HttpError becomes an inline form error; anything else is unexpected but still becomes an
|
|
102
|
+
// inline error, because throwing here would break `<Form>`.
|
|
103
|
+
if (e instanceof ServiceError) return FormResult.err(e.message);
|
|
104
|
+
if (isFail(e)) return FormResult.err(e.message);
|
|
105
|
+
if (isHttpError(e)) return FormResult.err(e.body.message);
|
|
106
|
+
logger.error(e, { op: 'remote' });
|
|
107
|
+
return FormResult.err('Something went wrong. Please try again.');
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return form(schema as never, wrapped as never) as unknown as RemoteForm<RemoteFormInput, O>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const Remote = {
|
|
114
|
+
Cmd: remoteCommand,
|
|
115
|
+
Form: remoteForm,
|
|
116
|
+
Query: remoteQuery,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Logs a remote-function schema validation failure from `handleError` (`kind: 'validation'`).
|
|
121
|
+
* SvelteKit's default names neither the function nor the field, which makes a production failure
|
|
122
|
+
* untraceable; this logs the remote function id plus each issue's field path and message.
|
|
123
|
+
*/
|
|
124
|
+
export function logRemoteValidation(
|
|
125
|
+
event: RequestEvent,
|
|
126
|
+
issues: readonly { message: string; path?: readonly (PropertyKey | { key: PropertyKey })[] }[]
|
|
127
|
+
): void {
|
|
128
|
+
// `event.url` is rewritten to the calling page's URL inside remote calls: the remote endpoint
|
|
129
|
+
// (`.../remote/<hash>/<name>`) only survives on the raw request.
|
|
130
|
+
const pathname = new URL(event.request.url).pathname;
|
|
131
|
+
const fn = pathname.split('/remote/')[1] ?? pathname;
|
|
132
|
+
logger.error(new Error(`remote function schema validation failed: ${fn}`), {
|
|
133
|
+
issues: issues.map((i) => ({
|
|
134
|
+
message: i.message,
|
|
135
|
+
path: i.path?.map((s) => String(typeof s === 'object' && s !== null ? s.key : s)).join('.'),
|
|
136
|
+
})),
|
|
137
|
+
op: 'remote-validation',
|
|
138
|
+
});
|
|
139
|
+
}
|
package/server/result.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberate application outcome returned from a handler at HTTP 200: a success or a soft
|
|
3
|
+
* failure (e.g. "not found", "out of stock"). Distinct from `ServiceError`, which is a real HTTP
|
|
4
|
+
* error status. Handlers express business outcomes with `Ok`/`Fail`; they do not throw `Error4XX`.
|
|
5
|
+
*/
|
|
6
|
+
export class AppResult<T = unknown> {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly ok: boolean,
|
|
9
|
+
readonly message?: string,
|
|
10
|
+
readonly data?: T
|
|
11
|
+
) {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A success outcome. */
|
|
15
|
+
export const Ok = <T>(data?: T, message?: string) => new AppResult<T>(true, message, data);
|
|
16
|
+
|
|
17
|
+
/** A soft failure; `data` may carry detail (e.g. fields). */
|
|
18
|
+
export const Fail = <T>(message: string, data?: T) => new AppResult<T>(false, message, data);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side verification of a Cloudflare Turnstile token against the siteverify API. Pure
|
|
3
|
+
* `fetch`, no framework or binding dependency. Each app owns how it obtains the secret key and how
|
|
4
|
+
* a rejection surfaces (see `apps/web/src/lib/server/turnstile.ts`).
|
|
5
|
+
*/
|
|
6
|
+
export async function verifyTurnstile(
|
|
7
|
+
token: string,
|
|
8
|
+
ip: null | string | undefined,
|
|
9
|
+
secretKey: string
|
|
10
|
+
): Promise<boolean> {
|
|
11
|
+
const params = new URLSearchParams({ response: token, secret: secretKey });
|
|
12
|
+
if (ip) params.set('remoteip', ip);
|
|
13
|
+
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
14
|
+
body: params,
|
|
15
|
+
method: 'POST',
|
|
16
|
+
});
|
|
17
|
+
const result = (await response.json()) as { success?: boolean };
|
|
18
|
+
return result.success === true;
|
|
19
|
+
}
|