@godxjp/sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -17
- package/dist/index.d.ts +32 -0
- package/dist/index.js +86 -0
- package/dist/laravel/index.d.ts +48 -2
- package/dist/laravel/index.js +86 -34
- package/dist/realtime/index.d.ts +36 -3
- package/dist/realtime/index.js +89 -1
- package/fixtures/me.v1.json +9 -0
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -8,43 +8,141 @@ Published for use by GoDX applications. The source repository is private; this p
|
|
|
8
8
|
|
|
9
9
|
Entries are split by **who owns the contract**, not by what happens to be duplicated. Duplication alone is not a reason to share code; a contract that two sides must agree on is.
|
|
10
10
|
|
|
11
|
-
| Entry
|
|
12
|
-
|
|
|
13
|
-
| `.`
|
|
14
|
-
| `./laravel`
|
|
15
|
-
| `./realtime` | Realtime seam: configuration, connection status, channel listeners | Moves faster
|
|
11
|
+
| Entry | Contents | Cadence |
|
|
12
|
+
| ------------ | ------------------------------------------------------------------ | --------------------------- |
|
|
13
|
+
| `.` | Platform contracts: permission DTO, tenant-scoped channel names | Binds every product at once |
|
|
14
|
+
| `./laravel` | House conventions for Laravel + React: HTTP client, `ApiError` | Moves faster |
|
|
15
|
+
| `./realtime` | Realtime seam: configuration, connection status, channel listeners | Moves faster |
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
## Scope — what belongs here, and what does not
|
|
18
|
+
|
|
19
|
+
Entries are decided by **who owns the contract**, not by what happens to be duplicated in two products. Duplication is a symptom; a contract that two sides must agree on is the criterion.
|
|
20
|
+
|
|
21
|
+
The question for any candidate: **is it a shared integration contract owned by the platform, with more than one real consumer, no product-specific workflow, and explicit safety, recovery and compatibility semantics?**
|
|
22
|
+
|
|
23
|
+
### In scope
|
|
24
|
+
|
|
25
|
+
| Area | What the package carries | Why it qualifies |
|
|
26
|
+
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
27
|
+
| HTTP transport for Laravel hosts | `createClient`, `ApiError`, query encoding, CSRF header, upload progress | Every Laravel + React product needs the identical transport. Two of them had thirty-five raw `fetch()` call sites each hand-rolling headers and error handling |
|
|
28
|
+
| Realtime seam | Configuration, connection status, channel and notification listeners | The transport underneath is expected to change; the seam is what keeps that change to one adapter |
|
|
29
|
+
| Channel names | Branded `ChannelName`, parser, org- and platform-scoped forms | Two-sided contract with the PHP SDK. Both sides must produce and read the identical string |
|
|
30
|
+
| Identity snapshot for the UI | The `/me` shape as a **branded snapshot type** carrying `authoritative: false`, reachable only through one parse gate | The same hook was copied between two products and has already drifted into incompatible role shapes. The PHP side already marks the equivalent call "display snapshot only"; the type is where that has to become unavoidable |
|
|
31
|
+
|
|
32
|
+
### Out of scope
|
|
33
|
+
|
|
34
|
+
| Area | Why not |
|
|
35
|
+
| ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| **Anything that stores platform identity** — organization, branch, brand or user rows written into a consumer's own database | The identity a request carries is request-scoped. A stored copy is a second source of truth that goes stale silently, and consumer paths depending on a stored organization id are a known defect class, not a pattern to standardise |
|
|
37
|
+
| **Business-domain state**, including a projection table that can be rebuilt from events | A projection still carries product semantics, schema and lifecycle. Rebuildable is not the same as contract-owned |
|
|
38
|
+
| **Product workflow** — what a notification means, which query to invalidate, what a screen does when an event arrives | The package delivers the event; the product decides what it means. A mapping from event to query key belongs to the application |
|
|
39
|
+
| **Generic React utilities** that happen to be identical in two products | Byte-identical duplication is not a contract. Utilities with no platform meaning stay in the products or in the design system |
|
|
40
|
+
| **Design-system concerns** — components, styling, tokens | `@godxjp/ui` owns those. The core of this package must run in a service with no DOM |
|
|
41
|
+
|
|
42
|
+
### Rules that follow from the split
|
|
43
|
+
|
|
44
|
+
- **The core has no DOM, no React, no CSS and no framework.** `./realtime` and any future `./react` entry declare their peers as optional, so a headless service can install the package and use the HTTP client alone.
|
|
45
|
+
- **A DTO published here is a snapshot for the interface, never authorization evidence.** The server re-checks authority on every request regardless of what the client believes.
|
|
46
|
+
- **Two-sided contracts are proven by a shared fixture set**, run through both implementations and compared line by line. Two suites passing separately does not show that two sides agree.
|
|
47
|
+
|
|
48
|
+
## `.` — platform contracts
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import {
|
|
52
|
+
mayShow,
|
|
53
|
+
parseUiPermissionSnapshot,
|
|
54
|
+
tryParseUiPermissionSnapshot,
|
|
55
|
+
type UiPermissionSnapshot,
|
|
56
|
+
} from "@godxjp/sdk";
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The `/me` response is a **display snapshot**, not authorization evidence. `parseUiPermissionSnapshot` is the only way to obtain a `UiPermissionSnapshot`; the branded type cannot be forged with `as`. The parser always sets `authoritative: false`, even if the wire payload claims otherwise — a hostile or broken server cannot upgrade what the UI believes into something the type treats as authoritative.
|
|
60
|
+
|
|
61
|
+
Wire shape: `user` (`id` as number, `name`, `email`), `permissions` (string array, may be empty), `roles` (string array — not `{ slug }` objects). Unknown top-level fields are ignored so the contract can grow without breaking older clients.
|
|
62
|
+
|
|
63
|
+
Use `mayShow(snapshot, permission)` only to decide whether a surface may appear in the UI; the server re-checks authority on every request. There are no `can` / `authorize` helpers on this entry — naming that implies client-side authorization is deliberately absent.
|
|
64
|
+
|
|
65
|
+
Legacy Inertia page props with `roles: [{ slug }]` are normalized at the product boundary via `src/identity/inertia-adapter.ts` (not re-exported from `.`).
|
|
18
66
|
|
|
19
67
|
## `./laravel`
|
|
20
68
|
|
|
21
69
|
```ts
|
|
22
|
-
import { createClient, ApiError } from
|
|
70
|
+
import { createClient, ApiError } from "@godxjp/sdk/laravel";
|
|
23
71
|
|
|
24
72
|
export const apiClient = createClient({
|
|
25
|
-
|
|
26
|
-
|
|
73
|
+
baseUrl: "/api/app/v1",
|
|
74
|
+
signInUrl: (returnTo) => `/login?return=${encodeURIComponent(returnTo)}`,
|
|
27
75
|
});
|
|
28
76
|
|
|
29
77
|
// A public API whose visitor has nowhere to sign in: omit `signInUrl`,
|
|
30
78
|
// and a 401 is simply reported.
|
|
31
|
-
export const publicApiClient = createClient({ baseUrl:
|
|
79
|
+
export const publicApiClient = createClient({ baseUrl: "/api" });
|
|
32
80
|
```
|
|
33
81
|
|
|
34
82
|
Authentication is the session cookie, so a write sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, exactly as Laravel expects. Also handled: `key[]=a&key[]=b` for array query values and `1`/`0` for booleans, `204` resolving to `undefined`, `419` as an expired session, an unreachable server as `status: 0`, and upload progress over `XMLHttpRequest` because `fetch` cannot report it.
|
|
35
83
|
|
|
36
84
|
A cancellation stays an `AbortError` and is never converted to an `ApiError` — TanStack Query tells a cancelled query from a failed one by exactly that.
|
|
37
85
|
|
|
86
|
+
### Other ways to authenticate
|
|
87
|
+
|
|
88
|
+
Every option below is optional; left out, the client behaves exactly as described above.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
export const apiClient = createClient({
|
|
92
|
+
baseUrl: "https://api.example.com/v1",
|
|
93
|
+
// Read before every request — and again before a retry — so a token
|
|
94
|
+
// refreshed a moment ago is the one that goes out. Wins over the defaults.
|
|
95
|
+
headers: async () => ({ Authorization: `Bearer ${await tokens.access()}` }),
|
|
96
|
+
// `fetch` credentials mode, `same-origin` by default. `include` also sets
|
|
97
|
+
// `withCredentials` on an upload that goes out over XHR.
|
|
98
|
+
credentials: "include",
|
|
99
|
+
// On a 401, before `signInUrl`: resolve `true` and the request is sent once
|
|
100
|
+
// more; resolve `false` and the 401 takes its usual course. Never more than
|
|
101
|
+
// one retry per request, and 401s that arrive together share one call.
|
|
102
|
+
onUnauthorized: () => tokens.refresh(),
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
A request body is always JSON text or `FormData`, never a stream, so it can be sent a second time. A request cancelled while the token was refreshed stays an `AbortError`.
|
|
107
|
+
|
|
108
|
+
### Downloading a file
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const { blob, headers, status } = await apiClient.download("/exports", {
|
|
112
|
+
method: "POST", // "GET" unless given
|
|
113
|
+
body: { ids: [1, 2] },
|
|
114
|
+
query: { format: "csv" },
|
|
115
|
+
});
|
|
116
|
+
const disposition = headers.get("Content-Disposition");
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Same headers, XSRF header, retry and errors as every other method: a 4xx, a 419 or an unreachable server is still an `ApiError`.
|
|
120
|
+
|
|
121
|
+
### Error messages
|
|
122
|
+
|
|
123
|
+
`ApiError.message` is the server's `message` when it sent a non-empty one. Otherwise it comes from `messages`, or is empty — the client has no language of its own, and never uses `statusText`, which HTTP/2 does not send.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
createClient({
|
|
127
|
+
baseUrl: "/api",
|
|
128
|
+
messages: (status) => (status === 419 ? t("session.expired") : undefined),
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
An unreachable server (`status: 0`) keeps its built-in text, `Could not reach the server. Check your connection and try again.`, unless `messages(0)` answers one.
|
|
133
|
+
|
|
134
|
+
**Changed in 0.3.0.** Before, a 419 always carried `Your session has expired. Reload the page and try again.` and a response without a message fell back to `statusText`. Both are now the server's message or empty. To keep the old 419 text, pass `messages: (status) => status === 419 ? "Your session has expired. Reload the page and try again." : undefined`.
|
|
135
|
+
|
|
38
136
|
## `./realtime`
|
|
39
137
|
|
|
40
138
|
```ts
|
|
41
139
|
import {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
} from
|
|
140
|
+
configureRealtime,
|
|
141
|
+
isRealtimeConfigured,
|
|
142
|
+
useChannelEvent,
|
|
143
|
+
useRealtimeStatus,
|
|
144
|
+
useUserNotification,
|
|
145
|
+
} from "@godxjp/sdk/realtime";
|
|
48
146
|
```
|
|
49
147
|
|
|
50
148
|
**Why the seam exists:** the transport underneath is expected to change. Putting the seam in first makes a later move one adapter in one package, rather than every listening component in every product.
|
|
@@ -54,7 +152,7 @@ So nothing from the transport appears in this API — no Echo type, no Pusher co
|
|
|
54
152
|
### Three words, not six
|
|
55
153
|
|
|
56
154
|
```ts
|
|
57
|
-
type RealtimeStatus =
|
|
155
|
+
type RealtimeStatus = "connecting" | "connected" | "unavailable";
|
|
58
156
|
```
|
|
59
157
|
|
|
60
158
|
`connecting` is the first state of **every** page load and says nothing yet — a screen must not start polling here, or every visit doubles its requests for the first second. `unavailable` means the client actually tried and is not live; that is the only state a fallback may fire on. The transport's `failed`, `unavailable` and `disconnected` all land there, because a screen has the same job in all three.
|
|
@@ -69,6 +167,15 @@ An unrecognised state maps to `unavailable`, never `connected`. Guessing toward
|
|
|
69
167
|
|
|
70
168
|
Laravel broadcasts every notification under one framework event name, so subscribing by a custom name succeeds and then never fires — a failure that presents as "connected but nothing happens". Naming the two cases apart is what stops the next person reaching for the wrong one.
|
|
71
169
|
|
|
170
|
+
### Channel names (PHP contract)
|
|
171
|
+
|
|
172
|
+
Realtime channels use a fixed shape so the JS client and the PHP broadcaster never disagree on a string:
|
|
173
|
+
|
|
174
|
+
- Organization scope: `org.{orgId}.{resource}.{resourceId}` — `orgId` and `resourceId` are `[A-Za-z0-9_-]+`; `resource` is lowercase `[a-z][a-z0-9-]*` (e.g. `issue`, `test-run`).
|
|
175
|
+
- Platform scope: `platform.{resource}.{resourceId}` — same `resource` / `resourceId` rules; no fake org prefix for cross-tenant traffic.
|
|
176
|
+
|
|
177
|
+
Build with `organizationChannel` / `platformChannel`, parse with `parseChannelName` or `tryParseChannelName`. The `ChannelName` brand is for compile-time safety only — it does not stop a client from subscribing to arbitrary strings. The server must always enforce user → organization → resource → authorization.
|
|
178
|
+
|
|
72
179
|
## Peer dependencies
|
|
73
180
|
|
|
74
181
|
`./realtime` needs `@laravel/echo-react` and `react`. Both are optional peers: an application using only `./laravel` installs neither.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-only permission snapshot from `/me` — not evidence the server will honor.
|
|
3
|
+
*
|
|
4
|
+
* Types here catch programmer mistakes only; a client can still send arbitrary
|
|
5
|
+
* requests. The server must re-check authority on every call regardless of what
|
|
6
|
+
* the UI believes.
|
|
7
|
+
*/
|
|
8
|
+
/** Only values produced by the parse gate carry this brand; `as` is not validation. */
|
|
9
|
+
type UiPermissionSnapshot = {
|
|
10
|
+
readonly authoritative: false;
|
|
11
|
+
readonly user: {
|
|
12
|
+
readonly id: number;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly email: string;
|
|
15
|
+
};
|
|
16
|
+
readonly permissions: readonly string[];
|
|
17
|
+
readonly roles: readonly string[];
|
|
18
|
+
} & {
|
|
19
|
+
readonly __brand: "UiPermissionSnapshot";
|
|
20
|
+
};
|
|
21
|
+
declare class UiPermissionSnapshotError extends Error {
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
declare function parseUiPermissionSnapshot(raw: unknown): UiPermissionSnapshot;
|
|
25
|
+
declare function tryParseUiPermissionSnapshot(raw: unknown): UiPermissionSnapshot | null;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the UI may show a surface gated by `permission`. This reads the
|
|
28
|
+
* display snapshot only; the server re-checks authority on every request.
|
|
29
|
+
*/
|
|
30
|
+
declare function mayShow(snapshot: UiPermissionSnapshot, permission: string): boolean;
|
|
31
|
+
|
|
32
|
+
export { type UiPermissionSnapshot, UiPermissionSnapshotError, mayShow, parseUiPermissionSnapshot, tryParseUiPermissionSnapshot };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// src/identity/me.ts
|
|
2
|
+
var UiPermissionSnapshotError = class extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "UiPermissionSnapshotError";
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
function brandUiPermissionSnapshot(snapshot) {
|
|
9
|
+
return snapshot;
|
|
10
|
+
}
|
|
11
|
+
function assertPlainObject(raw) {
|
|
12
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
13
|
+
throw new UiPermissionSnapshotError("snapshot must be a plain object");
|
|
14
|
+
}
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
function assertUser(raw) {
|
|
18
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
19
|
+
throw new UiPermissionSnapshotError("user must be an object");
|
|
20
|
+
}
|
|
21
|
+
const record = raw;
|
|
22
|
+
const { id, name, email } = record;
|
|
23
|
+
if (typeof id !== "number" || !Number.isFinite(id)) {
|
|
24
|
+
throw new UiPermissionSnapshotError("user.id must be a finite number");
|
|
25
|
+
}
|
|
26
|
+
if (typeof name !== "string") {
|
|
27
|
+
throw new UiPermissionSnapshotError("user.name must be a string");
|
|
28
|
+
}
|
|
29
|
+
if (typeof email !== "string") {
|
|
30
|
+
throw new UiPermissionSnapshotError("user.email must be a string");
|
|
31
|
+
}
|
|
32
|
+
return { id, name, email };
|
|
33
|
+
}
|
|
34
|
+
function assertStringArray(field, raw) {
|
|
35
|
+
if (!Array.isArray(raw)) {
|
|
36
|
+
throw new UiPermissionSnapshotError(`${field} must be an array`);
|
|
37
|
+
}
|
|
38
|
+
const result = [];
|
|
39
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
40
|
+
const item = raw[index];
|
|
41
|
+
if (typeof item !== "string") {
|
|
42
|
+
throw new UiPermissionSnapshotError(
|
|
43
|
+
`${field}[${index}] must be a string`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
result.push(item);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
function parseUiPermissionSnapshot(raw) {
|
|
51
|
+
const record = assertPlainObject(raw);
|
|
52
|
+
if (!("user" in record)) {
|
|
53
|
+
throw new UiPermissionSnapshotError("user is required");
|
|
54
|
+
}
|
|
55
|
+
const user = assertUser(record.user);
|
|
56
|
+
if (!("permissions" in record)) {
|
|
57
|
+
throw new UiPermissionSnapshotError("permissions is required");
|
|
58
|
+
}
|
|
59
|
+
const permissions = assertStringArray("permissions", record.permissions);
|
|
60
|
+
if (!("roles" in record)) {
|
|
61
|
+
throw new UiPermissionSnapshotError("roles is required");
|
|
62
|
+
}
|
|
63
|
+
const roles = assertStringArray("roles", record.roles);
|
|
64
|
+
const authoritative = false;
|
|
65
|
+
return brandUiPermissionSnapshot({
|
|
66
|
+
authoritative,
|
|
67
|
+
user,
|
|
68
|
+
permissions,
|
|
69
|
+
roles
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function tryParseUiPermissionSnapshot(raw) {
|
|
73
|
+
try {
|
|
74
|
+
return parseUiPermissionSnapshot(raw);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof UiPermissionSnapshotError) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function mayShow(snapshot, permission) {
|
|
83
|
+
return snapshot.permissions.includes(permission);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { UiPermissionSnapshotError, mayShow, parseUiPermissionSnapshot, tryParseUiPermissionSnapshot };
|
package/dist/laravel/index.d.ts
CHANGED
|
@@ -45,6 +45,18 @@ type WriteOptions = RequestOptions & {
|
|
|
45
45
|
*/
|
|
46
46
|
onUploadProgress?: (percent: number) => void;
|
|
47
47
|
};
|
|
48
|
+
type DownloadOptions = RequestOptions & {
|
|
49
|
+
/** `GET` unless given. A `POST` carries the XSRF header like any write. */
|
|
50
|
+
method?: "GET" | "POST";
|
|
51
|
+
body?: Body;
|
|
52
|
+
query?: ApiQuery;
|
|
53
|
+
};
|
|
54
|
+
/** A binary response, with the headers a screen reads the file name from. */
|
|
55
|
+
type DownloadResult = {
|
|
56
|
+
blob: Blob;
|
|
57
|
+
headers: Headers;
|
|
58
|
+
status: number;
|
|
59
|
+
};
|
|
48
60
|
type ClientConfig = {
|
|
49
61
|
/** Prefixed to every path, e.g. `/api/app/v1`. */
|
|
50
62
|
baseUrl: string;
|
|
@@ -53,6 +65,31 @@ type ClientConfig = {
|
|
|
53
65
|
* Omitted for a public API whose visitor has nowhere to sign in.
|
|
54
66
|
*/
|
|
55
67
|
signInUrl?: (returnTo: string) => string;
|
|
68
|
+
/**
|
|
69
|
+
* Extra headers, read again before EVERY request (and before a retry), so a
|
|
70
|
+
* bearer token refreshed a moment ago is the one that goes out. Applied
|
|
71
|
+
* after the defaults, so a header named here wins.
|
|
72
|
+
*/
|
|
73
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
74
|
+
/**
|
|
75
|
+
* `fetch` credentials mode; `same-origin` unless given. `include` is what a
|
|
76
|
+
* cross-origin API behind a cookie needs, and also turns on
|
|
77
|
+
* `withCredentials` for an upload that goes out over XHR.
|
|
78
|
+
*/
|
|
79
|
+
credentials?: RequestCredentials;
|
|
80
|
+
/**
|
|
81
|
+
* Called on a 401 before `signInUrl`. Resolve `true` (a token was refreshed,
|
|
82
|
+
* say) and the request is sent once more, headers read afresh; resolve
|
|
83
|
+
* `false` and the 401 takes its usual course. A request is retried at most
|
|
84
|
+
* once, and 401s that arrive together share one call.
|
|
85
|
+
*/
|
|
86
|
+
onUnauthorized?: () => Promise<boolean>;
|
|
87
|
+
/**
|
|
88
|
+
* The message for an `ApiError` whose response carried none — the client
|
|
89
|
+
* has no language of its own. Unset, or answering `undefined`, leaves
|
|
90
|
+
* `message` empty; status `0` then keeps its built-in text.
|
|
91
|
+
*/
|
|
92
|
+
messages?: (status: number) => string | undefined;
|
|
56
93
|
};
|
|
57
94
|
type ApiClient = {
|
|
58
95
|
get: <T>(path: string, options?: GetOptions) => Promise<T>;
|
|
@@ -60,8 +97,17 @@ type ApiClient = {
|
|
|
60
97
|
put: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
|
|
61
98
|
patch: <T>(path: string, body?: Body, options?: WriteOptions) => Promise<T>;
|
|
62
99
|
delete: <T>(path: string, options?: GetOptions) => Promise<T>;
|
|
100
|
+
/**
|
|
101
|
+
* The response as a `Blob` plus its headers, through the same error
|
|
102
|
+
* handling as every other method: a failure is still an `ApiError`.
|
|
103
|
+
*/
|
|
104
|
+
download: (path: string, options?: DownloadOptions) => Promise<DownloadResult>;
|
|
63
105
|
};
|
|
64
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* `FormData` goes out as multipart untouched; anything else is JSON-encoded.
|
|
108
|
+
* Either way the payload can be sent twice, which is what makes the retry
|
|
109
|
+
* after `onUnauthorized` safe — a stream body is never produced.
|
|
110
|
+
*/
|
|
65
111
|
type Body = FormData | object;
|
|
66
112
|
/** Laravel reads `key[]=a&key[]=b` as an array and '1'/'0' as booleans. */
|
|
67
113
|
declare function queryString(query: ApiQuery | undefined): string;
|
|
@@ -69,4 +115,4 @@ declare function queryString(query: ApiQuery | undefined): string;
|
|
|
69
115
|
declare function flattenErrors(errors: unknown): Record<string, string>;
|
|
70
116
|
declare function createClient(config: ClientConfig): ApiClient;
|
|
71
117
|
|
|
72
|
-
export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type GetOptions, type WriteOptions, createClient, flattenErrors, queryString };
|
|
118
|
+
export { type ApiClient, ApiError, type ApiQuery, type ClientConfig, type DownloadOptions, type DownloadResult, type GetOptions, type WriteOptions, createClient, flattenErrors, queryString };
|
package/dist/laravel/index.js
CHANGED
|
@@ -12,7 +12,6 @@ var ApiError = class extends Error {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
// src/laravel/api-client.ts
|
|
15
|
-
var SESSION_EXPIRED = "Your session has expired. Reload the page and try again.";
|
|
16
15
|
var UNREACHABLE = "Could not reach the server. Check your connection and try again.";
|
|
17
16
|
var redirectingToSignIn = false;
|
|
18
17
|
function readCookie(name) {
|
|
@@ -75,35 +74,33 @@ function redirectToSignIn(signInUrl) {
|
|
|
75
74
|
}
|
|
76
75
|
function send(url, init, onUploadProgress) {
|
|
77
76
|
if (onUploadProgress === void 0) {
|
|
78
|
-
return fetch(url,
|
|
79
|
-
async (response) => ({
|
|
80
|
-
status: response.status,
|
|
81
|
-
statusText: response.statusText,
|
|
82
|
-
text: await response.text()
|
|
83
|
-
})
|
|
84
|
-
);
|
|
77
|
+
return fetch(url, init);
|
|
85
78
|
}
|
|
86
79
|
return new Promise((resolve, reject) => {
|
|
87
80
|
const xhr = new XMLHttpRequest();
|
|
88
81
|
const abort = () => xhr.abort();
|
|
89
82
|
xhr.open(init.method, url);
|
|
83
|
+
xhr.withCredentials = init.credentials === "include";
|
|
90
84
|
for (const [name, value] of Object.entries(init.headers)) {
|
|
91
85
|
xhr.setRequestHeader(name, value);
|
|
92
86
|
}
|
|
93
87
|
xhr.upload.onprogress = (event) => {
|
|
94
88
|
if (event.lengthComputable && event.total > 0) {
|
|
95
|
-
onUploadProgress(
|
|
96
|
-
Math.round(event.loaded / event.total * 100)
|
|
97
|
-
);
|
|
89
|
+
onUploadProgress(Math.round(event.loaded / event.total * 100));
|
|
98
90
|
}
|
|
99
91
|
};
|
|
100
92
|
xhr.onload = () => {
|
|
101
93
|
init.signal?.removeEventListener("abort", abort);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
94
|
+
const empty = xhr.responseText === "" || [204, 205, 304].includes(xhr.status);
|
|
95
|
+
try {
|
|
96
|
+
resolve(
|
|
97
|
+
new Response(empty ? null : xhr.responseText, {
|
|
98
|
+
status: xhr.status
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
reject(error);
|
|
103
|
+
}
|
|
107
104
|
};
|
|
108
105
|
xhr.onerror = () => reject(new TypeError("Network request failed"));
|
|
109
106
|
xhr.onabort = () => reject(new DOMException("The request was aborted.", "AbortError"));
|
|
@@ -115,7 +112,20 @@ function send(url, init, onUploadProgress) {
|
|
|
115
112
|
xhr.send(init.body ?? null);
|
|
116
113
|
});
|
|
117
114
|
}
|
|
118
|
-
|
|
115
|
+
function unreachable(config) {
|
|
116
|
+
return new ApiError(0, config.messages?.(0) ?? UNREACHABLE);
|
|
117
|
+
}
|
|
118
|
+
async function read(response, as, config, signal) {
|
|
119
|
+
try {
|
|
120
|
+
return await response[as]();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (signal?.aborted) {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
throw unreachable(config);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function exchange({ config, unauthorized }, method, path, body, { signal, query, onUploadProgress }, retried = false) {
|
|
119
129
|
const headers = {
|
|
120
130
|
Accept: "application/json",
|
|
121
131
|
"X-Requested-With": "XMLHttpRequest"
|
|
@@ -131,51 +141,93 @@ async function request(config, method, path, body, { signal, query, onUploadProg
|
|
|
131
141
|
headers["Content-Type"] = "application/json";
|
|
132
142
|
payload = JSON.stringify(body);
|
|
133
143
|
}
|
|
144
|
+
Object.assign(headers, await config.headers?.());
|
|
134
145
|
let response;
|
|
135
146
|
try {
|
|
136
147
|
response = await send(
|
|
137
148
|
`${config.baseUrl}${path}${queryString(query)}`,
|
|
138
|
-
{
|
|
149
|
+
{
|
|
150
|
+
method,
|
|
151
|
+
headers,
|
|
152
|
+
body: payload,
|
|
153
|
+
signal,
|
|
154
|
+
credentials: config.credentials ?? "same-origin"
|
|
155
|
+
},
|
|
139
156
|
onUploadProgress
|
|
140
157
|
);
|
|
141
158
|
} catch (error) {
|
|
142
159
|
if (signal?.aborted) {
|
|
143
160
|
throw error;
|
|
144
161
|
}
|
|
145
|
-
throw
|
|
162
|
+
throw unreachable(config);
|
|
146
163
|
}
|
|
147
|
-
if (response.status
|
|
148
|
-
return
|
|
164
|
+
if (response.status >= 200 && response.status < 300) {
|
|
165
|
+
return response;
|
|
149
166
|
}
|
|
167
|
+
if (response.status === 401 && !retried && config.onUnauthorized !== void 0 && await unauthorized()) {
|
|
168
|
+
return exchange(
|
|
169
|
+
{ config, unauthorized },
|
|
170
|
+
method,
|
|
171
|
+
path,
|
|
172
|
+
body,
|
|
173
|
+
{ signal, query, onUploadProgress },
|
|
174
|
+
true
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const text = await read(response, "text", config, signal);
|
|
150
178
|
let json = void 0;
|
|
151
179
|
try {
|
|
152
|
-
json =
|
|
180
|
+
json = text === "" ? void 0 : JSON.parse(text);
|
|
153
181
|
} catch {
|
|
154
182
|
}
|
|
155
|
-
if (response.status >= 200 && response.status < 300) {
|
|
156
|
-
return json;
|
|
157
|
-
}
|
|
158
183
|
const bag = json ?? {};
|
|
159
184
|
if (response.status === 401 && config.signInUrl !== void 0) {
|
|
160
185
|
redirectToSignIn(config.signInUrl);
|
|
161
186
|
}
|
|
162
|
-
if (response.status === 419) {
|
|
163
|
-
throw new ApiError(419, SESSION_EXPIRED);
|
|
164
|
-
}
|
|
165
187
|
throw new ApiError(
|
|
166
188
|
response.status,
|
|
167
|
-
typeof bag.message === "string" && bag.message !== "" ? bag.message : response.
|
|
189
|
+
typeof bag.message === "string" && bag.message !== "" ? bag.message : config.messages?.(response.status) ?? "",
|
|
168
190
|
flattenErrors(bag.errors)
|
|
169
191
|
);
|
|
170
192
|
}
|
|
193
|
+
async function request(client, method, path, body, options) {
|
|
194
|
+
const response = await exchange(client, method, path, body, options);
|
|
195
|
+
if (response.status === 204) {
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
const text = await read(response, "text", client.config, options.signal);
|
|
199
|
+
try {
|
|
200
|
+
return text === "" ? void 0 : JSON.parse(text);
|
|
201
|
+
} catch {
|
|
202
|
+
return void 0;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
171
205
|
function createClient(config) {
|
|
206
|
+
let refreshing;
|
|
207
|
+
const client = {
|
|
208
|
+
config,
|
|
209
|
+
unauthorized: () => refreshing ??= (config.onUnauthorized?.() ?? Promise.resolve(false)).finally(() => {
|
|
210
|
+
refreshing = void 0;
|
|
211
|
+
})
|
|
212
|
+
};
|
|
172
213
|
return {
|
|
173
|
-
get: (path, options = {}) => request(
|
|
174
|
-
post: (path, body, options = {}) => request(
|
|
175
|
-
put: (path, body, options = {}) => request(
|
|
176
|
-
patch: (path, body, options = {}) => request(
|
|
214
|
+
get: (path, options = {}) => request(client, "GET", path, void 0, options),
|
|
215
|
+
post: (path, body, options = {}) => request(client, "POST", path, body, options),
|
|
216
|
+
put: (path, body, options = {}) => request(client, "PUT", path, body, options),
|
|
217
|
+
patch: (path, body, options = {}) => request(client, "PATCH", path, body, options),
|
|
177
218
|
/** `query` for what identifies the targets (`?ids[]=1&ids[]=2`). */
|
|
178
|
-
delete: (path, options = {}) => request(
|
|
219
|
+
delete: (path, options = {}) => request(client, "DELETE", path, void 0, options),
|
|
220
|
+
download: async (path, { method = "GET", body, query, signal } = {}) => {
|
|
221
|
+
const response = await exchange(client, method, path, body, {
|
|
222
|
+
query,
|
|
223
|
+
signal
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
blob: await read(response, "blob", config, signal),
|
|
227
|
+
headers: response.headers,
|
|
228
|
+
status: response.status
|
|
229
|
+
};
|
|
230
|
+
}
|
|
179
231
|
};
|
|
180
232
|
}
|
|
181
233
|
|
package/dist/realtime/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* on. Pusher's `failed`, `unavailable` and `disconnected` all land there,
|
|
22
22
|
* because a screen has the same job in all three.
|
|
23
23
|
*/
|
|
24
|
-
type RealtimeStatus =
|
|
24
|
+
type RealtimeStatus = "connecting" | "connected" | "unavailable";
|
|
25
25
|
/** What the host must hand over to reach its own server. */
|
|
26
26
|
type RealtimeConfig = {
|
|
27
27
|
/** The app key the server issued. */
|
|
@@ -29,7 +29,7 @@ type RealtimeConfig = {
|
|
|
29
29
|
host: string;
|
|
30
30
|
port: number;
|
|
31
31
|
/** `https` turns on TLS and the `wss` transport. */
|
|
32
|
-
scheme:
|
|
32
|
+
scheme: "http" | "https";
|
|
33
33
|
};
|
|
34
34
|
/**
|
|
35
35
|
* Map a transport's own vocabulary onto ours.
|
|
@@ -80,4 +80,37 @@ declare function useChannelEvent<T = unknown>(channel: string, event: string, ha
|
|
|
80
80
|
*/
|
|
81
81
|
declare function useUserNotification<T = unknown>(channel: string, handler: (payload: T) => void): void;
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Canonical realtime channel names shared with the PHP broadcaster.
|
|
85
|
+
*
|
|
86
|
+
* Types here catch programmer mistakes only — a client can still subscribe to
|
|
87
|
+
* any string it likes. The server must always map user → organization →
|
|
88
|
+
* resource → authorization before trusting a channel name.
|
|
89
|
+
*/
|
|
90
|
+
/** Only values produced by the factories or parser carry this brand; `as` is not validation. */
|
|
91
|
+
type ChannelName = string & {
|
|
92
|
+
readonly __brand: "ChannelName";
|
|
93
|
+
};
|
|
94
|
+
type ParsedChannelName = {
|
|
95
|
+
scope: "org";
|
|
96
|
+
orgId: string;
|
|
97
|
+
resource: string;
|
|
98
|
+
resourceId: string;
|
|
99
|
+
name: ChannelName;
|
|
100
|
+
} | {
|
|
101
|
+
scope: "platform";
|
|
102
|
+
resource: string;
|
|
103
|
+
resourceId: string;
|
|
104
|
+
name: ChannelName;
|
|
105
|
+
};
|
|
106
|
+
declare class ChannelNameError extends Error {
|
|
107
|
+
constructor(message: string);
|
|
108
|
+
}
|
|
109
|
+
/** Organization-scoped channel: org.{orgId}.{resource}.{resourceId} */
|
|
110
|
+
declare function organizationChannel(orgId: string, resource: string, resourceId: string): ChannelName;
|
|
111
|
+
/** Platform-wide channel: platform.{resource}.{resourceId} */
|
|
112
|
+
declare function platformChannel(resource: string, resourceId: string): ChannelName;
|
|
113
|
+
declare function parseChannelName(name: string): ParsedChannelName;
|
|
114
|
+
declare function tryParseChannelName(name: string): ParsedChannelName | null;
|
|
115
|
+
|
|
116
|
+
export { type ChannelName, ChannelNameError, type ParsedChannelName, type RealtimeConfig, type RealtimeStatus, configureRealtime, isRealtimeConfigured, organizationChannel, parseChannelName, platformChannel, toRealtimeStatus, tryParseChannelName, useChannelEvent, useRealtimeStatus, useUserNotification };
|
package/dist/realtime/index.js
CHANGED
|
@@ -38,4 +38,92 @@ function useUserNotification(channel, handler) {
|
|
|
38
38
|
useEchoNotification(channel, handler);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// src/realtime/channel-name.ts
|
|
42
|
+
var ChannelNameError = class extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "ChannelNameError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var ORG_ID = /^[A-Za-z0-9_-]+$/;
|
|
49
|
+
var RESOURCE = /^[a-z][a-z0-9-]*$/;
|
|
50
|
+
var RESOURCE_ID = /^[A-Za-z0-9_-]+$/;
|
|
51
|
+
function brandChannelName(value) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function assertOrgId(orgId) {
|
|
55
|
+
if (orgId === void 0 || !ORG_ID.test(orgId)) {
|
|
56
|
+
throw new ChannelNameError(`invalid organization id: ${orgId ?? ""}`);
|
|
57
|
+
}
|
|
58
|
+
return orgId;
|
|
59
|
+
}
|
|
60
|
+
function assertResource(resource) {
|
|
61
|
+
if (resource === void 0 || !RESOURCE.test(resource)) {
|
|
62
|
+
throw new ChannelNameError(`invalid resource: ${resource ?? ""}`);
|
|
63
|
+
}
|
|
64
|
+
return resource;
|
|
65
|
+
}
|
|
66
|
+
function assertResourceId(resourceId) {
|
|
67
|
+
if (resourceId === void 0 || !RESOURCE_ID.test(resourceId)) {
|
|
68
|
+
throw new ChannelNameError(`invalid resource id: ${resourceId ?? ""}`);
|
|
69
|
+
}
|
|
70
|
+
return resourceId;
|
|
71
|
+
}
|
|
72
|
+
function organizationChannel(orgId, resource, resourceId) {
|
|
73
|
+
const safeOrgId = assertOrgId(orgId);
|
|
74
|
+
const safeResource = assertResource(resource);
|
|
75
|
+
const safeResourceId = assertResourceId(resourceId);
|
|
76
|
+
return brandChannelName(`org.${safeOrgId}.${safeResource}.${safeResourceId}`);
|
|
77
|
+
}
|
|
78
|
+
function platformChannel(resource, resourceId) {
|
|
79
|
+
const safeResource = assertResource(resource);
|
|
80
|
+
const safeResourceId = assertResourceId(resourceId);
|
|
81
|
+
return brandChannelName(`platform.${safeResource}.${safeResourceId}`);
|
|
82
|
+
}
|
|
83
|
+
function parseChannelName(name) {
|
|
84
|
+
if (name === "") {
|
|
85
|
+
throw new ChannelNameError("channel name is empty");
|
|
86
|
+
}
|
|
87
|
+
const parts = name.split(".");
|
|
88
|
+
if (parts[0] === "org") {
|
|
89
|
+
if (parts.length !== 4) {
|
|
90
|
+
throw new ChannelNameError(`invalid org channel shape: ${name}`);
|
|
91
|
+
}
|
|
92
|
+
const orgId = assertOrgId(parts[1]);
|
|
93
|
+
const resource = assertResource(parts[2]);
|
|
94
|
+
const resourceId = assertResourceId(parts[3]);
|
|
95
|
+
return {
|
|
96
|
+
scope: "org",
|
|
97
|
+
orgId,
|
|
98
|
+
resource,
|
|
99
|
+
resourceId,
|
|
100
|
+
name: brandChannelName(name)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (parts[0] === "platform") {
|
|
104
|
+
if (parts.length !== 3) {
|
|
105
|
+
throw new ChannelNameError(`invalid platform channel shape: ${name}`);
|
|
106
|
+
}
|
|
107
|
+
const resource = assertResource(parts[1]);
|
|
108
|
+
const resourceId = assertResourceId(parts[2]);
|
|
109
|
+
return {
|
|
110
|
+
scope: "platform",
|
|
111
|
+
resource,
|
|
112
|
+
resourceId,
|
|
113
|
+
name: brandChannelName(name)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
throw new ChannelNameError(`unknown channel scope: ${name}`);
|
|
117
|
+
}
|
|
118
|
+
function tryParseChannelName(name) {
|
|
119
|
+
try {
|
|
120
|
+
return parseChannelName(name);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof ChannelNameError) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { ChannelNameError, configureRealtime, isRealtimeConfigured, organizationChannel, parseChannelName, platformChannel, toRealtimeStatus, tryParseChannelName, useChannelEvent, useRealtimeStatus, useUserNotification };
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "GoDX JavaScript SDK
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "GoDX JavaScript SDK \u2014 shared frontend infrastructure for GoDX products.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"sideEffects": false,
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
|
+
"fixtures",
|
|
10
11
|
"LICENSE",
|
|
11
12
|
"README.md"
|
|
12
13
|
],
|
|
13
14
|
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
},
|
|
14
19
|
"./laravel": {
|
|
15
20
|
"types": "./dist/laravel/index.d.ts",
|
|
16
21
|
"import": "./dist/laravel/index.js"
|
|
@@ -19,12 +24,13 @@
|
|
|
19
24
|
"./realtime": {
|
|
20
25
|
"types": "./dist/realtime/index.d.ts",
|
|
21
26
|
"import": "./dist/realtime/index.js"
|
|
22
|
-
}
|
|
27
|
+
},
|
|
28
|
+
"./fixtures/me.v1.json": "./fixtures/me.v1.json"
|
|
23
29
|
},
|
|
24
30
|
"scripts": {
|
|
25
31
|
"build": "tsup",
|
|
26
32
|
"types:check": "tsc --noEmit",
|
|
27
|
-
"test": "vitest run"
|
|
33
|
+
"test": "vitest run --typecheck"
|
|
28
34
|
},
|
|
29
35
|
"devDependencies": {
|
|
30
36
|
"@laravel/echo-react": "^2.5.0",
|