@loomup/astro 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Loomup contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @loomup/astro
2
+
3
+ Astro integration and SSR helpers for [Loomup](https://tryloomup.com), built on
4
+ `@loomup/client`.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @loomup/astro @loomup/client
10
+ ```
11
+
12
+ Astro 4 or newer is supported.
13
+
14
+ ## Integration
15
+
16
+ ```js
17
+ // astro.config.mjs
18
+ import { defineConfig } from "astro/config";
19
+ import loomup from "@loomup/astro";
20
+
21
+ export default defineConfig({
22
+ integrations: [
23
+ loomup({
24
+ url: process.env.LOOMUP_URL ?? "http://127.0.0.1:3000",
25
+ }),
26
+ ],
27
+ });
28
+ ```
29
+
30
+ ## Server-side client
31
+
32
+ ```astro
33
+ ---
34
+ import { createServerClient } from "@loomup/astro/server";
35
+
36
+ const loomup = createServerClient(Astro.cookies, {
37
+ url: import.meta.env.LOOMUP_URL,
38
+ });
39
+ const { data: todos } = await loomup.from("todos").select({ limit: 20 });
40
+ ---
41
+
42
+ <ul>
43
+ {todos.map((todo) => <li>{todo.title}</li>)}
44
+ </ul>
45
+ ```
46
+
47
+ Package exports:
48
+
49
+ - `@loomup/astro` — Astro integration.
50
+ - `@loomup/astro/server` — cookie-backed server client and storage helpers.
51
+ - `@loomup/astro/client` — browser client for Astro islands.
52
+ - `@loomup/astro/middleware` — authentication middleware.
53
+ - `@loomup/astro/auth` — lower-level cookie authentication helpers.
54
+
55
+ See the [Astro SDK guide](https://tryloomup.com/docs) for middleware,
56
+ authenticated islands, object storage, and deployment guidance.
57
+
58
+ ## License
59
+
60
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** Same-origin Astro auth endpoint for Loomup-backed applications. */
2
+ import { type CookieStore, type CreateServerClientOptions } from "./server.js";
3
+ export type LoomupAuthEndpointContext = {
4
+ request: Request;
5
+ cookies: CookieStore;
6
+ params?: Record<string, string | undefined>;
7
+ };
8
+ export type LoomupAuthHandlerOptions = CreateServerClientOptions & {
9
+ /** Catch-all Astro parameter name. Default: `loomup`. */
10
+ param?: string;
11
+ };
12
+ /**
13
+ * Create one Astro catch-all endpoint for login, logout, session hydration,
14
+ * refresh, registration, and password reset.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * // src/pages/api/loomup/[...loomup].ts
19
+ * import { createLoomupAuthHandler } from "@loomup/astro/auth";
20
+ * export const prerender = false;
21
+ * export const ALL = createLoomupAuthHandler({ url: import.meta.env.LOOMUP_URL });
22
+ * ```
23
+ */
24
+ export declare function createLoomupAuthHandler(options?: LoomupAuthHandlerOptions): (context: LoomupAuthEndpointContext) => Promise<Response>;
package/dist/auth.js ADDED
@@ -0,0 +1,134 @@
1
+ /** Same-origin Astro auth endpoint for Loomup-backed applications. */
2
+ import { LoomupError } from "@loomup/client";
3
+ import { createServerClient, } from "./server.js";
4
+ function response(data, status = 200) {
5
+ return Response.json(data, {
6
+ status,
7
+ headers: { "Cache-Control": "private, no-store" },
8
+ });
9
+ }
10
+ async function jsonBody(request) {
11
+ try {
12
+ const value = await request.json();
13
+ if (!value || typeof value !== "object" || Array.isArray(value))
14
+ throw new Error();
15
+ return value;
16
+ }
17
+ catch {
18
+ throw new LoomupError("request body must be a JSON object", "invalid_input", 400);
19
+ }
20
+ }
21
+ function assertSameOrigin(request) {
22
+ const origin = request.headers.get("Origin");
23
+ if (origin && origin !== new URL(request.url).origin) {
24
+ throw new LoomupError("cross-origin auth mutation rejected", "forbidden", 403);
25
+ }
26
+ }
27
+ function publicSession(user, accessToken) {
28
+ return { data: { user, access_token: accessToken } };
29
+ }
30
+ /**
31
+ * Create one Astro catch-all endpoint for login, logout, session hydration,
32
+ * refresh, registration, and password reset.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * // src/pages/api/loomup/[...loomup].ts
37
+ * import { createLoomupAuthHandler } from "@loomup/astro/auth";
38
+ * export const prerender = false;
39
+ * export const ALL = createLoomupAuthHandler({ url: import.meta.env.LOOMUP_URL });
40
+ * ```
41
+ */
42
+ export function createLoomupAuthHandler(options = {}) {
43
+ const param = options.param ?? "loomup";
44
+ return async (context) => {
45
+ const action = context.params?.[param]?.replace(/^\/+|\/+$/g, "") ?? "";
46
+ const client = createServerClient(context.cookies, options);
47
+ try {
48
+ switch (action) {
49
+ case "session": {
50
+ if (context.request.method !== "GET") {
51
+ return response({ error: { code: "method_not_allowed" } }, 405);
52
+ }
53
+ const user = await client.auth.me();
54
+ return response(publicSession(user, client.accessToken));
55
+ }
56
+ case "refresh": {
57
+ assertSameOrigin(context.request);
58
+ const tokens = await client.auth.refresh();
59
+ const user = tokens.user ?? (await client.auth.me());
60
+ return response(publicSession(user, tokens.access_token));
61
+ }
62
+ case "login": {
63
+ assertSameOrigin(context.request);
64
+ const body = await jsonBody(context.request);
65
+ const tokens = await client.auth.signIn({
66
+ email: String(body.email ?? ""),
67
+ password: String(body.password ?? ""),
68
+ });
69
+ const user = tokens.user ?? (await client.auth.me());
70
+ return response(publicSession(user, tokens.access_token));
71
+ }
72
+ case "register": {
73
+ assertSameOrigin(context.request);
74
+ const body = await jsonBody(context.request);
75
+ const tokens = await client.auth.signUp({
76
+ email: String(body.email ?? ""),
77
+ password: String(body.password ?? ""),
78
+ });
79
+ const user = tokens.user ?? (await client.auth.me());
80
+ return response(publicSession(user, tokens.access_token), 201);
81
+ }
82
+ case "logout": {
83
+ assertSameOrigin(context.request);
84
+ await client.auth.signOut();
85
+ return response({ data: { ok: true } });
86
+ }
87
+ case "change-password": {
88
+ assertSameOrigin(context.request);
89
+ const body = await jsonBody(context.request);
90
+ const currentPassword = String(body.currentPassword ?? "");
91
+ const newPassword = String(body.newPassword ?? "");
92
+ const user = await client.auth.me();
93
+ await client.request("POST", "/account/api/change-password", {
94
+ current_password: currentPassword,
95
+ new_password: newPassword,
96
+ });
97
+ // The core revokes every refresh session on password change. Issue a
98
+ // fresh current session so the browser does not fail on its next 401.
99
+ const tokens = await client.auth.signIn({ email: user.email, password: newPassword });
100
+ return response(publicSession(tokens.user ?? user, tokens.access_token));
101
+ }
102
+ case "password-reset/request": {
103
+ assertSameOrigin(context.request);
104
+ const body = await jsonBody(context.request);
105
+ await client.request("POST", "/auth/password-reset/request", {
106
+ email: String(body.email ?? ""),
107
+ });
108
+ // Never forward a self-hosted reset token to the browser. Hosted
109
+ // Loomup delivers it out of band.
110
+ return response({
111
+ data: { ok: true, message: "if the account exists, a reset email was sent" },
112
+ });
113
+ }
114
+ case "password-reset/confirm": {
115
+ assertSameOrigin(context.request);
116
+ const body = await jsonBody(context.request);
117
+ await client.request("POST", "/auth/password-reset/confirm", {
118
+ token: String(body.token ?? ""),
119
+ password: String(body.password ?? ""),
120
+ });
121
+ return response({ data: { ok: true } });
122
+ }
123
+ default:
124
+ return response({ error: { code: "not_found", message: "unknown auth action" } }, 404);
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (error instanceof LoomupError) {
129
+ return response({ error: { code: error.code ?? "auth_error", message: error.message } }, error.status ?? 400);
130
+ }
131
+ return response({ error: { code: "internal", message: error instanceof Error ? error.message : String(error) } }, 500);
132
+ }
133
+ };
134
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Browser Loomup client for Astro client islands (`client:load`, etc.).
3
+ */
4
+ import { type CreateClientOptions, type DefaultInsertMap, type DefaultTableMap, type DefaultUpdateMap, type LoomupClient, type LoomupProject, type User } from "@loomup/client";
5
+ export type { AuthTokens, ChangeEvent, ControlEvent, CreateClientOptions, ListMeta, User, } from "@loomup/client";
6
+ export { LoomupError, createClient, makeSubKey, parseSubKey, } from "@loomup/client";
7
+ export type CreateBrowserClientOptions = {
8
+ /**
9
+ * Loomup base URL. Defaults to `import.meta.env.PUBLIC_LOOMUP_URL`
10
+ * (injected by the Astro integration) or `PUBLIC_LOOMUP_URL` process env.
11
+ */
12
+ url?: string;
13
+ token?: string;
14
+ refreshToken?: string;
15
+ WebSocketImpl?: CreateClientOptions["WebSocketImpl"];
16
+ };
17
+ /**
18
+ * Create a browser Loomup client for islands.
19
+ *
20
+ * Realtime (`subscribe` / `subscribeReady`) is supported in the browser.
21
+ * Prefer `createServerClient` for SSR data loads; do not open WebSockets
22
+ * during the server render of a request.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { createBrowserClient } from "@loomup/astro/client";
27
+ * const lb = createBrowserClient();
28
+ * lb.from("todos").subscribe((ev) => console.log(ev));
29
+ * ```
30
+ */
31
+ export declare function createBrowserClient<TMap extends DefaultTableMap = DefaultTableMap, TInsertMap extends DefaultInsertMap = {
32
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
33
+ }, TUpdateMap extends DefaultUpdateMap = {
34
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
35
+ }>(options?: CreateBrowserClientOptions): LoomupClient<TMap, TInsertMap, TUpdateMap>;
36
+ export type CreateAuthenticatedProjectOptions = CreateBrowserClientOptions & {
37
+ /** Same-origin catch-all endpoint. Default: `/api/loomup`. */
38
+ authEndpoint?: string;
39
+ fetch?: typeof fetch;
40
+ };
41
+ export type AuthenticatedProject<TMap, TInsertMap, TUpdateMap> = {
42
+ db: LoomupProject<TMap, TInsertMap, TUpdateMap>;
43
+ user: User;
44
+ signOut(): Promise<void>;
45
+ };
46
+ /**
47
+ * Hydrate the typed `db.issues` project client without exposing the refresh
48
+ * token to JavaScript. Access-token renewal goes through the Astro endpoint.
49
+ */
50
+ export declare function createAuthenticatedProject<TMap = DefaultTableMap, TInsertMap = {
51
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
52
+ }, TUpdateMap = {
53
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
54
+ }>(options?: CreateAuthenticatedProjectOptions): Promise<AuthenticatedProject<TMap, TInsertMap, TUpdateMap>>;
package/dist/client.js ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Browser Loomup client for Astro client islands (`client:load`, etc.).
3
+ */
4
+ import { createClient, createProject, LoomupError, } from "@loomup/client";
5
+ export { LoomupError, createClient, makeSubKey, parseSubKey, } from "@loomup/client";
6
+ function envPublicUrl() {
7
+ try {
8
+ // Astro / Vite client and server modules.
9
+ const meta = import.meta;
10
+ if (meta.env?.PUBLIC_LOOMUP_URL)
11
+ return meta.env.PUBLIC_LOOMUP_URL;
12
+ }
13
+ catch {
14
+ /* ignore */
15
+ }
16
+ if (typeof process !== "undefined" && process.env?.PUBLIC_LOOMUP_URL) {
17
+ return process.env.PUBLIC_LOOMUP_URL;
18
+ }
19
+ return undefined;
20
+ }
21
+ function resolveBrowserUrl(explicit) {
22
+ if (explicit)
23
+ return explicit;
24
+ const fromEnv = envPublicUrl();
25
+ if (fromEnv)
26
+ return fromEnv;
27
+ throw new Error("@loomup/astro: createBrowserClient requires `url` or PUBLIC_LOOMUP_URL (set via the loomup() integration)");
28
+ }
29
+ /**
30
+ * Create a browser Loomup client for islands.
31
+ *
32
+ * Realtime (`subscribe` / `subscribeReady`) is supported in the browser.
33
+ * Prefer `createServerClient` for SSR data loads; do not open WebSockets
34
+ * during the server render of a request.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * import { createBrowserClient } from "@loomup/astro/client";
39
+ * const lb = createBrowserClient();
40
+ * lb.from("todos").subscribe((ev) => console.log(ev));
41
+ * ```
42
+ */
43
+ export function createBrowserClient(options = {}) {
44
+ return createClient({
45
+ url: resolveBrowserUrl(options.url),
46
+ token: options.token,
47
+ refreshToken: options.refreshToken,
48
+ WebSocketImpl: options.WebSocketImpl,
49
+ });
50
+ }
51
+ async function authRequest(fetchImpl, endpoint, action, init) {
52
+ const response = await fetchImpl(`${endpoint.replace(/\/$/, "")}/${action}`, {
53
+ credentials: "same-origin",
54
+ ...init,
55
+ headers: {
56
+ Accept: "application/json",
57
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
58
+ ...(init?.headers ?? {}),
59
+ },
60
+ });
61
+ const payload = (await response.json().catch(() => ({})));
62
+ if (!response.ok) {
63
+ throw new LoomupError(payload.error?.message ?? response.statusText, payload.error?.code, response.status);
64
+ }
65
+ return payload.data ?? {};
66
+ }
67
+ /**
68
+ * Hydrate the typed `db.issues` project client without exposing the refresh
69
+ * token to JavaScript. Access-token renewal goes through the Astro endpoint.
70
+ */
71
+ export async function createAuthenticatedProject(options = {}) {
72
+ const fetchImpl = options.fetch ?? globalThis.fetch;
73
+ const endpoint = options.authEndpoint ?? "/api/loomup";
74
+ const session = await authRequest(fetchImpl, endpoint, "session", { method: "GET" });
75
+ if (!session.user || !session.access_token) {
76
+ throw new LoomupError("authenticated session required", "unauthorized", 401);
77
+ }
78
+ const db = createProject({
79
+ url: resolveBrowserUrl(options.url),
80
+ token: session.access_token,
81
+ WebSocketImpl: options.WebSocketImpl,
82
+ accessTokenProvider: async () => {
83
+ const refreshed = await authRequest(fetchImpl, endpoint, "refresh", { method: "POST" });
84
+ return refreshed.access_token;
85
+ },
86
+ });
87
+ return {
88
+ db,
89
+ user: session.user,
90
+ async signOut() {
91
+ await authRequest(fetchImpl, endpoint, "logout", { method: "POST" });
92
+ db.setToken(undefined);
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cookie helpers for Loomup auth tokens in Astro SSR.
3
+ */
4
+ export declare const DEFAULT_ACCESS_COOKIE = "loomup-access";
5
+ export declare const DEFAULT_REFRESH_COOKIE = "loomup-refresh";
6
+ /** Minimal cookie API compatible with AstroCookies (and easy to mock in tests). */
7
+ export type CookieStore = {
8
+ get(name: string): {
9
+ value: string;
10
+ } | undefined;
11
+ set(name: string, value: string, options?: CookieWriteOptions): void;
12
+ delete(name: string, options?: {
13
+ path?: string;
14
+ }): void;
15
+ };
16
+ export type CookieWriteOptions = {
17
+ path?: string;
18
+ maxAge?: number;
19
+ httpOnly?: boolean;
20
+ secure?: boolean;
21
+ sameSite?: "strict" | "lax" | "none" | boolean;
22
+ expires?: Date;
23
+ };
24
+ export type CookieNames = {
25
+ access: string;
26
+ refresh: string;
27
+ };
28
+ export type CookieOptions = {
29
+ /** Cookie name overrides. */
30
+ names?: Partial<CookieNames>;
31
+ /**
32
+ * Force Secure flag. Default: true when NODE_ENV === "production",
33
+ * or when `secure: true` is passed.
34
+ */
35
+ secure?: boolean;
36
+ /** Cookie path (default "/"). */
37
+ path?: string;
38
+ /**
39
+ * Max-Age for the access token cookie when expires_in is missing.
40
+ * Default 3600 seconds.
41
+ */
42
+ accessMaxAge?: number;
43
+ /**
44
+ * Max-Age for the refresh token cookie.
45
+ * Default 60 * 60 * 24 * 30 (30 days).
46
+ */
47
+ refreshMaxAge?: number;
48
+ };
49
+ export declare function resolveCookieNames(names?: Partial<CookieNames>): CookieNames;
50
+ export declare function isSecureDefault(explicit?: boolean): boolean;
51
+ export declare function readTokens(cookies: CookieStore, names?: Partial<CookieNames>): {
52
+ access?: string;
53
+ refresh?: string;
54
+ };
55
+ export declare function writeTokens(cookies: CookieStore, tokens: {
56
+ access_token: string;
57
+ refresh_token: string;
58
+ expires_in?: number;
59
+ }, options?: CookieOptions): void;
60
+ export declare function clearTokens(cookies: CookieStore, options?: CookieOptions): void;
61
+ /**
62
+ * Adapt AstroCookies (or any compatible object) to CookieStore.
63
+ * Accepts a structural type so tests and non-Astro callers work.
64
+ */
65
+ export declare function asCookieStore(cookies: CookieStore): CookieStore;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cookie helpers for Loomup auth tokens in Astro SSR.
3
+ */
4
+ export const DEFAULT_ACCESS_COOKIE = "loomup-access";
5
+ export const DEFAULT_REFRESH_COOKIE = "loomup-refresh";
6
+ export function resolveCookieNames(names) {
7
+ return {
8
+ access: names?.access ?? DEFAULT_ACCESS_COOKIE,
9
+ refresh: names?.refresh ?? DEFAULT_REFRESH_COOKIE,
10
+ };
11
+ }
12
+ export function isSecureDefault(explicit) {
13
+ if (explicit !== undefined)
14
+ return explicit;
15
+ return (typeof process !== "undefined" &&
16
+ process.env?.NODE_ENV === "production");
17
+ }
18
+ export function readTokens(cookies, names) {
19
+ const n = resolveCookieNames(names);
20
+ return {
21
+ access: cookies.get(n.access)?.value,
22
+ refresh: cookies.get(n.refresh)?.value,
23
+ };
24
+ }
25
+ export function writeTokens(cookies, tokens, options) {
26
+ const n = resolveCookieNames(options?.names);
27
+ const path = options?.path ?? "/";
28
+ const secure = isSecureDefault(options?.secure);
29
+ const accessMaxAge = tokens.expires_in ?? options?.accessMaxAge ?? 3600;
30
+ const refreshMaxAge = options?.refreshMaxAge ?? 60 * 60 * 24 * 30;
31
+ const base = {
32
+ path,
33
+ httpOnly: true,
34
+ secure,
35
+ sameSite: "lax",
36
+ };
37
+ cookies.set(n.access, tokens.access_token, {
38
+ ...base,
39
+ maxAge: accessMaxAge,
40
+ });
41
+ cookies.set(n.refresh, tokens.refresh_token, {
42
+ ...base,
43
+ maxAge: refreshMaxAge,
44
+ });
45
+ }
46
+ export function clearTokens(cookies, options) {
47
+ const n = resolveCookieNames(options?.names);
48
+ const path = options?.path ?? "/";
49
+ cookies.delete(n.access, { path });
50
+ cookies.delete(n.refresh, { path });
51
+ }
52
+ /**
53
+ * Adapt AstroCookies (or any compatible object) to CookieStore.
54
+ * Accepts a structural type so tests and non-Astro callers work.
55
+ */
56
+ export function asCookieStore(cookies) {
57
+ return cookies;
58
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @loomup/astro — Astro integration for Loomup Realtime.
3
+ *
4
+ * Server helpers: `@loomup/astro/server`
5
+ * Browser helpers: `@loomup/astro/client`
6
+ * Middleware: `@loomup/astro/middleware`
7
+ */
8
+ export type LoomupIntegrationOptions = {
9
+ /**
10
+ * Loomup server URL (e.g. http://127.0.0.1:3000).
11
+ * Falls back to process.env.LOOMUP_URL or PUBLIC_LOOMUP_URL.
12
+ */
13
+ url?: string;
14
+ /**
15
+ * When true (default), inject PUBLIC_LOOMUP_URL for browser islands
16
+ * via Vite env.
17
+ */
18
+ injectPublicEnv?: boolean;
19
+ };
20
+ /** Structural Astro integration type (avoids hard dep on astro types at build). */
21
+ export type AstroIntegrationLike = {
22
+ name: string;
23
+ hooks: {
24
+ "astro:config:setup"?: (params: {
25
+ updateConfig: (config: {
26
+ vite?: {
27
+ define?: Record<string, string>;
28
+ envPrefix?: string | string[];
29
+ };
30
+ }) => void;
31
+ logger?: {
32
+ info: (msg: string) => void;
33
+ warn: (msg: string) => void;
34
+ };
35
+ }) => void | Promise<void>;
36
+ };
37
+ };
38
+ /**
39
+ * Astro integration: wires PUBLIC_LOOMUP_URL for `createBrowserClient()`.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * // astro.config.mjs
44
+ * import { defineConfig } from "astro/config";
45
+ * import loomup from "@loomup/astro";
46
+ *
47
+ * export default defineConfig({
48
+ * integrations: [loomup({ url: process.env.LOOMUP_URL })],
49
+ * });
50
+ * ```
51
+ */
52
+ export default function loomup(options?: LoomupIntegrationOptions): AstroIntegrationLike;
53
+ export type { LoomupIntegrationOptions as LoomupOptions };
package/dist/index.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @loomup/astro — Astro integration for Loomup Realtime.
3
+ *
4
+ * Server helpers: `@loomup/astro/server`
5
+ * Browser helpers: `@loomup/astro/client`
6
+ * Middleware: `@loomup/astro/middleware`
7
+ */
8
+ function resolveUrl(options) {
9
+ if (options?.url)
10
+ return options.url.replace(/\/$/, "");
11
+ if (typeof process !== "undefined") {
12
+ const v = process.env?.LOOMUP_URL || process.env?.PUBLIC_LOOMUP_URL;
13
+ if (v)
14
+ return v.replace(/\/$/, "");
15
+ }
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Astro integration: wires PUBLIC_LOOMUP_URL for `createBrowserClient()`.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * // astro.config.mjs
24
+ * import { defineConfig } from "astro/config";
25
+ * import loomup from "@loomup/astro";
26
+ *
27
+ * export default defineConfig({
28
+ * integrations: [loomup({ url: process.env.LOOMUP_URL })],
29
+ * });
30
+ * ```
31
+ */
32
+ export default function loomup(options = {}) {
33
+ const inject = options.injectPublicEnv !== false;
34
+ return {
35
+ name: "@loomup/astro",
36
+ hooks: {
37
+ "astro:config:setup"({ updateConfig, logger }) {
38
+ const url = resolveUrl(options);
39
+ if (!url) {
40
+ logger?.warn("@loomup/astro: no url configured (pass loomup({ url }) or set LOOMUP_URL). Browser client will need an explicit url.");
41
+ return;
42
+ }
43
+ if (!inject)
44
+ return;
45
+ // Vite define values must be JSON-serialized so they become string literals.
46
+ updateConfig({
47
+ vite: {
48
+ define: {
49
+ "import.meta.env.PUBLIC_LOOMUP_URL": JSON.stringify(url),
50
+ },
51
+ },
52
+ });
53
+ logger?.info(`@loomup/astro: PUBLIC_LOOMUP_URL → ${url}`);
54
+ },
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Optional Astro middleware helper: refresh session cookies and attach user.
3
+ */
4
+ import { type CreateServerClientOptions, type CookieStore } from "./server.js";
5
+ /** Minimal middleware context (Astro APIMiddlewareContext subset). */
6
+ export type LoomupMiddlewareContext = {
7
+ cookies: CookieStore;
8
+ locals: Record<string, unknown>;
9
+ };
10
+ export type LoomupMiddlewareOptions = CreateServerClientOptions & {
11
+ /**
12
+ * When true (default), call `auth.me()` after a successful token setup and
13
+ * set `locals.user` / `locals.loomup`. Failures clear cookies.
14
+ */
15
+ loadUser?: boolean;
16
+ /** Locals key for the user object (default "user"). */
17
+ userKey?: string;
18
+ /** Locals key for the Loomup client (default "loomup"). */
19
+ clientKey?: string;
20
+ };
21
+ export type MiddlewareNext = () => Promise<Response>;
22
+ /**
23
+ * Returns an Astro-compatible middleware function.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // src/middleware.ts
28
+ * import { defineMiddleware } from "astro:middleware";
29
+ * import { createLoomupMiddleware } from "@loomup/astro/middleware";
30
+ *
31
+ * const loomupMw = createLoomupMiddleware({
32
+ * url: import.meta.env.LOOMUP_URL,
33
+ * });
34
+ *
35
+ * export const onRequest = defineMiddleware((context, next) =>
36
+ * loomupMw(context, next),
37
+ * );
38
+ * ```
39
+ */
40
+ export declare function createLoomupMiddleware(options?: LoomupMiddlewareOptions): (context: LoomupMiddlewareContext, next: MiddlewareNext) => Promise<Response>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Optional Astro middleware helper: refresh session cookies and attach user.
3
+ */
4
+ import { createServerClient, } from "./server.js";
5
+ /**
6
+ * Returns an Astro-compatible middleware function.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * // src/middleware.ts
11
+ * import { defineMiddleware } from "astro:middleware";
12
+ * import { createLoomupMiddleware } from "@loomup/astro/middleware";
13
+ *
14
+ * const loomupMw = createLoomupMiddleware({
15
+ * url: import.meta.env.LOOMUP_URL,
16
+ * });
17
+ *
18
+ * export const onRequest = defineMiddleware((context, next) =>
19
+ * loomupMw(context, next),
20
+ * );
21
+ * ```
22
+ */
23
+ export function createLoomupMiddleware(options = {}) {
24
+ const loadUser = options.loadUser !== false;
25
+ const userKey = options.userKey ?? "user";
26
+ const clientKey = options.clientKey ?? "loomup";
27
+ return async (context, next) => {
28
+ const client = createServerClient(context.cookies, options);
29
+ context.locals[clientKey] = client;
30
+ const hasAccess = Boolean(client.accessToken);
31
+ const hasRefresh = Boolean(
32
+ // refresh is private; re-read from cookies via a no-op path:
33
+ // createServerClient already loaded tokens — try refresh if no access.
34
+ options.refreshToken ||
35
+ context.cookies.get(options.cookies?.names?.refresh ?? "loomup-refresh")?.value);
36
+ if (!hasAccess && hasRefresh) {
37
+ try {
38
+ await client.auth.refresh();
39
+ }
40
+ catch {
41
+ await client.auth.signOut().catch(() => { });
42
+ context.locals[userKey] = undefined;
43
+ return next();
44
+ }
45
+ }
46
+ if (loadUser && client.accessToken) {
47
+ try {
48
+ const user = await client.auth.me();
49
+ context.locals[userKey] = user;
50
+ }
51
+ catch {
52
+ // Stale access token — try refresh once, then clear.
53
+ try {
54
+ if (hasRefresh) {
55
+ await client.auth.refresh();
56
+ context.locals[userKey] = await client.auth.me();
57
+ }
58
+ else {
59
+ await client.auth.signOut().catch(() => { });
60
+ context.locals[userKey] = undefined;
61
+ }
62
+ }
63
+ catch {
64
+ await client.auth.signOut().catch(() => { });
65
+ context.locals[userKey] = undefined;
66
+ }
67
+ }
68
+ }
69
+ return next();
70
+ };
71
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Server-side object storage helpers for Astro endpoints / SSR.
3
+ * ServerLoomupClient extends LoomupClient and already has `.storage`.
4
+ */
5
+ import type { LoomupClient, StorageObject, StorageUploadOptions } from "@loomup/client";
6
+ export type UploadFormDataOptions = StorageUploadOptions & {
7
+ fileField?: string;
8
+ path?: string;
9
+ pathField?: string;
10
+ pathPrefix?: string;
11
+ };
12
+ export declare function fileAndPathFromFormData(form: FormData, options?: UploadFormDataOptions): {
13
+ path: string;
14
+ file: Blob;
15
+ contentType?: string;
16
+ };
17
+ export declare function uploadFromFormData(client: LoomupClient, bucket: string, form: FormData, options?: UploadFormDataOptions): Promise<StorageObject>;
18
+ export declare function storageDownloadResponse(client: LoomupClient, bucket: string, path: string, init?: ResponseInit): Promise<Response>;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Server-side object storage helpers for Astro endpoints / SSR.
3
+ * ServerLoomupClient extends LoomupClient and already has `.storage`.
4
+ */
5
+ function isFileLike(v) {
6
+ return (typeof Blob !== "undefined" &&
7
+ v instanceof Blob &&
8
+ typeof v.arrayBuffer === "function");
9
+ }
10
+ export function fileAndPathFromFormData(form, options) {
11
+ const fileField = options?.fileField ?? "file";
12
+ const pathField = options?.pathField ?? "path";
13
+ const raw = form.get(fileField);
14
+ if (!isFileLike(raw)) {
15
+ throw new Error(`@loomup/astro: FormData field "${fileField}" must be a File/Blob`);
16
+ }
17
+ let path = options?.path ??
18
+ (typeof form.get(pathField) === "string"
19
+ ? String(form.get(pathField))
20
+ : undefined) ??
21
+ (typeof raw.name === "string" && raw.name ? raw.name : undefined);
22
+ if (!path || !path.trim()) {
23
+ throw new Error(`@loomup/astro: object path required (pass options.path, form field "${pathField}", or a named File)`);
24
+ }
25
+ path = path.replace(/^\/+/, "");
26
+ if (options?.pathPrefix) {
27
+ const prefix = options.pathPrefix.endsWith("/")
28
+ ? options.pathPrefix
29
+ : `${options.pathPrefix}/`;
30
+ path = `${prefix}${path}`;
31
+ }
32
+ const contentType = options?.contentType ??
33
+ (raw.type && raw.type.length > 0 ? raw.type : undefined);
34
+ return { path, file: raw, contentType };
35
+ }
36
+ export async function uploadFromFormData(client, bucket, form, options) {
37
+ const { path, file, contentType } = fileAndPathFromFormData(form, options);
38
+ return client.storage.from(bucket).upload(path, file, {
39
+ contentType,
40
+ upsert: options?.upsert,
41
+ });
42
+ }
43
+ export async function storageDownloadResponse(client, bucket, path, init) {
44
+ const upstream = await client.storage.from(bucket).downloadResponse(path);
45
+ const headers = new Headers(init?.headers);
46
+ for (const name of [
47
+ "content-type",
48
+ "content-length",
49
+ "etag",
50
+ "cache-control",
51
+ "content-disposition",
52
+ ]) {
53
+ const v = upstream.headers.get(name);
54
+ if (v && !headers.has(name))
55
+ headers.set(name, v);
56
+ }
57
+ return new Response(upstream.body, {
58
+ status: init?.status ?? upstream.status,
59
+ statusText: init?.statusText ?? upstream.statusText,
60
+ headers,
61
+ });
62
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Server-side Loomup client for Astro (SSR frontmatter, endpoints, middleware).
3
+ * Persists access/refresh tokens in httpOnly cookies.
4
+ */
5
+ import { LoomupClient, type AuthTokens, type CreateClientOptions, type DefaultTableMap, type LoomupProject } from "@loomup/client";
6
+ import { type CookieOptions, type CookieStore } from "./cookies.js";
7
+ export type { CookieNames, CookieOptions, CookieStore, CookieWriteOptions, } from "./cookies.js";
8
+ export { DEFAULT_ACCESS_COOKIE, DEFAULT_REFRESH_COOKIE, clearTokens, readTokens, resolveCookieNames, writeTokens, } from "./cookies.js";
9
+ export type { AuthTokens, ChangeEvent, ControlEvent, CreateClientOptions, ListMeta, User, StorageObject, StorageBucketInfo, StorageUploadOptions, StorageListOptions, StorageListResult, StorageUploadBody, } from "@loomup/client";
10
+ export { LoomupError, createClient, StorageBucket, encodeObjectPath, normalizeStorageUpload, } from "@loomup/client";
11
+ export { fileAndPathFromFormData, uploadFromFormData, storageDownloadResponse, type UploadFormDataOptions, } from "./objectStorage.js";
12
+ export type CreateServerClientOptions = {
13
+ /**
14
+ * Loomup base URL. Defaults to LOOMUP_URL or PUBLIC_LOOMUP_URL env.
15
+ */
16
+ url?: string;
17
+ /** Override initial tokens (otherwise read from cookies). */
18
+ token?: string;
19
+ refreshToken?: string;
20
+ /** Cookie naming and security options. */
21
+ cookies?: CookieOptions;
22
+ /**
23
+ * Extra createClient options (e.g. WebSocketImpl if you ever use
24
+ * realtime on the server — not recommended for request-scoped SSR).
25
+ */
26
+ client?: Omit<CreateClientOptions, "url" | "token" | "refreshToken">;
27
+ };
28
+ /**
29
+ * Cookie-backed Loomup client for Astro SSR.
30
+ * Extends the core client so `.from()`, `.request()`, etc. stay identical.
31
+ * Auth methods and automatic refresh write tokens back to cookies.
32
+ */
33
+ export declare class ServerLoomupClient<TMap = DefaultTableMap, TInsertMap = {
34
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
35
+ }, TUpdateMap = {
36
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
37
+ }> extends LoomupClient<TMap, TInsertMap, TUpdateMap> {
38
+ private readonly cookieStore;
39
+ private readonly cookieOptions?;
40
+ /** Last known refresh token (core field is private; we mirror for cookie writes). */
41
+ private mirroredRefresh?;
42
+ constructor(cookieStore: CookieStore, options: CreateClientOptions & {
43
+ cookieOptions?: CookieOptions;
44
+ });
45
+ private persistFromTokens;
46
+ private clearCookieTokens;
47
+ signUp(creds: {
48
+ email: string;
49
+ password: string;
50
+ }): Promise<AuthTokens>;
51
+ signIn(creds: {
52
+ email: string;
53
+ password: string;
54
+ }): Promise<AuthTokens>;
55
+ refresh(): Promise<AuthTokens>;
56
+ signOut(): Promise<void>;
57
+ setToken(token: string | undefined): void;
58
+ setRefreshToken(token: string | undefined): void;
59
+ /**
60
+ * Same surface as LoomupClient.auth, but methods go through overrides
61
+ * so cookies stay in sync.
62
+ */
63
+ get auth(): {
64
+ signUp: (creds: {
65
+ email: string;
66
+ password: string;
67
+ }) => Promise<AuthTokens>;
68
+ register: (creds: {
69
+ email: string;
70
+ password: string;
71
+ }) => Promise<AuthTokens>;
72
+ signIn: (creds: {
73
+ email: string;
74
+ password: string;
75
+ }) => Promise<AuthTokens>;
76
+ login: (creds: {
77
+ email: string;
78
+ password: string;
79
+ }) => Promise<AuthTokens>;
80
+ signOut: () => Promise<void>;
81
+ logout: () => Promise<void>;
82
+ me: () => Promise<import("@loomup/client").User>;
83
+ refresh: () => Promise<AuthTokens>;
84
+ };
85
+ }
86
+ /**
87
+ * Create a server Loomup client bound to Astro cookies.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * ---
92
+ * import { createServerClient, uploadFromFormData } from "@loomup/astro/server";
93
+ * const lb = createServerClient(Astro.cookies, { url: import.meta.env.LOOMUP_URL });
94
+ * const { data } = await lb.from("todos").select({ limit: 20 });
95
+ * // Object storage: await lb.storage.from("avatars").upload(...)
96
+ * // or: await uploadFromFormData(lb, "avatars", await Astro.request.formData())
97
+ * ---
98
+ * ```
99
+ */
100
+ export declare function createServerClient<TMap = DefaultTableMap, TInsertMap = {
101
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
102
+ }, TUpdateMap = {
103
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
104
+ }>(cookies: CookieStore, options?: CreateServerClientOptions): ServerLoomupClient<TMap, TInsertMap, TUpdateMap>;
105
+ /**
106
+ * Cookie-backed SSR client with generated property access (`db.issues`).
107
+ * This is the server-side counterpart to `createAuthenticatedProject`.
108
+ */
109
+ export declare function createServerProject<TMap = DefaultTableMap, TInsertMap = {
110
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
111
+ }, TUpdateMap = {
112
+ [K in keyof TMap]: Partial<TMap[K]> & Record<string, unknown>;
113
+ }>(cookies: CookieStore, options?: CreateServerClientOptions): LoomupProject<TMap, TInsertMap, TUpdateMap>;
package/dist/server.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Server-side Loomup client for Astro (SSR frontmatter, endpoints, middleware).
3
+ * Persists access/refresh tokens in httpOnly cookies.
4
+ */
5
+ import { LoomupClient, projectFromClient, } from "@loomup/client";
6
+ import { asCookieStore, clearTokens, readTokens, writeTokens, } from "./cookies.js";
7
+ export { DEFAULT_ACCESS_COOKIE, DEFAULT_REFRESH_COOKIE, clearTokens, readTokens, resolveCookieNames, writeTokens, } from "./cookies.js";
8
+ export { LoomupError, createClient, StorageBucket, encodeObjectPath, normalizeStorageUpload, } from "@loomup/client";
9
+ export { fileAndPathFromFormData, uploadFromFormData, storageDownloadResponse, } from "./objectStorage.js";
10
+ function resolveServerUrl(explicit) {
11
+ if (explicit)
12
+ return explicit;
13
+ if (typeof process !== "undefined") {
14
+ const fromEnv = process.env?.LOOMUP_URL || process.env?.PUBLIC_LOOMUP_URL;
15
+ if (fromEnv)
16
+ return fromEnv;
17
+ }
18
+ throw new Error("@loomup/astro: createServerClient requires `url` or LOOMUP_URL / PUBLIC_LOOMUP_URL");
19
+ }
20
+ /**
21
+ * Cookie-backed Loomup client for Astro SSR.
22
+ * Extends the core client so `.from()`, `.request()`, etc. stay identical.
23
+ * Auth methods and automatic refresh write tokens back to cookies.
24
+ */
25
+ export class ServerLoomupClient extends LoomupClient {
26
+ cookieStore;
27
+ cookieOptions;
28
+ /** Last known refresh token (core field is private; we mirror for cookie writes). */
29
+ mirroredRefresh;
30
+ constructor(cookieStore, options) {
31
+ super(options);
32
+ this.cookieStore = cookieStore;
33
+ this.cookieOptions = options.cookieOptions;
34
+ this.mirroredRefresh = options.refreshToken;
35
+ }
36
+ persistFromTokens(data) {
37
+ this.mirroredRefresh = data.refresh_token;
38
+ writeTokens(this.cookieStore, data, this.cookieOptions);
39
+ }
40
+ clearCookieTokens() {
41
+ this.mirroredRefresh = undefined;
42
+ clearTokens(this.cookieStore, this.cookieOptions);
43
+ }
44
+ async signUp(creds) {
45
+ const data = await super.signUp(creds);
46
+ this.persistFromTokens(data);
47
+ return data;
48
+ }
49
+ async signIn(creds) {
50
+ const data = await super.signIn(creds);
51
+ this.persistFromTokens(data);
52
+ return data;
53
+ }
54
+ async refresh() {
55
+ const data = await super.refresh();
56
+ this.persistFromTokens(data);
57
+ return data;
58
+ }
59
+ async signOut() {
60
+ await super.signOut();
61
+ this.clearCookieTokens();
62
+ }
63
+ setToken(token) {
64
+ super.setToken(token);
65
+ if (token && this.mirroredRefresh) {
66
+ writeTokens(this.cookieStore, {
67
+ access_token: token,
68
+ refresh_token: this.mirroredRefresh,
69
+ }, this.cookieOptions);
70
+ }
71
+ else if (!token) {
72
+ // Clearing access only — still clear both for safety on full logout paths.
73
+ clearTokens(this.cookieStore, this.cookieOptions);
74
+ }
75
+ }
76
+ setRefreshToken(token) {
77
+ super.setRefreshToken(token);
78
+ this.mirroredRefresh = token;
79
+ }
80
+ /**
81
+ * Same surface as LoomupClient.auth, but methods go through overrides
82
+ * so cookies stay in sync.
83
+ */
84
+ get auth() {
85
+ return {
86
+ signUp: (creds) => this.signUp(creds),
87
+ register: (creds) => this.signUp(creds),
88
+ signIn: (creds) => this.signIn(creds),
89
+ login: (creds) => this.signIn(creds),
90
+ signOut: () => this.signOut(),
91
+ logout: () => this.signOut(),
92
+ me: () => this.me(),
93
+ refresh: () => this.refresh(),
94
+ };
95
+ }
96
+ }
97
+ /**
98
+ * Create a server Loomup client bound to Astro cookies.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * ---
103
+ * import { createServerClient, uploadFromFormData } from "@loomup/astro/server";
104
+ * const lb = createServerClient(Astro.cookies, { url: import.meta.env.LOOMUP_URL });
105
+ * const { data } = await lb.from("todos").select({ limit: 20 });
106
+ * // Object storage: await lb.storage.from("avatars").upload(...)
107
+ * // or: await uploadFromFormData(lb, "avatars", await Astro.request.formData())
108
+ * ---
109
+ * ```
110
+ */
111
+ export function createServerClient(cookies, options = {}) {
112
+ const store = asCookieStore(cookies);
113
+ const fromCookies = readTokens(store, options.cookies?.names);
114
+ const url = resolveServerUrl(options.url);
115
+ const token = options.token ?? fromCookies.access;
116
+ const refreshToken = options.refreshToken ?? fromCookies.refresh;
117
+ return new ServerLoomupClient(store, {
118
+ url,
119
+ token,
120
+ refreshToken,
121
+ cookieOptions: options.cookies,
122
+ ...options.client,
123
+ });
124
+ }
125
+ /**
126
+ * Cookie-backed SSR client with generated property access (`db.issues`).
127
+ * This is the server-side counterpart to `createAuthenticatedProject`.
128
+ */
129
+ export function createServerProject(cookies, options = {}) {
130
+ return projectFromClient(createServerClient(cookies, options));
131
+ }
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@loomup/astro",
3
+ "version": "0.1.0",
4
+ "description": "Astro integration and SSR helpers for Loomup Realtime",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ },
13
+ "./server": {
14
+ "import": "./dist/server.js",
15
+ "types": "./dist/server.d.ts"
16
+ },
17
+ "./client": {
18
+ "import": "./dist/client.js",
19
+ "types": "./dist/client.d.ts"
20
+ },
21
+ "./middleware": {
22
+ "import": "./dist/middleware.js",
23
+ "types": "./dist/middleware.d.ts"
24
+ },
25
+ "./auth": {
26
+ "import": "./dist/auth.js",
27
+ "types": "./dist/auth.d.ts"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist/*.js",
32
+ "dist/*.d.ts"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "test": "tsc -p tsconfig.json && node --test dist/test/*.test.js",
37
+ "prepack": "npm run build"
38
+ },
39
+ "peerDependencies": {
40
+ "@loomup/client": "^0.1.0",
41
+ "astro": ">=4.0.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "astro": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^5.7.0",
50
+ "@types/node": "^22.0.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ },
55
+ "homepage": "https://tryloomup.com/docs",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/bluppco/loomup-js.git",
59
+ "directory": "packages/astro"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/bluppco/loomup-js/issues"
63
+ },
64
+ "license": "MIT",
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }