@softr/sdk 0.0.99-alpha.0 → 0.0.99-alpha.1

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/dist/action.d.ts CHANGED
@@ -234,49 +234,6 @@ type IntegrationDefinition<TId extends string> = {
234
234
  * const res = await listEvents({ query: { timeMin: now, singleEvents: "true" } })
235
235
  */
236
236
  declare function defineIntegration<const TId extends string>(definition: IntegrationDefinition<TId>): IntegrationAction<TId>;
237
- type EmailFields = {
238
- senderId?: string;
239
- to?: ReadonlyArray<string>;
240
- cc?: ReadonlyArray<string>;
241
- bcc?: ReadonlyArray<string>;
242
- replyTo?: string;
243
- };
244
- type EmailResource = EmailFields & {
245
- type: "SOFTR_EMAIL";
246
- };
247
- type EmailAction<TId extends string = string> = Action<EmailResource> & {
248
- id: TId;
249
- };
250
- type EmailDefinition<TId extends string> = EmailFields & {
251
- id: TId;
252
- permissions: ActionPermissions;
253
- };
254
- /**
255
- * Declares an email action: who may send it, and the fields every email it sends keeps whatever the
256
- * call says. Softr sends it from the app's email sender and bills the workspace's workflow credits.
257
- *
258
- * - `to`, `cc`, `bcc`: addresses. An element may be a whole `{LOGGED_IN_USER:EMAIL}` or
259
- * `{LOGGED_IN_USER:<field>}` placeholder, filled in for the signed-in sender.
260
- * - `replyTo`: an address or one such placeholder.
261
- * - `senderId`: one of the workspace's email senders, for an email that must come from that address.
262
- * Left out, the app's sender is used. The call can never choose the sender.
263
- *
264
- * A field declared here wins over the call's, and declaring `to` also ignores the call's `cc` and
265
- * `bcc`. Fix the recipients whenever the use case allows,
266
- * because anyone in the audience can otherwise send any email to anyone.
267
- *
268
- * Pass the returned object to `email.send()` at runtime.
269
- *
270
- * @example
271
- * const notifyOwner = defineEmailAction({
272
- * id: "notifyOwner",
273
- * to: ["owner@acme.com"],
274
- * permissions: { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] },
275
- * })
276
- *
277
- * await email.send(notifyOwner, { subject: "New submission", body: html, replyTo: data.email })
278
- */
279
- declare function defineEmailAction<const TId extends string>(definition: EmailDefinition<TId>): EmailAction<TId>;
280
237
  type AiResource = {
281
238
  type: "SOFTR_AI";
282
239
  model: string;
@@ -311,4 +268,4 @@ type AiDefinition<TId extends string> = {
311
268
  */
312
269
  declare function defineAiAction<const TId extends string>(definition: AiDefinition<TId>): AiAction<TId>;
313
270
  //#endregion
314
- export { ActionData, ActionPermissions, ActionRecord, ActionShape, AiAction, DatasourceAction, EmailAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineAiAction, defineEmailAction, defineIntegration, defineMutation, defineQuery, defineWorkflow };
271
+ export { ActionData, ActionPermissions, ActionRecord, ActionShape, AiAction, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow };
package/dist/action.js CHANGED
@@ -50,42 +50,6 @@ function defineIntegration(definition) {
50
50
  permissions
51
51
  };
52
52
  }
53
- /**
54
- * Declares an email action: who may send it, and the fields every email it sends keeps whatever the
55
- * call says. Softr sends it from the app's email sender and bills the workspace's workflow credits.
56
- *
57
- * - `to`, `cc`, `bcc`: addresses. An element may be a whole `{LOGGED_IN_USER:EMAIL}` or
58
- * `{LOGGED_IN_USER:<field>}` placeholder, filled in for the signed-in sender.
59
- * - `replyTo`: an address or one such placeholder.
60
- * - `senderId`: one of the workspace's email senders, for an email that must come from that address.
61
- * Left out, the app's sender is used. The call can never choose the sender.
62
- *
63
- * A field declared here wins over the call's, and declaring `to` also ignores the call's `cc` and
64
- * `bcc`. Fix the recipients whenever the use case allows,
65
- * because anyone in the audience can otherwise send any email to anyone.
66
- *
67
- * Pass the returned object to `email.send()` at runtime.
68
- *
69
- * @example
70
- * const notifyOwner = defineEmailAction({
71
- * id: "notifyOwner",
72
- * to: ["owner@acme.com"],
73
- * permissions: { userGroupTypes: ["NON_LOGGED_IN_USERS", "LOGGED_IN_USERS"] },
74
- * })
75
- *
76
- * await email.send(notifyOwner, { subject: "New submission", body: html, replyTo: data.email })
77
- */
78
- function defineEmailAction(definition) {
79
- const { id, permissions,...fields } = definition;
80
- return {
81
- id,
82
- resource: {
83
- type: "SOFTR_EMAIL",
84
- ...fields
85
- },
86
- permissions
87
- };
88
- }
89
53
  function isDatasourceAction(value) {
90
54
  if (typeof value !== "object" || value === null) return false;
91
55
  const candidate = value;
@@ -105,4 +69,4 @@ function defineAiAction(definition) {
105
69
  }
106
70
 
107
71
  //#endregion
108
- export { defineAiAction, defineEmailAction, defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
72
+ export { defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
@@ -21,7 +21,7 @@ import * as _tanstack_react_start0 from "@tanstack/react-start";
21
21
  * requestMiddleware: [softrAuthMiddleware()],
22
22
  * }))
23
23
  */
24
- declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
24
+ declare function softrAuthMiddleware(): _tanstack_react_start0.RequestMiddlewareAfterServer<{}, readonly [_tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>, _tanstack_react_start0.RequestMiddlewareAfterServer<{}, undefined, undefined>], {
25
25
  user: User | null;
26
26
  permissions: {
27
27
  datasources: {};
@@ -8,6 +8,7 @@ import { makePlatform } from "./platform.js";
8
8
  import { settings } from "./methods/settings.js";
9
9
  import { bootErrorMiddleware } from "../boot-error.js";
10
10
  import { pwaMiddleware } from "../pwa.js";
11
+ import { pushMiddleware } from "../push.js";
11
12
  import { faviconMiddleware } from "../favicon.js";
12
13
  import { embeddingMiddleware } from "../embedding.js";
13
14
  import { appIdentityMiddleware } from "../app-identity.js";
@@ -38,6 +39,7 @@ function softrAuthMiddleware() {
38
39
  appIdentityMiddleware(),
39
40
  bootErrorMiddleware(),
40
41
  pwaMiddleware(),
42
+ pushMiddleware(),
41
43
  faviconMiddleware()
42
44
  ]).server(async ({ next }) => {
43
45
  async function getSession() {
package/dist/index.d.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User } from "./auth/user.js";
2
- import { ActionPermissions, SoftrDatasources, defineAiAction, defineEmailAction, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
+ import { ActionPermissions, SoftrDatasources, defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { FileAttachment } from "./core/file-upload.js";
4
4
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
5
5
  import { AiAnswer, AiFile, AiGenerateInit, AiMessage, AiUsage } from "./core/ai.js";
6
- import { EmailSendInit, EmailSent } from "./core/email.js";
7
6
  import { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
8
7
  import { SoftrContext } from "./auth/context.js";
9
8
  import { AuthSettings } from "./auth/platform.js";
@@ -17,6 +16,7 @@ import { FieldShape, SoftrFieldRegistry, fieldShape, formatDuration, formatField
17
16
  import { useOverrides } from "./core/use-overrides.js";
18
17
  import { useUserTimezone } from "./timezone.js";
19
18
  import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
19
+ import { PushNotifications, PushPrompt, usePushNotifications } from "./push.js";
20
20
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
21
21
  import { Audience } from "./route/audience.js";
22
- export { type ActionPermissions, type AiAnswer, type AiFile, type AiGenerateInit, type AiMessage, type AiUsage, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type EmailSendInit, type EmailSent, type FieldShape, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type Page, type PageSettings, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, type SoftrFieldRegistry, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type SoftrUserProperties, type User, type WeekDay, assertRouteAccess, checkAccess, defineAiAction, defineEmailAction, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
22
+ export { type ActionPermissions, type AiAnswer, type AiFile, type AiGenerateInit, type AiMessage, type AiUsage, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FieldShape, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type Page, type PageSettings, type PushNotifications, type PushPrompt, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, type SoftrFieldRegistry, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type SoftrUserProperties, type User, type WeekDay, assertRouteAccess, checkAccess, defineAiAction, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePushNotifications, usePwaInstall, useUserTimezone };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { defineAiAction, defineEmailAction, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
1
+ import { defineAiAction, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
2
  import { useFileUpload } from "./core/use-file-upload.js";
3
3
  import { checkAccess, defineVisibility, isAuthorized } from "./route/visibility.js";
4
4
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./route/route.js";
@@ -6,6 +6,7 @@ import { definePage, pageHead } from "./route/page.js";
6
6
  import { toReturnPath } from "./auth/returnPath.js";
7
7
  import { FormattedDate, FormattingScope, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
8
8
  import { SoftrPwaHead, usePwaInstall } from "./pwa.js";
9
+ import { usePushNotifications } from "./push.js";
9
10
  import { AuthSettingsProvider, getAuthSettings, getSoftrContext, initialSoftrContext, softrAuthMiddleware, useAuthSettings, useCurrentUser } from "./auth/isomorphic.js";
10
11
  import { softrMcpMiddleware } from "./mcp.js";
11
12
  import { fieldShape, formatDuration, formatField, formatValue, registerSoftrFields } from "./fields/format.js";
@@ -14,4 +15,4 @@ import { useUserTimezone } from "./timezone.js";
14
15
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
15
16
  import { Audience } from "./route/audience.js";
16
17
 
17
- export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineAiAction, defineEmailAction, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
18
+ export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineAiAction, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePushNotifications, usePwaInstall, useUserTimezone };
package/dist/mcp.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatasourceAction } from "./action.js";
2
- import * as _tanstack_react_start7 from "@tanstack/react-start";
2
+ import * as _tanstack_react_start8 from "@tanstack/react-start";
3
3
 
4
4
  //#region src/mcp.d.ts
5
5
 
@@ -31,6 +31,6 @@ type SoftrMcpOptions = {
31
31
  * ],
32
32
  * }))
33
33
  */
34
- declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start7.RequestMiddlewareAfterServer<{}, undefined, undefined>;
34
+ declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start8.RequestMiddlewareAfterServer<{}, undefined, undefined>;
35
35
  //#endregion
36
36
  export { softrMcpMiddleware };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.99-alpha.0";
2
+ var version = "0.0.99-alpha.1";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/push.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import "@tanstack/react-start";
2
+
3
+ //#region src/push.d.ts
4
+
5
+ /** What the builder wrote in Studio for the prompt. Any of it may be missing. */
6
+ type PushPrompt = {
7
+ title: string | null;
8
+ message: string | null;
9
+ enableButtonLabel: string | null;
10
+ };
11
+ type PushNotifications = /** Loading, signed out, push off for this app or device, or a browser without push. */
12
+ {
13
+ status: "unavailable";
14
+ }
15
+ /** iPhone and iPad offer push only inside the installed app. */ | {
16
+ status: "install-first";
17
+ prompt: PushPrompt;
18
+ } | {
19
+ status: "ask";
20
+ prompt: PushPrompt;
21
+ /** The visitor skipped the prompt recently, within the builder's skip days. Hide the prompt. */
22
+ dismissed: boolean;
23
+ /** Asks the browser for permission and subscribes. Call it from a click. */
24
+ subscribe: () => Promise<void>;
25
+ /** Hides the prompt for as long as the builder set in Studio. */
26
+ dismiss: () => void;
27
+ } | {
28
+ status: "subscribed";
29
+ unsubscribe: () => Promise<void>;
30
+ }
31
+ /** The visitor blocked notifications in the browser. Only they can undo it, in the browser's settings. */ | {
32
+ status: "blocked";
33
+ };
34
+ /**
35
+ * Push notifications for the signed-in visitor. Call it once, in the one
36
+ * component that shows the prompt, rendered once in the root route's
37
+ * `RootDocument`, so this browser stays registered and is unsubscribed on
38
+ * sign-out. Each call keeps state of its own, so a second call does not see a
39
+ * subscribe made through the first. Show a prompt only while `status` is `ask`
40
+ * and the prompt is not `dismissed`.
41
+ *
42
+ * @example
43
+ * function EnableNotifications() {
44
+ * const push = usePushNotifications()
45
+ * if (push.status !== "ask" || push.dismissed) return null
46
+ * const turnOn = () => push.subscribe().catch(() => toast.error("Notifications could not be turned on."))
47
+ * return <button onClick={turnOn}>{push.prompt.enableButtonLabel ?? "Turn on"}</button>
48
+ * }
49
+ */
50
+ declare function usePushNotifications(): PushNotifications;
51
+ //#endregion
52
+ export { PushNotifications, PushPrompt, usePushNotifications };
package/dist/push.js ADDED
@@ -0,0 +1,309 @@
1
+ import { logger } from "./logger.js";
2
+ import { softrApiUrl } from "./softr-api.js";
3
+ import { fetch } from "./fetch.js";
4
+ import { sessionConfig } from "./auth/config.js";
5
+ import { readSessionToken } from "./auth/session.js";
6
+ import { detectPlatform } from "./pwa.js";
7
+ import { z } from "zod";
8
+ import { useCallback, useEffect, useState } from "react";
9
+ import { createMiddleware } from "@tanstack/react-start";
10
+ import { rootRouteId, useRouteContext } from "@tanstack/react-router";
11
+
12
+ //#region src/push.ts
13
+ const WORKER_PATH = "/_softr/sw.js";
14
+ const SETTINGS_PATH = "/_softr/push.json";
15
+ const SUBSCRIPTION_PATH = "/_softr/push";
16
+ const TTL_MS = 6e4;
17
+ const promptSchema = z.object({
18
+ title: z.string().nullable(),
19
+ message: z.string().nullable(),
20
+ enableButtonLabel: z.string().nullable(),
21
+ skipDays: z.number().nullable()
22
+ });
23
+ const settingsSchema = z.object({
24
+ vapidPublicKey: z.string(),
25
+ desktopEnabled: z.boolean(),
26
+ mobileEnabled: z.boolean(),
27
+ permissionPrompt: promptSchema.nullable()
28
+ });
29
+ const browserSubscriptionSchema = z.object({
30
+ endpoint: z.string(),
31
+ keys: z.object({
32
+ p256dh: z.string(),
33
+ auth: z.string()
34
+ })
35
+ });
36
+ let cachedSettings;
37
+ let cachedWorker;
38
+ /**
39
+ * Reads through a one-minute cache. Never rejects: a failure reads as `null`
40
+ * and is not remembered, so the next request tries again.
41
+ */
42
+ function readCached(current, store, load) {
43
+ const now = Date.now();
44
+ const cached = current();
45
+ if (cached !== void 0 && cached.expires > now) return cached.value;
46
+ const entry = {
47
+ expires: now + TTL_MS,
48
+ value: load().catch((error) => {
49
+ logger.warn("Could not read push notifications from Softr:", error);
50
+ if (current() === entry) store(void 0);
51
+ return null;
52
+ })
53
+ };
54
+ store(entry);
55
+ return entry.value;
56
+ }
57
+ function platformPushUrl() {
58
+ const { applicationId } = sessionConfig();
59
+ return `${softrApiUrl("SOFTR_PLATFORM_API_URL")}/v1/vibe/applications/${applicationId}/push`;
60
+ }
61
+ function readSettings() {
62
+ return readCached(() => cachedSettings, (entry) => cachedSettings = entry, async () => {
63
+ const response = await fetch(platformPushUrl(), { headers: { accept: "application/json" } });
64
+ if (response.status === 404) return null;
65
+ if (!response.ok) throw new Error(`The platform answered ${response.status}`);
66
+ return settingsSchema.parse(await response.json());
67
+ });
68
+ }
69
+ function readWorker() {
70
+ return readCached(() => cachedWorker, (entry) => cachedWorker = entry, async () => {
71
+ const response = await fetch(`${softrApiUrl("SOFTR_PLATFORM_API_URL")}/pwa/sw.js`);
72
+ if (!response.ok) throw new Error(`The platform answered ${response.status}`);
73
+ return response.text();
74
+ });
75
+ }
76
+ /**
77
+ * Serves push notifications from the app's own origin: the service worker, the
78
+ * app's push settings, and saving or removing this browser's subscription for
79
+ * the signed-in visitor. Every other request passes through untouched.
80
+ */
81
+ function pushMiddleware() {
82
+ return createMiddleware({ type: "request" }).server(async ({ next, pathname, request }) => {
83
+ if (pathname === WORKER_PATH && request.method === "GET") {
84
+ const worker = await readWorker();
85
+ if (worker === null) return new Response(null, {
86
+ status: 503,
87
+ headers: { "cache-control": "no-store" }
88
+ });
89
+ return new Response(worker, { headers: {
90
+ "content-type": "text/javascript; charset=utf-8",
91
+ "cache-control": "no-cache",
92
+ "service-worker-allowed": "/"
93
+ } });
94
+ }
95
+ if (pathname === SETTINGS_PATH && request.method === "GET") {
96
+ const settings = await readSettings();
97
+ if (settings === null) return new Response(null, { status: 404 });
98
+ return Response.json(settings, { headers: { "cache-control": "no-store" } });
99
+ }
100
+ if (pathname === SUBSCRIPTION_PATH && (request.method === "POST" || request.method === "DELETE")) {
101
+ if (!request.headers.get("content-type")?.startsWith("application/json")) return new Response(null, { status: 415 });
102
+ const token = readSessionToken();
103
+ if (token === null) return new Response(null, { status: 401 });
104
+ const body = z.object({ subscription: browserSubscriptionSchema }).safeParse(await request.json().catch(() => null));
105
+ if (!body.success) return new Response(null, { status: 400 });
106
+ const response = request.method === "POST" ? await fetch(platformPushUrl(), {
107
+ method: "POST",
108
+ headers: {
109
+ "content-type": "application/json",
110
+ authorization: `Bearer ${token}`
111
+ },
112
+ body: JSON.stringify({
113
+ origin: new URL(request.url).origin,
114
+ userAgent: request.headers.get("user-agent"),
115
+ subscription: body.data.subscription
116
+ })
117
+ }) : await fetch(`${platformPushUrl()}?${new URLSearchParams({ endpoint: body.data.subscription.endpoint })}`, {
118
+ method: "DELETE",
119
+ headers: { authorization: `Bearer ${token}` }
120
+ });
121
+ return new Response(null, { status: response.ok ? 204 : response.status });
122
+ }
123
+ return next();
124
+ });
125
+ }
126
+ const DISMISSED_AT_KEY = "softr-push-dismissed-at";
127
+ /**
128
+ * Push notifications for the signed-in visitor. Call it once, in the one
129
+ * component that shows the prompt, rendered once in the root route's
130
+ * `RootDocument`, so this browser stays registered and is unsubscribed on
131
+ * sign-out. Each call keeps state of its own, so a second call does not see a
132
+ * subscribe made through the first. Show a prompt only while `status` is `ask`
133
+ * and the prompt is not `dismissed`.
134
+ *
135
+ * @example
136
+ * function EnableNotifications() {
137
+ * const push = usePushNotifications()
138
+ * if (push.status !== "ask" || push.dismissed) return null
139
+ * const turnOn = () => push.subscribe().catch(() => toast.error("Notifications could not be turned on."))
140
+ * return <button onClick={turnOn}>{push.prompt.enableButtonLabel ?? "Turn on"}</button>
141
+ * }
142
+ */
143
+ function usePushNotifications() {
144
+ const signedIn = useRouteContext({
145
+ from: rootRouteId,
146
+ select: (context) => context.user !== null
147
+ });
148
+ const [settings, setSettings] = useState(null);
149
+ const [registration, setRegistration] = useState(null);
150
+ const [subscribed, setSubscribed] = useState(false);
151
+ const [permission, setPermission] = useState("default");
152
+ const [dismissedAt, setDismissedAt] = useState(null);
153
+ useEffect(() => {
154
+ if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return;
155
+ if (!signedIn) {
156
+ navigator.serviceWorker.getRegistration("/").then((existing) => existing?.pushManager.getSubscription()).then((subscription) => subscription?.unsubscribe()).catch(() => {});
157
+ try {
158
+ localStorage.removeItem(DISMISSED_AT_KEY);
159
+ } catch {}
160
+ setDismissedAt(null);
161
+ setSettings(null);
162
+ setSubscribed(false);
163
+ return;
164
+ }
165
+ let cancelled = false;
166
+ (async () => {
167
+ const response = await globalThis.fetch(SETTINGS_PATH, { headers: { accept: "application/json" } });
168
+ if (!response.ok) return;
169
+ const parsed = settingsSchema.safeParse(await response.json());
170
+ if (!parsed.success || cancelled) return;
171
+ setDismissedAt(readDismissedAt());
172
+ setSettings(parsed.data);
173
+ if (!("PushManager" in window) || !("Notification" in window)) return;
174
+ setPermission(Notification.permission);
175
+ const registered = await navigator.serviceWorker.register(WORKER_PATH, { scope: "/" });
176
+ if (cancelled) return;
177
+ setRegistration(registered);
178
+ const existing = await registered.pushManager.getSubscription();
179
+ if (existing !== null && Notification.permission === "granted") setSubscribed(await sendSubscription("POST", existing));
180
+ })().catch((error) => logger.warn("Could not set up push notifications:", error));
181
+ return () => {
182
+ cancelled = true;
183
+ };
184
+ }, [signedIn]);
185
+ useEffect(() => {
186
+ if (registration === null) return;
187
+ return listenToWorker();
188
+ }, [registration]);
189
+ const subscribe = useCallback(async () => {
190
+ if (registration === null || settings === null) return;
191
+ const granted = await Notification.requestPermission();
192
+ setPermission(granted);
193
+ if (granted !== "granted") return;
194
+ const subscription = await registration.pushManager.subscribe({
195
+ userVisibleOnly: true,
196
+ applicationServerKey: decodeBase64Url(settings.vapidPublicKey)
197
+ });
198
+ if (!await sendSubscription("POST", subscription)) {
199
+ await subscription.unsubscribe();
200
+ throw new Error("Could not turn on push notifications");
201
+ }
202
+ setSubscribed(true);
203
+ }, [registration, settings]);
204
+ const unsubscribe = useCallback(async () => {
205
+ const subscription = await registration?.pushManager.getSubscription();
206
+ if (!subscription) return;
207
+ await sendSubscription("DELETE", subscription);
208
+ await subscription.unsubscribe();
209
+ setSubscribed(false);
210
+ }, [registration]);
211
+ const dismiss = useCallback(() => {
212
+ const now = Date.now();
213
+ try {
214
+ localStorage.setItem(DISMISSED_AT_KEY, String(now));
215
+ } catch {}
216
+ setDismissedAt(now);
217
+ }, []);
218
+ if (settings === null) return { status: "unavailable" };
219
+ const platform = detectPlatform();
220
+ if (platform !== "desktop" ? !settings.mobileEnabled : !settings.desktopEnabled) return { status: "unavailable" };
221
+ const prompt = {
222
+ title: settings.permissionPrompt?.title ?? null,
223
+ message: settings.permissionPrompt?.message ?? null,
224
+ enableButtonLabel: settings.permissionPrompt?.enableButtonLabel ?? null
225
+ };
226
+ if (platform === "ios" && !isStandalone()) return {
227
+ status: "install-first",
228
+ prompt
229
+ };
230
+ if (registration === null) return { status: "unavailable" };
231
+ if (subscribed) return {
232
+ status: "subscribed",
233
+ unsubscribe
234
+ };
235
+ if (permission === "denied") return { status: "blocked" };
236
+ return {
237
+ status: "ask",
238
+ prompt,
239
+ dismissed: isDismissed(dismissedAt, settings.permissionPrompt?.skipDays ?? null),
240
+ subscribe,
241
+ dismiss
242
+ };
243
+ }
244
+ async function sendSubscription(method, subscription) {
245
+ return (await globalThis.fetch(SUBSCRIPTION_PATH, {
246
+ method,
247
+ headers: { "content-type": "application/json" },
248
+ body: JSON.stringify({ subscription: subscription.toJSON() })
249
+ })).ok;
250
+ }
251
+ /** A missing skip-days setting means a skipped prompt never comes back on its own. */
252
+ function isDismissed(dismissedAt, skipDays) {
253
+ if (dismissedAt === null) return false;
254
+ if (skipDays === null) return true;
255
+ return Date.now() - dismissedAt < skipDays * 24 * 60 * 60 * 1e3;
256
+ }
257
+ function readDismissedAt() {
258
+ try {
259
+ const stored = Number(localStorage.getItem(DISMISSED_AT_KEY));
260
+ return Number.isFinite(stored) && stored > 0 ? stored : null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ function isStandalone() {
266
+ return window.matchMedia("(display-mode: standalone)").matches || "standalone" in navigator && navigator.standalone === true;
267
+ }
268
+ /**
269
+ * Answers the service worker, which routes a notification click to the
270
+ * installed app when it is open, and keeps the app icon's badge right.
271
+ */
272
+ function listenToWorker() {
273
+ const worker = navigator.serviceWorker;
274
+ const standalone = isStandalone();
275
+ if (standalone) worker.controller?.postMessage({ type: "pwa-standalone-opened" });
276
+ const onMessage = (event) => {
277
+ if (event.data === "is-this-standalone") {
278
+ event.ports[0]?.postMessage(standalone ? "yes" : "no");
279
+ return;
280
+ }
281
+ if (event.data?.type === "softr-pwa-redirect" && typeof event.data.url === "string") {
282
+ const target = new URL(event.data.url, window.location.href);
283
+ if (target.origin === window.location.origin) window.location.assign(target.href);
284
+ }
285
+ };
286
+ const onVisible = () => {
287
+ if (document.visibilityState === "visible") worker.controller?.postMessage("reset-badge-count");
288
+ };
289
+ const onInstallOffered = () => worker.controller?.postMessage({ type: "pwa-uninstalled" });
290
+ worker.addEventListener("message", onMessage);
291
+ document.addEventListener("visibilitychange", onVisible);
292
+ window.addEventListener("beforeinstallprompt", onInstallOffered);
293
+ onVisible();
294
+ return () => {
295
+ worker.removeEventListener("message", onMessage);
296
+ document.removeEventListener("visibilitychange", onVisible);
297
+ window.removeEventListener("beforeinstallprompt", onInstallOffered);
298
+ };
299
+ }
300
+ function decodeBase64Url(value) {
301
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
302
+ const binary = atob(base64);
303
+ const bytes = new Uint8Array(binary.length);
304
+ for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
305
+ return bytes;
306
+ }
307
+
308
+ //#endregion
309
+ export { pushMiddleware, usePushNotifications };
package/dist/pwa.js CHANGED
@@ -149,4 +149,4 @@ function detectPlatform() {
149
149
  }
150
150
 
151
151
  //#endregion
152
- export { SoftrPwaHead, pwaMiddleware, usePwaInstall };
152
+ export { SoftrPwaHead, detectPlatform, pwaMiddleware, usePwaInstall };
package/dist/server.d.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import { upload } from "./core/file-upload.js";
2
2
  import { AiError, ai } from "./core/ai.js";
3
- import { EmailError, EmailErrorCode, email } from "./core/email.js";
4
3
  import { auth } from "./auth/server.js";
5
4
  import { FieldChoice, RecordOption, RecordOptions } from "./core/options.js";
6
5
  import { ds } from "./datasource/datasource.js";
7
6
  import { workflow } from "./core/workflow.js";
8
7
  import { integration } from "./core/integration.js";
9
8
  import "@tanstack/react-start/server-only";
10
- export { AiError, EmailError, type EmailErrorCode, type FieldChoice, type RecordOption, type RecordOptions, ai, auth, ds, email, integration, upload, workflow };
9
+ export { AiError, type FieldChoice, type RecordOption, type RecordOptions, ai, auth, ds, integration, upload, workflow };
package/dist/server.js CHANGED
@@ -4,7 +4,6 @@ import { auth } from "./auth/server.js";
4
4
  import { workflow } from "./core/workflow.js";
5
5
  import { integration } from "./core/integration.js";
6
6
  import { AiError, ai } from "./core/ai.js";
7
- import { EmailError, email } from "./core/email.js";
8
7
  import "@tanstack/react-start/server-only";
9
8
 
10
- export { AiError, EmailError, ai, auth, ds, email, integration, upload, workflow };
9
+ export { AiError, ai, auth, ds, integration, upload, workflow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.99-alpha.0",
3
+ "version": "0.0.99-alpha.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },