@softr/sdk 0.0.99-alpha.1 → 0.0.99
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/auth/isomorphic.d.ts +1 -1
- package/dist/auth/isomorphic.js +0 -2
- package/dist/datasource/fields.js +2 -4
- package/dist/datasource/filter.js +39 -52
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/mcp.d.ts +2 -2
- package/dist/package.js +1 -1
- package/dist/pwa.js +1 -1
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +2 -6
- 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 +7 -5
- 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 -1
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +17 -3
- package/skills/softr-sdk/record-queries/SKILL.md +5 -3
- package/skills/softr-sdk/record-queries/references/filters-and-sorting.md +1 -2
- 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 +1 -1
- package/dist/push.d.ts +0 -52
- package/dist/push.js +0 -309
- package/skills/softr-sdk/push-notifications/SKILL.md +0 -140
|
@@ -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>], {
|
|
25
25
|
user: User | null;
|
|
26
26
|
permissions: {
|
|
27
27
|
datasources: {};
|
package/dist/auth/isomorphic.js
CHANGED
|
@@ -8,7 +8,6 @@ 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";
|
|
12
11
|
import { faviconMiddleware } from "../favicon.js";
|
|
13
12
|
import { embeddingMiddleware } from "../embedding.js";
|
|
14
13
|
import { appIdentityMiddleware } from "../app-identity.js";
|
|
@@ -39,7 +38,6 @@ function softrAuthMiddleware() {
|
|
|
39
38
|
appIdentityMiddleware(),
|
|
40
39
|
bootErrorMiddleware(),
|
|
41
40
|
pwaMiddleware(),
|
|
42
|
-
pushMiddleware(),
|
|
43
41
|
faviconMiddleware()
|
|
44
42
|
]).server(async ({ next }) => {
|
|
45
43
|
async function getSession() {
|
|
@@ -27,10 +27,8 @@ function makeSortSet(aliases) {
|
|
|
27
27
|
/**
|
|
28
28
|
* Ands the filters of every `where` call into one, which is what an accumulated `where` needs.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* would arrive three deep and be refused. So one filter goes through as it is, and a group that is itself
|
|
33
|
-
* an AND contributes its members to the group built here rather than sitting inside it.
|
|
30
|
+
* One filter goes through as it is. A group that is itself an AND contributes its members to the group
|
|
31
|
+
* built here rather than sitting inside it. So the request is no deeper than the filters the caller wrote.
|
|
34
32
|
*/
|
|
35
33
|
function andAll(filters) {
|
|
36
34
|
if (filters.length === 1) return filters[0];
|
|
@@ -5,62 +5,48 @@ const INTERNAL_KIND = Symbol("KIND");
|
|
|
5
5
|
/**
|
|
6
6
|
* Converts filter utility conditions to a format our backend supports.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Each group holds its conditions in `filters` and its nested groups in `filterGroups`, at any depth.
|
|
9
|
+
* Both lists are always present. A single condition becomes the only member of an AND group.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```tsx
|
|
13
|
-
* const json = buildFilterCriteria(fields.title.contains("hello"));
|
|
13
|
+
* const json = buildFilterCriteria(fields.$and(fields.title.contains("hello"), fields.$or(fields.rating.gt(3), fields.rating.isEmpty())));
|
|
14
14
|
* // result
|
|
15
15
|
* {
|
|
16
|
-
* "
|
|
17
|
-
* "
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* }]
|
|
28
|
-
* }
|
|
16
|
+
* "filterConditionGroup": {
|
|
17
|
+
* "logicalOperator": "AND",
|
|
18
|
+
* "filters": [{ "subject": "title", "type": "TEXT", "operator": "CONTAINS", "value": "hello" }],
|
|
19
|
+
* "filterGroups": [{
|
|
20
|
+
* "logicalOperator": "OR",
|
|
21
|
+
* "filters": [
|
|
22
|
+
* { "subject": "rating", "type": "NUMERIC", "operator": "GT", "value": "3" },
|
|
23
|
+
* { "subject": "rating", "type": "TEXT", "operator": "IS_EMPTY" }
|
|
24
|
+
* ],
|
|
25
|
+
* "filterGroups": []
|
|
26
|
+
* }]
|
|
29
27
|
* }
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* }
|
|
32
29
|
* ```
|
|
33
30
|
*/
|
|
34
31
|
function buildFilterCriteria(input) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} };
|
|
48
|
-
return { filterConditionGroup: {
|
|
49
|
-
logicalOperator: input.logicalOperator,
|
|
50
|
-
filterGroups: input.filters.map((inner) => {
|
|
51
|
-
if (isFilter(inner)) return {
|
|
52
|
-
logicalOperator: "AND",
|
|
53
|
-
filters: [stripInternalKind(inner)]
|
|
54
|
-
};
|
|
55
|
-
if (!inner.filters.every(isFilter)) throw new Error("Cannot nest filter groups more than three levels deep.");
|
|
56
|
-
return {
|
|
57
|
-
logicalOperator: inner.logicalOperator,
|
|
58
|
-
filters: inner.filters.map(stripInternalKind)
|
|
59
|
-
};
|
|
60
|
-
})
|
|
61
|
-
} };
|
|
32
|
+
return { filterConditionGroup: isFilterGroup(input) ? buildFilterGroup(input.logicalOperator, input.filters) : buildFilterGroup("AND", [input]) };
|
|
33
|
+
}
|
|
34
|
+
function buildFilterGroup(logicalOperator, members) {
|
|
35
|
+
const filters = [];
|
|
36
|
+
const filterGroups = [];
|
|
37
|
+
for (const member of members) {
|
|
38
|
+
assert(INTERNAL_KIND in member, "a filter must come from the callback a read hands you");
|
|
39
|
+
if (isFilterGroup(member)) filterGroups.push(buildFilterGroup(member.logicalOperator, member.filters));
|
|
40
|
+
else {
|
|
41
|
+
assert(isFilter(member), () => "Unknown filter kind: " + String(member[INTERNAL_KIND]));
|
|
42
|
+
filters.push(stripInternalKind(member));
|
|
43
|
+
}
|
|
62
44
|
}
|
|
63
|
-
|
|
45
|
+
return {
|
|
46
|
+
logicalOperator,
|
|
47
|
+
filters,
|
|
48
|
+
filterGroups
|
|
49
|
+
};
|
|
64
50
|
}
|
|
65
51
|
/**
|
|
66
52
|
* Converts order-by utility conditions to a format our backend supports
|
|
@@ -115,13 +101,14 @@ function dealiasOrderBy(orderbys, aliases) {
|
|
|
115
101
|
*/
|
|
116
102
|
function dealiasFilterCriteria(criteria, aliases) {
|
|
117
103
|
if (!criteria.filterConditionGroup) return criteria;
|
|
118
|
-
return { filterConditionGroup:
|
|
119
|
-
|
|
120
|
-
|
|
104
|
+
return { filterConditionGroup: dealiasGroup(criteria.filterConditionGroup) };
|
|
105
|
+
function dealiasGroup(group) {
|
|
106
|
+
return {
|
|
121
107
|
...group,
|
|
122
|
-
filters: group.filters.map(dealiasFilter)
|
|
123
|
-
|
|
124
|
-
|
|
108
|
+
filters: group.filters.map(dealiasFilter),
|
|
109
|
+
filterGroups: group.filterGroups.map(dealiasGroup)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
125
112
|
function dealiasFilter(filter) {
|
|
126
113
|
const subject = aliases[filter.subject];
|
|
127
114
|
assert(subject, () => `Could not find an alias for subject "${filter.subject}"`);
|
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,6 @@ 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";
|
|
20
19
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
21
20
|
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 FieldShape, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type Page, type PageSettings, type
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,6 @@ 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";
|
|
10
9
|
import { AuthSettingsProvider, getAuthSettings, getSoftrContext, initialSoftrContext, softrAuthMiddleware, useAuthSettings, useCurrentUser } from "./auth/isomorphic.js";
|
|
11
10
|
import { softrMcpMiddleware } from "./mcp.js";
|
|
12
11
|
import { fieldShape, formatDuration, formatField, formatValue, registerSoftrFields } from "./fields/format.js";
|
|
@@ -15,4 +14,4 @@ import { useUserTimezone } from "./timezone.js";
|
|
|
15
14
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
16
15
|
import { Audience } from "./route/audience.js";
|
|
17
16
|
|
|
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,
|
|
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 };
|
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_start7 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_start7.RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
35
35
|
//#endregion
|
|
36
36
|
export { softrMcpMiddleware };
|
package/dist/package.js
CHANGED
package/dist/pwa.js
CHANGED
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: core
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# Softr SDK core
|
|
@@ -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`, `
|
|
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 |
|
|
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,7 +53,6 @@ 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) |
|
|
57
56
|
| Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
|
|
58
57
|
|
|
59
58
|
## Quick Decision Tree
|
|
@@ -98,9 +97,6 @@ Opening the app to an AI assistant over MCP?
|
|
|
98
97
|
Adding an install button, or making the app installable?
|
|
99
98
|
load softr-sdk/pwa
|
|
100
99
|
|
|
101
|
-
Letting visitors turn on push notifications, or "notify users when …"?
|
|
102
|
-
load softr-sdk/push-notifications
|
|
103
|
-
|
|
104
100
|
Showing a date, a time or a number on a page?
|
|
105
101
|
load softr-sdk/formatting
|
|
106
102
|
|
|
@@ -13,7 +13,7 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.99
|
|
17
17
|
---
|
|
18
18
|
|
|
19
19
|
**Boundary.** `ai` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineAiAction` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# The current user
|
|
@@ -73,7 +73,7 @@ Never pass the visitor's id from a loader into a server function. The loader als
|
|
|
73
73
|
```ts
|
|
74
74
|
import { createServerFn } from '@tanstack/react-start'
|
|
75
75
|
import { defineQuery } from '@softr/sdk'
|
|
76
|
-
import { ds } from '@softr/sdk/server'
|
|
76
|
+
import { auth, ds } from '@softr/sdk/server'
|
|
77
77
|
|
|
78
78
|
const myInvoices = defineQuery({
|
|
79
79
|
id: 'myInvoices',
|
|
@@ -84,12 +84,14 @@ const myInvoices = defineQuery({
|
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
const getMyInvoices = createServerFn().handler(async ({ context }) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return await ds.list(myInvoices).where((q) => q.owner.
|
|
87
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
88
|
+
if (me === null) return { records: [], total: 0, nextOffset: null }
|
|
89
|
+
return await ds.list(myInvoices).where((q) => q.owner.isOneOf([me.id]))
|
|
90
90
|
})
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
Here `owner` links to Users, and `usersQuery` is the app's query on the Users table. `auth.currentUserRow` answers `null` when the visitor is signed out or has no row. See [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records) for the scope every read of the table takes.
|
|
94
|
+
|
|
93
95
|
`context.user` is typed. Start derives a handler's context from the app's registered request middleware as well as the server function's own, so there is nothing to import and nothing to cast.
|
|
94
96
|
|
|
95
97
|
### The visitor's role, in a server function
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.
|
|
10
|
+
library_version: 0.0.99
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.
|
|
10
|
+
library_version: 0.0.99
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -114,7 +114,7 @@ Resolves to nothing. A record that cannot be deleted throws.
|
|
|
114
114
|
|
|
115
115
|
### Change only the records the visitor may read
|
|
116
116
|
|
|
117
|
-
When a role reads only some records of a table, it updates and deletes only those records. `permissions` decides who may run the mutation, not which record. The id comes from the browser, so it can name any record. Before the write, read the record through the same query and scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). Refuse the write when that read answers `null`, with the same error as a missing record, so the visitor cannot find out which ids exist. Keep the field that the scope filters on, such as the owner, out of the update's `fields`. If it is in `fields`, a visitor can move a record to another person.
|
|
117
|
+
When a role reads only some records of a table, it updates and deletes only those records. `permissions` decides who may run the mutation, not which record. The id comes from the browser, so it can name any record. Before the write, read the record through the same query and scope as every read of the table (see [record-queries](../record-queries/SKILL.md#keep-each-visitor-to-their-own-records)). When the role changes fewer records than it reads, that read filters on the records it may change instead (see [Only the roles that change a table](#only-the-roles-that-change-a-table)). Refuse the write when that read answers `null`, with the same error as a missing record, so the visitor cannot find out which ids exist. Keep the field that the scope filters on, such as the owner, out of the update's `fields`. If it is in `fields`, a visitor can move a record to another person.
|
|
118
118
|
|
|
119
119
|
Below, `bookings` is the query every read of bookings uses, and `usersQuery` is the app's query on the Users table. `updateBooking` declares `status` only, not `client`.
|
|
120
120
|
|
|
@@ -136,7 +136,7 @@ A delete takes the same read before `ds.delete`. A create into a table that take
|
|
|
136
136
|
|
|
137
137
|
### Only the roles that change a table
|
|
138
138
|
|
|
139
|
-
Often fewer roles change a table than read it. A mutation's `customGroups` names only the roles that may change the table, also when more roles read it. In a barber app, every client reads the free hours, but only barbers open or close them. So the slot update names the barbers' group only
|
|
139
|
+
Often fewer roles change a table than read it. A mutation's `customGroups` names only the roles that may change the table, also when more roles read it. In a barber app, every client reads the free hours, but only barbers open or close them. So the slot update names the barbers' group only:
|
|
140
140
|
|
|
141
141
|
```ts
|
|
142
142
|
const updateSlot = defineMutation({
|
|
@@ -148,6 +148,20 @@ const updateSlot = defineMutation({
|
|
|
148
148
|
})
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
+
A barber also changes only their own slots. Every client reads every slot, so the read scope does not stop one barber from changing another barber's hours. When a role changes fewer records than it reads, the read before the write filters on the records it may change. Here `slots` is the query every read of slots uses, and its `barber` field links to Users:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const setSlotOpen = createServerFn({ method: 'POST' })
|
|
155
|
+
.validator(z.object({ id: z.string(), open: z.boolean() }))
|
|
156
|
+
.handler(async ({ context, data }) => {
|
|
157
|
+
const me = await auth.currentUserRow(context.user, usersQuery, 'email')
|
|
158
|
+
if (me === null) throw new Error('Slot not found')
|
|
159
|
+
const slot = await ds.get(slots, { id: data.id }).where((q) => q.barber.isOneOf([me.id]))
|
|
160
|
+
if (slot === null) throw new Error('Slot not found')
|
|
161
|
+
await ds.update(updateSlot, { id: slot.id, data: { open: data.open } })
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
151
165
|
### Edit an existing record
|
|
152
166
|
|
|
153
167
|
An edit form needs both halves: a `defineQuery` to load the record and a `defineMutation` to save it. They are separate declarations over the same table, so give both the fields the form touches, and read the loaded values off the query's names.
|
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.99
|
|
14
14
|
---
|
|
15
15
|
|
|
16
16
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -197,7 +197,7 @@ const getMyBooking = createServerFn()
|
|
|
197
197
|
})
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
-
The filter matches the field that holds the owner. A link to the Users table holds the visitor's row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds that row by email, or answers `null` when they are signed out or have no row.
|
|
200
|
+
The filter matches the field that holds the owner. A link to the Users table holds the visitor's row there, not `context.user.id`. `auth.currentUserRow(context.user, usersQuery, 'email')` finds that row by email, or answers `null` when they are signed out or have no row. See [current-user](../current-user/SKILL.md) for how the visitor gets onto `context`.
|
|
201
201
|
|
|
202
202
|
### A picker over a scoped table
|
|
203
203
|
|
|
@@ -264,11 +264,13 @@ const listMessages = createServerFn()
|
|
|
264
264
|
return await ds
|
|
265
265
|
.list(messages)
|
|
266
266
|
.where((q) => q.chat.isOneOf([chat.id]))
|
|
267
|
-
.orderBy((q) => q.sentAt.
|
|
267
|
+
.orderBy((q) => q.sentAt.desc)
|
|
268
268
|
.limit(100)
|
|
269
269
|
})
|
|
270
270
|
```
|
|
271
271
|
|
|
272
|
+
The read sorts newest first, so the limit keeps the newest 100 messages. Reverse them in the page to show the oldest on top.
|
|
273
|
+
|
|
272
274
|
A server function that posts a message reads the chat the same way before `ds.create`.
|
|
273
275
|
|
|
274
276
|
### Other people's records show only what the role needs
|
|
@@ -125,7 +125,7 @@ Both take filters or nested groups:
|
|
|
125
125
|
)
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
Groups nest to any depth.
|
|
129
129
|
|
|
130
130
|
**An `$or` with no members matches every row.** When its members come from a list that can be empty, return no rows for an empty list instead of reading the table.
|
|
131
131
|
|
|
@@ -144,5 +144,4 @@ Each field carries `.asc` and `.desc` handles and nothing else. An array applies
|
|
|
144
144
|
| --- | --- |
|
|
145
145
|
| `a filter must come from the callback a read hands you` | A `where` value built as a plain object |
|
|
146
146
|
| `a sort must come from the callback a read hands you` | An `orderBy` value not from the callback's `.asc` / `.desc` handles |
|
|
147
|
-
| `Cannot nest filter groups more than three levels deep.` | A group nested one level too far |
|
|
148
147
|
| A `TypeError` reading an operator off `undefined` | Filtering or sorting on a field the query does not declare |
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `definePage`, `pageHead`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.99
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|
package/dist/push.d.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
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 };
|
|
@@ -1,140 +0,0 @@
|
|
|
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
|