eraspace 0.3.0 → 0.4.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/README.md +2 -0
- package/bin.js +233 -161
- package/package.json +1 -1
- package/sdk/src/client/client.ts +10 -6
- package/sdk/src/client/files.ts +2 -0
- package/sdk/src/client/index.ts +1 -0
- package/sdk/src/client/sub-key.ts +3 -0
- package/templates/chat/client/index.tsx +11 -3
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ sign in and claim it (`eraspace auth login`, then `eraspace claim`).
|
|
|
25
25
|
| `typecheck` | Typecheck `server/`, `client/` and `shared/` |
|
|
26
26
|
| `deploy` | Build the client, compile the server and deploy (`--delete-table <table>=<rows>` confirms deleting a removed table; `--shadow` first reruns the app's live queries on the new code and reports which results change or start throwing, and `--fail-on-query-errors` refuses the deploy if any would throw) |
|
|
27
27
|
| `dev` | Deploy, then redeploy whenever `server/`, `client/`, `shared/` or `.env.eraspace.server` change |
|
|
28
|
+
| `deploys [app]` | List the latest 50 kept versions, newest first, marking the one serving; older kept versions still roll back by number |
|
|
29
|
+
| `rollback [version]` | Deploy an earlier version's server code, client files and server env again as a new version; without a version, the one before the serving version. Takes `--delete-table`, `--shadow` and `--fail-on-query-errors` like `deploy` |
|
|
28
30
|
| `call <fn> '<json>'...` | Call a query, mutation or action, each argument a JSON value |
|
|
29
31
|
| `profile [app] --seconds <n>` | Sample the app's server CPU time and write a `.cpuprofile` (TypeScript positions) for Chrome DevTools, printing the top functions by self time (`--out <file>`) |
|
|
30
32
|
| `explain <fn> '<json>'...` | Run a query once and print the rows it scanned and kept, with an index for each scan it filtered in JavaScript (`--json` for the full report) |
|
package/bin.js
CHANGED
|
@@ -32879,15 +32879,15 @@ var TomlParser = class {
|
|
|
32879
32879
|
}
|
|
32880
32880
|
parseDate(token) {
|
|
32881
32881
|
const date = "\\d{4}-\\d{2}-\\d{2}";
|
|
32882
|
-
const
|
|
32883
|
-
if (new RegExp(`^${date}[Tt]${
|
|
32882
|
+
const time4 = "\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?";
|
|
32883
|
+
if (new RegExp(`^${date}[Tt]${time4}(?:[Zz]|[+-]\\d{2}:\\d{2})$`).test(token)) {
|
|
32884
32884
|
const value4 = new Date(token.replace("t", "T").replace("z", "Z"));
|
|
32885
32885
|
if (Number.isNaN(value4.getTime())) {
|
|
32886
32886
|
this.fail(`Invalid date-time '${token}'`);
|
|
32887
32887
|
}
|
|
32888
32888
|
return value4;
|
|
32889
32889
|
}
|
|
32890
|
-
if (new RegExp(`^${date}[Tt]${
|
|
32890
|
+
if (new RegExp(`^${date}[Tt]${time4}$`).test(token) || new RegExp(`^${date}$`).test(token) || new RegExp(`^${time4}$`).test(token)) {
|
|
32891
32891
|
return token.replace("t", "T");
|
|
32892
32892
|
}
|
|
32893
32893
|
return void 0;
|
|
@@ -38994,7 +38994,7 @@ var runWith2 = (command2, config2) => {
|
|
|
38994
38994
|
// cli/package.json
|
|
38995
38995
|
var package_default = {
|
|
38996
38996
|
name: "eraspace-cli",
|
|
38997
|
-
version: "0.
|
|
38997
|
+
version: "0.4.0",
|
|
38998
38998
|
private: true,
|
|
38999
38999
|
license: "MIT",
|
|
39000
39000
|
type: "module",
|
|
@@ -39314,12 +39314,12 @@ function findSpecifier(source, spec) {
|
|
|
39314
39314
|
}
|
|
39315
39315
|
function formatLog(l) {
|
|
39316
39316
|
const d = l.ts === void 0 ? /* @__PURE__ */ new Date() : new Date(typeof l.ts === "number" ? l.ts : Date.parse(l.ts));
|
|
39317
|
-
const
|
|
39317
|
+
const time4 = isNaN(d.getTime()) ? String(l.ts) : d.toISOString().slice(11, 23);
|
|
39318
39318
|
const level = (l.level ?? "info").toLowerCase();
|
|
39319
39319
|
const color = level === "error" ? c.red : level === "warn" ? c.yellow : level === "debug" ? c.gray : c.blue;
|
|
39320
39320
|
const fn3 = l.fn ? c.magenta(l.fn) + " " : "";
|
|
39321
39321
|
const data = l.data === void 0 || l.data === null ? "" : " " + c.gray(JSON.stringify(l.data));
|
|
39322
|
-
return `${c.gray(
|
|
39322
|
+
return `${c.gray(time4)} ${color(level.padEnd(5))} ${fn3}${l.msg ?? ""}${data}`;
|
|
39323
39323
|
}
|
|
39324
39324
|
var SKIPPED = {
|
|
39325
39325
|
fetch: "uses fetch",
|
|
@@ -39404,7 +39404,13 @@ var Deployed = Schema_exports.Struct({
|
|
|
39404
39404
|
...App.fields,
|
|
39405
39405
|
version: Schema_exports.optional(Schema_exports.Number),
|
|
39406
39406
|
claimToken: Schema_exports.optional(Schema_exports.String),
|
|
39407
|
-
shadow: Schema_exports.optional(ShadowReport)
|
|
39407
|
+
shadow: Schema_exports.optional(ShadowReport),
|
|
39408
|
+
/** For a rollback: the kept version whose files were deployed again. */
|
|
39409
|
+
rolledBackTo: Schema_exports.optional(Schema_exports.Number)
|
|
39410
|
+
});
|
|
39411
|
+
var DeployHistory = Schema_exports.Struct({
|
|
39412
|
+
version: Schema_exports.optional(Schema_exports.Number),
|
|
39413
|
+
deploys: Schema_exports.optional(Schema_exports.Array(Schema_exports.Struct({ version: Schema_exports.Number, createdAt: Schema_exports.optional(Schema_exports.Number) })))
|
|
39408
39414
|
});
|
|
39409
39415
|
var AppList = Schema_exports.Struct({ apps: Schema_exports.Array(App) });
|
|
39410
39416
|
var StateChange = Schema_exports.Struct({ state: Schema_exports.String });
|
|
@@ -39421,7 +39427,7 @@ var Token2 = Schema_exports.Struct({
|
|
|
39421
39427
|
lastUsedAt: Schema_exports.optional(Timestamp)
|
|
39422
39428
|
});
|
|
39423
39429
|
var TokenList = Schema_exports.Struct({ tokens: Schema_exports.Array(Token2) });
|
|
39424
|
-
var CreatedToken = Schema_exports.Struct({ token: Schema_exports.String });
|
|
39430
|
+
var CreatedToken = Schema_exports.Struct({ token: Schema_exports.String, id: Schema_exports.String });
|
|
39425
39431
|
var DeviceStart = Schema_exports.Struct({
|
|
39426
39432
|
deviceCode: Schema_exports.String,
|
|
39427
39433
|
userCode: Schema_exports.String,
|
|
@@ -39961,6 +39967,11 @@ var createApp = Effect_exports.fnUntraced(function* (config2, payload, extra, op
|
|
|
39961
39967
|
return { result: result4, name: naming.name };
|
|
39962
39968
|
});
|
|
39963
39969
|
var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
|
|
39970
|
+
const t = yield* target(config2);
|
|
39971
|
+
if (t.ref && opts.name !== void 0)
|
|
39972
|
+
return yield* fail12(
|
|
39973
|
+
t.claim ? `--name only names a new app, and this checkout already deploys to ${t.ref}. Name it when you claim it: eraspace claim --name ${opts.name}` : `--name only names a new app, and this checkout already deploys to ${t.ref}. Add the name with eraspace names add ${opts.name}`
|
|
39974
|
+
);
|
|
39964
39975
|
const build3 = yield* buildCapsule(config2.root);
|
|
39965
39976
|
if (!build3.ok || !build3.payload)
|
|
39966
39977
|
return yield* fail12(yield* formatErrors(build3.errors, config2.root));
|
|
@@ -39972,7 +39983,6 @@ var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
|
|
|
39972
39983
|
...opts.failOnQueryErrors ? { failOnQueryErrors: true } : {}
|
|
39973
39984
|
};
|
|
39974
39985
|
const signedIn = (yield* bearerFor(config2.origin)) !== void 0;
|
|
39975
|
-
const t = yield* target(config2);
|
|
39976
39986
|
let result4;
|
|
39977
39987
|
if (t.ref) {
|
|
39978
39988
|
const payload = t.claim ? yield* withoutEnv(build3.payload) : build3.payload;
|
|
@@ -40035,6 +40045,40 @@ var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
|
|
|
40035
40045
|
}
|
|
40036
40046
|
return result4;
|
|
40037
40047
|
});
|
|
40048
|
+
var time2 = (ms) => ms === void 0 ? "" : new Date(ms).toISOString();
|
|
40049
|
+
var deploys = Effect_exports.fnUntraced(function* (config2, explicit) {
|
|
40050
|
+
const t = yield* requireTarget(config2, explicit);
|
|
40051
|
+
const history = yield* ok(DeployHistory)(config2, "GET", `/_api/apps/${enc(t.ref)}`, { claim: t.claim });
|
|
40052
|
+
const kept = history.deploys ?? [];
|
|
40053
|
+
if (!kept.length) return yield* Console_exports.log(`${t.ref} has no deploys.`);
|
|
40054
|
+
for (const d of kept) {
|
|
40055
|
+
const serving = d.version === history.version ? ` ${c.green("serving")}` : "";
|
|
40056
|
+
yield* Console_exports.log(`v${d.version} ${c.dim(time2(d.createdAt))}${serving}`);
|
|
40057
|
+
}
|
|
40058
|
+
if (kept.length >= 50) yield* Console_exports.log(c.dim(`Showing the latest ${kept.length}; any older kept versions still roll back by number: eraspace rollback <version>`));
|
|
40059
|
+
});
|
|
40060
|
+
var rollback = Effect_exports.fnUntraced(function* (config2, version, opts = {}) {
|
|
40061
|
+
const t = yield* requireTarget(config2);
|
|
40062
|
+
const deleteTables = yield* confirmedDeletes(opts.deleteTables);
|
|
40063
|
+
const body = {
|
|
40064
|
+
...version === void 0 ? {} : { version },
|
|
40065
|
+
...Object.keys(deleteTables).length ? { deleteTables } : {},
|
|
40066
|
+
...opts.shadow ? { shadow: true } : {},
|
|
40067
|
+
...opts.failOnQueryErrors ? { failOnQueryErrors: true } : {}
|
|
40068
|
+
};
|
|
40069
|
+
const path = `/_api/apps/${enc(t.ref)}/rollback`;
|
|
40070
|
+
const response = yield* api(config2, "POST", path, { body, claim: t.claim, timeout: DEPLOY_TIMEOUT });
|
|
40071
|
+
if (response.status >= 400 || response.json.ok === false) {
|
|
40072
|
+
const refused = Schema_exports.decodeUnknownOption(ShadowReport)(response.json.shadow);
|
|
40073
|
+
if (Option_exports.isSome(refused)) yield* Console_exports.log(formatShadow(refused.value));
|
|
40074
|
+
return yield* Effect_exports.fail(yield* failure(response, config2.root));
|
|
40075
|
+
}
|
|
40076
|
+
const result4 = yield* decode5(Deployed, response.json, `POST ${path}`);
|
|
40077
|
+
if (result4.shadow) yield* Console_exports.log(formatShadow(result4.shadow));
|
|
40078
|
+
const label = result4.names?.[0] ?? result4.id;
|
|
40079
|
+
yield* Console_exports.log(`${label} v${String(result4.version)}: rolled back to v${String(result4.rolledBackTo)}: ${result4.url ?? config2.url}`);
|
|
40080
|
+
return result4;
|
|
40081
|
+
});
|
|
40038
40082
|
var claimRequest = (config2, path, body, claim2) => api(config2, "POST", path, { body, claim: claim2 });
|
|
40039
40083
|
var claim = Effect_exports.fnUntraced(function* (config2, name) {
|
|
40040
40084
|
const local = yield* localDeploy(config2.root, config2.origin);
|
|
@@ -40082,10 +40126,14 @@ var IGNORED = /(^|[/\\])(node_modules|\.git|\.eraspace)([/\\]|$)/;
|
|
|
40082
40126
|
var isWatched = (file2) => !IGNORED.test(file2) && WATCHED.test(file2);
|
|
40083
40127
|
var dev = Effect_exports.fnUntraced(function* (config2, opts) {
|
|
40084
40128
|
const fs = yield* FileSystem_exports.FileSystem;
|
|
40129
|
+
let current = opts;
|
|
40085
40130
|
const redeploy = Effect_exports.suspend(
|
|
40086
40131
|
() => loadConfig({ dir: config2.root, app: config2.app, url: config2.url })
|
|
40087
40132
|
).pipe(
|
|
40088
|
-
Effect_exports.flatMap((
|
|
40133
|
+
Effect_exports.flatMap((loaded) => deploy(loaded, current)),
|
|
40134
|
+
Effect_exports.tap(() => Effect_exports.sync(() => {
|
|
40135
|
+
current = { ...current, name: void 0 };
|
|
40136
|
+
})),
|
|
40089
40137
|
Effect_exports.catch((error2) => Console_exports.error(messageOf(error2)))
|
|
40090
40138
|
);
|
|
40091
40139
|
yield* redeploy;
|
|
@@ -40193,7 +40241,7 @@ var logs = Effect_exports.fnUntraced(function* (config2, explicit, follow, limit
|
|
|
40193
40241
|
var okApp = ok(App);
|
|
40194
40242
|
var okJson = ok(JsonObject2);
|
|
40195
40243
|
var okState = ok(StateChange);
|
|
40196
|
-
var
|
|
40244
|
+
var time3 = (value4) => {
|
|
40197
40245
|
if (value4 === null || value4 === void 0) return void 0;
|
|
40198
40246
|
const date = new Date(typeof value4 === "number" ? value4 : Date.parse(value4));
|
|
40199
40247
|
return Number.isNaN(date.getTime()) ? String(value4) : date.toISOString();
|
|
@@ -40300,7 +40348,7 @@ var listTokens = Effect_exports.fnUntraced(function* (config2) {
|
|
|
40300
40348
|
if (!tokens.length) yield* Console_exports.log("No tokens.");
|
|
40301
40349
|
for (const t of tokens) {
|
|
40302
40350
|
const scope3 = t.scope === "personal" ? "personal (all your apps)" : `${t.app ?? "?"} (${t.access ?? "?"})`;
|
|
40303
|
-
const used =
|
|
40351
|
+
const used = time3(t.lastUsedAt);
|
|
40304
40352
|
yield* Console_exports.log(`${t.id} ${t.name} ${scope3} ${c.dim(used ? `last used ${used}` : "never used")}`);
|
|
40305
40353
|
}
|
|
40306
40354
|
});
|
|
@@ -40311,9 +40359,10 @@ var revokeToken = Effect_exports.fnUntraced(function* (config2, id2) {
|
|
|
40311
40359
|
var createToken = Effect_exports.fnUntraced(function* (config2, name, opts) {
|
|
40312
40360
|
if (opts.personal && opts.read) return yield* fail12("--read only applies to app tokens; personal tokens have full access to your apps.");
|
|
40313
40361
|
const spec = opts.personal ? { name, personal: true } : { name, app: (yield* requireTarget(config2)).ref, access: opts.read ? "read" : "deploy" };
|
|
40314
|
-
const { token } = yield* ok(CreatedToken)(config2, "POST", "/_api/tokens", { body: spec });
|
|
40362
|
+
const { token, id: id2 } = yield* ok(CreatedToken)(config2, "POST", "/_api/tokens", { body: spec });
|
|
40315
40363
|
yield* Console_exports.log(token);
|
|
40316
40364
|
yield* Console_exports.error(c.dim(`Shown once. Use it as ERASPACE_TOKEN with ERASPACE_TOKEN_API=${config2.origin}.`));
|
|
40365
|
+
yield* Console_exports.error(c.dim(`Token id ${id2}; revoke it with eraspace token revoke ${id2}.`));
|
|
40317
40366
|
});
|
|
40318
40367
|
function parseCallArgs(inputs) {
|
|
40319
40368
|
const args2 = [];
|
|
@@ -40350,138 +40399,74 @@ import { basename as basename2, join as join7, relative as relative3, resolve as
|
|
|
40350
40399
|
var TEMPLATES = ["chat", "todo", "poll"];
|
|
40351
40400
|
var agents = (cli) => `# eraspace capsule: agent instructions
|
|
40352
40401
|
|
|
40353
|
-
This directory is
|
|
40354
|
-
|
|
40355
|
-
|
|
40356
|
-
|
|
40357
|
-
## Commands
|
|
40358
|
-
|
|
40359
|
-
Run these from this directory. There is nothing to install: the CLI bundles the SDK, Preact and TypeScript.
|
|
40360
|
-
|
|
40361
|
-
- \`${cli} typecheck\`: tsc over \`server/\`, \`client/\` and \`shared/\`. Deploy does not typecheck.
|
|
40362
|
-
- \`${cli} deploy\`: build the client, compile the server and print the app URL.
|
|
40363
|
-
- \`${cli} call <fn> '<json arg>'...\`: call a query, mutation or action, e.g. \`call add '"milk"'\`.
|
|
40364
|
-
- \`${cli} logs\`: handler logs, \`ctx.log\` and \`console.log\` output.
|
|
40365
|
-
- \`${cli} inspect\`: deployed functions, schema and metadata.
|
|
40366
|
-
- \`${cli} dev\`: redeploy on every save (long-running; keep it open).
|
|
40367
|
-
|
|
40368
|
-
The platform URL comes from \`ERASPACE_URL\`, then \`eraspace.json\`, then \`${DEFAULT_URL}\`.
|
|
40402
|
+
This directory is an eraspace app ("capsule"): a TypeScript server in V8 and a Preact client over a database with
|
|
40403
|
+
live queries, hosted and driven by \`${cli}\`, which supplies TypeScript, Preact and \`eraspace/*\`, so there is
|
|
40404
|
+
nothing to install.
|
|
40369
40405
|
|
|
40370
40406
|
## Workflow
|
|
40371
40407
|
|
|
40372
|
-
1. Read \`server/index.ts\` and \`client/index.tsx
|
|
40373
|
-
2.
|
|
40374
|
-
3. Run \`${cli}
|
|
40375
|
-
4.
|
|
40376
|
-
|
|
40377
|
-
|
|
40378
|
-
|
|
40379
|
-
|
|
40380
|
-
|
|
40381
|
-
|
|
40382
|
-
|
|
40383
|
-
|
|
40384
|
-
|
|
40385
|
-
|
|
40386
|
-
|
|
40387
|
-
|
|
40388
|
-
|
|
40389
|
-
|
|
40390
|
-
|
|
40391
|
-
|
|
40392
|
-
|
|
40393
|
-
|
|
40394
|
-
|
|
40395
|
-
|
|
40396
|
-
|
|
40397
|
-
|
|
40398
|
-
|
|
40399
|
-
|
|
40400
|
-
|
|
40401
|
-
|
|
40402
|
-
|
|
40403
|
-
|
|
40404
|
-
|
|
40405
|
-
|
|
40406
|
-
|
|
40407
|
-
|
|
40408
|
-
|
|
40409
|
-
|
|
40410
|
-
|
|
40411
|
-
|
|
40412
|
-
|
|
40413
|
-
|
|
40414
|
-
|
|
40415
|
-
|
|
40416
|
-
|
|
40417
|
-
|
|
40418
|
-
|
|
40419
|
-
|
|
40420
|
-
|
|
40421
|
-
|
|
40422
|
-
|
|
40423
|
-
|
|
40424
|
-
|
|
40425
|
-
|
|
40426
|
-
|
|
40427
|
-
|
|
40428
|
-
|
|
40429
|
-
|
|
40430
|
-
- Mutations schedule later work with \`ctx.scheduler.runAfter(ms, "mutationName", ...args)\`, which returns a job id.
|
|
40431
|
-
The job commits with the mutation and its run commits once; only mutations can be scheduled.
|
|
40432
|
-
- Handler arguments are positional and not validated. Check them in the handler.
|
|
40433
|
-
- \`ctx.auth.userId\` is a per-connection guest id that changes on reload. It is not a login: never use it for
|
|
40434
|
-
ownership or access control. Every app is public.
|
|
40435
|
-
- \`ctx.log.info(msg, data?)\` (also debug/warn/error) writes to \`eraspace logs\`.
|
|
40436
|
-
- Endpoints receive \`req\` with \`method, path, url, headers.get(), query.get(), json(), text()\` and return
|
|
40437
|
-
\`json(value)\`, \`text(string)\`, \`empty()\`, \`redirect(url)\` or a plain value (sent as JSON).
|
|
40438
|
-
- Removing a populated table needs its row count confirmed: \`${cli} deploy --delete-table <table>=<rows>\` (the
|
|
40439
|
-
refused deploy prints the exact flag). Deletion runs in background batches.
|
|
40440
|
-
- Changing a field's type or making it required fails the deploy while stored rows do not match. Declare a
|
|
40441
|
-
migration instead: \`table({ ... }).migrate(1, row => ({ ...row, done: false }))\` upgrades old rows when read
|
|
40442
|
-
and rewrites them in the background. Number migrations 1, 2, 3, ... and keep each one until its rows are done.
|
|
40443
|
-
|
|
40444
|
-
## Client API (\`eraspace/client\`)
|
|
40445
|
-
|
|
40446
|
-
\`\`\`tsx
|
|
40447
|
-
import { createClient } from "eraspace/client";
|
|
40448
|
-
import { useState } from "preact/hooks";
|
|
40449
|
-
import type app from "../server/index";
|
|
40450
|
-
|
|
40451
|
-
const client = createClient<typeof app>();
|
|
40452
|
-
|
|
40453
|
-
export default function App() {
|
|
40454
|
-
const items = client.useQuery("items", "some-list"); // undefined while loading, then live
|
|
40455
|
-
const add = client.useMutation("add");
|
|
40456
|
-
const [title, setTitle] = useState("");
|
|
40457
|
-
if (items === undefined) return <p>Loading\u2026</p>;
|
|
40458
|
-
return <main class="p-6">
|
|
40459
|
-
<form onSubmit={async (e) => { e.preventDefault(); await add("some-list", title); setTitle(""); }}>
|
|
40460
|
-
<input value={title} onInput={(e) => setTitle(e.currentTarget.value)} />
|
|
40461
|
-
</form>
|
|
40462
|
-
<ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
|
|
40463
|
-
</main>;
|
|
40464
|
-
}
|
|
40465
|
-
\`\`\`
|
|
40466
|
-
|
|
40467
|
-
- Query, mutation and action names, arguments and results are typed from the server. Import the server with
|
|
40468
|
-
\`import type\` only.
|
|
40469
|
-
- Optimistic updates: \`client.useMutation("vote", { optimistic(store, id) { store.update("results", [],
|
|
40470
|
-
(rows) => rows.map((r) => (r.id === id ? { ...r, votes: r.votes + 1 } : r))) } })\`. The second argument of
|
|
40471
|
-
\`store.update\` is the query's argument list.
|
|
40472
|
-
- \`useAction(name)\`, \`usePaginatedQuery(name, args, { initialNumItems })\`, \`client.query/mutation/action(...)\`.
|
|
40473
|
-
- Realtime rooms, not stored: \`const room = client.useRoom(name, meta)\` gives \`room.presence\`,
|
|
40474
|
-
\`room.broadcast(event, payload)\` and \`room.on(event, cb)\` (call \`on\` inside \`useEffect\` and return its
|
|
40475
|
-
cleanup). \`client.usePresence(name, meta)\` returns just the members.
|
|
40476
|
-
- Files: see "File uploads" below.
|
|
40477
|
-
- Pages: \`Router\`, \`Route\`, \`Link\`, \`useLocation\`, \`useNavigate\`, \`useParams\` from \`eraspace/client\`.
|
|
40478
|
-
- Open tabs reload by themselves after each deploy. Pass \`createClient({ reloadOnDeploy: false })\` to keep the
|
|
40479
|
-
page and react to \`client.onDeployed(version => ...)\` instead.
|
|
40480
|
-
- Client imports are limited to \`eraspace/client\`, \`preact\`, \`preact/hooks\`, \`@preact/signals\` and relative files.
|
|
40408
|
+
1. Read \`CODING_STANDARDS.md\`, \`server/index.ts\` and \`client/index.tsx\`.
|
|
40409
|
+
2. Write the requested app or change.
|
|
40410
|
+
3. Run \`${cli} typecheck\` until it prints "No type errors."
|
|
40411
|
+
4. Run \`${cli} deploy\` until it prints the app URL. Pass \`--delete-table\` only for rows the user agreed to lose.
|
|
40412
|
+
5. Run \`${cli} call <fn> '<json arg>'...\` on every query, mutation and action, and on an invalid input for each
|
|
40413
|
+
mutation that takes arguments, until each returns what the code intends and each invalid input is refused with
|
|
40414
|
+
its message; then \`${cli} logs\` shows no unexpected error.
|
|
40415
|
+
6. Once steps 3 to 5 pass after your last edit, report the URL. For an unclaimed app, also report the expiry deploy
|
|
40416
|
+
printed and that \`${cli} auth login\` then \`${cli} claim\` keeps the app.
|
|
40417
|
+
`;
|
|
40418
|
+
var CODING_STANDARDS = `# eraspace capsule: coding standards
|
|
40419
|
+
|
|
40420
|
+
Before using one of these features, read its section in \`FEATURES.md\`: file uploads, rooms and presence, routing,
|
|
40421
|
+
optimistic updates, pagination, scheduled mutations, HTTP endpoints, reload on deploy.
|
|
40422
|
+
|
|
40423
|
+
## Security
|
|
40424
|
+
|
|
40425
|
+
- Every caller is an anonymous guest and every app is public. \`ctx.auth.userId\` is a guest id that changes with
|
|
40426
|
+
each connection, so it never grants ownership or access.
|
|
40427
|
+
- Keep secrets in \`.env.eraspace.server\` as \`KEY=value\` lines and read them in server code as \`ctx.env.KEY\`:
|
|
40428
|
+
\`client/\` and \`shared/\` ship to the browser.
|
|
40429
|
+
|
|
40430
|
+
## Server (\`eraspace/server\`)
|
|
40431
|
+
|
|
40432
|
+
- Fields: \`string() number() boolean() id("table") userId() any() array(field) object({...})\`, each with
|
|
40433
|
+
\`.optional()\` or \`.default(value)\`. Rows also have \`id\` and \`createdAt\` (epoch ms).
|
|
40434
|
+
- \`ctx.db.<table>\` has \`insert\`, \`get\`, \`update(id, partial)\` (\`null\` removes a field), \`replace\` and
|
|
40435
|
+
\`delete\`. Reads chain \`withIndex\`, \`order("asc" | "desc")\`, then \`collect()\`, \`take(n)\`, \`first()\`,
|
|
40436
|
+
\`unique()\`, \`count()\` or \`paginate(...)\`.
|
|
40437
|
+
- Declare an index with \`table({...}).index("by_list", ["listId"])\` and read it with
|
|
40438
|
+
\`ctx.db.items.withIndex("by_list", (q) => q.eq("listId", id))\`, then \`.gt(...)\` or \`.lt(...)\` on the next
|
|
40439
|
+
field. \`by_creation\` indexes every table by \`createdAt\`.
|
|
40440
|
+
- Validate every argument in the handler: arguments are positional, come from anyone and are not checked against
|
|
40441
|
+
their TypeScript types.
|
|
40442
|
+
- A query is a live query: its cached result is reused until rows it read change, then rerun and pushed to
|
|
40443
|
+
subscribers. Make it depend only on its arguments, its caller and the rows it reads: pass a time it needs as an
|
|
40444
|
+
argument, rounded so it stays the same across renders, and draw random values in mutations.
|
|
40445
|
+
- Mutations run one at a time per app, so a read-then-write in one mutation never races another.
|
|
40446
|
+
- Actions call outbound \`fetch\`, \`ctx.runQuery\`, \`ctx.runMutation\` and \`ctx.runAction\`: fetch in an action
|
|
40447
|
+
and save the result with \`ctx.runMutation\`.
|
|
40448
|
+
- Rows and results are JSON: keep times as epoch-ms numbers, as \`createdAt\` is.
|
|
40449
|
+
- Keep state in the database: module variables reset whenever the app unloads.
|
|
40450
|
+
- Handlers have CPU, wall-clock and heap limits: keep loops and reads bounded.
|
|
40451
|
+
- A \`.migrate\` upgrade is a pure function of the stored row.
|
|
40452
|
+
|
|
40453
|
+
## Client (\`eraspace/client\`)
|
|
40454
|
+
|
|
40455
|
+
- \`client.useQuery(name, ...args)\` subscribes to a live query: \`undefined\` while loading, then every new result,
|
|
40456
|
+
so mutations need no refetch.
|
|
40457
|
+
- \`client.useAction(name)\` returns a function, as \`useMutation\` does. Outside components, call \`client.query\`,
|
|
40458
|
+
\`client.mutation\` or \`client.action(name, ...args)\`.
|
|
40459
|
+
- Style with Tailwind utility classes, each written as a complete literal, and \`client/styles.css\`.
|
|
40460
|
+
|
|
40461
|
+
## Shared code
|
|
40462
|
+
|
|
40463
|
+
- Keep \`shared/\` to types, constants and pure functions, which both the browser and the server can run.
|
|
40464
|
+
`;
|
|
40465
|
+
var FEATURES = `# eraspace capsule: features
|
|
40481
40466
|
|
|
40482
40467
|
## File uploads
|
|
40483
40468
|
|
|
40484
|
-
Files
|
|
40469
|
+
Files live in app storage, not the database: a row saves the \`FileRef\` an upload returns.
|
|
40485
40470
|
|
|
40486
40471
|
\`\`\`tsx
|
|
40487
40472
|
// server/index.ts: declare the file in the schema and copy its fields in the mutation (extra fields are refused).
|
|
@@ -40501,36 +40486,93 @@ const addPhoto = client.useMutation("addPhoto");
|
|
|
40501
40486
|
- \`<FileUpload>\` is a drop zone and file picker with progress and error text. Props: \`accept\`, \`multiple\`,
|
|
40502
40487
|
\`maxBytes\`, \`disabled\`, \`class\` (replaces its default style; \`data-dragging\` is set during a drag) and
|
|
40503
40488
|
children for the label. \`onUpload(file)\` runs once per stored file; if it throws, the file is deleted again.
|
|
40504
|
-
- \`<FilePreview file={fileRef
|
|
40505
|
-
|
|
40506
|
-
|
|
40507
|
-
from the same page show at once from the browser's copy; \`<FileUpload>\` shows an image while it uploads.
|
|
40489
|
+
- \`<FilePreview file={fileRef}>\` shows images, video and audio inline, plain text in a frame, and other types as a
|
|
40490
|
+
download card. Pass it the saved \`FileRef\`: a bare key costs a metadata request per preview. \`caption={false}\`
|
|
40491
|
+
hides the name/size line; \`class\` replaces its style.
|
|
40508
40492
|
- Custom UI: \`const { upload, uploading, progress, error, file } = client.useUpload({ accept, maxBytes, onUpload })\`,
|
|
40509
40493
|
then \`upload(input.files)\`; \`file\` is the Blob uploading now. Lower level: \`client.storage.upload(blob, { onProgress })\`,
|
|
40510
40494
|
\`.url(key)\`, \`.localUrl(key)\`, \`.info(key)\` and \`.delete(key)\`.
|
|
40495
|
+
- Up to 5 MiB per file and 100 MiB per app.
|
|
40511
40496
|
- \`accept\` and \`maxBytes\` are checked in the browser only; the server stores any type up to its limit.
|
|
40512
|
-
-
|
|
40497
|
+
- Anyone with a file's key can read that file, so upload only what may be public. The \`deleteToken\` that
|
|
40498
|
+
\`client.storage.upload\` returns lets its holder delete the file: keep it out of rows.
|
|
40499
|
+
|
|
40500
|
+
## Rooms and presence
|
|
40501
|
+
|
|
40502
|
+
Rooms are realtime and never stored. \`const room = client.useRoom(name, meta)\` gives:
|
|
40503
|
+
|
|
40504
|
+
- \`room.presence\`, the members with their \`meta\`, and \`room.self\`, this connection's id.
|
|
40505
|
+
- \`room.broadcast(event, payload)\` and \`room.on(event, (payload, from) => ...)\`. Call \`on\` inside \`useEffect\`
|
|
40506
|
+
and return its cleanup.
|
|
40507
|
+
- \`room.state\`, shared by the members and set with \`room.setState(key, value)\`. It vanishes when the room
|
|
40508
|
+
empties: save what must last with a mutation.
|
|
40509
|
+
|
|
40510
|
+
\`client.usePresence(name, meta)\` returns just the members.
|
|
40513
40511
|
|
|
40514
|
-
##
|
|
40512
|
+
## Routing
|
|
40515
40513
|
|
|
40516
|
-
|
|
40517
|
-
|
|
40518
|
-
|
|
40514
|
+
\`\`\`tsx
|
|
40515
|
+
import { Router, Route, Link, useParams, useNavigate } from "eraspace/client";
|
|
40516
|
+
|
|
40517
|
+
<Router>
|
|
40518
|
+
<Route path="/" component={Home} />
|
|
40519
|
+
<Route path="/items/:id" component={Item} /> {/* Item reads useParams().id */}
|
|
40520
|
+
<Route default component={NotFound} />
|
|
40521
|
+
</Router>
|
|
40522
|
+
\`\`\`
|
|
40523
|
+
|
|
40524
|
+
Paths are app-relative: the app is served under \`/<app>/\`, and every path below it loads the app. Link with
|
|
40525
|
+
\`<Link to="/items/1">\` and navigate with \`useNavigate()("/items/1")\`, which add that base. \`useLocation()\`
|
|
40526
|
+
gives the current \`path\` and \`url\`.
|
|
40527
|
+
|
|
40528
|
+
## Optimistic updates
|
|
40519
40529
|
|
|
40520
|
-
|
|
40521
|
-
|
|
40522
|
-
|
|
40523
|
-
|
|
40524
|
-
|
|
40525
|
-
|
|
40526
|
-
|
|
40527
|
-
|
|
40528
|
-
|
|
40530
|
+
\`client.useMutation("vote", { optimistic(store, id) { store.update("results", [], (rows) => rows.map((r) =>
|
|
40531
|
+
(r.id === id ? { ...r, votes: r.votes + 1 } : r))) } })\`. The second argument of \`store.update\` is the query's
|
|
40532
|
+
argument list.
|
|
40533
|
+
|
|
40534
|
+
## Pagination
|
|
40535
|
+
|
|
40536
|
+
\`\`\`ts
|
|
40537
|
+
// server: the query takes PaginationOptions last and returns paginate(); cap numItems, which the client chooses.
|
|
40538
|
+
feed: query(async (ctx, listId: string, page: PaginationOptions) =>
|
|
40539
|
+
ctx.db.items.withIndex("by_list", (q) => q.eq("listId", listId)).order("desc")
|
|
40540
|
+
.paginate({ cursor: page.cursor, numItems: Math.min(page.numItems, 50) })),
|
|
40541
|
+
|
|
40542
|
+
// client
|
|
40543
|
+
const { results, status, loadMore } = client.usePaginatedQuery("feed", ["some-list"], { initialNumItems: 20 });
|
|
40544
|
+
\`\`\`
|
|
40529
40545
|
|
|
40530
|
-
|
|
40546
|
+
\`PaginationOptions\` comes from \`eraspace/server\`. \`status\` is \`"LoadingFirstPage"\`, \`"CanLoadMore"\`,
|
|
40547
|
+
\`"LoadingMore"\` or \`"Exhausted"\`; \`loadMore(n?)\` fetches the next page.
|
|
40531
40548
|
|
|
40532
|
-
|
|
40533
|
-
|
|
40549
|
+
## Scheduled mutations
|
|
40550
|
+
|
|
40551
|
+
Mutations and writable endpoints run a mutation later with \`ctx.scheduler.runAfter(ms, "mutationName", ...args)\`,
|
|
40552
|
+
which returns a job id. The job commits with the mutation that schedules it, and its run commits at most once. For
|
|
40553
|
+
recurring work, the scheduled mutation schedules its next run.
|
|
40554
|
+
|
|
40555
|
+
## HTTP endpoints
|
|
40556
|
+
|
|
40557
|
+
\`\`\`ts
|
|
40558
|
+
import { endpoint, json } from "eraspace/server";
|
|
40559
|
+
|
|
40560
|
+
endpoints: {
|
|
40561
|
+
health: endpoint({ method: "GET", path: "/health", readOnly: true }, async (ctx, req) => json({ ok: true })),
|
|
40562
|
+
},
|
|
40563
|
+
\`\`\`
|
|
40564
|
+
|
|
40565
|
+
- An endpoint is served at \`/<app>/<path>\`. \`readOnly: true\` gives it a read-only db; otherwise it writes like a
|
|
40566
|
+
mutation.
|
|
40567
|
+
- \`req\` has \`method, path, url, headers.get(), query.get(), json(), text(), bytes(), formData()\`. Return
|
|
40568
|
+
\`json(value)\`, \`text(string)\`, \`empty()\`, \`redirect(url)\` (each takes \`{ status, headers }\` last) or a
|
|
40569
|
+
plain value (sent as JSON).
|
|
40570
|
+
- Endpoints are public: authenticate a webhook by checking its signature with \`crypto.subtle\`.
|
|
40571
|
+
|
|
40572
|
+
## Reload on deploy
|
|
40573
|
+
|
|
40574
|
+
Open tabs reload by themselves after each deploy. Pass \`createClient({ reloadOnDeploy: false })\` to keep the page
|
|
40575
|
+
and react to \`client.onDeployed(version => ...)\` instead.
|
|
40534
40576
|
`;
|
|
40535
40577
|
var GITIGNORE = [".eraspace/", ".env.eraspace.server"];
|
|
40536
40578
|
var keepsOnInit = (name) => name.startsWith(".") || /^(README|LICENSE)(\.|$)/i.test(name);
|
|
@@ -40560,6 +40602,8 @@ var createCapsule = Effect_exports.fnUntraced(function* (directory, template, mo
|
|
|
40560
40602
|
const files = {
|
|
40561
40603
|
"eraspace.json": JSON.stringify({ name: app }, null, 2) + "\n",
|
|
40562
40604
|
"AGENTS.md": agents(command2),
|
|
40605
|
+
"CODING_STANDARDS.md": CODING_STANDARDS,
|
|
40606
|
+
"FEATURES.md": FEATURES,
|
|
40563
40607
|
"CLAUDE.md": "@AGENTS.md\n"
|
|
40564
40608
|
};
|
|
40565
40609
|
const gitignore = join7(destination, ".gitignore");
|
|
@@ -40659,7 +40703,7 @@ var config = Effect_exports.gen(function* () {
|
|
|
40659
40703
|
return yield* loadConfig({ dir: value3(flags.dir), app: value3(flags.app), url: value3(flags.url) });
|
|
40660
40704
|
});
|
|
40661
40705
|
var deployFlags = {
|
|
40662
|
-
name: optionalString("name", "Exact name for a new app, failing if it is taken (default: the eraspace.json or directory name, or a free variant of it)"),
|
|
40706
|
+
name: optionalString("name", "Exact name for a new app, failing if it is taken (default: the eraspace.json or directory name, or a free variant of it). A redeploy refuses it; use eraspace names add"),
|
|
40663
40707
|
publicInspect: Flag_exports.Boolean("public-inspect").pipe(Flag_exports.withDefault(false), Flag_exports.withDescription("Let anyone read this app's inspection data and logs"))
|
|
40664
40708
|
};
|
|
40665
40709
|
var deployOptions = (input) => ({ name: value3(input.name), publicInspect: input.publicInspect });
|
|
@@ -40714,6 +40758,32 @@ var deployCommand = Command_exports.make(
|
|
|
40714
40758
|
{ command: "eraspace deploy --fail-on-query-errors", description: "Deploy only if no live query starts throwing on the new code" }
|
|
40715
40759
|
])
|
|
40716
40760
|
);
|
|
40761
|
+
var deploysCommand = Command_exports.make(
|
|
40762
|
+
"deploys",
|
|
40763
|
+
{ app: optionalArg("app", "App name or id (default: this checkout's app)") },
|
|
40764
|
+
({ app }) => Effect_exports.flatMap(config, (c2) => deploys(c2, value3(app)))
|
|
40765
|
+
).pipe(Command_exports.withDescription("List the latest 50 kept versions you can roll back to, newest first"));
|
|
40766
|
+
var rollbackCommand = Command_exports.make(
|
|
40767
|
+
"rollback",
|
|
40768
|
+
{
|
|
40769
|
+
version: Argument_exports.Int("version").pipe(
|
|
40770
|
+
Argument_exports.withDescription("Version to roll back to (default: the newest kept version below the one serving)"),
|
|
40771
|
+
Argument_exports.optional
|
|
40772
|
+
),
|
|
40773
|
+
...deleteTableFlag,
|
|
40774
|
+
...shadowFlags
|
|
40775
|
+
},
|
|
40776
|
+
(input) => Effect_exports.flatMap(
|
|
40777
|
+
config,
|
|
40778
|
+
(c2) => rollback(c2, value3(input.version), { deleteTables: value3(input.deleteTable), shadow: input.shadow, failOnQueryErrors: input.failOnQueryErrors })
|
|
40779
|
+
)
|
|
40780
|
+
).pipe(
|
|
40781
|
+
Command_exports.withDescription("Deploy an earlier version's server code, client files and server env again, as a new version"),
|
|
40782
|
+
Command_exports.withExamples([
|
|
40783
|
+
{ command: "eraspace rollback", description: "Go back to the version before the one serving" },
|
|
40784
|
+
{ command: "eraspace rollback 3", description: "Serve version 3's code again" }
|
|
40785
|
+
])
|
|
40786
|
+
);
|
|
40717
40787
|
var devCommand = Command_exports.make("dev", deployFlags, (input) => Effect_exports.flatMap(config, (c2) => dev(c2, deployOptions(input)))).pipe(
|
|
40718
40788
|
Command_exports.withDescription("Deploy, then redeploy whenever server/, client/, shared/ or .env.eraspace.server change")
|
|
40719
40789
|
);
|
|
@@ -40861,6 +40931,8 @@ var command = eraspace.pipe(
|
|
|
40861
40931
|
initCommand,
|
|
40862
40932
|
typecheckCommand,
|
|
40863
40933
|
deployCommand,
|
|
40934
|
+
deploysCommand,
|
|
40935
|
+
rollbackCommand,
|
|
40864
40936
|
devCommand,
|
|
40865
40937
|
callCommand,
|
|
40866
40938
|
explainCommand,
|
package/package.json
CHANGED
package/sdk/src/client/client.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { Pager } from "./pagination.ts";
|
|
|
13
13
|
|
|
14
14
|
export { readConfig, type EraspaceConfig, type ClientOptions } from "./config.ts";
|
|
15
15
|
export { subKey } from "./sub-key.ts";
|
|
16
|
-
export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult } from "./types.ts";
|
|
16
|
+
export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult, Status } from "./types.ts";
|
|
17
17
|
|
|
18
18
|
type Fn = (...args: any[]) => any;
|
|
19
19
|
|
|
@@ -114,11 +114,8 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
114
114
|
|
|
115
115
|
function useRoom(room: string, meta?: Record<string, unknown>): Room<any, any> {
|
|
116
116
|
const r = useRoomConn(room, meta);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
self: r.self.value,
|
|
120
|
-
state: r.state.value,
|
|
121
|
-
error: r.error.value,
|
|
117
|
+
// Stable per room, so effects and memoized children that depend on them do not rerun every render.
|
|
118
|
+
const methods = useMemo(() => ({
|
|
122
119
|
broadcast: (event: string, payload?: unknown) => {
|
|
123
120
|
void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
|
|
124
121
|
},
|
|
@@ -131,6 +128,13 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
131
128
|
setState: (key: string, value: unknown) => {
|
|
132
129
|
r.setState(key, value);
|
|
133
130
|
},
|
|
131
|
+
}), [r]);
|
|
132
|
+
return {
|
|
133
|
+
presence: r.members.value,
|
|
134
|
+
self: r.self.value,
|
|
135
|
+
state: r.state.value,
|
|
136
|
+
error: r.error.value,
|
|
137
|
+
...methods,
|
|
134
138
|
};
|
|
135
139
|
}
|
|
136
140
|
|
package/sdk/src/client/files.ts
CHANGED
|
@@ -343,6 +343,8 @@ export function FilePreview(props: FilePreviewProps) {
|
|
|
343
343
|
if (meta === undefined || meta === null || meta === "failed" || broken === key) {
|
|
344
344
|
// A media error cannot tell a missing file from a failed request, so every failure offers a retry.
|
|
345
345
|
const retry = () => {
|
|
346
|
+
// Found metadata stays cached, so a file deleted since would show as found again and fail the same way.
|
|
347
|
+
infoCache.delete(client.storage.url(key));
|
|
346
348
|
setBroken(null);
|
|
347
349
|
setFetched(undefined);
|
|
348
350
|
setAttempt((n) => n + 1);
|
package/sdk/src/client/index.ts
CHANGED
|
@@ -18,9 +18,12 @@ function canonical(v: unknown): unknown {
|
|
|
18
18
|
// Own data properties, so a `__proto__` key stays a key rather than setting the prototype.
|
|
19
19
|
return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canonical(o[k])]));
|
|
20
20
|
}
|
|
21
|
+
/** Sorted-key JSON. Like `JSON.stringify` and `canonicalArgs`, it serializes what `toJSON` returns, so two Dates differ. */
|
|
21
22
|
export function stableStringify(v: unknown): string {
|
|
22
23
|
if (v === undefined) return "null";
|
|
23
24
|
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
25
|
+
const json = (v as { toJSON?: unknown }).toJSON;
|
|
26
|
+
if (typeof json === "function") return stableStringify(json.call(v));
|
|
24
27
|
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
|
25
28
|
const o = v as Record<string, unknown>;
|
|
26
29
|
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createClient, FilePreview, FileUpload, type FileRef } from "eraspace/client";
|
|
2
|
-
import { useState, useEffect } from "preact/hooks";
|
|
2
|
+
import { useState, useEffect, useRef } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
4
|
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
@@ -12,7 +12,12 @@ export default function App() {
|
|
|
12
12
|
const [file, setFile] = useState<FileRef | null>(null);
|
|
13
13
|
const messages = client.useQuery("messages", room);
|
|
14
14
|
const send = client.useMutation("send");
|
|
15
|
-
|
|
15
|
+
// Room messages share the app's request budget, so presence takes the name once typing pauses and the typing
|
|
16
|
+
// notice goes out at most once a second; the 1.5 s display below bridges the gap.
|
|
17
|
+
const [shownName, setShownName] = useState(author);
|
|
18
|
+
useEffect(() => { const timer = setTimeout(() => setShownName(author), 500); return () => clearTimeout(timer); }, [author]);
|
|
19
|
+
const lastTyping = useRef(0);
|
|
20
|
+
const channel = client.useRoom<{ name: string }> (room, { name: shownName });
|
|
16
21
|
useEffect(() => channel.on("typing", (payload: unknown) => {
|
|
17
22
|
if (typeof payload === "string") setTyping(payload);
|
|
18
23
|
}), [room]);
|
|
@@ -29,7 +34,10 @@ export default function App() {
|
|
|
29
34
|
try { await send(room, author, body, file); setBody(""); setFile(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
|
|
30
35
|
}}>
|
|
31
36
|
<input aria-label="Your name" value={author} maxLength={40} onInput={e => setAuthor(e.currentTarget.value)}/>
|
|
32
|
-
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => {
|
|
37
|
+
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => {
|
|
38
|
+
setBody(e.currentTarget.value);
|
|
39
|
+
if (Date.now() - lastTyping.current >= 1000) { lastTyping.current = Date.now(); channel.broadcast("typing", author); }
|
|
40
|
+
}}/>
|
|
33
41
|
<button disabled={sending || (!body.trim() && !file)}>Send</button>
|
|
34
42
|
{file
|
|
35
43
|
? <div class="pending"><FilePreview client={client} file={file} caption={false} class="attachment"/><span>{file.name}</span><button type="button" class="subtle" disabled={sending} onClick={() => { void client.storage.delete(file.key).catch(() => {}); setFile(null); }}>Remove</button></div>
|