@flaredev/core 0.1.0
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 +18 -0
- package/dist/chunk-DARDHZPX.js +192 -0
- package/dist/chunk-KSVLQLJD.js +91 -0
- package/dist/chunk-LYQT4S47.js +248 -0
- package/dist/chunk-ZJPJGME5.js +40 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +52 -0
- package/dist/define-D5CtCkOm.d.ts +232 -0
- package/dist/index.d.ts +438 -0
- package/dist/index.js +770 -0
- package/dist/react.d.ts +34 -0
- package/dist/react.js +95 -0
- package/dist/realtime/client.d.ts +82 -0
- package/dist/realtime/client.js +7 -0
- package/dist/realtime/server.d.ts +109 -0
- package/dist/realtime/server.js +200 -0
- package/dist/security/index.d.ts +159 -0
- package/dist/security/index.js +147 -0
- package/dist/security/worker.d.ts +104 -0
- package/dist/security/worker.js +116 -0
- package/dist/server.d.ts +156 -0
- package/dist/server.js +319 -0
- package/dist/types-B6Y73BgH.d.ts +54 -0
- package/package.json +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muke Johnbaptist
|
|
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
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @flaredev/core
|
|
2
|
+
|
|
3
|
+
The runtime for [Flare](https://flare-docs.codetotech.com) apps: resource
|
|
4
|
+
descriptors and the field grammar, the resource store and validators, policies,
|
|
5
|
+
auth, mail and storage helpers, realtime (Durable Objects), billing rules and
|
|
6
|
+
the security layer.
|
|
7
|
+
|
|
8
|
+
Apps created with [`@flaredev/cli`](https://www.npmjs.com/package/@flaredev/cli)
|
|
9
|
+
depend on this package already. You rarely add it by hand:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm create flare-framework@latest my-app
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Entry points: `@flaredev/core`, `/server`, `/client`, `/react`, `/realtime`,
|
|
16
|
+
`/realtime/server`, `/security`, `/security/server`.
|
|
17
|
+
|
|
18
|
+
Requires Node.js 22 or later.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// src/resource/naming.ts
|
|
2
|
+
function words(value) {
|
|
3
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
|
|
4
|
+
}
|
|
5
|
+
var pascalCase = (value) => words(value).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
6
|
+
var camelCase = (value) => {
|
|
7
|
+
const pascal = pascalCase(value);
|
|
8
|
+
return pascal ? pascal[0].toLowerCase() + pascal.slice(1) : pascal;
|
|
9
|
+
};
|
|
10
|
+
var snakeCase = (value) => words(value).join("_");
|
|
11
|
+
var kebabCase = (value) => words(value).join("-");
|
|
12
|
+
var humanize = (value) => {
|
|
13
|
+
const text = words(value).join(" ");
|
|
14
|
+
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
15
|
+
};
|
|
16
|
+
var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news"]);
|
|
17
|
+
var IRREGULAR = { person: "people", man: "men", woman: "women", child: "children", mouse: "mice" };
|
|
18
|
+
function pluralize(value) {
|
|
19
|
+
const match = /([A-Za-z]+)$/.exec(value);
|
|
20
|
+
if (!match) return value;
|
|
21
|
+
const word = match[1];
|
|
22
|
+
const lower = word.toLowerCase();
|
|
23
|
+
const head = value.slice(0, value.length - word.length);
|
|
24
|
+
const keepCase = (plural) => word[0] === word[0].toUpperCase() ? plural[0].toUpperCase() + plural.slice(1) : plural;
|
|
25
|
+
if (UNCOUNTABLE.has(lower)) return value;
|
|
26
|
+
if (IRREGULAR[lower]) return head + keepCase(IRREGULAR[lower]);
|
|
27
|
+
if (/[^aeiou]y$/i.test(word)) return head + word.slice(0, -1) + "ies";
|
|
28
|
+
if (/(s|x|z|ch|sh)$/i.test(word)) return head + word + "es";
|
|
29
|
+
return head + word + "s";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/resource/define.ts
|
|
33
|
+
var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt"];
|
|
34
|
+
var ResourceDefinitionError = class extends Error {
|
|
35
|
+
constructor(resource, message) {
|
|
36
|
+
super(`Resource "${resource}": ${message}`);
|
|
37
|
+
this.name = "ResourceDefinitionError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
41
|
+
function defaultFieldLabel(key, field) {
|
|
42
|
+
return humanize(field.kind === "belongsTo" ? key.replace(/Id$/, "") : key);
|
|
43
|
+
}
|
|
44
|
+
function defineResource(config) {
|
|
45
|
+
const name = config.name;
|
|
46
|
+
const fail = (message) => {
|
|
47
|
+
throw new ResourceDefinitionError(name, message);
|
|
48
|
+
};
|
|
49
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || pascalCase(name) !== name) fail('name must be PascalCase, e.g. "OrderItem".');
|
|
50
|
+
const entries = Object.entries(config.fields);
|
|
51
|
+
if (entries.length === 0) fail("define at least one field.");
|
|
52
|
+
const fields = {};
|
|
53
|
+
for (const [key, def] of entries) {
|
|
54
|
+
if (!IDENTIFIER.test(key)) fail(`field "${key}" must be camelCase (letters and digits, starting lowercase).`);
|
|
55
|
+
if (RESERVED_FIELD_NAMES.includes(key)) fail(`"${key}" is added automatically; don't declare it.`);
|
|
56
|
+
if (def.kind === "enum") {
|
|
57
|
+
if (def.options.length === 0) fail(`enum field "${key}" needs at least one option.`);
|
|
58
|
+
if (new Set(def.options).size !== def.options.length) fail(`enum field "${key}" has duplicate options.`);
|
|
59
|
+
if (def.default !== void 0 && !def.options.includes(def.default)) fail(`default of "${key}" isn't one of its options.`);
|
|
60
|
+
}
|
|
61
|
+
if (def.kind === "belongsTo" && !key.endsWith("Id")) fail(`belongsTo field "${key}" must end in "Id", e.g. "${camelCase(def.target)}Id".`);
|
|
62
|
+
if ((def.kind === "belongsTo" || def.kind === "hasMany") && pascalCase(def.target) !== def.target) {
|
|
63
|
+
fail(`relation "${key}" must target a PascalCase resource name.`);
|
|
64
|
+
}
|
|
65
|
+
if (def.kind === "belongsTo" && def.onDelete === "set null" && def.required) {
|
|
66
|
+
fail(`"${key}" uses onDelete "set null" but is required; mark it required: false.`);
|
|
67
|
+
}
|
|
68
|
+
if (def.kind === "file" && def.accept.length === 0) fail(`file field "${key}" must accept at least one category.`);
|
|
69
|
+
fields[key] = { ...def, label: def.label ?? defaultFieldLabel(key, def) };
|
|
70
|
+
}
|
|
71
|
+
const stored = entries.filter(([, def]) => def.kind !== "hasMany");
|
|
72
|
+
const titleField = config.titleField ?? stored.find(([, def]) => def.kind === "string")?.[0] ?? stored[0]?.[0] ?? "id";
|
|
73
|
+
if (config.titleField && !stored.some(([key]) => key === config.titleField)) {
|
|
74
|
+
fail(`titleField "${config.titleField}" isn't a stored field.`);
|
|
75
|
+
}
|
|
76
|
+
const defaultSort = config.defaultSort ?? { field: "createdAt", direction: "desc" };
|
|
77
|
+
if (!["createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
|
|
78
|
+
fail(`defaultSort field "${defaultSort.field}" isn't a stored field.`);
|
|
79
|
+
}
|
|
80
|
+
const perPage = config.perPage ?? 25;
|
|
81
|
+
if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) fail("perPage must be an integer from 1 to 100.");
|
|
82
|
+
const pluralName = pluralize(name);
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
table: config.table ?? snakeCase(pluralName),
|
|
86
|
+
slug: config.slug ?? kebabCase(pluralName),
|
|
87
|
+
label: config.label ?? humanize(name),
|
|
88
|
+
pluralLabel: config.pluralLabel ?? humanize(pluralName),
|
|
89
|
+
icon: config.icon,
|
|
90
|
+
titleField,
|
|
91
|
+
defaultSort,
|
|
92
|
+
perPage,
|
|
93
|
+
fields,
|
|
94
|
+
$types: void 0
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function storedFields(resource) {
|
|
98
|
+
return Object.entries(resource.fields).filter(([, def]) => def.kind !== "hasMany");
|
|
99
|
+
}
|
|
100
|
+
var columnName = (key) => snakeCase(key);
|
|
101
|
+
|
|
102
|
+
// src/resource/validators.ts
|
|
103
|
+
import { z } from "zod";
|
|
104
|
+
var DEFAULT_STRING_MAX = 255;
|
|
105
|
+
var DEFAULT_TEXT_MAX = 65535;
|
|
106
|
+
var OBJECT_KEY = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).{1,1024}$/;
|
|
107
|
+
var typeError = (message) => (issue) => issue.input === void 0 || issue.input === null || issue.input === "" ? "Required" : message;
|
|
108
|
+
function stringSchema(max, min, required) {
|
|
109
|
+
let s = z.string({ error: typeError("Enter text") });
|
|
110
|
+
if (max) s = s.max(max, `At most ${max} characters`);
|
|
111
|
+
if (min !== void 0) s = s.min(min, min === 1 ? "Required" : `At least ${min} characters`);
|
|
112
|
+
else if (required) s = s.min(1, "Required");
|
|
113
|
+
return s;
|
|
114
|
+
}
|
|
115
|
+
function fieldSchema(def) {
|
|
116
|
+
switch (def.kind) {
|
|
117
|
+
case "string": {
|
|
118
|
+
let s = z.string({ error: typeError("Enter text") }).trim().max(def.maxLength ?? DEFAULT_STRING_MAX, `At most ${def.maxLength ?? DEFAULT_STRING_MAX} characters`);
|
|
119
|
+
if (def.minLength !== void 0) s = s.min(def.minLength, `At least ${def.minLength} characters`);
|
|
120
|
+
else if (def.required) s = s.min(1, "Required");
|
|
121
|
+
if (def.pattern) s = s.regex(new RegExp(def.pattern), "Invalid format");
|
|
122
|
+
if (def.format === "email") return s.pipe(z.email({ error: "Enter a valid email address" }));
|
|
123
|
+
if (def.format === "url") return s.pipe(z.url({ protocol: /^https?$/, error: "Enter a valid URL (http or https)" }));
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
case "text":
|
|
127
|
+
return stringSchema(def.maxLength ?? DEFAULT_TEXT_MAX, def.minLength, def.required);
|
|
128
|
+
case "int":
|
|
129
|
+
case "float": {
|
|
130
|
+
let s = z.number({ error: typeError("Enter a number") });
|
|
131
|
+
if (def.kind === "int") s = s.int("Enter a whole number");
|
|
132
|
+
if (def.min !== void 0) s = s.min(def.min, `Must be at least ${def.min}`);
|
|
133
|
+
if (def.max !== void 0) s = s.max(def.max, `Must be at most ${def.max}`);
|
|
134
|
+
return s;
|
|
135
|
+
}
|
|
136
|
+
case "boolean":
|
|
137
|
+
return z.boolean({ error: typeError("Choose yes or no") });
|
|
138
|
+
case "date":
|
|
139
|
+
return z.iso.date({ error: typeError("Enter a valid date") });
|
|
140
|
+
case "datetime":
|
|
141
|
+
return z.iso.datetime({ offset: true, error: typeError("Enter a valid date and time") });
|
|
142
|
+
case "enum":
|
|
143
|
+
return z.enum(def.options, { error: typeError("Choose one of the options") });
|
|
144
|
+
case "file":
|
|
145
|
+
return z.string({ error: typeError("Upload a file") }).regex(OBJECT_KEY, "Invalid file");
|
|
146
|
+
case "belongsTo":
|
|
147
|
+
return z.string({ error: typeError("Choose a record") }).min(1, "Required");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
var fileKeyPrefix = (resource, fieldKey) => `${resource.table}/${fieldKey}/`;
|
|
151
|
+
function createValidators(resource) {
|
|
152
|
+
const create = {};
|
|
153
|
+
const update = {};
|
|
154
|
+
for (const [key, def] of storedFields(resource)) {
|
|
155
|
+
let base = fieldSchema(def);
|
|
156
|
+
if (def.kind === "file") {
|
|
157
|
+
const prefix = fileKeyPrefix(resource, key);
|
|
158
|
+
base = base.refine(
|
|
159
|
+
(value2) => typeof value2 !== "string" || !OBJECT_KEY.test(value2) || value2.startsWith(prefix),
|
|
160
|
+
"This file wasn't uploaded for this field"
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const value = def.required ? base : base.nullable();
|
|
164
|
+
update[key] = value.optional();
|
|
165
|
+
const hasDefault = "default" in def && def.default !== void 0;
|
|
166
|
+
create[key] = def.required && !hasDefault ? value : value.optional();
|
|
167
|
+
}
|
|
168
|
+
const unknownKeys = { error: (issue) => issue.code === "unrecognized_keys" ? `Unknown field(s): ${issue.keys?.join(", ")}` : void 0 };
|
|
169
|
+
return {
|
|
170
|
+
create: z.strictObject(create, unknownKeys),
|
|
171
|
+
update: z.strictObject(update, unknownKeys)
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export {
|
|
176
|
+
words,
|
|
177
|
+
pascalCase,
|
|
178
|
+
camelCase,
|
|
179
|
+
snakeCase,
|
|
180
|
+
kebabCase,
|
|
181
|
+
humanize,
|
|
182
|
+
pluralize,
|
|
183
|
+
RESERVED_FIELD_NAMES,
|
|
184
|
+
ResourceDefinitionError,
|
|
185
|
+
defaultFieldLabel,
|
|
186
|
+
defineResource,
|
|
187
|
+
storedFields,
|
|
188
|
+
columnName,
|
|
189
|
+
fieldSchema,
|
|
190
|
+
fileKeyPrefix,
|
|
191
|
+
createValidators
|
|
192
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// src/security/config.ts
|
|
2
|
+
var NAME = /^[a-z][a-z0-9-]{1,47}$/;
|
|
3
|
+
function defineSecurity(config) {
|
|
4
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5
|
+
for (const detector of config.detectors ?? []) {
|
|
6
|
+
if (!NAME.test(detector.name)) throw new Error(`Detector name "${detector.name}" must be lowercase letters, digits and dashes.`);
|
|
7
|
+
if (seen.has(detector.name)) throw new Error(`Detector "${detector.name}" is defined twice.`);
|
|
8
|
+
seen.add(detector.name);
|
|
9
|
+
if (!(detector.threshold >= 1) || !(detector.windowSeconds >= 1)) {
|
|
10
|
+
throw new Error(`Detector "${detector.name}" needs a threshold and windowSeconds of at least 1.`);
|
|
11
|
+
}
|
|
12
|
+
if (detector.ban && !(detector.ban.seconds >= 60)) throw new Error(`Detector "${detector.name}" must ban for at least 60 seconds.`);
|
|
13
|
+
}
|
|
14
|
+
for (const rule of [...config.zone?.rateLimits ?? [], ...config.zone?.customRules ?? []]) {
|
|
15
|
+
if (!NAME.test(rule.name)) throw new Error(`Zone rule name "${rule.name}" must be lowercase letters, digits and dashes.`);
|
|
16
|
+
if (!rule.expression.trim()) throw new Error(`Zone rule "${rule.name}" needs an expression.`);
|
|
17
|
+
}
|
|
18
|
+
for (const ip of config.allow ?? []) {
|
|
19
|
+
if (!isIp(ip)) throw new Error(`allow: "${ip}" is not an IP address.`);
|
|
20
|
+
}
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
function detectorMatches(detector, request, status) {
|
|
24
|
+
const { method, path, status: statuses } = detector.match;
|
|
25
|
+
if (method && method.toUpperCase() !== request.method.toUpperCase()) return false;
|
|
26
|
+
const pathname = new URL(request.url).pathname;
|
|
27
|
+
if (typeof path === "string" ? pathname !== path : !path.test(pathname)) return false;
|
|
28
|
+
if (statuses === void 0) return true;
|
|
29
|
+
return (Array.isArray(statuses) ? statuses : [statuses]).includes(status);
|
|
30
|
+
}
|
|
31
|
+
function recordHit(previous, now, windowSeconds, threshold) {
|
|
32
|
+
const since = now - windowSeconds * 1e3;
|
|
33
|
+
const hits = [...previous.filter((at) => at > since), now];
|
|
34
|
+
return { hits, tripped: hits.length === threshold };
|
|
35
|
+
}
|
|
36
|
+
function clientIp(request) {
|
|
37
|
+
const ip = request.headers.get("cf-connecting-ip")?.trim();
|
|
38
|
+
return ip && isIp(ip) ? ip : null;
|
|
39
|
+
}
|
|
40
|
+
var IPV4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
|
|
41
|
+
function isIp(value) {
|
|
42
|
+
if (IPV4.test(value)) return true;
|
|
43
|
+
if (!/^[0-9a-f:.]+$/i.test(value) || !value.includes(":")) return false;
|
|
44
|
+
try {
|
|
45
|
+
new URL(`http://[${value}]/`);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
var zoneRuleTag = (app, name) => `flare:${app}:${name}`;
|
|
52
|
+
var isFlareRule = (app, description) => Boolean(description?.startsWith(`flare:${app}:`));
|
|
53
|
+
var DEFAULT_DETECTORS = [
|
|
54
|
+
{
|
|
55
|
+
name: "login-brute-force",
|
|
56
|
+
description: "Repeated failed sign-ins from one IP",
|
|
57
|
+
match: { method: "POST", path: "/api/auth/sign-in/email", status: [401, 403] },
|
|
58
|
+
threshold: 5,
|
|
59
|
+
windowSeconds: 60,
|
|
60
|
+
ban: { seconds: 60 * 60 },
|
|
61
|
+
severity: "high"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "checkout-abuse",
|
|
65
|
+
description: "Many checkout attempts from one IP",
|
|
66
|
+
match: { method: "POST", path: /^\/api\/billing\/checkout$/ },
|
|
67
|
+
threshold: 10,
|
|
68
|
+
windowSeconds: 60,
|
|
69
|
+
ban: { seconds: 30 * 60 },
|
|
70
|
+
severity: "medium"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "api-scraping",
|
|
74
|
+
description: "Rapid reads across the REST API from one IP",
|
|
75
|
+
match: { method: "GET", path: /^\/api\/(?!auth\/|health$)/ },
|
|
76
|
+
threshold: 300,
|
|
77
|
+
windowSeconds: 60,
|
|
78
|
+
severity: "low"
|
|
79
|
+
}
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
defineSecurity,
|
|
84
|
+
detectorMatches,
|
|
85
|
+
recordHit,
|
|
86
|
+
clientIp,
|
|
87
|
+
isIp,
|
|
88
|
+
zoneRuleTag,
|
|
89
|
+
isFlareRule,
|
|
90
|
+
DEFAULT_DETECTORS
|
|
91
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_RECONNECT_POLICY
|
|
3
|
+
} from "./chunk-ZJPJGME5.js";
|
|
4
|
+
|
|
5
|
+
// src/realtime/client.ts
|
|
6
|
+
var SERVICE_RESTART = 1012;
|
|
7
|
+
function subscribe(set, handler) {
|
|
8
|
+
set.add(handler);
|
|
9
|
+
return () => {
|
|
10
|
+
set.delete(handler);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
var RealtimeClient = class {
|
|
14
|
+
channel;
|
|
15
|
+
members = [];
|
|
16
|
+
status = "connecting";
|
|
17
|
+
/** This connection's id, from the server's `hello` (new on every reconnect). */
|
|
18
|
+
connectionId = null;
|
|
19
|
+
url;
|
|
20
|
+
policy;
|
|
21
|
+
presence;
|
|
22
|
+
ws = null;
|
|
23
|
+
attempt = 0;
|
|
24
|
+
explicitClose = false;
|
|
25
|
+
reconnectTimer = null;
|
|
26
|
+
deadTimer = null;
|
|
27
|
+
stableTimer = null;
|
|
28
|
+
heartbeatInterval = null;
|
|
29
|
+
lastPongAt = 0;
|
|
30
|
+
listeners = /* @__PURE__ */ new Map();
|
|
31
|
+
anyHandlers = /* @__PURE__ */ new Set();
|
|
32
|
+
memberHandlers = /* @__PURE__ */ new Set();
|
|
33
|
+
statusHandlers = /* @__PURE__ */ new Set();
|
|
34
|
+
/**
|
|
35
|
+
* @param channel Channel name to join, e.g. `"deals"`.
|
|
36
|
+
* @param opts.path Websocket endpoint (default `/realtime`); the URL is
|
|
37
|
+
* resolved against `location` in the browser, or the given
|
|
38
|
+
* `baseUrl`. Scheme is upgraded to `wss:` / `ws:` automatically.
|
|
39
|
+
*/
|
|
40
|
+
constructor(channel, opts = {}) {
|
|
41
|
+
this.channel = channel;
|
|
42
|
+
this.policy = { ...DEFAULT_RECONNECT_POLICY, ...opts.reconnect };
|
|
43
|
+
this.presence = opts.presence ?? null;
|
|
44
|
+
const path = opts.path ?? "/realtime";
|
|
45
|
+
const base = opts.baseUrl ?? (typeof location !== "undefined" ? location.href : "http://localhost:8787/");
|
|
46
|
+
const url = new URL(base);
|
|
47
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
48
|
+
url.pathname = `${path.replace(/\/$/, "")}/${channel}/ws`;
|
|
49
|
+
url.search = "";
|
|
50
|
+
url.hash = "";
|
|
51
|
+
this.url = url.toString();
|
|
52
|
+
}
|
|
53
|
+
/** Subscribe to one application event type. Returns an unsubscribe fn. */
|
|
54
|
+
on(e, handler) {
|
|
55
|
+
let set = this.listeners.get(e);
|
|
56
|
+
if (!set) {
|
|
57
|
+
set = /* @__PURE__ */ new Set();
|
|
58
|
+
this.listeners.set(e, set);
|
|
59
|
+
}
|
|
60
|
+
return subscribe(set, handler);
|
|
61
|
+
}
|
|
62
|
+
/** Subscribe to every application event on the channel. */
|
|
63
|
+
onAny(handler) {
|
|
64
|
+
return subscribe(this.anyHandlers, handler);
|
|
65
|
+
}
|
|
66
|
+
/** Presence change notifications (full member list on every change). */
|
|
67
|
+
onMembers(handler) {
|
|
68
|
+
return subscribe(this.memberHandlers, handler);
|
|
69
|
+
}
|
|
70
|
+
onStatus(handler) {
|
|
71
|
+
return subscribe(this.statusHandlers, handler);
|
|
72
|
+
}
|
|
73
|
+
/** Set presence for this connection; sends a `join` frame if already connected. */
|
|
74
|
+
setPresence(presence) {
|
|
75
|
+
this.presence = presence;
|
|
76
|
+
if (this.connected()) this.send({ t: "join", presence });
|
|
77
|
+
}
|
|
78
|
+
/** Broadcast an application event to every other member (needs a `send` grant). */
|
|
79
|
+
publish(e, d) {
|
|
80
|
+
return this.send({ t: "send", e, d });
|
|
81
|
+
}
|
|
82
|
+
connected() {
|
|
83
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
84
|
+
}
|
|
85
|
+
/** Connect and join; no-op if already connected or connecting. Resumes after close(). */
|
|
86
|
+
connect() {
|
|
87
|
+
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
this.explicitClose = false;
|
|
91
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
92
|
+
this.reconnectTimer = null;
|
|
93
|
+
this.setStatus(this.attempt > 0 ? "reconnecting" : "connecting");
|
|
94
|
+
let ws;
|
|
95
|
+
try {
|
|
96
|
+
ws = new WebSocket(this.url);
|
|
97
|
+
} catch {
|
|
98
|
+
this.setStatus("dead");
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
this.ws = ws;
|
|
102
|
+
ws.addEventListener("open", () => ws === this.ws && this.handleOpen());
|
|
103
|
+
ws.addEventListener("message", (event) => ws === this.ws && this.handleMessage(String(event.data)));
|
|
104
|
+
ws.addEventListener("close", (event) => ws === this.ws && this.handleClose(event));
|
|
105
|
+
return this;
|
|
106
|
+
}
|
|
107
|
+
/** Close and stop reconnecting and keepalive, until connect() is called again. */
|
|
108
|
+
close() {
|
|
109
|
+
this.explicitClose = true;
|
|
110
|
+
this.clearTimers();
|
|
111
|
+
this.setStatus("closed");
|
|
112
|
+
const ws = this.ws;
|
|
113
|
+
this.ws = null;
|
|
114
|
+
if (ws) {
|
|
115
|
+
try {
|
|
116
|
+
ws.close(1e3, "Client closed");
|
|
117
|
+
} catch {
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
send(frame) {
|
|
122
|
+
const ws = this.ws;
|
|
123
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
124
|
+
try {
|
|
125
|
+
ws.send(JSON.stringify(frame));
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
handleOpen() {
|
|
132
|
+
if (this.deadTimer) clearTimeout(this.deadTimer);
|
|
133
|
+
this.deadTimer = null;
|
|
134
|
+
this.stableTimer = setTimeout(() => {
|
|
135
|
+
this.attempt = 0;
|
|
136
|
+
this.stableTimer = null;
|
|
137
|
+
}, this.policy.stableMs);
|
|
138
|
+
this.send({ t: "join", presence: this.presence });
|
|
139
|
+
this.startHeartbeat();
|
|
140
|
+
this.setStatus("open");
|
|
141
|
+
}
|
|
142
|
+
handleMessage(raw) {
|
|
143
|
+
let frame;
|
|
144
|
+
try {
|
|
145
|
+
frame = JSON.parse(raw);
|
|
146
|
+
} catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
switch (frame.t) {
|
|
150
|
+
case "hello":
|
|
151
|
+
this.connectionId = frame.connectionId;
|
|
152
|
+
this.setMembers(frame.members);
|
|
153
|
+
break;
|
|
154
|
+
case "presence":
|
|
155
|
+
this.setMembers(frame.members);
|
|
156
|
+
break;
|
|
157
|
+
case "event":
|
|
158
|
+
this.dispatch(frame.e, frame.d, frame.from);
|
|
159
|
+
break;
|
|
160
|
+
case "pong":
|
|
161
|
+
this.lastPongAt = Date.now();
|
|
162
|
+
break;
|
|
163
|
+
case "error":
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
setMembers(members) {
|
|
168
|
+
this.members = members;
|
|
169
|
+
for (const handler of [...this.memberHandlers]) handler(members);
|
|
170
|
+
}
|
|
171
|
+
dispatch(e, d, from) {
|
|
172
|
+
for (const handler of [...this.anyHandlers]) handler(e, d, from);
|
|
173
|
+
const set = this.listeners.get(e);
|
|
174
|
+
if (!set) return;
|
|
175
|
+
for (const handler of [...set]) handler(d, from);
|
|
176
|
+
}
|
|
177
|
+
handleClose(event) {
|
|
178
|
+
this.ws = null;
|
|
179
|
+
this.clearHeartbeat();
|
|
180
|
+
if (this.stableTimer) clearTimeout(this.stableTimer);
|
|
181
|
+
this.stableTimer = null;
|
|
182
|
+
if (this.explicitClose) return;
|
|
183
|
+
if (event.code === SERVICE_RESTART) this.attempt = 0;
|
|
184
|
+
this.scheduleReconnect();
|
|
185
|
+
}
|
|
186
|
+
scheduleReconnect() {
|
|
187
|
+
if (this.policy.maxRetries > 0 && this.attempt >= this.policy.maxRetries) {
|
|
188
|
+
this.setStatus("dead");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.setStatus("reconnecting");
|
|
192
|
+
if (!this.deadTimer && this.policy.deadAfterMs > 0) {
|
|
193
|
+
this.deadTimer = setTimeout(() => {
|
|
194
|
+
this.deadTimer = null;
|
|
195
|
+
if (this.status === "reconnecting") this.setStatus("dead");
|
|
196
|
+
}, this.policy.deadAfterMs);
|
|
197
|
+
}
|
|
198
|
+
const delay = this.nextDelay();
|
|
199
|
+
this.attempt += 1;
|
|
200
|
+
this.reconnectTimer = setTimeout(() => {
|
|
201
|
+
this.reconnectTimer = null;
|
|
202
|
+
this.connect();
|
|
203
|
+
}, delay);
|
|
204
|
+
}
|
|
205
|
+
/** Exponential backoff with jitter, never above maxDelayMs. */
|
|
206
|
+
nextDelay() {
|
|
207
|
+
const base = this.policy.minDelayMs * Math.pow(this.policy.factor, Math.min(this.attempt, 16));
|
|
208
|
+
const jitter = this.policy.jitterRatio > 0 ? (Math.random() * 2 - 1) * this.policy.jitterRatio : 0;
|
|
209
|
+
return Math.max(0, Math.round(Math.min(base * (1 + jitter), this.policy.maxDelayMs)));
|
|
210
|
+
}
|
|
211
|
+
startHeartbeat() {
|
|
212
|
+
this.clearHeartbeat();
|
|
213
|
+
const interval = this.policy.heartbeatMs;
|
|
214
|
+
if (interval <= 0) return;
|
|
215
|
+
this.lastPongAt = Date.now();
|
|
216
|
+
this.heartbeatInterval = setInterval(() => {
|
|
217
|
+
if (!this.connected()) return;
|
|
218
|
+
if (Date.now() - this.lastPongAt >= interval * 2) {
|
|
219
|
+
try {
|
|
220
|
+
this.ws?.close(4e3, "Heartbeat timeout");
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.send({ t: "ping" });
|
|
226
|
+
}, interval);
|
|
227
|
+
}
|
|
228
|
+
clearTimers() {
|
|
229
|
+
for (const timer of [this.reconnectTimer, this.deadTimer, this.stableTimer]) if (timer) clearTimeout(timer);
|
|
230
|
+
this.reconnectTimer = null;
|
|
231
|
+
this.deadTimer = null;
|
|
232
|
+
this.stableTimer = null;
|
|
233
|
+
this.clearHeartbeat();
|
|
234
|
+
}
|
|
235
|
+
clearHeartbeat() {
|
|
236
|
+
if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
|
|
237
|
+
this.heartbeatInterval = null;
|
|
238
|
+
}
|
|
239
|
+
setStatus(status) {
|
|
240
|
+
if (this.status === status) return;
|
|
241
|
+
this.status = status;
|
|
242
|
+
for (const handler of [...this.statusHandlers]) handler(status);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export {
|
|
247
|
+
RealtimeClient
|
|
248
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/realtime/types.ts
|
|
2
|
+
var DEFAULT_RECONNECT_POLICY = {
|
|
3
|
+
minDelayMs: 1e3,
|
|
4
|
+
maxDelayMs: 3e4,
|
|
5
|
+
factor: 2,
|
|
6
|
+
jitterRatio: 0.2,
|
|
7
|
+
maxRetries: 0,
|
|
8
|
+
deadAfterMs: 12e4,
|
|
9
|
+
heartbeatMs: 3e4,
|
|
10
|
+
stableMs: 1e4
|
|
11
|
+
};
|
|
12
|
+
function parseClientFrame(raw) {
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(raw);
|
|
16
|
+
} catch {
|
|
17
|
+
return { ok: false, reason: "Not valid JSON." };
|
|
18
|
+
}
|
|
19
|
+
if (typeof parsed !== "object" || parsed === null) return { ok: false, reason: "Frame must be an object." };
|
|
20
|
+
const frame = parsed;
|
|
21
|
+
if (frame.t === "join") {
|
|
22
|
+
if (frame.presence !== void 0 && (typeof frame.presence !== "object" || frame.presence === null)) {
|
|
23
|
+
return { ok: false, reason: "presence must be an object." };
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, frame: { t: "join", presence: frame.presence } };
|
|
26
|
+
}
|
|
27
|
+
if (frame.t === "ping") return { ok: true, frame: { t: "ping" } };
|
|
28
|
+
if (frame.t === "send") {
|
|
29
|
+
if (typeof frame.e !== "string" || frame.e.length === 0 || frame.e.length > 128) {
|
|
30
|
+
return { ok: false, reason: "send.e must be a string of up to 128 characters." };
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, frame: { t: "send", e: frame.e, d: frame.d } };
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, reason: `Unknown frame type "${String(frame.t)}".` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
DEFAULT_RECONNECT_POLICY,
|
|
39
|
+
parseClientFrame
|
|
40
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed client for a generated resource's REST API. Browser- and server-safe: no
|
|
3
|
+
* zod or drizzle imports, types come from the descriptor via `import type`.
|
|
4
|
+
*
|
|
5
|
+
* import type contact from "@/resources/contact.resource";
|
|
6
|
+
* const contacts = createResourceClient<typeof contact>("/api/contacts");
|
|
7
|
+
* const { data, meta } = await contacts.list({ q: "ada", sort: "-createdAt" });
|
|
8
|
+
*/
|
|
9
|
+
interface ResourceTypes {
|
|
10
|
+
$types: {
|
|
11
|
+
create: object;
|
|
12
|
+
update: object;
|
|
13
|
+
record: object;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
interface ListParams {
|
|
17
|
+
page?: number;
|
|
18
|
+
perPage?: number;
|
|
19
|
+
/** Field key, prefixed with "-" for descending. */
|
|
20
|
+
sort?: string;
|
|
21
|
+
q?: string;
|
|
22
|
+
filter?: Record<string, string | number | boolean | null>;
|
|
23
|
+
}
|
|
24
|
+
interface ListResult<T> {
|
|
25
|
+
data: T[];
|
|
26
|
+
meta: {
|
|
27
|
+
page: number;
|
|
28
|
+
perPage: number;
|
|
29
|
+
total: number;
|
|
30
|
+
totalPages: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
interface ApiIssue {
|
|
34
|
+
path?: string;
|
|
35
|
+
param?: string;
|
|
36
|
+
message: string;
|
|
37
|
+
}
|
|
38
|
+
declare class ApiError extends Error {
|
|
39
|
+
readonly status: number;
|
|
40
|
+
readonly issues: ApiIssue[];
|
|
41
|
+
readonly field?: string;
|
|
42
|
+
constructor(status: number, body: {
|
|
43
|
+
error?: string;
|
|
44
|
+
issues?: ApiIssue[];
|
|
45
|
+
field?: string;
|
|
46
|
+
} | null);
|
|
47
|
+
}
|
|
48
|
+
interface ClientOptions {
|
|
49
|
+
/** Origin for server-side use, e.g. "https://example.com". Default: relative URLs. */
|
|
50
|
+
baseURL?: string;
|
|
51
|
+
fetch?: typeof fetch;
|
|
52
|
+
headers?: HeadersInit;
|
|
53
|
+
}
|
|
54
|
+
declare function createResourceClient<R extends ResourceTypes>(path: string, options?: ClientOptions): {
|
|
55
|
+
list(params?: ListParams): Promise<ListResult<R["$types"]["record"]>>;
|
|
56
|
+
get: (id: string) => Promise<R["$types"]["record"]>;
|
|
57
|
+
create: (input: R["$types"]["create"]) => Promise<R["$types"]["record"]>;
|
|
58
|
+
/** Partial update (PATCH). */
|
|
59
|
+
update: (id: string, input: R["$types"]["update"]) => Promise<R["$types"]["record"]>;
|
|
60
|
+
/** Full replacement (PUT): omitted optional fields are cleared. */
|
|
61
|
+
replace: (id: string, input: R["$types"]["create"]) => Promise<R["$types"]["record"]>;
|
|
62
|
+
delete: (id: string) => Promise<void>;
|
|
63
|
+
};
|
|
64
|
+
type ResourceClient<R extends ResourceTypes> = ReturnType<typeof createResourceClient<R>>;
|
|
65
|
+
|
|
66
|
+
export { ApiError, type ApiIssue, type ClientOptions, type ListParams, type ListResult, type ResourceClient, type ResourceTypes, createResourceClient };
|