@simpleplatform/sdk 2.0.1 → 2.1.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
@@ -39,7 +39,7 @@ The TypeScript SDK is organized into focused modules for different capabilities:
39
39
  | **HTTP** | `@simpleplatform/sdk/http` | External HTTP requests |
40
40
  | **Security** | `@simpleplatform/sdk/security` | Security policy authoring |
41
41
  | **Settings** | `@simpleplatform/sdk/settings` | Application settings retrieval |
42
- | **Storage** | `@simpleplatform/sdk/storage` | File upload and management |
42
+ | **Storage** | `@simpleplatform/sdk/storage` | File upload, and reading a stored file's bytes |
43
43
  | **Space** | `@simpleplatform/sdk/space` | Behavior-aware record workflows in a Space |
44
44
 
45
45
  ## Embedded Spaces
@@ -274,8 +274,23 @@ const response = await http.fetch(
274
274
  },
275
275
  request.context
276
276
  )
277
+
278
+ // A browser request that carries the page's cookies
279
+ const session = await http.fetch(
280
+ {
281
+ credentials: 'include',
282
+ url: 'https://api.example.com/session'
283
+ },
284
+ request.context
285
+ )
277
286
  ```
278
287
 
288
+ `credentials` is the browser's credentials mode for the request: `'omit'`,
289
+ `'same-origin'` or `'include'`. A request that names none sends none, and the
290
+ browser host then omits credentials. A cross-origin request that includes them
291
+ still needs the endpoint to allow the page's origin and credentials through
292
+ CORS. The server host has no browser credentials and ignores the option.
293
+
279
294
  ### Security Module
280
295
 
281
296
  Define declarative security policies with a fluent, global-style API:
@@ -370,6 +385,29 @@ console.log(documentHandle.mime_type) // "application/pdf"
370
385
  console.log(documentHandle.size) // File size in bytes
371
386
  ```
372
387
 
388
+ The way back out takes the same handle and answers with the file's bytes:
389
+
390
+ ```typescript
391
+ import { read, readRange, size } from '@simpleplatform/sdk/storage'
392
+
393
+ const bytes: Uint8Array = await read(documentHandle, request.context)
394
+
395
+ const length = await size(documentHandle, request.context) // without reading any of it
396
+ const head = await readRange(documentHandle, 0, 1024, request.context) // the first kilobyte
397
+ ```
398
+
399
+ The bytes cross from the host as they are, with no JSON and no base64. `read`
400
+ asks for the size first and reads the file in ranges of at most
401
+ `MAX_RANGE_BYTES` (16 MiB): a file that fits one range arrives as one array, and
402
+ a larger one is read into a single buffer allocated once at exactly its size.
403
+ `readRange` asks for the size first too, so a range that runs past the end of
404
+ the file answers with exactly the bytes up to the end, and one that starts at or
405
+ past the end is refused before any of it is read.
406
+
407
+ Reading is for server actions; a browser action is refused. It needs a runtime
408
+ plugin that provides `__host.callBytes`, and an older plugin is refused with a
409
+ message saying so rather than handed bytes it would try to read as JSON.
410
+
373
411
  ### Type Definitions
374
412
 
375
413
  The TypeScript SDK is **fully typed** with comprehensive TypeScript definitions. Leverage IDE autocompletion and compile-time type checking:
package/dist/host.d.ts CHANGED
@@ -7,6 +7,21 @@ import type { Context, SimpleResponse } from './types';
7
7
  * parsed response as a value.
8
8
  */
9
9
  export declare function execute<T = any>(actionName: string, params: any, context: Context): SimpleResponse<T>;
