naro-js 0.6.0 → 0.7.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 CHANGED
@@ -21,8 +21,8 @@ with the same keys — nothing to change in your app.
21
21
  import { createClient } from "naro-js";
22
22
 
23
23
  const naro = createClient({
24
- url: "https://my-app.naro.sh",
25
- anonKey: "naro_pk_…",
24
+ url: process.env.NEXT_PUBLIC_NARO_URL!,
25
+ anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
26
26
  });
27
27
 
28
28
  await naro.auth.signIn({ email, password });
@@ -46,6 +46,20 @@ await naro.from("posts").delete().eq("id", 10);
46
46
  throws only for a configuration mistake.
47
47
  - `url` is the project's origin alone (`https://my-app.naro.sh`): no path,
48
48
  no `?` or `#`.
49
+ - Keep the URL and the anon key in the project's environment variables, not
50
+ in the code: `NEXT_PUBLIC_NARO_URL` and `NEXT_PUBLIC_NARO_ANON_KEY` (Vite
51
+ `VITE_…`, Astro and SvelteKit `PUBLIC_…`, server code `NARO_URL` and
52
+ `NARO_ANON_KEY`), saved for all environments with `naro env add` and in
53
+ `.env.local` for local development. Production and preview use the values
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 naro never
56
+ sets, goes in `NARO_SERVICE_KEY`: save one for each environment, because a
57
+ production service key saved for all environments is refused (401) by an
58
+ environment's backend. A named environment of a project with a backend has a backend of
59
+ its own, and naro sets those names to its address and anon key, so the same
60
+ code calls the right backend everywhere — an address written into the code
61
+ calls production's from every environment. On a custom domain, save
62
+ `NEXT_PUBLIC_NARO_URL=https://<your domain>` for production.
49
63
  - Queries: `.select("id, title")`, `.eq` `.neq` `.gt` `.gte` `.lt` `.lte`
50
64
  `.in` `.is` `.like` `.ilike`, `.or` `.and` `.not` `.filter` `.match`
