eraspace 0.0.0-stage → 0.2.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 +55 -2
- package/bin.js +40620 -0
- package/package.json +32 -4
- package/sdk/src/client/client.ts +171 -0
- package/sdk/src/client/config.ts +39 -0
- package/sdk/src/client/connection.ts +450 -0
- package/sdk/src/client/errors.ts +37 -0
- package/sdk/src/client/files.ts +364 -0
- package/sdk/src/client/index.ts +36 -0
- package/sdk/src/client/mount.ts +45 -0
- package/sdk/src/client/pagination.ts +104 -0
- package/sdk/src/client/request-key.ts +13 -0
- package/sdk/src/client/result-diff.ts +41 -0
- package/sdk/src/client/retry.ts +5 -0
- package/sdk/src/client/room.ts +110 -0
- package/sdk/src/client/router.ts +177 -0
- package/sdk/src/client/storage.ts +132 -0
- package/sdk/src/client/sub-key.ts +12 -0
- package/sdk/src/client/subscription.ts +55 -0
- package/sdk/src/client/transport.ts +356 -0
- package/sdk/src/client/types.ts +132 -0
- package/sdk/src/server/index.ts +635 -0
- package/templates/chat/client/index.tsx +41 -0
- package/templates/chat/client/styles.css +43 -0
- package/templates/chat/server/index.ts +27 -0
- package/templates/poll/client/index.tsx +24 -0
- package/templates/poll/client/styles.css +28 -0
- package/templates/poll/server/index.ts +17 -0
- package/templates/todo/client/index.tsx +26 -0
- package/templates/todo/client/styles.css +28 -0
- package/templates/todo/server/index.ts +18 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Socket, Channel } from "./transport.ts";
|
|
2
|
+
import { signal, type Signal } from "@preact/signals";
|
|
3
|
+
import type { PresenceEntry } from "./types.ts";
|
|
4
|
+
import { EraspaceError } from "./errors.ts";
|
|
5
|
+
import { stableStringify } from "./sub-key.ts";
|
|
6
|
+
|
|
7
|
+
export class RoomConn {
|
|
8
|
+
channel: Channel;
|
|
9
|
+
readonly members: Signal<PresenceEntry<Record<string, unknown>>[]> = signal([]);
|
|
10
|
+
readonly state: Signal<Record<string, unknown>> = signal({});
|
|
11
|
+
readonly self: Signal<string>;
|
|
12
|
+
readonly error = signal<EraspaceError | undefined>(undefined);
|
|
13
|
+
private serverState: Record<string, unknown> = {};
|
|
14
|
+
private pending = new Map<number, { key: string; value: unknown }>();
|
|
15
|
+
private revisions = new Map<string, number>();
|
|
16
|
+
private nextChange = 0;
|
|
17
|
+
private closed = false;
|
|
18
|
+
refs = 0;
|
|
19
|
+
meta: Record<string, unknown> = {};
|
|
20
|
+
listeners = new Map<string, Set<(payload: any, from: string | undefined) => void>>();
|
|
21
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
+
readonly topic: string;
|
|
23
|
+
|
|
24
|
+
constructor(socket: Socket, topic: string, clientSelf: Signal<string>) {
|
|
25
|
+
this.topic = topic;
|
|
26
|
+
// The join reply carries the server-derived id; until then this is the id from the app channel, if known.
|
|
27
|
+
this.self = signal(clientSelf.value);
|
|
28
|
+
this.channel = socket.channel(topic, () => ({ meta: this.meta }));
|
|
29
|
+
this.channel.on<Record<string, Record<string, unknown>[]>>("presence", (members) => {
|
|
30
|
+
this.members.value = Object.entries(members).map(([id, metas]) => ({ id, meta: metas[0] ?? {}, metas }));
|
|
31
|
+
});
|
|
32
|
+
this.channel.on("msg", (m: { event: string; payload: unknown; from?: string }) => {
|
|
33
|
+
const set = this.listeners.get(m.event);
|
|
34
|
+
if (set) for (const cb of [...set]) cb(m.payload, m.from);
|
|
35
|
+
const all = this.listeners.get("*");
|
|
36
|
+
if (all) for (const cb of [...all]) cb(m, m.from);
|
|
37
|
+
});
|
|
38
|
+
this.channel.on("state:full", (m: { state?: Record<string, unknown> }) => {
|
|
39
|
+
this.serverState = { ...(m.state ?? {}) };
|
|
40
|
+
this.revisions.clear();
|
|
41
|
+
// Writes buffered while joining still need their replies to settle or report an error.
|
|
42
|
+
this.recompute();
|
|
43
|
+
});
|
|
44
|
+
this.channel.on("state", (m: { key: string; value: unknown }) => {
|
|
45
|
+
this.serverState = { ...this.serverState, [m.key]: m.value };
|
|
46
|
+
this.revisions.set(m.key, (this.revisions.get(m.key) ?? 0) + 1);
|
|
47
|
+
this.recompute();
|
|
48
|
+
});
|
|
49
|
+
const failed = (payload: unknown) => {
|
|
50
|
+
this.pending.clear();
|
|
51
|
+
this.recompute();
|
|
52
|
+
this.error.value = EraspaceError.from(payload, "Room channel disconnected");
|
|
53
|
+
if (payload && typeof payload === "object" && "name" in payload && payload.name === "AppGone") this.close();
|
|
54
|
+
};
|
|
55
|
+
this.channel.on("gone", () => failed({ name: "AppGone", message: "This app is no longer serving", reason: "gone" }));
|
|
56
|
+
this.channel.onError(failed);
|
|
57
|
+
this.channel.onClose(() => { if (!this.closed) failed({ name: "UnavailableError", message: "Room channel closed" }); });
|
|
58
|
+
this.channel.onJoin((resp) => {
|
|
59
|
+
this.error.value = undefined;
|
|
60
|
+
if (resp && typeof resp.self === "string") this.self.value = clientSelf.value = resp.self;
|
|
61
|
+
});
|
|
62
|
+
this.channel.join();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setMeta(meta: Record<string, unknown>): void {
|
|
66
|
+
if (stableStringify(meta) === stableStringify(this.meta)) return;
|
|
67
|
+
const previous = this.meta;
|
|
68
|
+
this.meta = meta;
|
|
69
|
+
const failed = (payload: unknown) => {
|
|
70
|
+
if (this.meta === meta) this.meta = previous;
|
|
71
|
+
this.error.value = EraspaceError.from(payload, "Room metadata update failed");
|
|
72
|
+
};
|
|
73
|
+
void this.channel.push("meta", { meta }).catch(failed);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private recompute(): void {
|
|
77
|
+
const state = { ...this.serverState };
|
|
78
|
+
for (const { key, value } of this.pending.values()) state[key] = value;
|
|
79
|
+
this.state.value = state;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setState(key: string, value: unknown): void {
|
|
83
|
+
if (this.closed) return;
|
|
84
|
+
const id = ++this.nextChange;
|
|
85
|
+
const revision = this.revisions.get(key) ?? 0;
|
|
86
|
+
this.pending.set(id, { key, value: value ?? null });
|
|
87
|
+
this.recompute();
|
|
88
|
+
const failed = (payload: unknown) => {
|
|
89
|
+
if (!this.pending.delete(id)) return;
|
|
90
|
+
this.recompute();
|
|
91
|
+
this.error.value = EraspaceError.from(payload, "Room state update failed");
|
|
92
|
+
};
|
|
93
|
+
void this.channel.push("state:set", { key, value: value ?? null })
|
|
94
|
+
.then(() => {
|
|
95
|
+
if (!this.pending.delete(id)) return;
|
|
96
|
+
if ((this.revisions.get(key) ?? 0) === revision) this.serverState = { ...this.serverState, [key]: value ?? null };
|
|
97
|
+
this.error.value = undefined;
|
|
98
|
+
this.recompute();
|
|
99
|
+
}, failed);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
close(): void {
|
|
103
|
+
this.closed = true;
|
|
104
|
+
clearTimeout(this.timer);
|
|
105
|
+
this.pending.clear();
|
|
106
|
+
this.recompute();
|
|
107
|
+
this.channel.leave();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Client routing. preact-iso's <Router> does the matching; our <LocationProvider> feeds it app-relative paths, so
|
|
2
|
+
// `<Route path="/todos/:id">` matches `/<app>/todos/123`. Links and navigation add the base back.
|
|
3
|
+
import { h, isValidElement, toChildArray, type ComponentChildren, type ComponentType, type Context, type JSX, type VNode } from "preact";
|
|
4
|
+
import { useContext, useLayoutEffect, useMemo, useState } from "preact/hooks";
|
|
5
|
+
import { LocationProvider as IsoLocationProvider, Router as IsoRouter, useLocation as isoUseLocation, useRoute } from "preact-iso/router";
|
|
6
|
+
import { readConfig } from "./client.ts";
|
|
7
|
+
|
|
8
|
+
export interface LocationValue {
|
|
9
|
+
/** App-relative URL including the query string, e.g. `/todos/1?x=2`. */
|
|
10
|
+
url: string;
|
|
11
|
+
/** App-relative path, e.g. `/todos/1`. */
|
|
12
|
+
path: string;
|
|
13
|
+
query: Record<string, string>;
|
|
14
|
+
/** Navigate to an app-relative URL. */
|
|
15
|
+
route(url: string, replace?: boolean): void;
|
|
16
|
+
back(): void;
|
|
17
|
+
forward(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ctx = (IsoLocationProvider as unknown as { ctx: Context<any> }).ctx;
|
|
21
|
+
|
|
22
|
+
function normalizeBase(base: string | undefined): string {
|
|
23
|
+
let b = base ?? readConfig().base ?? "/";
|
|
24
|
+
if (!b.startsWith("/")) b = "/" + b;
|
|
25
|
+
if (!b.endsWith("/")) b += "/";
|
|
26
|
+
return b;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Strip the app base from an absolute path: `/chat/rooms/1` → `/rooms/1`. */
|
|
30
|
+
export function stripBase(base: string, url: string): string {
|
|
31
|
+
if (url.startsWith(base)) return "/" + url.slice(base.length);
|
|
32
|
+
const bare = base.slice(0, -1);
|
|
33
|
+
if (url === bare || url.startsWith(bare + "?") || url.startsWith(bare + "#")) return "/" + url.slice(bare.length);
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Turn an app-relative URL into an absolute one: `/rooms/1` → `/chat/rooms/1`. */
|
|
38
|
+
export function withBase(base: string, to: string): string {
|
|
39
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith("//")) return to;
|
|
40
|
+
if (to.startsWith(base)) return to;
|
|
41
|
+
return base + (to.startsWith("/") ? to.slice(1) : to);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function currentUrl(base: string): string {
|
|
45
|
+
if (typeof location === "undefined") return "/";
|
|
46
|
+
return stripBase(base, location.pathname + location.search);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let globalBase = "/";
|
|
50
|
+
|
|
51
|
+
/** Provides the app-relative location. `mount()` adds one around your App unless `router: false`. */
|
|
52
|
+
export function LocationProvider(props: { base?: string; url?: string; children?: ComponentChildren }) {
|
|
53
|
+
const base = normalizeBase(props.base);
|
|
54
|
+
globalBase = base;
|
|
55
|
+
const [state, setState] = useState(() => ({ url: props.url ?? currentUrl(base), wasPush: false }));
|
|
56
|
+
|
|
57
|
+
const value = useMemo(() => {
|
|
58
|
+
const u = new URL(state.url, "http://x");
|
|
59
|
+
const path = u.pathname.replace(/\/+$/g, "") || "/";
|
|
60
|
+
const route = (to: string, replace?: boolean) => {
|
|
61
|
+
const abs = withBase(base, to);
|
|
62
|
+
if (typeof history !== "undefined") {
|
|
63
|
+
if (replace) history.replaceState(null, "", abs);
|
|
64
|
+
else history.pushState(null, "", abs);
|
|
65
|
+
}
|
|
66
|
+
setState({ url: stripBase(base, abs), wasPush: !replace });
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
url: state.url,
|
|
70
|
+
path,
|
|
71
|
+
query: Object.fromEntries(u.searchParams),
|
|
72
|
+
route,
|
|
73
|
+
back: () => history.back(),
|
|
74
|
+
forward: () => history.forward(),
|
|
75
|
+
wasPush: state.wasPush,
|
|
76
|
+
};
|
|
77
|
+
}, [state, base]);
|
|
78
|
+
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
const onPop = () => setState({ url: currentUrl(base), wasPush: false });
|
|
81
|
+
const onClick = (e: MouseEvent) => {
|
|
82
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
83
|
+
const a = (e.composedPath() as Element[]).find((el) => el.nodeName === "A") as HTMLAnchorElement | undefined;
|
|
84
|
+
if (!a || !a.href || a.origin !== location.origin || a.download) return;
|
|
85
|
+
if (a.target && !/^_?self$/i.test(a.target)) return;
|
|
86
|
+
const href = a.getAttribute("href") ?? "";
|
|
87
|
+
if (href.startsWith("#")) return;
|
|
88
|
+
const abs = a.pathname + a.search;
|
|
89
|
+
if (!(abs + "/").startsWith(base) && !abs.startsWith(base)) return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
value.route(stripBase(base, abs + a.hash));
|
|
92
|
+
};
|
|
93
|
+
addEventListener("popstate", onPop);
|
|
94
|
+
addEventListener("click", onClick);
|
|
95
|
+
return () => {
|
|
96
|
+
removeEventListener("popstate", onPop);
|
|
97
|
+
removeEventListener("click", onClick);
|
|
98
|
+
};
|
|
99
|
+
}, [base, value]);
|
|
100
|
+
|
|
101
|
+
return h(ctx.Provider, { value }, props.children);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isRouteChild(v: unknown): boolean {
|
|
105
|
+
const p = (v as VNode<any> | null)?.props;
|
|
106
|
+
return !!p && (p.path !== undefined || p.default);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Renders the first child `<Route>` whose `path` matches. Without route children it only provides the location,
|
|
111
|
+
* so both `<Router><Route .../></Router>` and Lakebed's `<Router><Routes>...</Routes></Router>` work.
|
|
112
|
+
*/
|
|
113
|
+
export function Router(props: {
|
|
114
|
+
children?: ComponentChildren;
|
|
115
|
+
onRouteChange?: (url: string) => void;
|
|
116
|
+
onLoadStart?: (url: string) => void;
|
|
117
|
+
onLoadEnd?: (url: string) => void;
|
|
118
|
+
}) {
|
|
119
|
+
const hasProvider = !!useContext(ctx)?.url;
|
|
120
|
+
const routes = toChildArray(props.children).some(isRouteChild);
|
|
121
|
+
// preact-iso serializes route props, and rendered VNodes contain circular parent links, so elements travel in
|
|
122
|
+
// a function. It re-renders a matched route only when the URL or those serialized props change, and functions
|
|
123
|
+
// do not serialize: the revision marks every new element as a change. ElementRoute stays the same component,
|
|
124
|
+
// so the route updates in place instead of remounting.
|
|
125
|
+
const children = useMemo(() => toChildArray(props.children).filter(isValidElement).map((child) => {
|
|
126
|
+
if (child.type !== Route) return child;
|
|
127
|
+
const { element, ...rest } = (child as VNode<RouteProps>).props;
|
|
128
|
+
if (element === undefined) return child;
|
|
129
|
+
return h(Route, { ...rest, key: child.key, component: ElementRoute, render: () => element, revision: ++elementRevision });
|
|
130
|
+
}), [props.children]);
|
|
131
|
+
const content = routes ? h(IsoRouter as ComponentType<typeof props>, { ...props, children }) : props.children;
|
|
132
|
+
return hasProvider ? (content as any) : h(LocationProvider, null, content);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let elementRevision = 0;
|
|
136
|
+
|
|
137
|
+
function ElementRoute(props: { render: () => JSX.Element | null }) {
|
|
138
|
+
return props.render();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Alias of `<Router>` (react-router style naming). */
|
|
142
|
+
export const Routes = Router;
|
|
143
|
+
|
|
144
|
+
export type RouteProps<P = {}> = {
|
|
145
|
+
path?: string;
|
|
146
|
+
default?: boolean;
|
|
147
|
+
} & ({ component: ComponentType<any>; element?: undefined } | { element: JSX.Element | null; component?: undefined }) &
|
|
148
|
+
P;
|
|
149
|
+
|
|
150
|
+
/** A route. Use `component={Page}` (gets `params` as props) or `element={<Page />}` (read `useParams()`). */
|
|
151
|
+
export function Route(props: RouteProps<Record<string, unknown>>) {
|
|
152
|
+
const { component, element, ...rest } = props as any;
|
|
153
|
+
return element !== undefined ? element : h(component, rest);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `<a>` with an app-relative `to`. */
|
|
157
|
+
export function Link(props: JSX.IntrinsicElements["a"] & { to?: string; href?: string }) {
|
|
158
|
+
const { to, href, ...rest } = props as any;
|
|
159
|
+
return h("a", { ...rest, href: withBase(globalBase, to ?? href ?? "/") });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function useLocation(): LocationValue {
|
|
163
|
+
return isoUseLocation() as unknown as LocationValue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Returns `navigate(to, { replace })` for app-relative paths. */
|
|
167
|
+
export function useNavigate(): (to: string, options?: { replace?: boolean }) => void {
|
|
168
|
+
const loc = useLocation();
|
|
169
|
+
return (to, options) => loc.route(to, options?.replace);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Route params of the matched `<Route path="/x/:id">`. */
|
|
173
|
+
export function useParams<P extends Record<string, string> = Record<string, string>>(): P {
|
|
174
|
+
return ((useRoute() as any)?.params ?? {}) as P;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { useRoute };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { EraspaceError } from "./errors.ts";
|
|
2
|
+
|
|
3
|
+
export interface UploadedObject {
|
|
4
|
+
key: string;
|
|
5
|
+
url: string;
|
|
6
|
+
size: number;
|
|
7
|
+
contentType: string;
|
|
8
|
+
name: string | null;
|
|
9
|
+
/** Private upload capability. Keep it out of public rows and file references. */
|
|
10
|
+
deleteToken?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StorageUploadOptions {
|
|
14
|
+
name?: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/** Called with the uploaded fraction, from 0 to 1, as the bytes leave the browser. */
|
|
17
|
+
onProgress?: (fraction: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface StorageClient {
|
|
21
|
+
/** Store a file in this app's bucket. Apps and their files are public. */
|
|
22
|
+
upload(body: Blob, options?: StorageUploadOptions): Promise<UploadedObject>;
|
|
23
|
+
url(key: string): string;
|
|
24
|
+
/** A stored file's metadata, or null when there is no such file. */
|
|
25
|
+
info(key: string, options?: { signal?: AbortSignal }): Promise<UploadedObject | null>;
|
|
26
|
+
delete(key: string, options?: { signal?: AbortSignal; deleteToken?: string }): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Storage keys are UUIDs. */
|
|
30
|
+
export function isStorageKey(key: unknown): key is string {
|
|
31
|
+
return typeof key === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The filename in a `filename*=UTF-8''...` Content-Disposition. */
|
|
35
|
+
function dispositionName(value: string | null): string | null {
|
|
36
|
+
const encoded = value?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
37
|
+
if (!encoded) return null;
|
|
38
|
+
try {
|
|
39
|
+
return decodeURIComponent(encoded);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** POST `body` with XMLHttpRequest, which reports upload progress where fetch cannot. */
|
|
46
|
+
function sendWithProgress(target: string, headers: Headers, body: Blob, signal: AbortSignal | undefined, onProgress: (fraction: number) => void) {
|
|
47
|
+
return new Promise<{ status: number; text: string }>((resolve, reject) => {
|
|
48
|
+
const abortError = () => signal?.reason ?? new DOMException("The upload was aborted", "AbortError");
|
|
49
|
+
if (signal?.aborted) return reject(abortError());
|
|
50
|
+
const xhr = new XMLHttpRequest();
|
|
51
|
+
const abort = () => xhr.abort();
|
|
52
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
53
|
+
const done = () => signal?.removeEventListener("abort", abort);
|
|
54
|
+
xhr.open("POST", target);
|
|
55
|
+
headers.forEach((value, name) => xhr.setRequestHeader(name, value));
|
|
56
|
+
xhr.upload.onprogress = (e) => {
|
|
57
|
+
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
|
|
58
|
+
};
|
|
59
|
+
xhr.onload = () => {
|
|
60
|
+
done();
|
|
61
|
+
if (xhr.status >= 200 && xhr.status < 300) onProgress(1);
|
|
62
|
+
resolve({ status: xhr.status, text: xhr.responseText });
|
|
63
|
+
};
|
|
64
|
+
xhr.onerror = () => {
|
|
65
|
+
done();
|
|
66
|
+
reject(new EraspaceError("StorageError", "The upload failed. Check your connection and try again."));
|
|
67
|
+
};
|
|
68
|
+
xhr.onabort = () => {
|
|
69
|
+
done();
|
|
70
|
+
reject(abortError());
|
|
71
|
+
};
|
|
72
|
+
xhr.send(body);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createStorageClient(base: () => string): StorageClient {
|
|
77
|
+
const deleteTokens = new Map<string, string>();
|
|
78
|
+
function url(key?: string): string {
|
|
79
|
+
const path = `${base().replace(/\/+$/, "")}/_sb/storage`;
|
|
80
|
+
return key === undefined ? path : `${path}/${encodeURIComponent(key)}`;
|
|
81
|
+
}
|
|
82
|
+
function check(status: number, text: string): Record<string, unknown> {
|
|
83
|
+
let payload: unknown;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(text);
|
|
86
|
+
} catch {}
|
|
87
|
+
if (!payload || typeof payload !== "object") throw new EraspaceError("StorageError", `HTTP ${status}`);
|
|
88
|
+
const result = payload as Record<string, unknown>;
|
|
89
|
+
if (status < 200 || status > 299 || result.ok !== true) throw EraspaceError.from(result.error, `Storage request failed: HTTP ${status}`);
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
url,
|
|
94
|
+
async upload(body, options = {}) {
|
|
95
|
+
const headers = new Headers({ "content-type": body.type || "application/octet-stream" });
|
|
96
|
+
const name = options.name ?? (typeof File !== "undefined" && body instanceof File ? body.name : undefined);
|
|
97
|
+
if (name) headers.set("x-filename", encodeURIComponent(name));
|
|
98
|
+
const { onProgress, signal } = options;
|
|
99
|
+
const response = onProgress && typeof XMLHttpRequest !== "undefined"
|
|
100
|
+
? await sendWithProgress(url(), headers, body, signal, onProgress)
|
|
101
|
+
: await fetch(url(), { method: "POST", headers, body, signal }).then(async (r) => ({ status: r.status, text: await r.text() }));
|
|
102
|
+
const result = check(response.status, response.text);
|
|
103
|
+
if (typeof result.key !== "string" || typeof result.url !== "string" || typeof result.size !== "number" || typeof result.contentType !== "string" || (result.name !== null && typeof result.name !== "string")) {
|
|
104
|
+
throw new EraspaceError("StorageError", "Invalid upload response");
|
|
105
|
+
}
|
|
106
|
+
const deleteToken = typeof result.deleteToken === "string" ? result.deleteToken : undefined;
|
|
107
|
+
if (deleteToken) deleteTokens.set(result.key, deleteToken);
|
|
108
|
+
return { key: result.key, url: result.url, size: result.size, contentType: result.contentType, name: result.name, ...(deleteToken ? { deleteToken } : {}) };
|
|
109
|
+
},
|
|
110
|
+
async info(key, options = {}) {
|
|
111
|
+
if (!isStorageKey(key)) return null;
|
|
112
|
+
const response = await fetch(url(key), { method: "HEAD", signal: options.signal });
|
|
113
|
+
if (response.status === 404) return null;
|
|
114
|
+
if (!response.ok) throw new EraspaceError("StorageError", `Storage request failed: HTTP ${response.status}`);
|
|
115
|
+
return {
|
|
116
|
+
key,
|
|
117
|
+
url: url(key),
|
|
118
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
119
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
120
|
+
name: dispositionName(response.headers.get("content-disposition")),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
async delete(key, options = {}) {
|
|
124
|
+
const deleteToken = options.deleteToken ?? deleteTokens.get(key);
|
|
125
|
+
const headers = new Headers();
|
|
126
|
+
if (deleteToken) headers.set("x-eraspace-file-token", deleteToken);
|
|
127
|
+
const response = await fetch(url(key), { method: "DELETE", headers, signal: options.signal });
|
|
128
|
+
check(response.status, await response.text());
|
|
129
|
+
deleteTokens.delete(key);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Stable key for `(fn, args)`. Object keys are sorted so `{a,b}` and `{b,a}` dedupe. */
|
|
2
|
+
export function subKey(fn: string, args: unknown[]): string {
|
|
3
|
+
return fn + ":" + stableStringify(args);
|
|
4
|
+
}
|
|
5
|
+
export function stableStringify(v: unknown): string {
|
|
6
|
+
if (v === undefined) return "null";
|
|
7
|
+
if (v === null || typeof v !== "object") return JSON.stringify(v);
|
|
8
|
+
if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
|
|
9
|
+
const o = v as Record<string, unknown>;
|
|
10
|
+
const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
|
|
11
|
+
return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}";
|
|
12
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { signal, type Signal } from "@preact/signals";
|
|
2
|
+
import { EraspaceError } from "./errors.ts";
|
|
3
|
+
import type { Patch } from "./result-diff.ts";
|
|
4
|
+
|
|
5
|
+
export interface FullResult {
|
|
6
|
+
value: unknown;
|
|
7
|
+
commitSeq: number;
|
|
8
|
+
revision: number;
|
|
9
|
+
}
|
|
10
|
+
export type DataMessage = { id: string } & (FullResult | {
|
|
11
|
+
patch: Patch[];
|
|
12
|
+
base: number;
|
|
13
|
+
revision: number;
|
|
14
|
+
commitSeq: number;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
interface Layer {
|
|
18
|
+
mid: number;
|
|
19
|
+
apply: (prev: any) => any;
|
|
20
|
+
commitSeq?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class Subscription {
|
|
24
|
+
readonly value: Signal<any> = signal(undefined);
|
|
25
|
+
readonly error: Signal<EraspaceError | undefined> = signal(undefined);
|
|
26
|
+
server: any = undefined;
|
|
27
|
+
hasServer = false;
|
|
28
|
+
commitSeq = -1;
|
|
29
|
+
revision: number | undefined;
|
|
30
|
+
request = 0;
|
|
31
|
+
retries = 0;
|
|
32
|
+
retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
+
refs = 0;
|
|
34
|
+
layers: Layer[] = [];
|
|
35
|
+
timer: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
/** The id last sent to the server, and the app channel join it was sent on. */
|
|
37
|
+
sent: { id: string; join: number } | undefined;
|
|
38
|
+
// Resubscriptions take a new id so messages for the previous one are ignored.
|
|
39
|
+
id: string;
|
|
40
|
+
readonly fn: string;
|
|
41
|
+
readonly args: unknown[];
|
|
42
|
+
readonly key: string;
|
|
43
|
+
constructor(id: string, fn: string, args: unknown[], key: string) {
|
|
44
|
+
this.id = id;
|
|
45
|
+
this.fn = fn;
|
|
46
|
+
this.args = args;
|
|
47
|
+
this.key = key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
recompute(): void {
|
|
51
|
+
let v = this.server;
|
|
52
|
+
if (this.hasServer) for (const l of this.layers) v = l.apply(v);
|
|
53
|
+
this.value.value = v;
|
|
54
|
+
}
|
|
55
|
+
}
|