10
+ /**
11
+ * Calls an action whose reply is a run of bytes, such as a range of a stored
12
+ * file, and returns them.
13
+ *
14
+ * The runtime hands the bytes over as a `Uint8Array` that owns them: no JSON,
15
+ * no base64, and no address. A host that refused answers with its ordinary
16
+ * envelope instead, returned here as a failed response, so a caller checks
17
+ * `ok` exactly as it does for `execute`. Its message names the call — `<action>
18
+ * failed: <the host's reason>` — worded as the Rust and Go SDKs word it.
19
+ *
20
+ * A runtime plugin released before this call existed does not provide it, and
21
+ * is refused here, before anything is sent, rather than handed a reply it
22
+ * would try to read as JSON.
23
+ */
24
+ export declare function executeBytes(actionName: string, params: any, context: Context): SimpleResponse<Uint8Array>;
10
25
  /**
11
26
  * Calls an action on the host without waiting for it to answer.
12
27
  */
package/dist/host.js CHANGED
@@ -11,6 +11,7 @@ function assertRuntimeAbi() {
11
11
  throw new Error(ABI_MISMATCH_MESSAGE);
12
12
  }
13
13
  }
14
+ const BYTES_ABI_MISSING_MESSAGE = 'Reading a reply as bytes needs __host.callBytes, which this runtime plugin does not provide. Install a runtime plugin released with the matching SDK.';
14
15
  /**
15
16
  * Calls an action on the host and returns its response.
16
17
  *
@@ -23,6 +24,40 @@ export function execute(actionName, params, context) {
23
24
  void context;
24
25
  return __host.call(actionName, params ?? null);
25
26
  }
27
+ /**
28
+ * Calls an action whose reply is a run of bytes, such as a range of a stored
29
+ * file, and returns them.
30
+ *
31
+ * The runtime hands the bytes over as a `Uint8Array` that owns them: no JSON,
32
+ * no base64, and no address. A host that refused answers with its ordinary
33
+ * envelope instead, returned here as a failed response, so a caller checks
34
+ * `ok` exactly as it does for `execute`. Its message names the call — `<action>
35
+ * failed: <the host's reason>` — worded as the Rust and Go SDKs word it.
36
+ *
37
+ * A runtime plugin released before this call existed does not provide it, and
38
+ * is refused here, before anything is sent, rather than handed a reply it
39
+ * would try to read as JSON.
40
+ */
41
+ export function executeBytes(actionName, params, context) {
42
+ assertRuntimeAbi();
43
+ void context;
44
+ if (typeof __host.callBytes !== 'function') {
45
+ throw new TypeError(BYTES_ABI_MISSING_MESSAGE);
46
+ }
47
+ const reply = __host.callBytes(actionName, params ?? null);
48
+ if (reply instanceof Uint8Array) {
49
+ return { data: reply, ok: true };
50
+ }
51
+ // The runtime answers with bytes or with the host's refusal, and nothing
52
+ // else. A refusal that says it succeeded is still a refusal: no bytes came.
53
+ const refusal = reply;
54
+ if (refusal?.ok !== false)
55
+ return { error: { message: `${actionName} was refused and gave no reason.` }, ok: false };
56
+ const reason = refusal.error?.message;
57
+ if (typeof reason !== 'string' || reason.trim() === '')
58
+ return { error: { message: `${actionName} failed: The host refused the call and gave no reason.` }, ok: false };
59
+ return { error: { message: `${actionName} failed: ${reason}` }, ok: false };
60
+ }
26
61
  /**
27
62
  * Calls an action on the host without waiting for it to answer.
28
63
  */
package/dist/http.d.ts CHANGED
@@ -4,6 +4,13 @@ import type { Context } from './types';
4
4
  */