51
65
  `.contains` `.containedBy` `.overlaps` (see [Filters](#filters)),
@@ -69,6 +83,12 @@ await naro.from("posts").delete().eq("id", 10);
69
83
  - The session token lives in `localStorage` in a browser and in memory
70
84
  elsewhere. On a server, create one client per request, or pass
71
85
  `auth: { storage }` with your own `getItem` / `setItem` / `removeItem`.
86
+ - Queries and Storage calls carry a short-lived access token (15 minutes)
87
+ next to the session, which lasts days. The client renews it on its own
88
+ before it expires, and once more if the backend refuses it — nothing to
89
+ call, no timer running. When the session itself has ended (signed out
90
+ elsewhere, expired, the user deleted), the call answers `INVALID_SESSION`
91
+ and the client forgets both tokens: sign in again.
72
92
  - Types: `createClient<Database>()` with the `Database` that
73
93
  `naro gen types` writes — see [Types](#types).
74
94
  - Sign-in is naro Auth, the project's own (like Supabase Auth). Email and
@@ -80,6 +100,7 @@ await naro.from("posts").delete().eq("id", 10);
80
100
  - Email and social sign-in — see [Sign-in flows](#sign-in-flows).
81
101
  - Files — see [Storage](#storage).
82
102
  - Live changes, broadcast and presence — see [Realtime](#realtime).
103
+ - Server code your app calls by name — see [Functions](#functions).
83
104
 
84
105
  ## Policies
85
106
 
@@ -141,7 +162,7 @@ The same from your own server, with supabase-js's `auth.admin` names:
141
162
  ```ts
142
163
  // Server only: the service key skips every policy.
143
164
  const admin = createClient({
144
- url: "https://my-app.naro.sh",
165
+ url: process.env.NARO_URL!,
145
166
  serviceKey: process.env.NARO_SERVICE_KEY,
146
167
  });
147
168
 
@@ -318,8 +339,8 @@ import { createClient } from "naro-js";
318
339
  import type { Database, Tables } from "./database.types";
319
340
 
320
341
  const naro = createClient<Database>({
321
- url: "https://my-app.naro.sh",
322
- anonKey: "naro_pk_…",
342
+ url: process.env.NEXT_PUBLIC_NARO_URL!,
343
+ anonKey: process.env.NEXT_PUBLIC_NARO_ANON_KEY!,
323
344
  });
324
345
 
325
346
  // { id: number; title: string }[] | null
@@ -860,3 +881,135 @@ const naro = createClient({ url, anonKey, realtime: { WebSocket } });
860
881
 
861
882
  The whole guide, with the policies, the close codes and the limits:
862
883
  https://naro.sh/docs/realtime
884
+
885
+ ## Functions
886
+
887
+ Server code your app calls by name, written the way Supabase Edge Functions
888
+ are (naro-js 0.7.0). `naro functions new hello` writes one and
889
+ `naro functions deploy hello` puts it live; the folders, imports, secrets and
890
+ limits are in https://naro.sh/docs/functions.
891
+
892
+ ```ts
893
+ // naro/functions/hello/index.ts
894
+ import { withNaro } from "naro-js/functions";
895
+
896
+ Deno.serve(
897
+ withNaro(async (req, { user, naro }) => {
898
+ const { name } = await req.json();
899
+ // naro acts as the caller: the tables' policies apply.
900
+ const { data } = await naro
901
+ .from("profiles")
902
+ .select("*")
903
+ .eq("id", user?.id ?? "");
904
+ return Response.json({ message: `Hello ${name}!`, profile: data });
905
+ }),
906
+ );
907
+ ```
908
+
909
+ ```ts
910
+ // your app
911
+ import { createClient, FunctionsHttpError } from "naro-js";
912
+
913
+ const naro = createClient({ url, anonKey });
914
+ const { data, error } = await naro.functions.invoke("hello", {
915
+ body: { name: "Naro" },
916
+ });
917
+ if (error instanceof FunctionsHttpError)
918
+ console.log(await error.context.json());
919
+ ```
920
+
921
+ - `invoke(name, { body, headers, method, timeout, signal })` calls
922
+ `<url>/_naro/v1/functions/<name>` (`POST` by default) with the anon key
923
+ and, when someone is signed in, their access token — renewed as a query's
924
+ is. `name` may carry a path and a query (`"hello/items?limit=5"`); only
925
+ the name is encoded. A name of `.` or `..`, or a path whose `..` would
926
+ take the URL out of `/functions/` once it is normalised, is refused
927
+ before anything is sent. `region` is taken and ignored.
928
+ - An object or array goes as JSON, a string as text, a Blob or bytes as
929
+ `application/octet-stream`; `FormData`, `URLSearchParams` and a
930
+ `ReadableStream` go as they are, and your own `content-type` wins; a
931
+ `GET` or `HEAD` call takes no body. The
932
+ answer is read by its content type: JSON, a Blob for
933
+ `application/octet-stream` and `application/pdf`, the `Response` itself
934
+ for `text/event-stream`, a `FormData` for `multipart/form-data`, text
935
+ otherwise, `null` for 204 and `HEAD`. `timeout` (ms) is the whole call's:
936
+ the wait for a sign-in the page URL carried, a token renewal and the one
937
+ retry of a call Naro refused for an expired token count in it, up to the
938
+ final answer's headers. Your `signal` stops the call at any of these
939
+ waits; the sign-in and the renewal go on for the rest of the page. The
940
+ body is not timed — pass a `signal` to stop a body that is slow to
941
+ arrive.
942
+ - It resolves to `{ data, error }` and never throws, with `response` beside
943
+ them when one came. `error` is one of supabase-js's classes, and has
944
+ `code`, `message` and `details` too: `FunctionsHttpError` — your function
945
+ answered 4xx or 5xx (`FUNCTION_HTTP_ERROR`; `error.context` is the unread
946
+ `Response`, and a function that threw answers
947
+ `500 FUNCTION_ERROR` in it); `FunctionsRelayError` — Naro answered for it
948
+ (`x-relay-error: true`): `FUNCTION_NOT_FOUND`, `INVALID_API_KEY`,
949
+ `INVALID_SESSION`, `FUNCTIONS_QUOTA_EXCEEDED` with `details.retryAfter`,
950
+ …, and `RELAY_ERROR` for a `HEAD` call; `FunctionsFetchError` — everything
951
+ else, `error.context` holding the cause:
952
+ - no answer came: `NETWORK_ERROR` (fetch threw, `details.cause`),
953
+ `TIMEOUT` (the call did not finish within `timeout` — a sign-in
954
+ exchange or a token renewal that outlasts it included, and Naro's
955
+ refusal of the first token whose body was still being read then, which
956
+ is dropped — a refusal of the renewed token is the call's answer, so it
957
+ comes back as `INVALID_SESSION` even past `timeout`), `ABORTED` (your
958
+ `signal`);
959
+ - nothing was sent: `INVALID_BODY` (a body JSON cannot hold),
960
+ `INVALID_OPTIONS` (a name, `timeout`, `headers` or `signal` it cannot
961
+ use, or a body with `GET` or `HEAD`), `ACCESS_TOKEN_UNAVAILABLE` (the
962
+ `accessToken` function of a client made with one failed), or the error
963
+ renewing the access token before the call answered — `INVALID_SESSION`
964
+ when the session is gone, `NETWORK_ERROR` when Naro could not be
965
+ reached;
966
+ - the function answered 2xx but its body could not be read as its
967
+ content type says: `INVALID_RESPONSE`, with `response` beside it.
968
+ - Only Naro's own `INVALID_SESSION` renews the access token and tries once
969
+ more; your function answering `{ "code": "INVALID_SESSION" }` signs nobody
970
+ out. A `HEAD` answer has no body, so Naro's refusal of a `HEAD` call is
971
+ `RELAY_ERROR` with the status alone, and a `HEAD` call is never renewed:
972
+ the `INVALID_SESSION` that would say so is not there to read.
973
+ - Inside a function, `withNaro(handler)` hands the handler `user` (`id`,
974
+ `email` — `null` until it is confirmed — and `emailVerified`, or `null`),
975
+ `role` (`anon`, `authenticated`, `service`, or `null` for a function
976
+ deployed with `--no-verify-jwt`), `traceId`, `naro` and `naroAdmin`. Naro
977
+ checked the caller before your code runs; `withNaro` does not check again.
978
+ `naro` sends `NARO_ANON_KEY` and the caller's token, so the tables'
979
+ policies apply; `naroAdmin` sends `NARO_SERVICE_KEY` and skips them, and
980
+ is made the first time you read it. Outside a Naro function `withNaro`
981
+ refuses with `withNaro can only run inside a Naro function.`
982
+ - `role` says how the caller came in; `naro` always sends the anon key — use
983
+ `naroAdmin` for service access.
984
+ - Inside a Naro function, `withNaro` finds the caller for the request it was
985
+ handed and for one your code rebuilt from it (`req.clone()`,
986
+ `new Request(req, …)`). `naro` sends the token of the request you hand
987
+ it, so a request you rebuild must copy `authorization` (or start from
988
+ `req.headers`): `new Request(req, { headers: { "x-extra": "1" } })` has no
989
+ `authorization`, and `naro` goes as anon while `context.user` still names
990
+ the caller. The caller is found per call, not per request: work one call
991
+ started and your module state keeps (a memoised promise, say) answers with
992
+ that call's caller when another call awaits it. A rebuilt request whose
993
+ `x-naro-trace-id` is not this call's is refused rather than run as this
994
+ call's caller. The trace id belongs to a chain of calls, not to one call
995
+ (a function calling another passes it on), so two calls sharing one are
996
+ not told apart.
997
+ - With verify off (`--no-verify-jwt`), Naro checked nobody: `user` is `null`
998
+ even when `naro` carries the caller's token (the Data API judges that
999
+ token on each query), and `naro.auth.*` is refused on that client. An
1000
+ `Authorization` that is not a bearer token — a webhook's `Basic …`, or
1001
+ anything but printable ASCII without spaces — gives `naro` no token: it
1002
+ goes as anon.
1003
+ - `naro` is `createClient({ url, anonKey, accessToken })`: `accessToken` is
1004
+ a function that returns the token to send, or `null`, read for every
1005
+ call. Such a client never stores, renews or clears a token; `naro.auth`
1006
+ and `naro.channel()` throw on it (Realtime signs in with a session —
1007
+ broadcast from a function with `naroAdmin`), and it cannot take a
1008
+ `serviceKey`.
1009
+ - `naro-js/functions` declares the globals Naro provides — `Deno.serve`,
1010
+ `Deno.env` and `EdgeRuntime.waitUntil` — so a function type-checks. Leave
1011
+ `naro/functions` out of your app's `tsconfig.json`
1012
+ (`"exclude": ["naro/functions"]`): a project that also loads Deno's or
1013
+ Next.js's own declarations can see them twice.
1014
+ - A function's request has no Cloudflare `request.cf`; the caller's address
1015
+ is in `cf-connecting-ip`.
@@ -61,6 +61,17 @@ export declare class TokenStore {
61
61
  update(transform: (current: string | null) => string | null): Promise<void>;
62
62
  /** Stores `next` only if `expected` is still stored, as one operation. */
63
63
  replaceIf(expected: string, next: string): Promise<void>;
64
+ /**
65
+ * Runs `operation` only while `expected` is the stored token (null: none
66
+ * is stored), inside this
67
+ * store's queue, and says whether it ran: no write to this store lands
68
+ * between the check and the end of the operation. A refreshed access JWT
69
+ * is kept this way (`http.ts`), so one minted for a session that a
70
+ * sign-in replaced meanwhile never lands on top of the new session's.
71
+ * `operation` must not call this store's public methods (it would wait
72
+ * on itself); another store's are fine.
73
+ */
74
+ whileIs(expected: string | null, operation: () => Promise<void>): Promise<boolean>;
64
75
  /**
65
76
  * Calls `listener` after every write of the token — a sign-in, a
66
77
  * sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
package/dist/client.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AuthClient } from "./auth/client.js";
2
2
  import { type AuthStorage } from "./auth/storage.js";
3
3
  import { type FetchLike } from "./http.js";
4
+ import { FunctionsClient } from "./invoke/client.js";
4
5
  import { TableBuilder } from "./query/builder.js";
5
6
  import type { RealtimeChannel } from "./realtime/channel.js";
6
7
  import type { RealtimeChannelOptions, RealtimeOptions, RealtimeResult } from "./realtime/types.js";
@@ -32,9 +33,17 @@ type CommonOptions = {
32
33
  */
33
34
  realtime?: RealtimeOptions;
34
35
  };
36
+ /**
37
+ * The token a client sends, from your code (design F20) — a Naro function's
38
+ * caller, or a token your own auth gives — read for every call; null (or
39
+ * undefined, or "") sends none. The client then stores, renews and clears
40
+ * nothing, and `auth` and `channel()` are not available.
41
+ */
42
+ export type AccessToken = () => string | null | undefined | Promise<string | null | undefined>;
35
43
  export type ClientOptions = CommonOptions & ({
36
44
  anonKey: string;
37
45
  serviceKey?: undefined;
46
+ accessToken?: AccessToken;
38
47
  }
39
48
  /**
40
49
  * Server only: skips every policy (spec §7). Refused unless the runtime
@@ -43,6 +52,7 @@ export type ClientOptions = CommonOptions & ({
43
52
  | {
44
53
  serviceKey: string;
45
54
  anonKey?: undefined;
55
+ accessToken?: undefined;
46
56
  });
47
57
  /** User-facing; quoted in spec §15's "(P1-4 구현 결정)". */
48
58
  export declare const CONFIG_MESSAGES: {
@@ -53,6 +63,8 @@ export declare const CONFIG_MESSAGES: {
53
63
  readonly serviceKeyOffServer: "A Naro service key (naro_sk_…) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_…) here and keep the service key on your server.";
54
64
  readonly fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).";
55
65
  readonly realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647.";
66
+ readonly accessTokenWithServiceKey: "Pass createClient an accessToken or a serviceKey, not both: the service key skips every policy, so the user's token would not count.";
67
+ readonly accessToken: "createClient's accessToken is a function that returns the user's access token, or null for none.";
56
68
  };
57
69
  export declare class NaroClient<Database extends DatabaseShape<Database>> {
58
70
  #private;
@@ -67,6 +79,11 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
67
79
  * them, as supabase-js does.
68
80
  */
69
81
  readonly storage: StorageClient;
82
+ /**
83
+ * The project's Naro Functions (design §9): `functions.invoke(name, {
84
+ * body })`, as in supabase-js.
85
+ */
86
+ readonly functions: FunctionsClient;
70
87
  constructor(options: ClientOptions);
71
88
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
72
89
  from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
@@ -88,8 +105,10 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
88
105
  }
89
106
  /**
90
107
  * The one entry point (spec §15). Throws only for a configuration mistake —
91
- * no url or not an origin, no key, or a service key off a server; every call
92
- * on the client resolves to `{ data, error }` instead.
108
+ * no url or not an origin, no key, a service key off a server, or an
109
+ * accessToken beside a service key or not a function. On a client made with
110
+ * accessToken, `naro.auth` and `naro.channel()` throw when used, not here.
111
+ * Every other call on the client resolves to `{ data, error }` instead.
93
112
  */
94
113
  export declare function createClient<Database extends DatabaseShape<Database> = UntypedDatabase>(options: ClientOptions): NaroClient<Database>;
95
114
  export {};
package/dist/errors.d.ts CHANGED
@@ -92,6 +92,12 @@ export declare function invalidChannel(reason: string): NaroError;
92
92
  export declare function invalidChannelConfig(path: string, reason: string): NaroError;
93
93
  /** `createClient`'s refusal of a service key off a server, for the call that reaches the socket late. */
94
94
  export declare function serviceKeyOffServer(): NaroError;
95
+ /** The accessToken function threw, rejected, or gave what is not a string: nothing is sent. */
96
+ export declare function accessTokenUnavailable(cause: unknown): NaroError;
97
+ /** `naro.auth.<name>` on such a client. */
98
+ export declare function accessTokenAuthMessage(name: string): string;
99
+ /** `naro.channel()` on such a client: the hub takes a session token, which it has none of. */
100
+ export declare const ACCESS_TOKEN_CHANNEL_MESSAGE = "naro.channel() is not available on a client made with accessToken: Realtime signs in with a session, not an access token. Broadcast from a function with naroAdmin.";
95
101
  /**
96
102
  * The hub's NOT_JOINED, raised before sending. A name that passed
97
103
  * channelNameProblem has at most 128 code points, which the hub quotes
@@ -0,0 +1,13 @@
1
+ export declare const WITH_NARO_MESSAGES: {
2
+ readonly outside: "withNaro can only run inside a Naro function.";
3
+ };
4
+ /**
5
+ * The caller's token for `naro`: the Authorization value with `Bearer `
6
+ * (any case) taken off, as the gate reads it (Naro worker `stripBearer`) —
7
+ * none when it is empty or one of the project's keys, and none when it is
8
+ * not printable ASCII without spaces. That last rule turns away another
9
+ * scheme (a webhook's `Basic …` has a space after the scheme) and anything
10
+ * `createClient`'s `accessToken` check would refuse — which would end every
11
+ * `naro` call in ACCESS_TOKEN_UNAVAILABLE instead of going as anon.
12
+ */
13
+ export declare function callerToken(request: Request): string | null;
@@ -0,0 +1,65 @@
1
+ import { type DatabaseShape, type NaroClient, type UntypedDatabase } from "./index.js";
2
+ export type NaroFunctionRole = "anon" | "authenticated" | "service";
3
+ /** The caller as the access token says (design F7): the email only once confirmed. */
4
+ export type NaroFunctionUser = {
5
+ id: string;
6
+ email: string | null;
7
+ emailVerified: boolean;
8
+ };
9
+ export type NaroFunctionContext<Database extends DatabaseShape<Database> = UntypedDatabase> = {
10
+ /** The signed-in caller, or null. */
11
+ user: NaroFunctionUser | null;
12
+ /** null when the function was deployed with --no-verify-jwt. */
13
+ role: NaroFunctionRole | null;
14
+ traceId: string;
15
+ /** The anon key and the caller's token: the tables' policies apply. */
16
+ naro: NaroClient<Database>;
17
+ /** The service key: skips every policy. Made the first time it is read. */
18
+ readonly naroAdmin: NaroClient<Database>;
19
+ };
20
+ export type NaroFunctionHandler<Database extends DatabaseShape<Database> = UntypedDatabase> = (request: Request, context: NaroFunctionContext<Database>) => Response | Promise<Response>;
21
+ /**
22
+ * Wraps a function's handler: `Deno.serve(withNaro(async (req, { user,
23
+ * naro }) => …))`, or `export default { fetch: withNaro(…) }`. Outside a
24
+ * Naro function — no context for the request — it refuses before the
25
+ * handler runs.
26
+ */
27
+ export declare function withNaro<Database extends DatabaseShape<Database> = UntypedDatabase>(handler: NaroFunctionHandler<Database>): (request: Request, ...rest: unknown[]) => Promise<Response>;
28
+ declare global {
29
+ namespace Deno {
30
+ interface ServeHandlerInfo {
31
+ remoteAddr: {
32
+ transport: "tcp";
33
+ hostname: string;
34
+ port: number;
35
+ };
36
+ completed: Promise<void>;
37
+ }
38
+ type ServeHandler = (request: Request, info: ServeHandlerInfo) => Response | Promise<Response>;
39
+ interface ServeOptions {
40
+ port?: number;
41
+ hostname?: string;
42
+ signal?: AbortSignal;
43
+ onListen?: (address: unknown) => void;
44
+ onError?: (error: unknown) => Response | Promise<Response>;
45
+ }
46
+ interface HttpServer {
47
+ finished: Promise<void>;
48
+ shutdown(): Promise<void>;
49
+ }
50
+ function serve(handler: ServeHandler): HttpServer;
51
+ function serve(options: ServeOptions, handler: ServeHandler): HttpServer;
52
+ function serve(options: ServeOptions & {
53
+ handler: ServeHandler;
54
+ }): HttpServer;
55
+ interface Env {
56
+ get(name: string): string | undefined;
57
+ has(name: string): boolean;
58
+ toObject(): Record<string, string>;
59
+ }
60
+ const env: Env;
61
+ }
62
+ var EdgeRuntime: {
63
+ waitUntil<T>(promise: Promise<T>): Promise<T>;
64
+ };
65
+ }
@@ -0,0 +1,74 @@
1
+ // src/functions-caller.ts
2
+ var WITH_NARO_MESSAGES = {
3
+ outside: "withNaro can only run inside a Naro function."
4
+ };
5
+ var KEY_PREFIXES = ["naro_pk_", "naro_sk_"];
6
+ function callerToken(request) {
7
+ const authorization = request.headers.get("authorization");
8
+ if (authorization === null) return null;
9
+ const token = authorization.replace(/^Bearer\s+/i, "");
10
+ if (token === "" || /^Bearer$/i.test(token)) return null;
11
+ if (!/^[\x21-\x7E]+$/.test(token)) return null;
12
+ return KEY_PREFIXES.some((prefix) => token.startsWith(prefix)) ? null : token;
13
+ }
14
+
15
+ // src/functions.ts
16
+ import {
17
+ createClient
18
+ } from "./index.js";
19
+ var CONTEXTS = Symbol.for("naro.functions.context");
20
+ var RUNTIME = Symbol.for("naro.functions.runtime");
21
+ function isCall(value) {
22
+ return typeof value === "object" && value !== null && typeof value.traceId === "string";
23
+ }
24
+ function callOf(request) {
25
+ const globals = globalThis;
26
+ const contexts = globals[CONTEXTS];
27
+ const mapped = contexts instanceof WeakMap ? contexts.get(request) : void 0;
28
+ if (mapped !== void 0) return mapped;
29
+ const runtime = globals[RUNTIME];
30
+ const store = runtime?.requests?.getStore?.();
31
+ if (isCall(store?.call)) {
32
+ const traceId = request.headers.get("x-naro-trace-id");
33
+ if (traceId === null || traceId === store.call.traceId) return store.call;
34
+ }
35
+ throw new Error(WITH_NARO_MESSAGES.outside);
36
+ }
37
+ function binding(name) {
38
+ const env = globalThis.Deno?.env;
39
+ const value = typeof env?.get === "function" ? env.get(name) : void 0;
40
+ if (typeof value !== "string" || value === "")
41
+ throw new Error(WITH_NARO_MESSAGES.outside);
42
+ return value;
43
+ }
44
+ function withNaro(handler) {
45
+ return async (request) => {
46
+ const call = callOf(request);
47
+ const url = binding("NARO_URL");
48
+ const anonKey = binding("NARO_ANON_KEY");
49
+ const token = callerToken(request);
50
+ const naro = createClient({
51
+ url,
52
+ anonKey,
53
+ accessToken: () => token
54
+ });
55
+ let admin;
56
+ const context = {
57
+ user: call.user === null ? null : { ...call.user },
58
+ role: call.role,
59
+ traceId: call.traceId,
60
+ naro,
61
+ get naroAdmin() {
62
+ admin ??= createClient({
63
+ url,
64
+ serviceKey: binding("NARO_SERVICE_KEY")
65
+ });
66
+ return admin;
67
+ }
68
+ };
69
+ return handler(request, context);
70
+ };
71
+ }
72
+ export {
73
+ withNaro
74
+ };
package/dist/http.d.ts CHANGED
@@ -7,6 +7,10 @@ import { type NaroError } from "./errors.js";
7
7
  * unbound `window.fetch` works.
8
8
  */
9
9
  export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
10
+ /** Where a client made with `accessToken` reads its token: a string, or null for none. */
11
+ export type AccessTokenSource = {
12
+ get(): Promise<string | null>;
13
+ };
10
14
  export type HttpConfig = {
11
15
  /** The project URL without a trailing slash. */
12
16
  baseUrl: string;
@@ -20,7 +24,32 @@ export type HttpConfig = {
20
24
  * Without it (not signed in) those requests go as anon.
21
25
  */
22
26
  accessJwt?: TokenStore;
27
+ /**
28
+ * Made with the service key: the worker does not read a bearer token on
29
+ * its requests, so the access JWT is never renewed for them.
30
+ */
31
+ service?: boolean;
32
+ /**
33
+ * `createClient({ accessToken })` (design F20): the bearer of every data,
34
+ * storage and function request comes from the app's function, read for
35
+ * each one. Nothing is stored, renewed or cleared; `accessJwt` is then
36
+ * absent.
37
+ */
38
+ accessToken?: AccessTokenSource;
23
39
  };
40
+ /**
41
+ * A request that carries the access JWT renews it this long before its
42
+ * `exp`, so it does not expire on the way or on a clock a little ahead of
43
+ * this one. The worker mints it for 15 minutes.
44
+ */
45
+ export declare const ACCESS_JWT_MARGIN_MS = 60000;
46
+ /**
47
+ * The `exp` of a JWT in epoch milliseconds, read without checking the
48
+ * signature — only to tell when to renew it; the worker verifies it.
49
+ * Null when it cannot be read: such a token goes as it is, and the worker's
50
+ * INVALID_SESSION, if it comes, renews it (`call`, `send`).
51
+ */
52
+ export declare function expiryOf(jwt: string): number | null;
24
53
  export type CallOptions = {
25
54
  method: "GET" | "POST" | "PUT" | "DELETE";
26
55
  /** Below `/_naro/v1`, e.g. `/data/query`. */
@@ -36,7 +65,9 @@ export type CallOptions = {
36
65
  withToken: boolean;
37
66
  /**
38
67
  * Give up after this long: the request is aborted (where the runtime has
39
- * AbortController) and the call answers TIMEOUT. None by default.
68
+ * AbortController) and the call answers TIMEOUT. It is the whole call's —
69
+ * a token renewal and the one retry count in it (known-issues #339).
70
+ * None by default.
40
71
  */
41
72
  timeoutMs?: number;
42
73
  };
@@ -62,7 +93,10 @@ export type CallResult = {
62
93
  * fetch, an unreadable body and a worker error all come back as `error`.
63
94
  *
64
95
  * An INVALID_SESSION answer from any route clears the stored token (spec
65
- * §11.1), but only if it is still the token this request carried.
96
+ * §11.1), but only if it is still the token this request carried. A Data
97
+ * or Storage request renews its access JWT before it goes when the JWT is
98
+ * about to expire (`accessJwtFor`), and once more after the worker refused
99
+ * it (`renewsOn`).
66
100
  */
67
101
  export declare function call(config: HttpConfig, options: CallOptions): Promise<CallResult>;
68
102
  export type SendOptions = {
@@ -99,6 +133,57 @@ export type SendResult = {
99
133
  * envelope, or INVALID_RESPONSE — and every case resolves.
100
134
  */
101
135
  export declare function send(config: HttpConfig, options: SendOptions): Promise<SendResult>;
136
+ export type UnreadOptions = {
137
+ method: string;
138
+ /** Below `/_naro/v1`, e.g. `/functions/hello`. */
139
+ path: string;
140
+ /**
141
+ * The caller's headers. `apikey` is always the client's; an
142
+ * `authorization` of the caller's goes as it is, and the client's token
143
+ * is then neither sent nor renewed nor cleared for it (plan P3).
144
+ */
145
+ headers: Headers;
146
+ body?: BodyInit | undefined;
147
+ /**
148
+ * How long the whole call waits — renewals and the one retry included —
149
+ * up to the final answer's headers; the body is never timed.
150
+ */
151
+ timeoutMs?: number | undefined;
152
+ /** The caller's: calls the request off, and the body of an answer that came. */
153
+ signal?: AbortSignal | undefined;
154
+ /**
155
+ * Waited for before anything else, within the same deadline and signal —
156
+ * `auth.ready()`, a sign-in the page URL carried. Stopping the wait does
157
+ * not stop what it waits for: the exchange goes on, as a renewal does.
158
+ */
159
+ ready?: (() => Promise<unknown>) | undefined;
160
+ };
161
+ export type UnreadResult = {
162
+ response: Response;
163
+ } | {
164
+ failed: "network" | "timeout" | "aborted";
165
+ cause: unknown;
166
+ }
167
+ /** Not sent: the token it needed could not be had (a renewal's error). */
168
+ | {
169
+ refused: NaroError;
170
+ };
171
+ /**
172
+ * The third primitive (design F20), for a function's answer, which is the
173
+ * function's own — any status, any body: the response comes back unread.
174
+ * Identity and cookies are as `call`'s: `apikey`, the access JWT (renewed
175
+ * before it goes when about to expire), `credentials: "omit"`. A stored JWT
176
+ * is cleared only on Naro's own `401 INVALID_SESSION` — `x-relay-error:
177
+ * true` — never on a function's answer that happens to say the same; then
178
+ * it is renewed and the request sent once more, unless its body is a
179
+ * stream (read once) or the session changed meanwhile (`retryBearer`).
180
+ * The caller's `signal` answers aborted at once, a renewal in flight
181
+ * included (`untilStopped`; `send` stops the same way with `untilAborted`);
182
+ * `timeoutMs` is the whole call's
183
+ * (known-issues #339): the wait for `ready`, the renewals, both sends and
184
+ * the reading of a refusal share it, each send ending at its headers.
185
+ */
186
+ export declare function fetchUnread(config: HttpConfig, options: UnreadOptions): Promise<UnreadResult>;
102
187
  /**
103
188
  * The worker's 429s that say how long to wait. `RATE_LIMITED` is the
104
189
  * sign-in routes' per-address limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the
@@ -106,3 +191,11 @@ export declare function send(config: HttpConfig, options: SendOptions): Promise<
106
191
  * and creating or deleting users wait for 00:00 UTC, reads still work.
107
192
  */
108
193
  export declare const RETRY_AFTER_CODES: ReadonlySet<string>;
194
+ /**
195
+ * The sign-in limiter says in `X-Retry-After` how many seconds to wait, and
196
+ * the worker keeps the header on its 429 — and sends the same header, the
197
+ * seconds to 00:00 UTC, with WRITE_QUOTA_EXCEEDED. It becomes `details.retryAfter`
198
+ * (seconds), beside any details the worker sent. The worker lists it in
199
+ * Access-Control-Expose-Headers, so a page on another origin reads it too.
200
+ */
201
+ export declare function withRetryAfter(error: NaroError, headers: Headers): NaroError;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- export { createClient, NaroClient, type ClientOptions } from "./client.js";
1
+ export { createClient, NaroClient, type AccessToken, type ClientOptions, } from "./client.js";
2
+ export { FunctionsError, FunctionsFetchError, FunctionsHttpError, FunctionsRelayError, } from "./invoke/errors.js";
3
+ export type { FunctionInvokeOptions, FunctionsClient, FunctionsResponse, } from "./invoke/client.js";
2
4
  export type { AuthAdminApi } from "./auth/admin.js";
3
5
  export type { AuthClient } from "./auth/client.js";
4
6
  export type { AuthStorage } from "./auth/storage.js";