@c9up/aurora 0.1.5 → 0.1.7
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 +1 -1
- package/dist/browser.d.ts +136 -4
- package/dist/browser.js +324 -15
- package/dist/component.js +7 -0
- package/dist/http.d.ts +79 -0
- package/dist/http.js +199 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -7
- package/dist/reactive.d.ts +7 -0
- package/dist/reactive.js +25 -1
- package/dist/relay.js +16 -13
- package/dist/server/renderPage.js +1 -1
- package/package.json +1 -1
- package/src/browser.ts +425 -16
- package/src/component.ts +7 -0
- package/src/http.ts +278 -0
- package/src/index.ts +32 -1
- package/src/reactive.ts +29 -1
- package/src/relay.ts +19 -11
- package/src/server/renderPage.ts +1 -1
package/src/http.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `HttpClient` — a small typed wrapper over `fetch` so call sites read
|
|
3
|
+
* `await http.get<User>("/auth/me")` instead of hand-rolling headers,
|
|
4
|
+
* `res.json()`, and status checks.
|
|
5
|
+
*
|
|
6
|
+
* - Auto JSON: a plain-object/array body is `JSON.stringify`-d with a
|
|
7
|
+
* `Content-Type: application/json` header; a JSON response is parsed.
|
|
8
|
+
* `FormData`/`Blob`/`URLSearchParams`/`string`/binary bodies pass through
|
|
9
|
+
* untouched.
|
|
10
|
+
* - Bearer auth: a `token` (string or getter, read fresh per request) is sent
|
|
11
|
+
* as `Authorization: Bearer …` unless the caller set the header themselves.
|
|
12
|
+
* - Errors: a non-2xx response rejects with an {@link HttpError} carrying the
|
|
13
|
+
* status, the `Response`, and the parsed body.
|
|
14
|
+
*
|
|
15
|
+
* Node-free and isomorphic — uses the global `fetch` (browsers, Node 18+,
|
|
16
|
+
* Workers, Bun, Deno). Part of the client barrel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface HttpClientOptions {
|
|
20
|
+
/** Prepended to every request URL, unless the URL is already absolute. */
|
|
21
|
+
baseURL?: string;
|
|
22
|
+
/** Headers merged into every request. */
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
/**
|
|
25
|
+
* Bearer token sent as `Authorization: Bearer <token>`. A getter is read
|
|
26
|
+
* fresh on each request (so a rotated/late-set token is always current);
|
|
27
|
+
* a `null`/`undefined` result omits the header.
|
|
28
|
+
*/
|
|
29
|
+
token?: string | null | (() => string | null | undefined);
|
|
30
|
+
/** Default `credentials` mode (e.g. `"include"` to send cookies). */
|
|
31
|
+
credentials?: RequestCredentials;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HttpRequestOptions<T = unknown> {
|
|
35
|
+
/** Query params appended to the URL. `null`/`undefined` values are skipped. */
|
|
36
|
+
query?: Record<string, string | number | boolean | null | undefined>;
|
|
37
|
+
/** Extra headers for this request (override the client defaults). */
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
/** Per-request bearer token override (`null` to force-omit). */
|
|
40
|
+
token?: string | null;
|
|
41
|
+
/** Abort signal. */
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
/** `credentials` mode for this request. */
|
|
44
|
+
credentials?: RequestCredentials;
|
|
45
|
+
/**
|
|
46
|
+
* Runtime validator/mapper for the parsed body. When provided, the return
|
|
47
|
+
* type is whatever it returns — no unchecked cast. When omitted, the parsed
|
|
48
|
+
* body is returned as `T` (an UNCHECKED assertion of the response shape).
|
|
49
|
+
*/
|
|
50
|
+
parse?: (raw: unknown) => T;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Thrown on a non-2xx response. Carries the status, the `Response`, and the parsed body. */
|
|
54
|
+
export class HttpError extends Error {
|
|
55
|
+
readonly status: number;
|
|
56
|
+
readonly response: Response;
|
|
57
|
+
readonly data: unknown;
|
|
58
|
+
|
|
59
|
+
constructor(response: Response, data: unknown) {
|
|
60
|
+
super(`HTTP ${response.status} ${response.statusText} for ${response.url}`);
|
|
61
|
+
this.name = "HttpError";
|
|
62
|
+
this.status = response.status;
|
|
63
|
+
this.response = response;
|
|
64
|
+
this.data = data;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether `body` should be JSON-encoded (vs. passed to `fetch` untouched). */
|
|
69
|
+
function shouldJsonEncode(body: unknown): boolean {
|
|
70
|
+
if (body === null || typeof body !== "object") {
|
|
71
|
+
return typeof body !== "string";
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
body instanceof FormData ||
|
|
75
|
+
body instanceof Blob ||
|
|
76
|
+
body instanceof URLSearchParams ||
|
|
77
|
+
body instanceof ArrayBuffer ||
|
|
78
|
+
ArrayBuffer.isView(body)
|
|
79
|
+
) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Case-insensitive header presence check. */
|
|
89
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
90
|
+
const lower = name.toLowerCase();
|
|
91
|
+
for (const key of Object.keys(headers)) {
|
|
92
|
+
if (key.toLowerCase() === lower) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parse a response by content-type; `null` for empty / no-content bodies. */
|
|
98
|
+
async function parseBody(response: Response): Promise<unknown> {
|
|
99
|
+
if (response.status === 204 || response.status === 205) return null;
|
|
100
|
+
const type = response.headers.get("content-type") ?? "";
|
|
101
|
+
const text = await response.text();
|
|
102
|
+
if (text === "") return null;
|
|
103
|
+
if (type.includes("application/json")) return JSON.parse(text);
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class HttpClient {
|
|
108
|
+
readonly #baseURL: string;
|
|
109
|
+
readonly #headers: Record<string, string>;
|
|
110
|
+
readonly #token?: string | null | (() => string | null | undefined);
|
|
111
|
+
readonly #credentials?: RequestCredentials;
|
|
112
|
+
|
|
113
|
+
constructor(options: HttpClientOptions = {}) {
|
|
114
|
+
this.#baseURL = options.baseURL ?? "";
|
|
115
|
+
this.#headers = { ...options.headers };
|
|
116
|
+
this.#token = options.token;
|
|
117
|
+
this.#credentials = options.credentials;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Set a default header for every subsequent request (case-insensitive replace). Chainable. */
|
|
121
|
+
setHeader(name: string, value: string): this {
|
|
122
|
+
this.#deleteHeader(name);
|
|
123
|
+
this.#headers[name] = value;
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Merge several default headers at once. Chainable. */
|
|
128
|
+
setHeaders(headers: Record<string, string>): this {
|
|
129
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
130
|
+
this.setHeader(name, value);
|
|
131
|
+
}
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Remove a default header (case-insensitive). Chainable. */
|
|
136
|
+
removeHeader(name: string): this {
|
|
137
|
+
this.#deleteHeader(name);
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A copy of the current default headers. */
|
|
142
|
+
getHeaders(): Record<string, string> {
|
|
143
|
+
return { ...this.#headers };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#deleteHeader(name: string): void {
|
|
147
|
+
const lower = name.toLowerCase();
|
|
148
|
+
for (const key of Object.keys(this.#headers)) {
|
|
149
|
+
if (key.toLowerCase() === lower) delete this.#headers[key];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
get<T>(url: string, options?: HttpRequestOptions<T>): Promise<T> {
|
|
154
|
+
return this.#request("GET", url, undefined, options);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
delete<T>(url: string, options?: HttpRequestOptions<T>): Promise<T> {
|
|
158
|
+
return this.#request("DELETE", url, undefined, options);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
post<T>(
|
|
162
|
+
url: string,
|
|
163
|
+
body?: unknown,
|
|
164
|
+
options?: HttpRequestOptions<T>,
|
|
165
|
+
): Promise<T> {
|
|
166
|
+
return this.#request("POST", url, body, options);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
put<T>(
|
|
170
|
+
url: string,
|
|
171
|
+
body?: unknown,
|
|
172
|
+
options?: HttpRequestOptions<T>,
|
|
173
|
+
): Promise<T> {
|
|
174
|
+
return this.#request("PUT", url, body, options);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
patch<T>(
|
|
178
|
+
url: string,
|
|
179
|
+
body?: unknown,
|
|
180
|
+
options?: HttpRequestOptions<T>,
|
|
181
|
+
): Promise<T> {
|
|
182
|
+
return this.#request("PATCH", url, body, options);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Send a request and return the raw `Response` (no parsing, no throw on non-2xx). */
|
|
186
|
+
raw(
|
|
187
|
+
method: string,
|
|
188
|
+
url: string,
|
|
189
|
+
body?: unknown,
|
|
190
|
+
options: HttpRequestOptions = {},
|
|
191
|
+
): Promise<Response> {
|
|
192
|
+
return this.#send(method, url, body, options);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Derive a new client with merged defaults (e.g. a scope that adds a token). */
|
|
196
|
+
extend(options: HttpClientOptions): HttpClient {
|
|
197
|
+
return new HttpClient({
|
|
198
|
+
baseURL: options.baseURL ?? this.#baseURL,
|
|
199
|
+
headers: { ...this.#headers, ...options.headers },
|
|
200
|
+
token: options.token ?? this.#token,
|
|
201
|
+
credentials: options.credentials ?? this.#credentials,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#resolveToken(override?: string | null): string | null | undefined {
|
|
206
|
+
if (override !== undefined) return override;
|
|
207
|
+
return typeof this.#token === "function" ? this.#token() : this.#token;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#buildUrl(url: string, query?: HttpRequestOptions["query"]): string {
|
|
211
|
+
const base = /^[a-z][a-z\d+\-.]*:\/\//i.test(url)
|
|
212
|
+
? url
|
|
213
|
+
: this.#baseURL + url;
|
|
214
|
+
if (!query) return base;
|
|
215
|
+
const params = new URLSearchParams();
|
|
216
|
+
for (const [key, value] of Object.entries(query)) {
|
|
217
|
+
if (value !== null && value !== undefined)
|
|
218
|
+
params.append(key, String(value));
|
|
219
|
+
}
|
|
220
|
+
const qs = params.toString();
|
|
221
|
+
if (qs === "") return base;
|
|
222
|
+
return `${base}${base.includes("?") ? "&" : "?"}${qs}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#send(
|
|
226
|
+
method: string,
|
|
227
|
+
url: string,
|
|
228
|
+
body: unknown,
|
|
229
|
+
options: HttpRequestOptions,
|
|
230
|
+
): Promise<Response> {
|
|
231
|
+
const headers: Record<string, string> = {
|
|
232
|
+
...this.#headers,
|
|
233
|
+
...options.headers,
|
|
234
|
+
};
|
|
235
|
+
const token = this.#resolveToken(options.token);
|
|
236
|
+
if (token != null && !hasHeader(headers, "authorization")) {
|
|
237
|
+
headers.Authorization = `Bearer ${token}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let payload: BodyInit | undefined;
|
|
241
|
+
if (body !== undefined && body !== null) {
|
|
242
|
+
if (shouldJsonEncode(body)) {
|
|
243
|
+
payload = JSON.stringify(body);
|
|
244
|
+
if (!hasHeader(headers, "content-type")) {
|
|
245
|
+
headers["Content-Type"] = "application/json";
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
// Already a valid BodyInit (string / FormData / Blob / …).
|
|
249
|
+
payload = body as BodyInit;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return fetch(this.#buildUrl(url, options.query), {
|
|
254
|
+
method,
|
|
255
|
+
headers,
|
|
256
|
+
body: payload,
|
|
257
|
+
signal: options.signal,
|
|
258
|
+
credentials: options.credentials ?? this.#credentials,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async #request<T>(
|
|
263
|
+
method: string,
|
|
264
|
+
url: string,
|
|
265
|
+
body: unknown,
|
|
266
|
+
options: HttpRequestOptions<T> = {},
|
|
267
|
+
): Promise<T> {
|
|
268
|
+
const response = await this.#send(method, url, body, options);
|
|
269
|
+
const data = await parseBody(response);
|
|
270
|
+
if (!response.ok) throw new HttpError(response, data);
|
|
271
|
+
// `parse` validates at runtime; without it, `T` is the caller's
|
|
272
|
+
// unchecked assertion of the response shape (the usual HTTP boundary).
|
|
273
|
+
return options.parse ? options.parse(data) : (data as T);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Default same-origin client. Configure your own via `new HttpClient({ … })`. */
|
|
278
|
+
export const http = new HttpClient();
|
package/src/index.ts
CHANGED
|
@@ -4,9 +4,40 @@
|
|
|
4
4
|
// node:fs / node:path / node:url live in `@c9up/aurora/server`. Keeping them off
|
|
5
5
|
// this barrel is what lets a browser bundle import the client primitives without
|
|
6
6
|
// the bundler dragging Node built-ins through the import graph.
|
|
7
|
-
export {
|
|
7
|
+
export type {
|
|
8
|
+
CookieOptions,
|
|
9
|
+
PersistedSignalOptions,
|
|
10
|
+
ShareData,
|
|
11
|
+
StorageArea,
|
|
12
|
+
WebStorageOptions,
|
|
13
|
+
WindowSize,
|
|
14
|
+
} from "./browser.js";
|
|
15
|
+
export {
|
|
16
|
+
back,
|
|
17
|
+
clipboard,
|
|
18
|
+
cookie,
|
|
19
|
+
forward,
|
|
20
|
+
hash,
|
|
21
|
+
mediaQuery,
|
|
22
|
+
navigate,
|
|
23
|
+
online,
|
|
24
|
+
persistedSignal,
|
|
25
|
+
prefersDark,
|
|
26
|
+
queryParam,
|
|
27
|
+
redirect,
|
|
28
|
+
reload,
|
|
29
|
+
replace,
|
|
30
|
+
session,
|
|
31
|
+
share,
|
|
32
|
+
storage,
|
|
33
|
+
visibility,
|
|
34
|
+
WebStorage,
|
|
35
|
+
windowSize,
|
|
36
|
+
} from "./browser.js";
|
|
8
37
|
export { component, onMount, onUnmount } from "./component.js";
|
|
9
38
|
export { html, isTemplateResult } from "./html.js";
|
|
39
|
+
export type { HttpClientOptions, HttpRequestOptions } from "./http.js";
|
|
40
|
+
export { HttpClient, HttpError, http } from "./http.js";
|
|
10
41
|
export { hydrate } from "./hydrate.js";
|
|
11
42
|
export {
|
|
12
43
|
batch,
|
package/src/reactive.ts
CHANGED
|
@@ -64,6 +64,28 @@ function activeObserver(): Effect | undefined {
|
|
|
64
64
|
return observerStack[observerStack.length - 1];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Ambient disposal owner. A non-reactive scope (e.g. a component's setup
|
|
69
|
+
* run) registers an array here so effects/memos created during its
|
|
70
|
+
* execution push their disposer into it and are torn down when the scope
|
|
71
|
+
* ends. `undefined` at top level — no scope, no auto-disposal.
|
|
72
|
+
*/
|
|
73
|
+
let currentOwner: Array<() => void> | undefined;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @internal Swap the ambient owner, returning the previous one so the
|
|
77
|
+
* caller can restore it. `component()` uses this to own the effects and
|
|
78
|
+
* memos a setup function creates, so they dispose at unmount instead of
|
|
79
|
+
* keeping their signal subscriptions alive forever.
|
|
80
|
+
*/
|
|
81
|
+
export function setOwner(
|
|
82
|
+
owner: Array<() => void> | undefined,
|
|
83
|
+
): Array<() => void> | undefined {
|
|
84
|
+
const prev = currentOwner;
|
|
85
|
+
currentOwner = owner;
|
|
86
|
+
return prev;
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/**
|
|
68
90
|
* Create a writable signal seeded with `initial`. Reads register the
|
|
69
91
|
* current observer; writes notify every observer that previously read.
|
|
@@ -178,7 +200,13 @@ export function effect(fn: EffectCallback): () => void {
|
|
|
178
200
|
},
|
|
179
201
|
};
|
|
180
202
|
eff.run();
|
|
181
|
-
|
|
203
|
+
const dispose = () => eff.dispose();
|
|
204
|
+
// Register with the ambient owner (e.g. a component's setup scope) so the
|
|
205
|
+
// effect is torn down when that scope ends. `memo()` builds on this — its
|
|
206
|
+
// internal recompute effect inherits the same ownership, which is what
|
|
207
|
+
// stops a memo created in component setup from leaking after unmount.
|
|
208
|
+
currentOwner?.push(dispose);
|
|
209
|
+
return dispose;
|
|
182
210
|
}
|
|
183
211
|
|
|
184
212
|
/**
|
package/src/relay.ts
CHANGED
|
@@ -27,14 +27,12 @@ interface RelayState {
|
|
|
27
27
|
sse: EventSource | null;
|
|
28
28
|
uid: string | null;
|
|
29
29
|
channels: Map<string, Set<(event: unknown) => void>>;
|
|
30
|
-
pending: Array<() => void>;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
const STATE: RelayState = {
|
|
34
33
|
sse: null,
|
|
35
34
|
uid: null,
|
|
36
35
|
channels: new Map(),
|
|
37
|
-
pending: [],
|
|
38
36
|
};
|
|
39
37
|
|
|
40
38
|
export interface RelayOptions {
|
|
@@ -87,15 +85,15 @@ const CLIENT: RelayClient = {
|
|
|
87
85
|
const adapted = handler as (event: unknown) => void;
|
|
88
86
|
handlers.add(adapted);
|
|
89
87
|
|
|
90
|
-
// Subscribe over POST as soon as we have a uid.
|
|
91
|
-
//
|
|
92
|
-
|
|
88
|
+
// Subscribe over POST as soon as we have a uid. Before the first uid (or
|
|
89
|
+
// during an auto-reconnect) the channel already lives in STATE.channels
|
|
90
|
+
// and is (re-)subscribed by the `connected` handler — so the server,
|
|
91
|
+
// which assigns a fresh uid per connection, always learns every channel.
|
|
92
|
+
if (STATE.uid) {
|
|
93
93
|
postSubscribe(channel).catch((err: unknown) => {
|
|
94
94
|
console.warn(`[aurora/relay] subscribe to ${channel} failed:`, err);
|
|
95
95
|
});
|
|
96
|
-
}
|
|
97
|
-
if (STATE.uid) doSubscribe();
|
|
98
|
-
else STATE.pending.push(doSubscribe);
|
|
96
|
+
}
|
|
99
97
|
|
|
100
98
|
// Detacher — only removes the local listener. The server-side
|
|
101
99
|
// subscription stays open; closing it would interrupt other
|
|
@@ -112,7 +110,6 @@ const CLIENT: RelayClient = {
|
|
|
112
110
|
}
|
|
113
111
|
STATE.uid = null;
|
|
114
112
|
STATE.channels.clear();
|
|
115
|
-
STATE.pending.length = 0;
|
|
116
113
|
},
|
|
117
114
|
};
|
|
118
115
|
|
|
@@ -124,8 +121,19 @@ function open(): void {
|
|
|
124
121
|
const data = safeJson<{ uid?: string }>((ev as MessageEvent).data);
|
|
125
122
|
if (data && typeof data.uid === "string") {
|
|
126
123
|
STATE.uid = data.uid;
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
// Re-apply EVERY active subscription on each (re)connect. The server
|
|
125
|
+
// assigns a fresh uid per connection and has no memory of prior
|
|
126
|
+
// subscriptions, so both the first connect AND browser auto-reconnects
|
|
127
|
+
// must re-POST every live channel — otherwise the client silently
|
|
128
|
+
// stops receiving after a reconnect.
|
|
129
|
+
for (const channel of STATE.channels.keys()) {
|
|
130
|
+
postSubscribe(channel).catch((err: unknown) => {
|
|
131
|
+
console.warn(
|
|
132
|
+
`[aurora/relay] re-subscribe to ${channel} failed:`,
|
|
133
|
+
err,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
129
137
|
}
|
|
130
138
|
});
|
|
131
139
|
|
package/src/server/renderPage.ts
CHANGED
|
@@ -90,7 +90,7 @@ export async function renderPage<P>(
|
|
|
90
90
|
<head>
|
|
91
91
|
<meta charset="utf-8" />
|
|
92
92
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
93
|
-
<script type="importmap">${
|
|
93
|
+
<script type="importmap">${escapeJsonForScript({ imports: importmap })}</script>
|
|
94
94
|
${options.headExtra ?? ""}
|
|
95
95
|
</head>
|
|
96
96
|
<body>
|