5
5
  export interface HttpRequest {
6
6
  body?: any;
7
+ /**
8
+ * The browser's credentials mode for the request: whether it carries the
9
+ * page's cookies and HTTP authentication. A request that names none sends
10
+ * none, and the browser host then omits credentials. The server host has no
11
+ * browser credentials and ignores it.
12
+ */
13
+ credentials?: 'include' | 'omit' | 'same-origin';
7
14
  headers?: Record<string, string>;
8
15
  method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
9
16
  url: string;
package/dist/http.js CHANGED
@@ -18,6 +18,7 @@ export async function fetch(request, context) {
18
18
  }
19
19
  const hostRequest = {
20
20
  body: request.body ? JSON.stringify(request.body) : undefined,
21
+ credentials: request.credentials,
21
22
  headers: request.headers,
22
23
  method: request.method ?? 'GET',
23
24
  url: request.url,
package/dist/index.js CHANGED
@@ -88,12 +88,11 @@ async function handle(handler) {
88
88
  // All other contexts (WASM Loader, Elixir Backend) are handled below.
89
89
  let context;
90
90
  try {
91
- const inputText = readInputFromHost();
92
- if (!inputText) {
91
+ const simpleReq = readRequestFromHost();
92
+ if (simpleReq === undefined) {
93
93
  returnError('no input payload provided by the host environment', undefined);
94
94
  return;
95
95
  }
96
- const simpleReq = JSON.parse(inputText);
97
96
  context = simpleReq.context;
98
97
  // Context 1: This is the initial WASM loader running in the browser.
99
98
  // Its only job is to start the script worker.
@@ -146,6 +145,39 @@ function readInputFromHost() {
146
145
  // text because that is what its callers parse.
147
146
  return JSON.stringify(host.getContext());
148
147
  }
148
+ /**
149
+ * Reads the request the host sent, or `undefined` when it sent none.
150
+ *
151
+ * Inside a WASM module the request is handed over as a value the runtime
152
+ * parsed from the host's own JSON, so it is read as it arrived. It used to be
153
+ * serialised and parsed again, which cost the whole payload twice over — the
154
+ * data is one string holding all of it, so ten megabytes were escaped into a
155
+ * new string and unescaped into another — and gave back what it started with,
156
+ * apart from the two values JSON cannot write: a negative zero came back as
157
+ * zero, and an infinite number as null. An action now sees what the host sent,
158
+ * those two included.
159
+ *
160
+ * Nothing here writes to the value. It belongs to the host, which stays free to
161
+ * hand over a frozen envelope, to answer `data` from a getter, or to read its
162
+ * own envelope back — as an action does when it calls `getContext` itself.
163
+ *
164
+ * The script worker in the browser reads a different thing. There the host
165
+ * leaves the request on the global as a JavaScript object of its own making,
166
+ * which reached the worker as a structured clone and can hold what JSON has no
167
+ * form for, such as a `Date` or an `undefined`. The round trip is what turns
168
+ * that into the request an action expects, so that path keeps it until a sweep
169
+ * of its own decides otherwise.
170
+ */
171
+ function readRequestFromHost() {
172
+ if (globalThis.__SIMPLE_INITIAL_PAYLOAD__) {
173
+ const inputText = readInputFromHost();
174
+ return inputText ? JSON.parse(inputText) : undefined;
175
+ }
176
+ // `undefined` is what the runtime answers when the host sent no input at all,
177
+ // and it is the one thing a parsed JSON document can never be, so no request
178
+ // is mistaken for a missing one.
179
+ return host.getContext();
180
+ }
149
181
  function returnError(message, context) {
150
182
  const response = { data: null, errors: [message], ok: false };
151
183
  // Provide a minimal, safe context if the original is not available.
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  import type { Context, DocumentHandle, ExternalFileSource, StorageTarget } from './types';
2
+ /**
3
+ * The longest range the host answers one read with.
4
+ *
5
+ * `read` and `readRange` cover anything longer in ranges of this size, into one
6
+ * buffer, so it bounds what the host holds for one call rather than what an
7
+ * action can read.
8
+ */
9
+ export declare const MAX_RANGE_BYTES: number;
2
10
  /**
3
11
  * Uploads an in-memory binary buffer as a document to the platform's storage system.
4
12
  *
@@ -58,3 +66,71 @@ export declare function uploadBuffer(buffer: ArrayBuffer | Uint8Array, filename:
58
66
  * ```
59
67
  */
60
68
  export declare function uploadExternal(source: ExternalFileSource, target: StorageTarget, context: Context): Promise<DocumentHandle>;
69
+ /**
70
+ * Returns how many bytes a stored file holds, from the store's own record of it
71
+ * rather than the handle's `size`.
72
+ *
73
+ * @param handle The document handle, exactly as a `:document` field holds it.
74
+ * @param context The execution context for the request.
75
+ * @returns A promise that resolves with the file's size in bytes.
76
+ * @throws If the handle does not name a stored file, or the host refuses.
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * const bytes = await size(record.attachment, request.context)
81
+ * ```
82
+ */
83
+ export declare function size(handle: DocumentHandle, context: Context): Promise<number>;
84
+ /**
85
+ * Returns the whole of a stored file, as its bytes.
86
+ *
87
+ * The size is asked for first, and the file arrives in ranges of at most
88
+ * `MAX_RANGE_BYTES`, each handed over by the host as it is — no JSON and no
89
+ * base64. A file that fits one range arrives as one array holding exactly its
90
+ * bytes. A larger one is read into a single buffer allocated once at exactly
91
+ * the file's size, and is refused before any range is read when this action
92
+ * has no memory for it. A range answered short — a file that changed while it
93
+ * was read — is refused rather than handed over incomplete.
94
+ *
95
+ * Reading is for server actions; a browser action is refused.
96
+ *
97
+ * @param handle The document handle, exactly as a `:document` field holds it.
98
+ * @param context The execution context for the request.
99
+ * @returns A promise that resolves with the file's bytes.
100
+ * @throws If the handle does not name a stored file, the host refuses, or the
101
+ * file changed while it was read.
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * const bytes = await read(record.attachment, request.context)
106
+ * const text = new TextDecoder().decode(bytes)
107
+ * ```
108
+ */
109
+ export declare function read(handle: DocumentHandle, context: Context): Promise<Uint8Array>;
110
+ /**
111
+ * Returns up to `length` bytes of a stored file, starting `offset` bytes in.
112
+ *
113
+ * The size is asked for first, so the range is held at what the file has past
114
+ * `offset`: one that runs past the end answers with exactly the bytes up to
115
+ * the end, and one that starts at or past the end is refused before any range
116
+ * is read. What is left is read as `read` reads a whole file — in ranges of at
117
+ * most `MAX_RANGE_BYTES`, into one buffer, each range answered in full or
118
+ * refused.
119
+ *
120
+ * Asking for the size first is also what makes a host that cannot read stored
121
+ * files refuse in its own words, before any range is asked of it.
122
+ *
123
+ * @param handle The document handle, exactly as a `:document` field holds it.
124
+ * @param offset How many bytes into the file the range starts, zero or more.
125
+ * @param length How many bytes to read, one or more.
126
+ * @param context The execution context for the request.
127
+ * @returns A promise that resolves with the range's bytes.
128
+ * @throws If the handle or the range is not valid, the range starts at or past
129
+ * the end of the file, or the host refuses.
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * const head = await readRange(record.attachment, 0, 1024, request.context)
134
+ * ```
135
+ */
136
+ export declare function readRange(handle: DocumentHandle, offset: number, length: number, context: Context): Promise<Uint8Array>;
package/dist/storage.js CHANGED
@@ -1,4 +1,16 @@
1
- import { execute as hostExecute } from './host';
1
+ import { execute as hostExecute, executeBytes as hostExecuteBytes } from './host';
2
+ /** The host action that answers a stored file's size. */
3
+ const STAT = 'action:storage/stat';
4
+ /** The host action that answers one range of a stored file as its bytes. */
5
+ const READ = 'action:storage/read';
6
+ /**
7
+ * The longest range the host answers one read with.
8
+ *
9
+ * `read` and `readRange` cover anything longer in ranges of this size, into one
10
+ * buffer, so it bounds what the host holds for one call rather than what an
11
+ * action can read.
12
+ */
13
+ export const MAX_RANGE_BYTES = 16 * 1024 * 1024;
2
14
  /**
3
15
  * Uploads an in-memory binary buffer as a document to the platform's storage system.
4
16
  *
@@ -115,3 +127,142 @@ export async function uploadExternal(source, target, context) {
115
127
  }
116
128
  return response.data;
117
129
  }
130
+ /**
131
+ * Returns how many bytes a stored file holds, from the store's own record of it
132
+ * rather than the handle's `size`.
133
+ *
134
+ * @param handle The document handle, exactly as a `:document` field holds it.
135
+ * @param context The execution context for the request.
136
+ * @returns A promise that resolves with the file's size in bytes.
137
+ * @throws If the handle does not name a stored file, or the host refuses.
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * const bytes = await size(record.attachment, request.context)
142
+ * ```
143
+ */
144
+ export async function size(handle, context) {
145
+ checkHandle(handle);
146
+ const response = await hostExecute(STAT, { handle }, context);
147
+ if (!response.ok)
148
+ throw refused(STAT, response.error?.message);
149
+ const answered = response.data?.size;
150
+ if (typeof answered !== 'number' || !Number.isSafeInteger(answered) || answered < 0)
151
+ throw new Error(`${STAT} answered without a size: ${JSON.stringify(response.data ?? null)}`);
152
+ return answered;
153
+ }
154
+ /**
155
+ * Returns the whole of a stored file, as its bytes.
156
+ *
157
+ * The size is asked for first, and the file arrives in ranges of at most
158
+ * `MAX_RANGE_BYTES`, each handed over by the host as it is — no JSON and no
159
+ * base64. A file that fits one range arrives as one array holding exactly its
160
+ * bytes. A larger one is read into a single buffer allocated once at exactly
161
+ * the file's size, and is refused before any range is read when this action
162
+ * has no memory for it. A range answered short — a file that changed while it
163
+ * was read — is refused rather than handed over incomplete.
164
+ *
165
+ * Reading is for server actions; a browser action is refused.
166
+ *
167
+ * @param handle The document handle, exactly as a `:document` field holds it.
168
+ * @param context The execution context for the request.
169
+ * @returns A promise that resolves with the file's bytes.
170
+ * @throws If the handle does not name a stored file, the host refuses, or the
171
+ * file changed while it was read.
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * const bytes = await read(record.attachment, request.context)
176
+ * const text = new TextDecoder().decode(bytes)
177
+ * ```
178
+ */
179
+ export async function read(handle, context) {
180
+ const total = await size(handle, context);
181
+ return total === 0 ? new Uint8Array(0) : readSpan(handle, 0, total, context);
182
+ }
183
+ /**
184
+ * Returns up to `length` bytes of a stored file, starting `offset` bytes in.
185
+ *
186
+ * The size is asked for first, so the range is held at what the file has past
187
+ * `offset`: one that runs past the end answers with exactly the bytes up to
188
+ * the end, and one that starts at or past the end is refused before any range
189
+ * is read. What is left is read as `read` reads a whole file — in ranges of at
190
+ * most `MAX_RANGE_BYTES`, into one buffer, each range answered in full or
191
+ * refused.
192
+ *
193
+ * Asking for the size first is also what makes a host that cannot read stored
194
+ * files refuse in its own words, before any range is asked of it.
195
+ *
196
+ * @param handle The document handle, exactly as a `:document` field holds it.
197
+ * @param offset How many bytes into the file the range starts, zero or more.
198
+ * @param length How many bytes to read, one or more.
199
+ * @param context The execution context for the request.
200
+ * @returns A promise that resolves with the range's bytes.
201
+ * @throws If the handle or the range is not valid, the range starts at or past
202
+ * the end of the file, or the host refuses.
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * const head = await readRange(record.attachment, 0, 1024, request.context)
207
+ * ```
208
+ */
209
+ export async function readRange(handle, offset, length, context) {
210
+ checkHandle(handle);
211
+ if (!Number.isSafeInteger(offset) || offset < 0)
212
+ throw new Error('A range needs an offset that is a whole number of bytes, zero or more.');
213
+ if (!Number.isSafeInteger(length) || length < 1)
214
+ throw new Error('A range needs at least one byte. Pass a length of one or more, or ask size() how large the file is.');
215
+ const total = await size(handle, context);
216
+ if (offset >= total)
217
+ throw new Error(`The range starts at byte ${offset}, at or past the end of the file, which is ${total} bytes. Ask size() how large the file is, and start the range before its end.`);
218
+ return readSpan(handle, offset, Math.min(length, total - offset), context);
219
+ }
220
+ /**
221
+ * Exactly `length` bytes from `offset`, which the file's size says are there.
222
+ *
223
+ * One range is returned as the host handed it over. More are read into one
224
+ * buffer, allocated once at exactly `length` before any of them is asked for.
225
+ */
226
+ async function readSpan(handle, offset, length, context) {
227
+ if (length <= MAX_RANGE_BYTES)
228
+ return readExactly(handle, offset, length, context);
229
+ const bytes = allocate(length);
230
+ for (let done = 0; done < length; done += MAX_RANGE_BYTES) {
231
+ const part = await readExactly(handle, offset + done, Math.min(MAX_RANGE_BYTES, length - done), context);
232
+ bytes.set(part, done);
233
+ }
234
+ return bytes;
235
+ }
236
+ /** One range, which must be answered in full. */
237
+ async function readExactly(handle, offset, length, context) {
238
+ const response = await hostExecuteBytes(READ, { handle, length, offset }, context);
239
+ if (!response.ok)
240
+ throw new Error(response.error?.message ?? `${READ} failed: The host refused the call and gave no reason.`);
241
+ const part = response.data;
242
+ if (part.length !== length)
243
+ throw new Error(`The file answered ${part.length} bytes for the ${length} at offset ${offset}, so it is not the size it was when the read began. Read it again.`);
244
+ return part;
245
+ }
246
+ /** The error for a call the host refused in its envelope, naming the call. */
247
+ function refused(actionName, message) {
248
+ if (message === undefined || message.trim() === '')
249
+ return new Error(`${actionName} failed: The host refused the call and gave no reason.`);
250
+ return new Error(`${actionName} failed: ${message}`);
251
+ }
252
+ /** A buffer of exactly `length` bytes, or a refusal saying there is no room. */
253
+ function allocate(length) {
254
+ try {
255
+ return new Uint8Array(length);
256
+ }
257
+ catch {
258
+ throw new Error(`Reading ${length} bytes needs more memory than this action has. Raise the action's mem_limit, or read the file in parts with readRange.`);
259
+ }
260
+ }
261
+ /** Whether a handle carries what the host locates a stored file by. */
262
+ function checkHandle(handle) {
263
+ for (const member of ['storage_path', 'filename', 'file_hash']) {
264
+ const value = handle?.[member];
265
+ if (typeof value !== 'string' || value.trim() === '')
266
+ throw new Error(`A document handle needs ${member}. Pass the handle exactly as the :document field holds it.`);
267
+ }
268
+ }
@@ -56,6 +56,18 @@ export function execute(actionName, params, context) {
56
56
  })
57
57
  }
58
58
 
59
+ /**
60
+ * A reply that is a run of bytes reaches only a server action: the browser
61
+ * runtime has no way to hand one over. Refused here, before anything is sent.
62
+ * @returns {Promise<import('./types').SimpleResponse<Uint8Array>>} A promise that rejects
63
+ */
64
+ export function executeBytes(actionName) {
65
+ return Promise.reject(new Error(
66
+ `${actionName} answers with bytes, which only a server action can receive. `
67
+ + 'Set the action\'s execution environment to server.',
68
+ ))
69
+ }
70
+
59
71
  /**
60
72
  * Worker-compatible fire-and-forget implementation.
61
73
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simpleplatform/sdk",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Simple Platform Typescript SDK",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://docs.simple.dev",
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/security.d.ts",
46
46
  "default": "./dist/security.js"
47
47
  },
48
+ "./storage": {
49
+ "types": "./dist/storage.d.ts",
50
+ "default": "./dist/storage.js"
51
+ },
48
52
  "./host": {
49
53
  "types": "./dist/host.d.ts",
50
54
  "default": "./dist/host.js"