@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 +1 -44
- package/dist/action.js +1 -37
- package/dist/auth/isomorphic.d.ts +1 -1
- package/dist/auth/isomorphic.js +2 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -2
- package/dist/mcp.d.ts +2 -2
- package/dist/package.js +1 -1
- package/dist/push.d.ts +52 -0
- package/dist/push.js +309 -0
- package/dist/pwa.js +1 -1
- package/dist/server.d.ts +1 -2
- package/dist/server.js +1 -2
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +8 -10
- package/skills/softr-sdk/ai/SKILL.md +1 -1
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/formatting/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +1 -3
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/push-notifications/SKILL.md +140 -0
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/record-queries/SKILL.md +1 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/workflows/SKILL.md +8 -10
- package/dist/core/email.d.ts +0 -48
- package/dist/core/email.js +0 -100
- package/skills/softr-sdk/email/SKILL.md +0 -147
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,
|
|
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,
|
|
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: {};
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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):
|
|
34
|
+
declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start8.RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
35
35
|
//#endregion
|
|
36
36
|
export { softrMcpMiddleware };
|
package/dist/package.js
CHANGED
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
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,
|
|
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,
|
|
9
|
+
export { AiError, ai, auth, ds, integration, upload, workflow };
|