eraspace 0.3.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eraspace",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "eraspace: build, deploy and manage eraspace capsules",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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
- return {
118
- presence: r.members.value,
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
 
@@ -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);
@@ -10,6 +10,7 @@ export {
10
10
  type PaginatedQueryResult,
11
11
  type PresenceEntry,
12
12
  type Room,
13
+ type Status,
13
14
  type EraspaceClient,
14
15
  type EraspaceConfig,
15
16
  } from "./client.ts";
@@ -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
- const channel = client.useRoom<{ name: string }> (room, { name: author });
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 => { setBody(e.currentTarget.value); channel.broadcast("typing", author); }}/>
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>