naro-js 0.7.0 → 0.8.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 +115 -6
- package/dist/http.d.ts +9 -0
- package/dist/index.js +15 -1
- package/dist/realtime/close.d.ts +6 -0
- package/dist/ssr/cookies.d.ts +54 -0
- package/dist/ssr/storage.d.ts +37 -0
- package/dist/ssr.d.ts +39 -0
- package/dist/ssr.js +205 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npm install naro-js
|
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
The backend is off until you turn it on, one project at a time: Turn on in
|
|
13
|
-
the Backend
|
|
13
|
+
the Backend section of the project's Settings page, or `naro backend enable`.
|
|
14
14
|
`naro backend status` prints the `url` and the anon key this client takes.
|
|
15
15
|
`naro backend disable` turns it off again: in up to about 90 seconds every call
|
|
16
16
|
answers `404 NOT_FOUND` ("There is no Naro backend at this host."). The data,
|
|
@@ -52,11 +52,11 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
52
52
|
`NARO_ANON_KEY`), saved for all environments with `naro env add` and in
|
|
53
53
|
`.env.local` for local development. Production and preview use the values
|
|
54
54
|
you saved. Server code reads `NARO_URL` and `NARO_ANON_KEY`, so save those
|
|
55
|
-
two for production and preview as well. The service key, which
|
|
55
|
+
two for production and preview as well. The service key, which Naro never
|
|
56
56
|
sets, goes in `NARO_SERVICE_KEY`: save one for each environment, because a
|
|
57
57
|
production service key saved for all environments is refused (401) by an
|
|
58
58
|
environment's backend. A named environment of a project with a backend has a backend of
|
|
59
|
-
its own, and
|
|
59
|
+
its own, and Naro sets those names to its address and anon key, so the same
|
|
60
60
|
code calls the right backend everywhere — an address written into the code
|
|
61
61
|
calls production's from every environment. On a custom domain, save
|
|
62
62
|
`NEXT_PUBLIC_NARO_URL=https://<your domain>` for production.
|
|
@@ -83,6 +83,8 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
83
83
|
- The session token lives in `localStorage` in a browser and in memory
|
|
84
84
|
elsewhere. On a server, create one client per request, or pass
|
|
85
85
|
`auth: { storage }` with your own `getItem` / `setItem` / `removeItem`.
|
|
86
|
+
To render pages as the signed-in user, keep it in cookies with
|
|
87
|
+
`naro-js/ssr` — see [Server rendering](#server-rendering).
|
|
86
88
|
- Queries and Storage calls carry a short-lived access token (15 minutes)
|
|
87
89
|
next to the session, which lasts days. The client renews it on its own
|
|
88
90
|
before it expires, and once more if the backend refuses it — nothing to
|
|
@@ -91,7 +93,7 @@ await naro.from("posts").delete().eq("id", 10);
|
|
|
91
93
|
and the client forgets both tokens: sign in again.
|
|
92
94
|
- Types: `createClient<Database>()` with the `Database` that
|
|
93
95
|
`naro gen types` writes — see [Types](#types).
|
|
94
|
-
- Sign-in is
|
|
96
|
+
- Sign-in is Naro Auth, the project's own (like Supabase Auth). Email and
|
|
95
97
|
password sign-up and sign-in need no setup: they work as soon as the
|
|
96
98
|
backend is on. Resend is needed only to send confirmation and password
|
|
97
99
|
reset emails.
|
|
@@ -325,6 +327,110 @@ await naro.auth.updateUser({ password: newPassword });
|
|
|
325
327
|
`TIMEOUT` ("The Naro backend did not answer within 15 seconds. Check the
|
|
326
328
|
network and try again."), and queries go out as anon — sign in again.
|
|
327
329
|
|
|
330
|
+
## Server rendering
|
|
331
|
+
|
|
332
|
+
`naro-js/ssr` keeps the session in your app's own cookies instead of
|
|
333
|
+
`localStorage`, so a Server Component, a route handler, a server action or
|
|
334
|
+
`proxy.ts` knows who is signed in — the place `@supabase/ssr` has. The
|
|
335
|
+
backend still gets the anon key and a bearer token, never a cookie.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// lib/naro/client.ts — Client Components
|
|
339
|
+
import { createBrowserClient } from "naro-js/ssr";
|
|
340
|
+
|
|
341
|
+
export const naro = createBrowserClient<Database>({
|
|
342
|
+
url: process.env.NEXT_PUBLIC_NARO_URL!,
|
|
343
|
+
anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
|
|
344
|
+
});
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
// lib/naro/server.ts — Server Components, route handlers, server actions
|
|
349
|
+
import { cookies } from "next/headers";
|
|
350
|
+
import { createServerClient } from "naro-js/ssr";
|
|
351
|
+
|
|
352
|
+
export async function naroServer() {
|
|
353
|
+
const cookieStore = await cookies();
|
|
354
|
+
return createServerClient<Database>({
|
|
355
|
+
url: process.env.NEXT_PUBLIC_NARO_URL!,
|
|
356
|
+
anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
|
|
357
|
+
cookies: {
|
|
358
|
+
getAll: () => cookieStore.getAll(),
|
|
359
|
+
setAll(cookies) {
|
|
360
|
+
try {
|
|
361
|
+
for (const { name, value, options } of cookies)
|
|
362
|
+
cookieStore.set(name, value, options);
|
|
363
|
+
} catch {
|
|
364
|
+
// A Server Component cannot write cookies: proxy.ts does.
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// app/todos/page.tsx
|
|
372
|
+
const naro = await naroServer();
|
|
373
|
+
const { data: todos } = await naro.from("todos").select("id, title");
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
// src/proxy.ts — keeps the access token in the cookies fresh
|
|
378
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
379
|
+
import { createServerClient } from "naro-js/ssr";
|
|
380
|
+
|
|
381
|
+
export async function proxy(request: NextRequest) {
|
|
382
|
+
let response = NextResponse.next({ request });
|
|
383
|
+
const naro = createServerClient({
|
|
384
|
+
url: process.env.NEXT_PUBLIC_NARO_URL!,
|
|
385
|
+
anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
|
|
386
|
+
cookies: {
|
|
387
|
+
getAll: () => request.cookies.getAll(),
|
|
388
|
+
setAll(cookies) {
|
|
389
|
+
for (const { name, value } of cookies) request.cookies.set(name, value);
|
|
390
|
+
response = NextResponse.next({ request });
|
|
391
|
+
for (const { name, value, options } of cookies)
|
|
392
|
+
response.cookies.set(name, value, options);
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
await naro.auth.getUser();
|
|
397
|
+
return response;
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
- Sign in, sign up and sign out in the browser with `createBrowserClient`,
|
|
402
|
+
or in a server action with `naroServer()`: both write the cookies. The
|
|
403
|
+
next request renders as that user.
|
|
404
|
+
- `createBrowserClient` is one client per url and anon key on a page.
|
|
405
|
+
Where there is no page (a Client Component's server render) it keeps the
|
|
406
|
+
session in memory.
|
|
407
|
+
- `createServerClient` is one client per request. It never reads a sign-in
|
|
408
|
+
from the page URL, and takes the anon key only: a `serviceKey` throws
|
|
409
|
+
("naro-js/ssr keeps a signed-in user's session in cookies, so it takes the
|
|
410
|
+
anon key only. …"). Without `cookies.getAll` it throws too.
|
|
411
|
+
- The access token lasts 15 minutes. A Server Component that finds it about
|
|
412
|
+
to expire gets a new one for that request, but cannot write it back;
|
|
413
|
+
`getUser()` in `proxy.ts` writes it to the response, so the pages behind
|
|
414
|
+
it do not renew on every render. `getSession()` and `getUser()` renew it
|
|
415
|
+
within a minute of its expiry on every client.
|
|
416
|
+
- Google and GitHub sign-in come back to the page with the code in the URL
|
|
417
|
+
fragment, which never reaches the server: the browser client trades it
|
|
418
|
+
and writes the cookies, so the page they come back to renders signed out
|
|
419
|
+
once. Send them back to a client page (`redirectTo`) and refresh from
|
|
420
|
+
there (`router.refresh()`).
|
|
421
|
+
- One cookie per key, named for the backend's host:
|
|
422
|
+
`naro-auth-token.<host>` (the session), `naro-auth-jwt.<host>` (the access
|
|
423
|
+
token), and while a sign-in or reset link is under way
|
|
424
|
+
`naro-auth-code-verifiers.<host>` and `naro-auth-recovery.<host>`. They are
|
|
425
|
+
`Path=/`, `SameSite=Lax`, `Max-Age` 400 days (the session ends on the
|
|
426
|
+
backend, which then clears them), and `Secure` on https pages and from the
|
|
427
|
+
server. They are not `HttpOnly`: your page's JavaScript reads them, as it
|
|
428
|
+
reads `localStorage` — the same exposure to an injected script.
|
|
429
|
+
- `cookieOptions: { domain, path, sameSite, secure, maxAge }` changes them,
|
|
430
|
+
on both clients — `domain: "example.com"` to share the session with
|
|
431
|
+
subdomains, `secure: false` for a browser that refuses `Secure` cookies
|
|
432
|
+
from `http://localhost`.
|
|
433
|
+
|
|
328
434
|
## Types
|
|
329
435
|
|
|
330
436
|
Generate the types from the project's schema, and hand them to
|
|
@@ -774,7 +880,10 @@ await naro.removeChannel(room);
|
|
|
774
880
|
4003, 4004 and 4410 stop it — call `subscribe()` again; it connects after
|
|
775
881
|
the backoff, counted from the stop, so a `subscribe()` in the
|
|
776
882
|
`CHANNEL_ERROR` callback does not spin. 4029 comes back after 5 seconds at
|
|
777
|
-
least (60 to 70 for `IP_RATE_LIMITED
|
|
883
|
+
least (60 to 70 for `IP_RATE_LIMITED`; 900 to 910 for
|
|
884
|
+
`REALTIME_QUOTA_EXCEEDED` — the project used its realtime messages for the
|
|
885
|
+
month (an operator's cap): tried again every 15 minutes, naro-js 0.7.1);
|
|
886
|
+
4503 after 0 to 10 seconds, every
|
|
778
887
|
time; anything else after 1, 2, 5 and then 10 seconds, each times 0.5 to
|
|
779
888
|
1.5 — after a 4008 the steps start over only once the connection outlived
|
|
780
889
|
a heartbeat. Removing the last channel and subscribing again keeps the wait
|
|
@@ -853,7 +962,7 @@ await naro.removeChannel(room);
|
|
|
853
962
|
- Unlike supabase-js: broadcast and presence listeners match one event name
|
|
854
963
|
exactly, case included — `{ event: "*" }` hears nothing; `DELETE` events
|
|
855
964
|
pass the select policies too; a subscription takes one filter (`eq`, `neq`,
|
|
856
|
-
`lt`, `lte`, `gt`, `gte`, `in`); settings
|
|
965
|
+
`lt`, `lte`, `gt`, `gte`, `in`); settings Naro does not take
|
|
857
966
|
(`broadcast.replay`) are refused with `INVALID_CHANNEL_CONFIG`; `send()` and
|
|
858
967
|
the rest answer `{ data, error }` (`send()`, `track()` and `untrack()` with
|
|
859
968
|
`lost` too), not `'ok' | 'timed out' | 'error'`.
|
package/dist/http.d.ts
CHANGED
|
@@ -50,6 +50,15 @@ export declare const ACCESS_JWT_MARGIN_MS = 60000;
|
|
|
50
50
|
* INVALID_SESSION, if it comes, renews it (`call`, `send`).
|
|
51
51
|
*/
|
|
52
52
|
export declare function expiryOf(jwt: string): number | null;
|
|
53
|
+
/**
|
|
54
|
+
* Renews the stored access JWT when it is within `ACCESS_JWT_MARGIN_MS` of
|
|
55
|
+
* its `exp` (naro-js/ssr design 2026-11-04 decision 11): `getSession` calls
|
|
56
|
+
* it, so a server's proxy.ts gets a fresh JWT into the response cookies with
|
|
57
|
+
* one `getUser()`. A missing or unreadable JWT is left for the next query to
|
|
58
|
+
* mint; the service key and a client on an `accessToken` renew nothing.
|
|
59
|
+
* Never fails: a renewal that does not work leaves things as they were.
|
|
60
|
+
*/
|
|
61
|
+
export declare function freshAccessJwt(config: HttpConfig): Promise<void>;
|
|
53
62
|
export type CallOptions = {
|
|
54
63
|
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
55
64
|
/** Below `/_naro/v1`, e.g. `/data/query`. */
|
package/dist/index.js
CHANGED
|
@@ -308,6 +308,15 @@ async function accessJwtFor(config, jwtStore) {
|
|
|
308
308
|
}
|
|
309
309
|
return { error: invalidSession() };
|
|
310
310
|
}
|
|
311
|
+
async function freshAccessJwt(config) {
|
|
312
|
+
const jwtStore = config.accessJwt;
|
|
313
|
+
if (jwtStore === void 0 || config.service) return;
|
|
314
|
+
const jwt = await jwtStore.get();
|
|
315
|
+
if (jwt === null) return;
|
|
316
|
+
const expiry = localExpiry(config, jwt);
|
|
317
|
+
if (expiry === null || expiry - ACCESS_JWT_MARGIN_MS > Date.now()) return;
|
|
318
|
+
await accessJwtFor(config, jwtStore);
|
|
319
|
+
}
|
|
311
320
|
async function bearerFor(config, path, withToken) {
|
|
312
321
|
if (!withToken) return { token: null, session: null };
|
|
313
322
|
const store = storeFor(config, path);
|
|
@@ -717,6 +726,9 @@ var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
|
|
|
717
726
|
"STORAGE_OPS_QUOTA_EXCEEDED",
|
|
718
727
|
// Realtime's REST broadcast over the project's message budget (x-retry-after: 1).
|
|
719
728
|
"TOO_MANY_MESSAGES",
|
|
729
|
+
// The dispatcher's REST 429 of a project over its month of Realtime (3-2e):
|
|
730
|
+
// `x-retry-after` is the seconds to the 1st at 00:00 UTC.
|
|
731
|
+
"REALTIME_QUOTA_EXCEEDED",
|
|
720
732
|
// Naro Functions (design F20): a team over its monthly function CPU, and
|
|
721
733
|
// the control plane's Cloudflare limit. `invoke` reads x-retry-after on
|
|
722
734
|
// every relay error anyway (invoke/client.ts).
|
|
@@ -1251,6 +1263,7 @@ var AuthClient = class {
|
|
|
1251
1263
|
const renewed = tokenFrom(result.answer);
|
|
1252
1264
|
if (renewed !== null && token !== null && renewed !== token)
|
|
1253
1265
|
await tokens.replaceIf(token, renewed);
|
|
1266
|
+
await freshAccessJwt(this.#http);
|
|
1254
1267
|
return { data: { session: body.session, user }, error: null };
|
|
1255
1268
|
}
|
|
1256
1269
|
/** `{ data: { user } }`; `user` is null when signed out. */
|
|
@@ -2672,6 +2685,7 @@ var TableBuilder = class {
|
|
|
2672
2685
|
var BACKOFF_STEPS_MS = [1e3, 2e3, 5e3, 1e4];
|
|
2673
2686
|
var RATE_LIMITED_MIN_MS = 5e3;
|
|
2674
2687
|
var IP_LIMIT_WAIT_MS = 6e4;
|
|
2688
|
+
var QUOTA_WAIT_MS = 9e5;
|
|
2675
2689
|
var RESTART_JITTER_MS = 1e4;
|
|
2676
2690
|
var UNOPENED_SLOWDOWN_AFTER = 6;
|
|
2677
2691
|
var UNOPENED_DELAY_MS = 6e4;
|
|
@@ -2688,7 +2702,7 @@ function closeAction(code, reason) {
|
|
|
2688
2702
|
if (code === 4401) return { kind: "session" };
|
|
2689
2703
|
if (code === 4503) return { kind: "restart" };
|
|
2690
2704
|
if (code === 4029)
|
|
2691
|
-
return reason === "IP_RATE_LIMITED" ? { kind: "wait", ms: IP_LIMIT_WAIT_MS } : { kind: "retry", minMs: RATE_LIMITED_MIN_MS };
|
|
2705
|
+
return reason === "IP_RATE_LIMITED" ? { kind: "wait", ms: IP_LIMIT_WAIT_MS } : reason === "REALTIME_QUOTA_EXCEEDED" ? { kind: "wait", ms: QUOTA_WAIT_MS } : { kind: "retry", minMs: RATE_LIMITED_MIN_MS };
|
|
2692
2706
|
return { kind: "retry", minMs: 0 };
|
|
2693
2707
|
}
|
|
2694
2708
|
function backoffMs(attempt2, random) {
|
package/dist/realtime/close.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export declare const BACKOFF_STEPS_MS: readonly [1000, 2000, 5000, 10000];
|
|
|
4
4
|
export declare const RATE_LIMITED_MIN_MS = 5000;
|
|
5
5
|
/** The dispatcher's per-address window is a fixed minute (3-2c-2 결정 9). */
|
|
6
6
|
export declare const IP_LIMIT_WAIT_MS = 60000;
|
|
7
|
+
/**
|
|
8
|
+
* A month's block (Realtime 3-2e): the meter looks again every 15 minutes, so
|
|
9
|
+
* a refused client comes back on that beat — not every few seconds for the
|
|
10
|
+
* rest of the month. After the 1st it is back within about 15 minutes.
|
|
11
|
+
*/
|
|
12
|
+
export declare const QUOTA_WAIT_MS = 900000;
|
|
7
13
|
/**
|
|
8
14
|
* A restart closes everyone at once, so every 4503 return waits 0–10 s (design
|
|
9
15
|
* D6). The wait does not grow with the attempt count or the unopened tries:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** What an app may change about the cookies. */
|
|
2
|
+
export type CookieOptions = {
|
|
3
|
+
domain?: string;
|
|
4
|
+
path?: string;
|
|
5
|
+
sameSite?: "lax" | "strict" | "none";
|
|
6
|
+
secure?: boolean;
|
|
7
|
+
/** Seconds. */
|
|
8
|
+
maxAge?: number;
|
|
9
|
+
};
|
|
10
|
+
export type ResolvedCookieOptions = {
|
|
11
|
+
domain?: string;
|
|
12
|
+
path: string;
|
|
13
|
+
sameSite: "lax" | "strict" | "none";
|
|
14
|
+
secure: boolean;
|
|
15
|
+
maxAge: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* 400 days, the most a browser keeps a cookie. Longer than the session's 7
|
|
19
|
+
* days on purpose (decision 6): the session slides while it is used but its
|
|
20
|
+
* token does not change, so the cookie is never written again — a 7-day
|
|
21
|
+
* cookie would outlive nothing. A session that ends is cleared by
|
|
22
|
+
* INVALID_SESSION instead.
|
|
23
|
+
*/
|
|
24
|
+
export declare const COOKIE_MAX_AGE = 34560000;
|
|
25
|
+
/**
|
|
26
|
+
* `naro-auth-token:https://abc.naro.sh` → `naro-auth-token.abc.naro.sh`: a
|
|
27
|
+
* cookie name holds no `:` or `/`. What else the host has (a port's `:`)
|
|
28
|
+
* becomes `_`; a key without a `:` is made safe the same way.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cookieName(key: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* `base64-` and the value's UTF-8 as base64url, as `@supabase/ssr` writes:
|
|
33
|
+
* a session token's signature has `+`, `/` and `=`, and a verifier list is
|
|
34
|
+
* JSON. Next's `cookies().set()` encodes a value into `Set-Cookie`, which the
|
|
35
|
+
* browser's `document.cookie` hands back as it is — so a value that any
|
|
36
|
+
* layer would encode again comes back changed. base64url is left alone by
|
|
37
|
+
* all of them.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeValue(value: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The value `encodeValue` wrote; one without the prefix as it is. Null for
|
|
42
|
+
* one that does not decode — it is read as no cookie.
|
|
43
|
+
*/
|
|
44
|
+
export declare function decodeValue(value: string): string | null;
|
|
45
|
+
export declare function resolveCookieOptions(options: CookieOptions | undefined, secure: boolean): ResolvedCookieOptions;
|
|
46
|
+
/** A `document.cookie` line; `value` is already encoded. */
|
|
47
|
+
export declare function serializeCookie(name: string, value: string, options: ResolvedCookieOptions): string;
|
|
48
|
+
/** `a=1; b=2` as pairs, in order; what is not a pair is skipped. */
|
|
49
|
+
export declare function parseCookies(header: string): {
|
|
50
|
+
name: string;
|
|
51
|
+
value: string;
|
|
52
|
+
}[];
|
|
53
|
+
/** One warning per cookie name (decision 7): it is written all the same. */
|
|
54
|
+
export declare function warnIfLarge(name: string, value: string): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AuthStorage } from "../auth/storage.js";
|
|
2
|
+
import { type ResolvedCookieOptions } from "./cookies.js";
|
|
3
|
+
export type CookieToSet = {
|
|
4
|
+
name: string;
|
|
5
|
+
/** Encoded; "" when the cookie is removed. */
|
|
6
|
+
value: string;
|
|
7
|
+
/** `maxAge` 0 removes it. */
|
|
8
|
+
options: ResolvedCookieOptions;
|
|
9
|
+
};
|
|
10
|
+
export type CookieMethods = {
|
|
11
|
+
/** The request's cookies — `cookieStore.getAll()`, `request.cookies.getAll()`. */
|
|
12
|
+
getAll(): {
|
|
13
|
+
name: string;
|
|
14
|
+
value: string;
|
|
15
|
+
}[] | Promise<{
|
|
16
|
+
name: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}[]>;
|
|
19
|
+
/**
|
|
20
|
+
* Writes them to the response. Left out, or throwing (a Server Component
|
|
21
|
+
* cannot write cookies), the client keeps them for this request only.
|
|
22
|
+
*/
|
|
23
|
+
setAll?(cookies: CookieToSet[]): void | Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
/** What of `document` the browser storage uses. */
|
|
26
|
+
type CookieDocument = {
|
|
27
|
+
cookie: string;
|
|
28
|
+
};
|
|
29
|
+
/** `document.cookie`, read again on every get: another tab's sign-in shows. */
|
|
30
|
+
export declare function browserCookieStorage(options: ResolvedCookieOptions, doc: CookieDocument): AuthStorage;
|
|
31
|
+
/**
|
|
32
|
+
* The request's cookies, read once, under what this client wrote since
|
|
33
|
+
* (decision 10): a JWT renewed in a Server Component, whose `setAll`
|
|
34
|
+
* throws, is still the one the next query of the request sends.
|
|
35
|
+
*/
|
|
36
|
+
export declare function serverCookieStorage(methods: CookieMethods, options: ResolvedCookieOptions): AuthStorage;
|
|
37
|
+
export {};
|
package/dist/ssr.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type DatabaseShape, type FetchLike, type NaroClient, type RealtimeOptions, type UntypedDatabase } from "./index.js";
|
|
2
|
+
import { type CookieOptions } from "./ssr/cookies.js";
|
|
3
|
+
import { type CookieMethods } from "./ssr/storage.js";
|
|
4
|
+
export type { CookieOptions } from "./ssr/cookies.js";
|
|
5
|
+
export type { CookieMethods, CookieToSet } from "./ssr/storage.js";
|
|
6
|
+
/** User-facing; the design's §4.1. */
|
|
7
|
+
export declare const SSR_MESSAGES: {
|
|
8
|
+
readonly serviceKey: "naro-js/ssr keeps a signed-in user's session in cookies, so it takes the anon key only. Make the service key's client with naro-js's createClient({ url, serviceKey }), on your server.";
|
|
9
|
+
readonly getAll: "createServerClient needs cookies.getAll: a function that returns the request's cookies, e.g. () => cookieStore.getAll().";
|
|
10
|
+
};
|
|
11
|
+
type SsrKeys = {
|
|
12
|
+
url: string;
|
|
13
|
+
anonKey: string;
|
|
14
|
+
serviceKey?: undefined;
|
|
15
|
+
};
|
|
16
|
+
export type BrowserClientOptions = SsrKeys & {
|
|
17
|
+
cookieOptions?: CookieOptions;
|
|
18
|
+
fetch?: FetchLike;
|
|
19
|
+
realtime?: RealtimeOptions;
|
|
20
|
+
};
|
|
21
|
+
export type ServerClientOptions = SsrKeys & {
|
|
22
|
+
cookies: CookieMethods;
|
|
23
|
+
cookieOptions?: CookieOptions;
|
|
24
|
+
fetch?: FetchLike;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The client for the browser: `createClient` with its session in
|
|
28
|
+
* `document.cookie`. On a page, the same url and key give the same client.
|
|
29
|
+
* Where there is no page (a Client Component's server render) it keeps the
|
|
30
|
+
* session in memory and is new each time.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createBrowserClient<Database extends DatabaseShape<Database> = UntypedDatabase>(options: BrowserClientOptions): NaroClient<Database>;
|
|
33
|
+
/**
|
|
34
|
+
* The client for a request on the server — a Server Component, a route
|
|
35
|
+
* handler, a server action or proxy.ts: its session is the request's
|
|
36
|
+
* cookies, and what it writes goes to `cookies.setAll`. Make one per
|
|
37
|
+
* request. It never reads a session from a URL.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createServerClient<Database extends DatabaseShape<Database> = UntypedDatabase>(options: ServerClientOptions): NaroClient<Database>;
|
package/dist/ssr.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// src/ssr.ts
|
|
2
|
+
import {
|
|
3
|
+
createClient
|
|
4
|
+
} from "./index.js";
|
|
5
|
+
|
|
6
|
+
// src/runtime.ts
|
|
7
|
+
function isBrowser(scope = globalThis) {
|
|
8
|
+
const g = scope;
|
|
9
|
+
return g.window !== void 0 && g.document !== void 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/ssr/cookies.ts
|
|
13
|
+
var COOKIE_MAX_AGE = 3456e4;
|
|
14
|
+
var LARGE_COOKIE_BYTES = 4e3;
|
|
15
|
+
function cookieName(key) {
|
|
16
|
+
const colon = key.indexOf(":");
|
|
17
|
+
if (colon === -1) return safe(key);
|
|
18
|
+
const prefix = key.slice(0, colon);
|
|
19
|
+
const rest = key.slice(colon + 1);
|
|
20
|
+
let host = rest;
|
|
21
|
+
try {
|
|
22
|
+
host = new URL(rest).host;
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
return `${safe(prefix)}.${safe(host)}`;
|
|
26
|
+
}
|
|
27
|
+
function safe(text) {
|
|
28
|
+
return text.replace(/[^A-Za-z0-9.-]/g, "_");
|
|
29
|
+
}
|
|
30
|
+
var BASE64_PREFIX = "base64-";
|
|
31
|
+
function encodeValue(value) {
|
|
32
|
+
let binary = "";
|
|
33
|
+
for (const byte of new TextEncoder().encode(value))
|
|
34
|
+
binary += String.fromCharCode(byte);
|
|
35
|
+
return BASE64_PREFIX + btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
36
|
+
}
|
|
37
|
+
function decodeValue(value) {
|
|
38
|
+
if (!value.startsWith(BASE64_PREFIX)) return value;
|
|
39
|
+
const base64 = value.slice(BASE64_PREFIX.length).replace(/-/g, "+").replace(/_/g, "/");
|
|
40
|
+
try {
|
|
41
|
+
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="));
|
|
42
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
43
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function resolveCookieOptions(options, secure) {
|
|
49
|
+
return {
|
|
50
|
+
...options?.domain !== void 0 ? { domain: options.domain } : {},
|
|
51
|
+
path: options?.path ?? "/",
|
|
52
|
+
sameSite: options?.sameSite ?? "lax",
|
|
53
|
+
secure: options?.secure ?? secure,
|
|
54
|
+
maxAge: options?.maxAge ?? COOKIE_MAX_AGE
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
var SAME_SITE = { lax: "Lax", strict: "Strict", none: "None" };
|
|
58
|
+
function serializeCookie(name, value, options) {
|
|
59
|
+
const parts = [
|
|
60
|
+
`${name}=${value}`,
|
|
61
|
+
`Max-Age=${options.maxAge}`,
|
|
62
|
+
`Path=${options.path}`
|
|
63
|
+
];
|
|
64
|
+
if (options.domain !== void 0) parts.push(`Domain=${options.domain}`);
|
|
65
|
+
parts.push(`SameSite=${SAME_SITE[options.sameSite]}`);
|
|
66
|
+
if (options.secure) parts.push("Secure");
|
|
67
|
+
return parts.join("; ");
|
|
68
|
+
}
|
|
69
|
+
function parseCookies(header) {
|
|
70
|
+
const pairs = [];
|
|
71
|
+
for (const part of header.split(";")) {
|
|
72
|
+
const at = part.indexOf("=");
|
|
73
|
+
if (at <= 0) continue;
|
|
74
|
+
const name = part.slice(0, at).trim();
|
|
75
|
+
if (name === "") continue;
|
|
76
|
+
pairs.push({ name, value: part.slice(at + 1).trim() });
|
|
77
|
+
}
|
|
78
|
+
return pairs;
|
|
79
|
+
}
|
|
80
|
+
var warned = /* @__PURE__ */ new Set();
|
|
81
|
+
function warnIfLarge(name, value) {
|
|
82
|
+
const bytes = new TextEncoder().encode(`${name}=${value}`).length;
|
|
83
|
+
if (bytes <= LARGE_COOKIE_BYTES || warned.has(name)) return;
|
|
84
|
+
warned.add(name);
|
|
85
|
+
console.warn(
|
|
86
|
+
`naro-js/ssr: the cookie ${name} is ${bytes} bytes. Browsers drop a cookie past 4096 bytes, and the session then lives only until the page closes.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/ssr/storage.ts
|
|
91
|
+
function browserCookieStorage(options, doc) {
|
|
92
|
+
return {
|
|
93
|
+
getItem(key) {
|
|
94
|
+
const name = cookieName(key);
|
|
95
|
+
const found = parseCookies(doc.cookie).find((c) => c.name === name);
|
|
96
|
+
return found === void 0 ? null : decodeValue(found.value);
|
|
97
|
+
},
|
|
98
|
+
setItem(key, value) {
|
|
99
|
+
const name = cookieName(key);
|
|
100
|
+
const encoded = encodeValue(value);
|
|
101
|
+
warnIfLarge(name, encoded);
|
|
102
|
+
doc.cookie = serializeCookie(name, encoded, options);
|
|
103
|
+
},
|
|
104
|
+
removeItem(key) {
|
|
105
|
+
doc.cookie = serializeCookie(cookieName(key), "", {
|
|
106
|
+
...options,
|
|
107
|
+
maxAge: 0
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function serverCookieStorage(methods, options) {
|
|
113
|
+
let request;
|
|
114
|
+
const written = /* @__PURE__ */ new Map();
|
|
115
|
+
const read = () => {
|
|
116
|
+
request ??= (async () => {
|
|
117
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const { name, value } of await methods.getAll())
|
|
119
|
+
if (!cookies.has(name)) cookies.set(name, value);
|
|
120
|
+
return cookies;
|
|
121
|
+
})();
|
|
122
|
+
return request;
|
|
123
|
+
};
|
|
124
|
+
const send = async (cookie) => {
|
|
125
|
+
try {
|
|
126
|
+
await methods.setAll?.([cookie]);
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
async getItem(key) {
|
|
132
|
+
const name = cookieName(key);
|
|
133
|
+
if (written.has(name)) return written.get(name) ?? null;
|
|
134
|
+
const value = (await read()).get(name);
|
|
135
|
+
return value === void 0 ? null : decodeValue(value);
|
|
136
|
+
},
|
|
137
|
+
async setItem(key, value) {
|
|
138
|
+
const name = cookieName(key);
|
|
139
|
+
written.set(name, value);
|
|
140
|
+
const encoded = encodeValue(value);
|
|
141
|
+
warnIfLarge(name, encoded);
|
|
142
|
+
await send({ name, value: encoded, options });
|
|
143
|
+
},
|
|
144
|
+
async removeItem(key) {
|
|
145
|
+
const name = cookieName(key);
|
|
146
|
+
written.set(name, null);
|
|
147
|
+
await send({ name, value: "", options: { ...options, maxAge: 0 } });
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/ssr.ts
|
|
153
|
+
var SSR_MESSAGES = {
|
|
154
|
+
serviceKey: "naro-js/ssr keeps a signed-in user's session in cookies, so it takes the anon key only. Make the service key's client with naro-js's createClient({ url, serviceKey }), on your server.",
|
|
155
|
+
getAll: "createServerClient needs cookies.getAll: a function that returns the request's cookies, e.g. () => cookieStore.getAll()."
|
|
156
|
+
};
|
|
157
|
+
function refuseServiceKey(options) {
|
|
158
|
+
if (options.serviceKey !== void 0)
|
|
159
|
+
throw new Error(SSR_MESSAGES.serviceKey);
|
|
160
|
+
}
|
|
161
|
+
var pageClients = /* @__PURE__ */ new Map();
|
|
162
|
+
function createBrowserClient(options) {
|
|
163
|
+
refuseServiceKey(options);
|
|
164
|
+
const { url, anonKey, cookieOptions, fetch, realtime } = options;
|
|
165
|
+
const common = {
|
|
166
|
+
url,
|
|
167
|
+
anonKey,
|
|
168
|
+
...fetch !== void 0 ? { fetch } : {},
|
|
169
|
+
...realtime !== void 0 ? { realtime } : {}
|
|
170
|
+
};
|
|
171
|
+
if (!isBrowser()) return createClient(common);
|
|
172
|
+
const id = `${url}
|
|
173
|
+
${anonKey}`;
|
|
174
|
+
const kept = pageClients.get(id);
|
|
175
|
+
if (kept !== void 0) return kept;
|
|
176
|
+
const https = globalThis.location?.protocol === "https:";
|
|
177
|
+
const storage = browserCookieStorage(
|
|
178
|
+
resolveCookieOptions(cookieOptions, https),
|
|
179
|
+
globalThis.document
|
|
180
|
+
);
|
|
181
|
+
const client = createClient({ ...common, auth: { storage } });
|
|
182
|
+
pageClients.set(id, client);
|
|
183
|
+
return client;
|
|
184
|
+
}
|
|
185
|
+
function createServerClient(options) {
|
|
186
|
+
refuseServiceKey(options);
|
|
187
|
+
const { url, anonKey, cookies, cookieOptions, fetch } = options;
|
|
188
|
+
if (typeof cookies?.getAll !== "function")
|
|
189
|
+
throw new Error(SSR_MESSAGES.getAll);
|
|
190
|
+
const storage = serverCookieStorage(
|
|
191
|
+
cookies,
|
|
192
|
+
resolveCookieOptions(cookieOptions, true)
|
|
193
|
+
);
|
|
194
|
+
return createClient({
|
|
195
|
+
url,
|
|
196
|
+
anonKey,
|
|
197
|
+
...fetch !== void 0 ? { fetch } : {},
|
|
198
|
+
auth: { storage, detectSessionInUrl: false }
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export {
|
|
202
|
+
SSR_MESSAGES,
|
|
203
|
+
createBrowserClient,
|
|
204
|
+
createServerClient
|
|
205
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naro-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Auth, data and files for your Naro project, from the browser or the server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"*": {
|
|
13
13
|
"functions": [
|
|
14
14
|
"./dist/functions.d.ts"
|
|
15
|
+
],
|
|
16
|
+
"ssr": [
|
|
17
|
+
"./dist/ssr.d.ts"
|
|
15
18
|
]
|
|
16
19
|
}
|
|
17
20
|
},
|
|
@@ -23,6 +26,10 @@
|
|
|
23
26
|
"./functions": {
|
|
24
27
|
"types": "./dist/functions.d.ts",
|
|
25
28
|
"default": "./dist/functions.js"
|
|
29
|
+
},
|
|
30
|
+
"./ssr": {
|
|
31
|
+
"types": "./dist/ssr.d.ts",
|
|
32
|
+
"default": "./dist/ssr.js"
|
|
26
33
|
}
|
|
27
34
|
},
|
|
28
35
|
"publishConfig": {
|
|
@@ -34,11 +41,11 @@
|
|
|
34
41
|
"tsup": "8.5.0",
|
|
35
42
|
"typescript": "7.0.2",
|
|
36
43
|
"vitest": "3.2.7",
|
|
37
|
-
"@next-deploy/
|
|
44
|
+
"@next-deploy/backend-worker": "0.1.0",
|
|
38
45
|
"@next-deploy/data-core": "0.1.0",
|
|
39
|
-
"@next-deploy/sdk": "0.1.0",
|
|
40
46
|
"@next-deploy/functions-runtime": "0.1.0",
|
|
41
|
-
"@next-deploy/
|
|
47
|
+
"@next-deploy/sdk": "0.1.0",
|
|
48
|
+
"@next-deploy/config": "0.1.0"
|
|
42
49
|
},
|
|
43
50
|
"scripts": {
|
|
44
51
|
"build": "tsup && pnpm build:types",
|