@simpleplatform/sdk 2.2.0 → 2.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 +174 -1
- package/dist/space/core.d.ts +99 -3
- package/dist/space/core.js +211 -1
- package/dist/space/index.d.ts +5 -4
- package/dist/space/index.js +14 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ The TypeScript SDK is organized into focused modules for different capabilities:
|
|
|
40
40
|
| **Security** | `@simpleplatform/sdk/security` | Security policy authoring |
|
|
41
41
|
| **Settings** | `@simpleplatform/sdk/settings` | Application settings retrieval |
|
|
42
42
|
| **Storage** | `@simpleplatform/sdk/storage` | File upload, and reading a stored file's bytes |
|
|
43
|
-
| **Space** | `@simpleplatform/sdk/space` |
|
|
43
|
+
| **Space** | `@simpleplatform/sdk/space` | Records, data, tasks, and documents in a Space |
|
|
44
44
|
|
|
45
45
|
## Embedded Spaces
|
|
46
46
|
|
|
@@ -100,6 +100,10 @@ and tools. In a non-record Space, `simple.data` remains available while
|
|
|
100
100
|
`simple.records.current()` rejects with `SpaceProtocolError` code `unavailable`
|
|
101
101
|
and explains that the Space must be configured as a record view.
|
|
102
102
|
|
|
103
|
+
Each capability beyond `simple.data` is negotiated with the host when the Space
|
|
104
|
+
connects. A capability the host did not negotiate rejects with
|
|
105
|
+
`SpaceProtocolError` code `unavailable` when it is called, and sends nothing.
|
|
106
|
+
|
|
103
107
|
### Space data access
|
|
104
108
|
|
|
105
109
|
Use `simple.data` for application data that is not the record form currently
|
|
@@ -131,6 +135,175 @@ Those commands preserve Record Behaviors, validation, documents, and the shared
|
|
|
131
135
|
record state used by the platform header. `simple.data.mutate()` is for other
|
|
132
136
|
authorized application data; it must not be used to bypass a record workflow.
|
|
133
137
|
|
|
138
|
+
### Space tasks
|
|
139
|
+
|
|
140
|
+
`simple.tasks` creates a task from a task type and replies on a task. Tasks are
|
|
141
|
+
not the page's record, so they work in standalone and record Spaces alike.
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
const { task } = await simple.tasks.create({
|
|
145
|
+
assignedToId: 'USR000005', // Optional: defaults to the user creating the task.
|
|
146
|
+
input: { packet: 'DOC000001' }, // The typed input the task type declares.
|
|
147
|
+
taskTypeId: 'TTY000003',
|
|
148
|
+
title: 'Review the contract packet',
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const { messageId, taskRevision } = await simple.tasks.reply({
|
|
152
|
+
content: 'The revised drawing is attached.',
|
|
153
|
+
inReplyToMessageId: 'MSG000006', // Optional: the message this reply answers.
|
|
154
|
+
taskId: task.id,
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`create()` returns `{ task: { id, status, revision } }`, where `status` is one
|
|
159
|
+
of `queued`, `in_progress`, `waiting`, `completed`, `cancelled`, or `failed`.
|
|
160
|
+
`reply()` returns the new message's ID and the task's revision after the reply.
|
|
161
|
+
|
|
162
|
+
`input` is always a JSON object; pass `{}` when the task type needs no input.
|
|
163
|
+
Everything inside it must be a JSON value (plain objects, arrays, strings,
|
|
164
|
+
finite numbers, booleans, and `null`) so it means the same thing on every
|
|
165
|
+
transport. `null`, an array, or a single value as the input itself is refused.
|
|
166
|
+
The host holds a task type's typed input to 32,768 encoded bytes and 64 levels
|
|
167
|
+
of nesting.
|
|
168
|
+
|
|
169
|
+
`assignedToId`, when given, must name an existing user in the tenant; left out,
|
|
170
|
+
the task is assigned to the user creating it.
|
|
171
|
+
|
|
172
|
+
A request the SDK can tell is incomplete is refused before it is sent, with
|
|
173
|
+
`SpaceProtocolError` code `invalid_request`.
|
|
174
|
+
|
|
175
|
+
When the task service refuses a create or a reply, the call rejects with
|
|
176
|
+
`SpaceProtocolError` code `task_rejected`, whatever the reason. Its `message` is
|
|
177
|
+
the service's message, and its `details` is the service's error exactly as sent:
|
|
178
|
+
`{ code, category, message, pointers, details }`. Neither the host nor the SDK
|
|
179
|
+
adds, drops, or renames anything in it. The reason is `details.code`, not
|
|
180
|
+
`error.code`:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { SpaceProtocolError } from '@simpleplatform/sdk/space'
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await simple.tasks.create({ input: { amount: 700 }, taskTypeId: 'TTY000003', title: 'Open the job' })
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!(error instanceof SpaceProtocolError) || error.code !== 'task_rejected')
|
|
190
|
+
throw error
|
|
191
|
+
|
|
192
|
+
const refusal = error.details as { category: string, code: string, details: unknown, pointers: string[] }
|
|
193
|
+
// refusal.category says whether to correct the request or send it again,
|
|
194
|
+
// refusal.code says why, and refusal.pointers says where.
|
|
195
|
+
console.warn(refusal.category, refusal.code, refusal.pointers)
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`details.category` says whether to correct the request or send it again:
|
|
200
|
+
|
|
201
|
+
- `validation` is final. The request is wrong, and the host keeps nothing of
|
|
202
|
+
the refused create, so correct the request before sending it again.
|
|
203
|
+
- `runtime` means the task service did not answer, not that the request is
|
|
204
|
+
wrong. Its codes are `TASK_RUNTIME_UNAVAILABLE`, `TASK_RUNTIME_TIMEOUT`,
|
|
205
|
+
`TASK_RUNTIME_BUSY`, and `TASK_RUNTIME_REMOTE_FAILURE`. The host keeps the
|
|
206
|
+
pending create, so calling `create()` again with the same arguments resends
|
|
207
|
+
that create under the same task id, and a create that did land is not made
|
|
208
|
+
twice.
|
|
209
|
+
|
|
210
|
+
A create's title, input, and assignee are refused with these `validation`
|
|
211
|
+
codes:
|
|
212
|
+
|
|
213
|
+
| `details.code` | When | `details.pointers` | `details.details` |
|
|
214
|
+
| ----------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------- |
|
|
215
|
+
| `TASK_INPUT_INVALID` | The input fails its task type's input schema | Each issue's `instance_pointer` under `/input`, once each | `{ errors, truncated }` |
|
|
216
|
+
| `TASK_INPUT_INVALID` | The title is longer than 255 characters, or the input is nested deeper than 64 levels | `/title` or `/input` | `{}` |
|
|
217
|
+
| `TASK_INPUT_TOO_LARGE` | The input encodes to more than 32,768 bytes | `/input` | `{ measured_bytes, allowed_bytes }` |
|
|
218
|
+
| `TASK_ASSIGNEE_INVALID` | `assignedToId` is not a user in the tenant | `/assigned_to_id` | `{}` |
|
|
219
|
+
|
|
220
|
+
For a schema failure, `errors` holds at most 50 issues sorted by
|
|
221
|
+
`instance_pointer`, and `truncated` is `true` when there were more. Each issue
|
|
222
|
+
is exactly `{ code, instance_pointer, schema_pointer }` and never carries the
|
|
223
|
+
value that failed. `instance_pointer` is relative to the input, while
|
|
224
|
+
`pointers` carries the `/input` prefix. `schema_pointer` is the location in the
|
|
225
|
+
task type's input schema of the object holding the keyword that failed.
|
|
226
|
+
|
|
227
|
+
For example, a task type whose input schema is
|
|
228
|
+
`{ type: 'object', properties: { amount: { type: 'integer' } }, required: ['job_id'], additionalProperties: false }`
|
|
229
|
+
refuses the input `{ amount: 'seven hundred', note: 'a private note' }` with
|
|
230
|
+
this `error.details`:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"code": "TASK_INPUT_INVALID",
|
|
235
|
+
"category": "validation",
|
|
236
|
+
"message": "The task input is invalid.",
|
|
237
|
+
"pointers": ["/input", "/input/amount", "/input/note"],
|
|
238
|
+
"details": {
|
|
239
|
+
"errors": [
|
|
240
|
+
{ "code": "required", "instance_pointer": "", "schema_pointer": "" },
|
|
241
|
+
{ "code": "type", "instance_pointer": "/amount", "schema_pointer": "/properties/amount" },
|
|
242
|
+
{ "code": "boolean_schema", "instance_pointer": "/note", "schema_pointer": "/additionalProperties" }
|
|
243
|
+
],
|
|
244
|
+
"truncated": false
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A `required` issue points at the object that lacks the member, not at the
|
|
250
|
+
member: above it sits at `/input` and does not name `job_id`.
|
|
251
|
+
|
|
252
|
+
The input `{ notes }`, where `notes` holds 32,769 characters and the input
|
|
253
|
+
encodes to 32,781 bytes, is refused with:
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"code": "TASK_INPUT_TOO_LARGE",
|
|
258
|
+
"category": "validation",
|
|
259
|
+
"message": "The task input is too large. Shorten the request or split the work into more than one task.",
|
|
260
|
+
"pointers": ["/input"],
|
|
261
|
+
"details": { "measured_bytes": 32781, "allowed_bytes": 32768 }
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
An assignee who is not a user in the tenant is refused with:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{
|
|
269
|
+
"code": "TASK_ASSIGNEE_INVALID",
|
|
270
|
+
"category": "validation",
|
|
271
|
+
"message": "The task assignee is invalid.",
|
|
272
|
+
"pointers": ["/assigned_to_id"],
|
|
273
|
+
"details": {}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Pointers name the members of the task service's command, not the SDK's
|
|
278
|
+
arguments, so the assignee is `/assigned_to_id` rather than `assignedToId`. A
|
|
279
|
+
refused `reply()` arrives the same way, with the service's own code in
|
|
280
|
+
`details.code` and the same categories: after a `runtime` refusal, calling
|
|
281
|
+
`reply()` again with the same arguments resends the kept message rather than
|
|
282
|
+
posting it twice.
|
|
283
|
+
|
|
284
|
+
### Space documents
|
|
285
|
+
|
|
286
|
+
`simple.documents.stage()` stores a file without attaching it to any record and
|
|
287
|
+
returns its handle. The host uploads it, so a large file never travels inside
|
|
288
|
+
an action request. The file's bytes are transferred to the host, not copied.
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
const picker = document.querySelector<HTMLInputElement>('#packet')!
|
|
292
|
+
const { handle } = await simple.documents.stage({ file: picker.files![0] })
|
|
293
|
+
|
|
294
|
+
// A plain Blob has no name of its own, so it needs one.
|
|
295
|
+
await simple.documents.stage({
|
|
296
|
+
file: new Blob([csv]),
|
|
297
|
+
mimeType: 'text/csv',
|
|
298
|
+
name: 'quantities.csv',
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The handle is `{ file_hash, filename, mime_type, size, storage_path, scope? }`.
|
|
303
|
+
`name` defaults to a `File`'s own name, and `mimeType` to the file's type, then
|
|
304
|
+
to `application/octet-stream`. A staged document is not attached to anything
|
|
305
|
+
yet; attach its handle through the workflow that owns the record.
|
|
306
|
+
|
|
134
307
|
---
|
|
135
308
|
|
|
136
309
|
## API Documentation
|
package/dist/space/core.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DocumentHandle } from '../types.js';
|
|
1
2
|
export declare const PROTOCOL_VERSION: 1;
|
|
2
3
|
export type SpaceContext = {
|
|
3
4
|
applicationId: string;
|
|
@@ -29,6 +30,66 @@ export interface RecordSnapshot {
|
|
|
29
30
|
values: Readonly<Record<string, unknown>>;
|
|
30
31
|
}
|
|
31
32
|
export type GraphQLVariables = Readonly<Record<string, unknown>>;
|
|
33
|
+
/** A value that survives a JSON round trip unchanged. */
|
|
34
|
+
export type JsonValue = boolean | JsonObject | null | number | string | readonly JsonValue[];
|
|
35
|
+
/** A JSON object: not `null`, not an array, and not a single value. */
|
|
36
|
+
export interface JsonObject {
|
|
37
|
+
readonly [key: string]: JsonValue;
|
|
38
|
+
}
|
|
39
|
+
/** The platform-standard status of a task. */
|
|
40
|
+
export type TaskStatus = 'cancelled' | 'completed' | 'failed' | 'in_progress' | 'queued' | 'waiting';
|
|
41
|
+
export interface TaskCreateInput {
|
|
42
|
+
/** The user the task is assigned to. Omit it to assign the task to its creator. */
|
|
43
|
+
assignedToId?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The typed input the task type declares for its tasks. It is always a JSON
|
|
46
|
+
* object, even when the task type needs nothing: then it is `{}`.
|
|
47
|
+
*/
|
|
48
|
+
input: JsonObject;
|
|
49
|
+
/** The ID of the task type the task is created from. */
|
|
50
|
+
taskTypeId: string;
|
|
51
|
+
title: string;
|
|
52
|
+
}
|
|
53
|
+
export interface TaskCreateResult {
|
|
54
|
+
task: {
|
|
55
|
+
id: string;
|
|
56
|
+
revision: number;
|
|
57
|
+
status: TaskStatus;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export interface TaskReplyInput {
|
|
61
|
+
content: string;
|
|
62
|
+
/** The message this reply answers, when it answers one. */
|
|
63
|
+
inReplyToMessageId?: string;
|
|
64
|
+
taskId: string;
|
|
65
|
+
}
|
|
66
|
+
export interface TaskReplyResult {
|
|
67
|
+
messageId: string;
|
|
68
|
+
/** The task's revision after the reply was recorded. */
|
|
69
|
+
taskRevision: number;
|
|
70
|
+
}
|
|
71
|
+
export interface SimpleTasksClient {
|
|
72
|
+
create: (task: TaskCreateInput) => Promise<TaskCreateResult>;
|
|
73
|
+
reply: (reply: TaskReplyInput) => Promise<TaskReplyResult>;
|
|
74
|
+
}
|
|
75
|
+
/** A stored file that is not yet attached to any record. */
|
|
76
|
+
export interface StagedDocumentHandle extends DocumentHandle {
|
|
77
|
+
scope?: 'ephemeral' | 'record' | 'staged';
|
|
78
|
+
}
|
|
79
|
+
export interface DocumentStageInput {
|
|
80
|
+
/** The file to stage. Its bytes are transferred to the host, not copied. */
|
|
81
|
+
file: Blob | File;
|
|
82
|
+
/** Defaults to the file's own type, then to `application/octet-stream`. */
|
|
83
|
+
mimeType?: string;
|
|
84
|
+
/** Defaults to the name of a `File`. A plain `Blob` has none, so it needs one. */
|
|
85
|
+
name?: string;
|
|
86
|
+
}
|
|
87
|
+
export interface DocumentStageResult {
|
|
88
|
+
handle: StagedDocumentHandle;
|
|
89
|
+
}
|
|
90
|
+
export interface SimpleDocumentsClient {
|
|
91
|
+
stage: (document: DocumentStageInput) => Promise<DocumentStageResult>;
|
|
92
|
+
}
|
|
32
93
|
export interface SpaceDataTransport {
|
|
33
94
|
execute: <TResult = unknown>(document: string, variables?: GraphQLVariables) => Promise<TResult>;
|
|
34
95
|
}
|
|
@@ -45,9 +106,11 @@ export interface RecordHandle {
|
|
|
45
106
|
export interface SimpleClient {
|
|
46
107
|
context: SpaceContext;
|
|
47
108
|
data: SimpleDataClient;
|
|
109
|
+
documents: SimpleDocumentsClient;
|
|
48
110
|
records: {
|
|
49
111
|
current: () => Promise<RecordHandle>;
|
|
50
112
|
};
|
|
113
|
+
tasks: SimpleTasksClient;
|
|
51
114
|
}
|
|
52
115
|
export interface SpaceProtocolErrorPayload {
|
|
53
116
|
code: string;
|
|
@@ -104,7 +167,30 @@ export interface RecordSubmitResult {
|
|
|
104
167
|
ok: boolean;
|
|
105
168
|
snapshot: RecordSnapshot;
|
|
106
169
|
}
|
|
107
|
-
export
|
|
170
|
+
export interface TaskCreateRequest {
|
|
171
|
+
operation: 'task.create';
|
|
172
|
+
payload: TaskCreateInput;
|
|
173
|
+
protocol: typeof PROTOCOL_VERSION;
|
|
174
|
+
requestId: string;
|
|
175
|
+
}
|
|
176
|
+
export interface TaskReplyRequest {
|
|
177
|
+
operation: 'task.reply';
|
|
178
|
+
payload: TaskReplyInput;
|
|
179
|
+
protocol: typeof PROTOCOL_VERSION;
|
|
180
|
+
requestId: string;
|
|
181
|
+
}
|
|
182
|
+
export interface DocumentStageRequest {
|
|
183
|
+
operation: 'document.stage';
|
|
184
|
+
payload: {
|
|
185
|
+
/** Transferred with the request, so it is detached in the Space afterwards. */
|
|
186
|
+
bytes: ArrayBuffer;
|
|
187
|
+
mimeType: string;
|
|
188
|
+
name: string;
|
|
189
|
+
};
|
|
190
|
+
protocol: typeof PROTOCOL_VERSION;
|
|
191
|
+
requestId: string;
|
|
192
|
+
}
|
|
193
|
+
export type ProtocolRequest = CurrentRecordRequest | DocumentStageRequest | RecordSubmitRequest | RecordUpdateRequest | TaskCreateRequest | TaskReplyRequest;
|
|
108
194
|
export interface ProtocolSuccessResponse<TResult> {
|
|
109
195
|
ok: true;
|
|
110
196
|
protocol: typeof PROTOCOL_VERSION;
|
|
@@ -119,12 +205,22 @@ export interface ProtocolErrorResponse {
|
|
|
119
205
|
}
|
|
120
206
|
export type ProtocolResponse<TResult> = ProtocolErrorResponse | ProtocolSuccessResponse<TResult>;
|
|
121
207
|
export interface SpaceTransport {
|
|
122
|
-
|
|
208
|
+
/**
|
|
209
|
+
* `transfer` names buffers inside the request whose ownership moves to the
|
|
210
|
+
* host with it instead of being copied. A transport that cannot transfer
|
|
211
|
+
* sends them by value.
|
|
212
|
+
*/
|
|
213
|
+
request: <TResult>(request: ProtocolRequest, transfer?: ArrayBuffer[]) => Promise<ProtocolResponse<TResult>>;
|
|
123
214
|
}
|
|
124
215
|
export interface SimpleClientOptions {
|
|
125
216
|
context?: SpaceContext;
|
|
126
217
|
dataTransport?: SpaceDataTransport;
|
|
218
|
+
/** Present only when the host negotiated the document protocol. */
|
|
219
|
+
documentTransport?: SpaceTransport;
|
|
127
220
|
nextRequestId?: () => string;
|
|
221
|
+
/** Present only when the host negotiated the task protocol. */
|
|
222
|
+
taskTransport?: SpaceTransport;
|
|
223
|
+
/** Present only when the host negotiated the record protocol. */
|
|
128
224
|
transport?: SpaceTransport;
|
|
129
225
|
}
|
|
130
226
|
/**
|
|
@@ -134,5 +230,5 @@ export interface SimpleClientOptions {
|
|
|
134
230
|
* contract can be exercised in a first-party direct adapter and in any UI
|
|
135
231
|
* framework without importing browser-specific code.
|
|
136
232
|
*/
|
|
137
|
-
export declare function createSimpleClient({ context, dataTransport, nextRequestId, transport, }: SimpleClientOptions): SimpleClient;
|
|
233
|
+
export declare function createSimpleClient({ context, dataTransport, documentTransport, nextRequestId, taskTransport, transport, }: SimpleClientOptions): SimpleClient;
|
|
138
234
|
export declare function isSpaceContext(value: unknown): value is SpaceContext;
|
package/dist/space/core.js
CHANGED
|
@@ -34,7 +34,7 @@ export class SpaceDataError extends Error {
|
|
|
34
34
|
* contract can be exercised in a first-party direct adapter and in any UI
|
|
35
35
|
* framework without importing browser-specific code.
|
|
36
36
|
*/
|
|
37
|
-
export function createSimpleClient({ context = { kind: 'standalone' }, dataTransport, nextRequestId = createRequestId, transport, }) {
|
|
37
|
+
export function createSimpleClient({ context = { kind: 'standalone' }, dataTransport, documentTransport, nextRequestId = createRequestId, taskTransport, transport, }) {
|
|
38
38
|
const immutableContext = immutableSpaceContext(context);
|
|
39
39
|
return {
|
|
40
40
|
context: immutableContext,
|
|
@@ -42,6 +42,7 @@ export function createSimpleClient({ context = { kind: 'standalone' }, dataTrans
|
|
|
42
42
|
mutate: (document, variables) => executeData(dataTransport, document, variables),
|
|
43
43
|
query: (document, variables) => executeData(dataTransport, document, variables),
|
|
44
44
|
},
|
|
45
|
+
documents: createDocumentsClient(documentTransport, nextRequestId),
|
|
45
46
|
records: {
|
|
46
47
|
async current() {
|
|
47
48
|
if (immutableContext.kind !== 'record') {
|
|
@@ -70,6 +71,7 @@ export function createSimpleClient({ context = { kind: 'standalone' }, dataTrans
|
|
|
70
71
|
return new ProtocolRecordHandle(result, nextRequestId, transport);
|
|
71
72
|
},
|
|
72
73
|
},
|
|
74
|
+
tasks: createTasksClient(taskTransport, nextRequestId),
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
export function isSpaceContext(value) {
|
|
@@ -97,6 +99,83 @@ function executeData(dataTransport, document, variables) {
|
|
|
97
99
|
}
|
|
98
100
|
return dataTransport.execute(document, variables);
|
|
99
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* A staged document is stored without being attached to a record, so staging
|
|
104
|
+
* is available in any Space whose host negotiated the document protocol.
|
|
105
|
+
*/
|
|
106
|
+
function createDocumentsClient(transport, nextRequestId) {
|
|
107
|
+
return {
|
|
108
|
+
async stage(document) {
|
|
109
|
+
if (!transport) {
|
|
110
|
+
throw new SpaceProtocolError({
|
|
111
|
+
code: 'unavailable',
|
|
112
|
+
message: 'Documents are unavailable because the Space host did not negotiate the document protocol.',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const { file, mimeType, name } = readDocumentStageInput(document);
|
|
116
|
+
// A Blob cannot itself be transferred. Its bytes are read into a buffer
|
|
117
|
+
// once, and that buffer is handed to the host rather than copied again.
|
|
118
|
+
const bytes = await file.arrayBuffer();
|
|
119
|
+
const request = {
|
|
120
|
+
operation: 'document.stage',
|
|
121
|
+
payload: { bytes, mimeType, name },
|
|
122
|
+
protocol: PROTOCOL_VERSION,
|
|
123
|
+
requestId: nextRequestId(),
|
|
124
|
+
};
|
|
125
|
+
const response = await transport.request(request, [bytes]);
|
|
126
|
+
const result = readResponse(response, request);
|
|
127
|
+
if (!isDocumentStageResult(result))
|
|
128
|
+
throw invalidResponse('The document-stage response is malformed.');
|
|
129
|
+
return { handle: { ...result.handle } };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Tasks are not tied to a page record, so they are available in any Space
|
|
135
|
+
* whose host negotiated the task protocol, standalone or record.
|
|
136
|
+
*/
|
|
137
|
+
function createTasksClient(transport, nextRequestId) {
|
|
138
|
+
const requireTransport = () => {
|
|
139
|
+
if (!transport) {
|
|
140
|
+
throw new SpaceProtocolError({
|
|
141
|
+
code: 'unavailable',
|
|
142
|
+
message: 'Tasks are unavailable because the Space host did not negotiate the task protocol.',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return transport;
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
async create(task) {
|
|
149
|
+
const taskTransport = requireTransport();
|
|
150
|
+
const request = {
|
|
151
|
+
operation: 'task.create',
|
|
152
|
+
payload: readTaskCreatePayload(task),
|
|
153
|
+
protocol: PROTOCOL_VERSION,
|
|
154
|
+
requestId: nextRequestId(),
|
|
155
|
+
};
|
|
156
|
+
const response = await taskTransport.request(request);
|
|
157
|
+
const result = readResponse(response, request);
|
|
158
|
+
if (!isTaskCreateResult(result))
|
|
159
|
+
throw invalidResponse('The task-create response is malformed.');
|
|
160
|
+
const { id, revision, status } = result.task;
|
|
161
|
+
return { task: { id, revision, status } };
|
|
162
|
+
},
|
|
163
|
+
async reply(reply) {
|
|
164
|
+
const taskTransport = requireTransport();
|
|
165
|
+
const request = {
|
|
166
|
+
operation: 'task.reply',
|
|
167
|
+
payload: readTaskReplyPayload(reply),
|
|
168
|
+
protocol: PROTOCOL_VERSION,
|
|
169
|
+
requestId: nextRequestId(),
|
|
170
|
+
};
|
|
171
|
+
const response = await taskTransport.request(request);
|
|
172
|
+
const result = readResponse(response, request);
|
|
173
|
+
if (!isTaskReplyResult(result))
|
|
174
|
+
throw invalidResponse('The task-reply response is malformed.');
|
|
175
|
+
return { messageId: result.messageId, taskRevision: result.taskRevision };
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
100
179
|
class ProtocolRecordHandle {
|
|
101
180
|
constructor({ sessionId, snapshot }, nextRequestId, transport) {
|
|
102
181
|
_ProtocolRecordHandle_nextRequestId.set(this, void 0);
|
|
@@ -168,6 +247,137 @@ function deepFreeze(value) {
|
|
|
168
247
|
function invalidResponse(message) {
|
|
169
248
|
return new SpaceProtocolError({ code: 'invalid_response', message });
|
|
170
249
|
}
|
|
250
|
+
function invalidRequest(message) {
|
|
251
|
+
return new SpaceProtocolError({ code: 'invalid_request', message });
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Checks a task before it is sent, so a plain-JavaScript caller learns what is
|
|
255
|
+
* wrong without a host round trip. The payload names only the members the
|
|
256
|
+
* operation defines; an optional member left out is not sent at all.
|
|
257
|
+
*/
|
|
258
|
+
function readTaskCreatePayload(task) {
|
|
259
|
+
if (!isObjectRecord(task))
|
|
260
|
+
throw invalidRequest('A task needs a title, a task type, and its input.');
|
|
261
|
+
if (!isNonBlankString(task.title))
|
|
262
|
+
throw invalidRequest('A task needs a title.');
|
|
263
|
+
if (!isNonBlankString(task.taskTypeId))
|
|
264
|
+
throw invalidRequest('A task needs the ID of its task type.');
|
|
265
|
+
if (!isJsonObject(task.input))
|
|
266
|
+
throw invalidRequest('A task input must be a JSON object, and everything in it a JSON value.');
|
|
267
|
+
if (task.assignedToId !== undefined && !isNonBlankString(task.assignedToId))
|
|
268
|
+
throw invalidRequest('A task assignee must be a user ID.');
|
|
269
|
+
return {
|
|
270
|
+
...(task.assignedToId === undefined ? {} : { assignedToId: task.assignedToId }),
|
|
271
|
+
input: task.input,
|
|
272
|
+
taskTypeId: task.taskTypeId,
|
|
273
|
+
title: task.title,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function readTaskReplyPayload(reply) {
|
|
277
|
+
if (!isObjectRecord(reply))
|
|
278
|
+
throw invalidRequest('A task reply needs a task ID and its content.');
|
|
279
|
+
if (!isNonBlankString(reply.taskId))
|
|
280
|
+
throw invalidRequest('A task reply needs the ID of its task.');
|
|
281
|
+
if (!isNonBlankString(reply.content))
|
|
282
|
+
throw invalidRequest('A task reply needs content.');
|
|
283
|
+
if (reply.inReplyToMessageId !== undefined && !isNonBlankString(reply.inReplyToMessageId))
|
|
284
|
+
throw invalidRequest('A task reply can answer only a message ID.');
|
|
285
|
+
return {
|
|
286
|
+
content: reply.content,
|
|
287
|
+
...(reply.inReplyToMessageId === undefined ? {} : { inReplyToMessageId: reply.inReplyToMessageId }),
|
|
288
|
+
taskId: reply.taskId,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The task.create contract takes an object for input, never null, an array, or
|
|
293
|
+
* a single value, so a caller learns that here instead of from the host.
|
|
294
|
+
*/
|
|
295
|
+
function isJsonObject(value) {
|
|
296
|
+
return isObjectRecord(value) && isJsonValue(value);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Accepts only what JSON carries unchanged. A structured clone would pass a
|
|
300
|
+
* Date, Map, or class instance through a MessagePort and a JSON transport
|
|
301
|
+
* would not, so the task input is held to JSON on every transport.
|
|
302
|
+
*/
|
|
303
|
+
function isJsonValue(value, ancestors = new Set()) {
|
|
304
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'string')
|
|
305
|
+
return true;
|
|
306
|
+
if (typeof value === 'number')
|
|
307
|
+
return Number.isFinite(value);
|
|
308
|
+
if (!value || typeof value !== 'object' || ancestors.has(value))
|
|
309
|
+
return false;
|
|
310
|
+
const prototype = Object.getPrototypeOf(value);
|
|
311
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
|
|
312
|
+
return false;
|
|
313
|
+
ancestors.add(value);
|
|
314
|
+
const valid = Object.values(value).every(child => isJsonValue(child, ancestors));
|
|
315
|
+
ancestors.delete(value);
|
|
316
|
+
return valid;
|
|
317
|
+
}
|
|
318
|
+
function readDocumentStageInput(document) {
|
|
319
|
+
if (!isObjectRecord(document) || !isBlob(document.file))
|
|
320
|
+
throw invalidRequest('A staged document needs a File or Blob.');
|
|
321
|
+
const name = document.name ?? readFileName(document.file);
|
|
322
|
+
if (!isNonBlankString(name))
|
|
323
|
+
throw invalidRequest('A staged document needs a name, and a Blob has none of its own.');
|
|
324
|
+
if (document.mimeType !== undefined && typeof document.mimeType !== 'string')
|
|
325
|
+
throw invalidRequest('A staged document type must be a MIME type.');
|
|
326
|
+
return {
|
|
327
|
+
file: document.file,
|
|
328
|
+
mimeType: document.mimeType || document.file.type || 'application/octet-stream',
|
|
329
|
+
name,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
/** Duck-typed so a Blob from another realm, or a test double, is accepted. */
|
|
333
|
+
function isBlob(value) {
|
|
334
|
+
if (!isObjectRecord(value))
|
|
335
|
+
return false;
|
|
336
|
+
return typeof value.arrayBuffer === 'function' && typeof value.size === 'number' && typeof value.type === 'string';
|
|
337
|
+
}
|
|
338
|
+
function readFileName(file) {
|
|
339
|
+
const { name } = file;
|
|
340
|
+
return typeof name === 'string' ? name : undefined;
|
|
341
|
+
}
|
|
342
|
+
function isNonBlankString(value) {
|
|
343
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
344
|
+
}
|
|
345
|
+
function isNonNegativeInteger(value) {
|
|
346
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
347
|
+
}
|
|
348
|
+
const DOCUMENT_SCOPES = new Set([
|
|
349
|
+
'ephemeral',
|
|
350
|
+
'record',
|
|
351
|
+
'staged',
|
|
352
|
+
]);
|
|
353
|
+
function isDocumentStageResult(value) {
|
|
354
|
+
if (!isObjectRecord(value) || !isObjectRecord(value.handle))
|
|
355
|
+
return false;
|
|
356
|
+
const handle = value.handle;
|
|
357
|
+
return isNonBlankString(handle.file_hash)
|
|
358
|
+
&& isNonBlankString(handle.filename)
|
|
359
|
+
&& typeof handle.mime_type === 'string'
|
|
360
|
+
&& isNonNegativeInteger(handle.size)
|
|
361
|
+
&& isNonBlankString(handle.storage_path)
|
|
362
|
+
&& (handle.scope === undefined || DOCUMENT_SCOPES.has(handle.scope));
|
|
363
|
+
}
|
|
364
|
+
const TASK_STATUSES = new Set([
|
|
365
|
+
'cancelled',
|
|
366
|
+
'completed',
|
|
367
|
+
'failed',
|
|
368
|
+
'in_progress',
|
|
369
|
+
'queued',
|
|
370
|
+
'waiting',
|
|
371
|
+
]);
|
|
372
|
+
function isTaskCreateResult(value) {
|
|
373
|
+
if (!isObjectRecord(value) || !isObjectRecord(value.task))
|
|
374
|
+
return false;
|
|
375
|
+
const { id, revision, status } = value.task;
|
|
376
|
+
return isNonBlankString(id) && isNonNegativeInteger(revision) && TASK_STATUSES.has(status);
|
|
377
|
+
}
|
|
378
|
+
function isTaskReplyResult(value) {
|
|
379
|
+
return isObjectRecord(value) && isNonBlankString(value.messageId) && isNonNegativeInteger(value.taskRevision);
|
|
380
|
+
}
|
|
171
381
|
function isCurrentRecordResult(value) {
|
|
172
382
|
if (!value || typeof value !== 'object')
|
|
173
383
|
return false;
|
package/dist/space/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { SimpleClient } from './core.js';
|
|
2
2
|
import { SpaceDataError, SpaceProtocolError } from './core.js';
|
|
3
3
|
export { SpaceDataError, SpaceProtocolError, };
|
|
4
|
-
export type { GraphQLVariables, RecordErrorSnapshot, RecordFieldSnapshot, RecordFormError, RecordHandle, RecordSnapshot, RecordSubmitResult, RecordUpdateResult, SimpleClient, SimpleDataClient, SpaceContext, SpaceDataErrorPayload, SpaceProtocolErrorPayload, } from './core.js';
|
|
4
|
+
export type { DocumentStageInput, DocumentStageResult, GraphQLVariables, JsonObject, JsonValue, RecordErrorSnapshot, RecordFieldSnapshot, RecordFormError, RecordHandle, RecordSnapshot, RecordSubmitResult, RecordUpdateResult, SimpleClient, SimpleDataClient, SimpleDocumentsClient, SimpleTasksClient, SpaceContext, SpaceDataErrorPayload, SpaceProtocolErrorPayload, StagedDocumentHandle, TaskCreateInput, TaskCreateResult, TaskReplyInput, TaskReplyResult, TaskStatus, } from './core.js';
|
|
5
5
|
interface MessagePortLike {
|
|
6
6
|
onmessage: null | ((event: {
|
|
7
7
|
data: unknown;
|
|
8
8
|
}) => void);
|
|
9
|
-
postMessage: (message: unknown) => void;
|
|
9
|
+
postMessage: (message: unknown, transfer?: ArrayBuffer[]) => void;
|
|
10
10
|
start?: () => void;
|
|
11
11
|
}
|
|
12
12
|
export interface SpaceWindowLike {
|
|
@@ -27,7 +27,8 @@ export interface ConnectSpaceOptions {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Connects any embedded Space to its parent through the dedicated MessagePort
|
|
30
|
-
* handshake.
|
|
31
|
-
*
|
|
30
|
+
* handshake. Each protocol capability is offered in `SPACE_READY` and becomes
|
|
31
|
+
* available only when the host negotiates it in `INIT_RPC`: record operations
|
|
32
|
+
* for a configured record view, and task and document operations in any Space.
|
|
32
33
|
*/
|
|
33
34
|
export declare function connectSpace({ targetOrigin, window }: ConnectSpaceOptions): Promise<SimpleClient>;
|
package/dist/space/index.js
CHANGED
|
@@ -2,8 +2,9 @@ import { createSimpleClient, isSpaceContext, PROTOCOL_VERSION, SpaceDataError, S
|
|
|
2
2
|
export { SpaceDataError, SpaceProtocolError, };
|
|
3
3
|
/**
|
|
4
4
|
* Connects any embedded Space to its parent through the dedicated MessagePort
|
|
5
|
-
* handshake.
|
|
6
|
-
*
|
|
5
|
+
* handshake. Each protocol capability is offered in `SPACE_READY` and becomes
|
|
6
|
+
* available only when the host negotiates it in `INIT_RPC`: record operations
|
|
7
|
+
* for a configured record view, and task and document operations in any Space.
|
|
7
8
|
*/
|
|
8
9
|
export function connectSpace({ targetOrigin, window = globalThis.window }) {
|
|
9
10
|
if (!window) {
|
|
@@ -33,18 +34,22 @@ export function connectSpace({ targetOrigin, window = globalThis.window }) {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
const transport = createMessagePortTransport(port);
|
|
36
|
-
const
|
|
37
|
-
? transport
|
|
38
|
-
: undefined;
|
|
37
|
+
const protocols = event.data.protocols;
|
|
39
38
|
resolve(createSimpleClient({
|
|
40
39
|
context: event.data.context,
|
|
41
40
|
dataTransport: transport,
|
|
42
|
-
transport:
|
|
41
|
+
documentTransport: protocols?.document === PROTOCOL_VERSION ? transport : undefined,
|
|
42
|
+
taskTransport: protocols?.task === PROTOCOL_VERSION ? transport : undefined,
|
|
43
|
+
transport: protocols?.record === PROTOCOL_VERSION ? transport : undefined,
|
|
43
44
|
}));
|
|
44
45
|
};
|
|
45
46
|
window.addEventListener('message', onMessage);
|
|
46
47
|
window.parent.postMessage({
|
|
47
|
-
protocols: {
|
|
48
|
+
protocols: {
|
|
49
|
+
document: [PROTOCOL_VERSION],
|
|
50
|
+
record: [PROTOCOL_VERSION],
|
|
51
|
+
task: [PROTOCOL_VERSION],
|
|
52
|
+
},
|
|
48
53
|
type: 'SPACE_READY',
|
|
49
54
|
}, targetOrigin);
|
|
50
55
|
});
|
|
@@ -94,12 +99,12 @@ function createMessagePortTransport(port) {
|
|
|
94
99
|
});
|
|
95
100
|
});
|
|
96
101
|
},
|
|
97
|
-
request: (request) => {
|
|
102
|
+
request: (request, transfer = []) => {
|
|
98
103
|
return new Promise((resolve) => {
|
|
99
104
|
pending.set(request.requestId, {
|
|
100
105
|
resolve: response => resolve(response),
|
|
101
106
|
});
|
|
102
|
-
port.postMessage({ request, type: 'SPACE_PROTOCOL_REQUEST' });
|
|
107
|
+
port.postMessage({ request, type: 'SPACE_PROTOCOL_REQUEST' }, transfer);
|
|
103
108
|
});
|
|
104
109
|
},
|
|
105
110
|
};
|