@softr/sdk 0.0.98 → 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.
@@ -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
@@ -16,6 +16,7 @@ import { FieldShape, SoftrFieldRegistry, fieldShape, formatDuration, formatField
16
16
  import { useOverrides } from "./core/use-overrides.js";
17
17
  import { useUserTimezone } from "./timezone.js";
18
18
  import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
19
+ import { PushNotifications, PushPrompt, usePushNotifications } from "./push.js";
19
20
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
20
21
  import { Audience } from "./route/audience.js";
21
- 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 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, 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
@@ -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, 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.98";
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.98",
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" },
@@ -32,7 +32,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
32
32
 
33
33
  | Import | Runs | Holds |
34
34
  | --- | --- | --- |
35
- | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineAiAction`, `definePage`, `pageHead`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `formatValue`, `formatField`, `fieldShape`, `registerSoftrFields`, `useOverrides`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
35
+ | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineAiAction`, `definePage`, `pageHead`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `formatValue`, `formatField`, `fieldShape`, `registerSoftrFields`, `useOverrides`, `SoftrPwaHead`, `usePwaInstall`, `usePushNotifications`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
36
36
  | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration`, `ai` |
37
37
  | `@softr/sdk/client` | Browser only | `useFileUpload` |
38
38
  | `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
@@ -53,6 +53,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
53
53
  | Call a connected third-party API (Slack, Stripe, Gmail, …), or an AI model on the user's own provider account | [integrations/SKILL.md](./integrations/SKILL.md) |
54
54
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
55
55
  | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
56
+ | Let visitors turn on push notifications | [push-notifications/SKILL.md](./push-notifications/SKILL.md) |
56
57
  | Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
57
58
 
58
59
  ## Quick Decision Tree
@@ -97,6 +98,9 @@ Opening the app to an AI assistant over MCP?
97
98
  Adding an install button, or making the app installable?
98
99
  load softr-sdk/pwa
99
100
 
101
+ Letting visitors turn on push notifications, or "notify users when …"?
102
+ load softr-sdk/push-notifications
103
+
100
104
  Showing a date, a time or a number on a page?
101
105
  load softr-sdk/formatting
102
106
 
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: push-notifications
3
+ description: >-
4
+ Let signed-in visitors turn on push notifications: usePushNotifications for
5
+ the prompt, its states and the Studio texts, the PushNotifications and
6
+ PushPrompt types, and how to check that push is on for the app. Notifications
7
+ are sent by Softr Workflows, never by app code; the service worker is already
8
+ served, never write one.
9
+ metadata:
10
+ type: sub-skill
11
+ library: softr-sdk
12
+ library_version: 0.0.97
13
+ ---
14
+
15
+ # Push notifications
16
+
17
+ The builder turns push on in Studio, under Users → Notifications, for desktop, mobile or both, and writes the prompt's texts there. Notifications are sent by a Softr Workflow's "Send notification" action, which the builder sets up in Studio. What the app adds is one thing: a prompt that lets the signed-in visitor turn notifications on in this browser.
18
+
19
+ > **CRITICAL**: Never write a service worker, a `public/sw.js`, or any `navigator.serviceWorker`, `PushManager` or `Notification` code of your own. The app already serves its service worker, and `usePushNotifications()` registers it, subscribes and unsubscribes.
20
+ >
21
+ > **CRITICAL**: Never send a push from app code. Sending is a Softr Workflow's "Send notification" action. When the builder asks for "notify X when Y", point them to that workflow; when Y is something the app does, the app triggers the workflow (load the Softr SDK `workflows` guidance). The app itself adds only the prompt.
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ import { usePushNotifications } from '@softr/sdk'
27
+ import type { PushNotifications, PushPrompt } from '@softr/sdk'
28
+ ```
29
+
30
+ ```ts
31
+ type PushPrompt = {
32
+ title: string | null // what the builder wrote in Studio; any of these may be missing
33
+ message: string | null
34
+ enableButtonLabel: string | null
35
+ }
36
+
37
+ type PushNotifications =
38
+ | { status: 'unavailable' } // loading, signed out, push off, this device type off, or no browser support
39
+ | { status: 'install-first'; prompt: PushPrompt } // iPhone and iPad: push works only in the installed app
40
+ | {
41
+ status: 'ask'
42
+ prompt: PushPrompt
43
+ dismissed: boolean // skipped recently, within the builder's skip days; hide the prompt
44
+ subscribe: () => Promise<void> // call from a click
45
+ dismiss: () => void
46
+ }
47
+ | { status: 'subscribed'; unsubscribe: () => Promise<void> }
48
+ | { status: 'blocked' } // blocked in the browser; only the visitor can undo it there
49
+ ```
50
+
51
+ ## Is push on for the app?
52
+
53
+ Ask the running app for `/_softr/push.json`: `200` means push is on, `404` means it is off in Studio or the plan does not include it. Do not build a prompt against a `404`: push has to be turned on first. A change in Studio can take up to two minutes to reach the app.
54
+
55
+ Push works only on the published app. Inside the Studio preview the hook always says `unavailable`, so the prompt never shows there. That is expected, not a bug to fix.
56
+
57
+ ## Core Patterns
58
+
59
+ ### The prompt
60
+
61
+ Render it once, in `RootDocument` in `src/routes/__root.tsx`, so it shows on every page and the browser stays registered for the visitor. Use the prompt component the template ships when it has one. It already handles each status.
62
+
63
+ Call `usePushNotifications()` in that one component only. Each call keeps state of its own: a second call, in a header or on a settings page, does not see a subscribe made through the first, so the prompt would stay on screen after the visitor said yes.
64
+
65
+ `subscribe()` rejects when the browser or Softr refuses. Catch it and tell the visitor, so the button never fails in silence.
66
+
67
+ ```tsx
68
+ function EnableNotifications() {
69
+ const push = usePushNotifications()
70
+ if (push.status !== 'ask' || push.dismissed) return null
71
+ return (
72
+ <div role="dialog">
73
+ <p>{push.prompt.title ?? 'Turn on notifications'}</p>
74
+ <p>{push.prompt.message ?? 'Get notified about updates.'}</p>
75
+ <button onClick={() => push.subscribe().catch(() => toast.error('Notifications could not be turned on.'))}>
76
+ {push.prompt.enableButtonLabel ?? 'Turn on'}
77
+ </button>
78
+ <button onClick={push.dismiss}>Not now</button>
79
+ </div>
80
+ )
81
+ }
82
+ ```
83
+
84
+ Fall back to your own wording only where the builder left a text empty. On `install-first`, point the visitor to installing the app (load the Softr SDK `pwa` guidance); do not ask for permission.
85
+
86
+ ### Signing out
87
+
88
+ Nothing to add. The browser is unsubscribed when the visitor signs out, so the next person on the device gets nothing meant for them.
89
+
90
+ ## Common Mistakes
91
+
92
+ ### CRITICAL Asking for permission on page load
93
+
94
+ Wrong:
95
+
96
+ ```tsx
97
+ useEffect(() => { push.status === 'ask' && push.subscribe() }, [push.status])
98
+ ```
99
+
100
+ Correct:
101
+
102
+ ```tsx
103
+ <button onClick={() => push.subscribe().catch(() => toast.error('Notifications could not be turned on.'))}>
104
+ {push.prompt.enableButtonLabel ?? 'Turn on'}
105
+ </button>
106
+ ```
107
+
108
+ Browsers ignore or block a permission request that no click started, and a visitor who blocks it can only undo that in the browser's settings.
109
+
110
+ Source: packages/sdk/src/push.ts
111
+
112
+ ### HIGH Showing the prompt whatever the status
113
+
114
+ Wrong:
115
+
116
+ ```tsx
117
+ const push = usePushNotifications()
118
+ return <button onClick={() => push.subscribe()}>Turn on notifications</button>
119
+ ```
120
+
121
+ Correct:
122
+
123
+ ```tsx
124
+ const push = usePushNotifications()
125
+ if (push.status !== 'ask' || push.dismissed) return null
126
+ return (
127
+ <button onClick={() => push.subscribe().catch(() => toast.error('Notifications could not be turned on.'))}>
128
+ {push.prompt.enableButtonLabel ?? 'Turn on'}
129
+ </button>
130
+ )
131
+ ```
132
+
133
+ The hook says `unavailable` when push is off in Studio, for this device type, or for a signed-out visitor. A prompt that skips the check shows in all of those, and on iPhone it asks for something the browser cannot give.
134
+
135
+ Source: packages/sdk/src/push.ts
136
+
137
+ ## References
138
+
139
+ - [SKILL.md](../SKILL.md): the entrypoints and which one each export lives on
140
+ - [pwa/SKILL.md](../pwa/SKILL.md): installing the app, which iPhone and iPad need before push