@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 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 | Contents | Cadence |
12
- | --- | --- | --- |
13
- | `.` — not yet published | 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 |
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
- `.` is deliberately **absent from `exports`** until the contracts it would carry are agreed. An empty entry published for the sake of symmetry is the thing to avoid.
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 '@godxjp/sdk/laravel';
70
+ import { createClient, ApiError } from "@godxjp/sdk/laravel";
23
71
 
24
72
  export const apiClient = createClient({
25
- baseUrl: '/api/app/v1',
26
- signInUrl: (returnTo) => `/login?return=${encodeURIComponent(returnTo)}`,
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: '/api' });
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
- configureRealtime,
43
- isRealtimeConfigured,
44
- useChannelEvent,
45
- useRealtimeStatus,
46
- useUserNotification,
47
- } from '@godxjp/sdk/realtime';
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 = 'connecting' | 'connected' | 'unavailable';
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.
@@ -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 };
@@ -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
- /** `FormData` goes out as multipart untouched; anything else is JSON-encoded. */
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 };
@@ -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, { ...init, credentials: "same-origin" }).then(
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
- resolve({
103
- status: xhr.status,
104
- statusText: xhr.statusText,
105
- text: xhr.responseText
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
- async function request(config, method, path, body, { signal, query, onUploadProgress }) {
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
- { method, headers, body: payload, signal },
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 new ApiError(0, UNREACHABLE);
162
+ throw unreachable(config);
146
163
  }
147
- if (response.status === 204) {
148
- return void 0;
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 = response.text === "" ? void 0 : JSON.parse(response.text);
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.statusText,
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(config, "GET", path, void 0, options),
174
- post: (path, body, options = {}) => request(config, "POST", path, body, options),
175
- put: (path, body, options = {}) => request(config, "PUT", path, body, options),
176
- patch: (path, body, options = {}) => request(config, "PATCH", path, body, options),
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(config, "DELETE", path, void 0, options)
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
 
@@ -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 = 'connecting' | 'connected' | 'unavailable';
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: 'http' | 'https';
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
- export { type RealtimeConfig, type RealtimeStatus, configureRealtime, isRealtimeConfigured, toRealtimeStatus, useChannelEvent, useRealtimeStatus, useUserNotification };
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 };
@@ -38,4 +38,92 @@ function useUserNotification(channel, handler) {
38
38
  useEchoNotification(channel, handler);
39
39
  }
40
40
 
41
- export { configureRealtime, isRealtimeConfigured, toRealtimeStatus, useChannelEvent, useRealtimeStatus, useUserNotification };
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 };
@@ -0,0 +1,9 @@
1
+ {
2
+ "user": {
3
+ "id": 42,
4
+ "name": "Ada Lovelace",
5
+ "email": "ada@godx.jp"
6
+ },
7
+ "permissions": ["issues.view", "issues.create"],
8
+ "roles": ["member", "editor"]
9
+ }
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@godxjp/sdk",
3
- "version": "0.1.0",
4
- "description": "GoDX JavaScript SDK — shared frontend infrastructure for GoDX products.",
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",