@entrinsik/vite-plugin-informer 2.10.0 → 2.12.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 +7 -3
- package/bin/init.js +13 -2
- package/bin/workspace.js +15 -7
- package/index.d.ts +14 -0
- package/package.json +1 -1
- package/src/agent-dev.js +8 -11
- package/src/dev-bag.js +241 -0
- package/src/dev-channel-handlers.js +335 -0
- package/src/dev-channel-shim.js +276 -39
- package/src/dev-channels.js +138 -28
- package/src/dev-dependencies.js +140 -4
- package/src/dev-platform.js +81 -5
- package/src/dev-streams.js +176 -27
- package/src/env.js +17 -1
- package/src/index.js +87 -9
- package/src/server-routes.js +79 -211
- package/src/streams-client.js +121 -8
package/src/server-routes.js
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
|
-
import {
|
|
5
|
-
import { createDevChannels
|
|
4
|
+
import { createDevBagBuilder, buildDevUser } from './dev-bag.js';
|
|
5
|
+
import { createDevChannels } from './dev-channels.js';
|
|
6
6
|
import { devPlatform } from './dev-platform.js';
|
|
7
7
|
import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
|
|
8
8
|
|
|
9
|
-
// embed() is present on a real install even without the embeddings
|
|
10
|
-
// capability, where it throws a written explanation. Mirroring that here
|
|
11
|
-
// keeps the dev failure the same lesson as the deployed one, instead of a
|
|
12
|
-
// bare "embed is not a function" that reads like a missing binding.
|
|
13
|
-
const embed = async () => {
|
|
14
|
-
throw new Error('embed() is not available in the dev mirror: the embeddings capability needs a real Informer (platform.capabilities.embeddings is false)');
|
|
15
|
-
};
|
|
16
|
-
|
|
17
9
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
18
10
|
|
|
19
|
-
// Cap dev proxy calls at 30s so a hung upstream fails loudly instead of
|
|
20
|
-
// hanging the handler.
|
|
21
|
-
const FETCH_TIMEOUT_MS = 30000;
|
|
22
|
-
|
|
23
11
|
// Strict base64 — see view-api.js for the rationale. Mirror kept identical to
|
|
24
12
|
// keep dev and prod behavior aligned, down to the scan: the quantified
|
|
25
13
|
// /^[A-Za-z0-9+/]+={0,2}$/ throws "RangeError: Maximum call stack size
|
|
@@ -37,17 +25,19 @@ function isBase64(s) {
|
|
|
37
25
|
}
|
|
38
26
|
|
|
39
27
|
/**
|
|
40
|
-
* Convert a file path under
|
|
28
|
+
* Convert a file path under a handler directory to a route path (mirrors the
|
|
29
|
+
* server's route-scanner.js filePathToRoute).
|
|
41
30
|
*
|
|
42
31
|
* Examples:
|
|
43
|
-
* server/orders/index.js
|
|
44
|
-
* server/orders/[id].js
|
|
32
|
+
* server/orders/index.js -> /orders
|
|
33
|
+
* server/orders/[id].js -> /orders/:id
|
|
45
34
|
* server/orders/[id]/approve.js -> /orders/:id/approve
|
|
46
|
-
* server/index.js
|
|
35
|
+
* server/index.js -> /
|
|
36
|
+
* channels/rooms/[room].js -> /rooms/:room (dirPrefix 'channels')
|
|
47
37
|
*/
|
|
48
|
-
function filePathToRoute(filePath) {
|
|
38
|
+
function filePathToRoute(filePath, dirPrefix = 'server') {
|
|
49
39
|
let route = filePath
|
|
50
|
-
.replace(
|
|
40
|
+
.replace(new RegExp(`^${dirPrefix}/`), '')
|
|
51
41
|
.replace(/\.js$/, '');
|
|
52
42
|
|
|
53
43
|
route = route.replace(/\[([^\]]+)\]/g, ':$1');
|
|
@@ -143,7 +133,7 @@ async function scanRoutes(serverDir) {
|
|
|
143
133
|
}));
|
|
144
134
|
}
|
|
145
135
|
|
|
146
|
-
export { filePathToRoute, walkJsFiles };
|
|
136
|
+
export { filePathToRoute, matchRoute, walkJsFiles };
|
|
147
137
|
|
|
148
138
|
async function walkJsFiles(dir, basePath) {
|
|
149
139
|
const results = [];
|
|
@@ -169,6 +159,54 @@ async function walkJsFiles(dir, basePath) {
|
|
|
169
159
|
return results;
|
|
170
160
|
}
|
|
171
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Write a handler's `{ status, headers?, body?, encoding? }` response
|
|
164
|
+
* descriptor (mirrors app-sandbox.js buildInvokeScript / respondCallback).
|
|
165
|
+
* The encoding allow-list and contract checks are enforced inside the ivm in
|
|
166
|
+
* production; replicated here so handlers see the same errors in dev (where
|
|
167
|
+
* there's no isolate to attribute the throw to). If you change this, mirror
|
|
168
|
+
* it in modules/app/routes/view-api.js.
|
|
169
|
+
*/
|
|
170
|
+
function sendDescriptor(res, result) {
|
|
171
|
+
const encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
172
|
+
if (encoding !== null && encoding !== 'base64') {
|
|
173
|
+
throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
|
|
174
|
+
}
|
|
175
|
+
if (encoding === 'base64' && typeof result.body !== 'string') {
|
|
176
|
+
throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
|
|
177
|
+
}
|
|
178
|
+
const responseBody = result.body !== undefined ? result.body : null;
|
|
179
|
+
|
|
180
|
+
res.statusCode = result.status || 200;
|
|
181
|
+
for (const [key, value] of Object.entries(result.headers || {})) {
|
|
182
|
+
res.setHeader(key, value);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (responseBody === null) {
|
|
186
|
+
res.end();
|
|
187
|
+
} else if (encoding === 'base64') {
|
|
188
|
+
if (!isBase64(responseBody)) {
|
|
189
|
+
throw new Error('Handler returned malformed base64 body');
|
|
190
|
+
}
|
|
191
|
+
res.end(Buffer.from(responseBody, 'base64'));
|
|
192
|
+
} else if (typeof responseBody === 'string') {
|
|
193
|
+
// Strings go out verbatim, as prod passes them.
|
|
194
|
+
if (!res.getHeader('content-type')) {
|
|
195
|
+
res.setHeader('Content-Type', 'application/json');
|
|
196
|
+
}
|
|
197
|
+
res.end(responseBody);
|
|
198
|
+
} else {
|
|
199
|
+
if (!res.getHeader('content-type')) {
|
|
200
|
+
res.setHeader('Content-Type', 'application/json');
|
|
201
|
+
}
|
|
202
|
+
res.end(JSON.stringify(responseBody));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isDescriptor(value) {
|
|
207
|
+
return value !== null && typeof value === 'object' && typeof value.status === 'number';
|
|
208
|
+
}
|
|
209
|
+
|
|
172
210
|
/**
|
|
173
211
|
* Create Connect middleware for dev-mode server route execution.
|
|
174
212
|
*
|
|
@@ -187,100 +225,14 @@ async function walkJsFiles(dir, basePath) {
|
|
|
187
225
|
* and /_downloads middleware (createStreamStore()); a private one when omitted
|
|
188
226
|
* @returns {Function} Connect middleware
|
|
189
227
|
*/
|
|
190
|
-
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
const devUser = {
|
|
195
|
-
username: (user && user.username) || 'dev',
|
|
196
|
-
displayName: (user && user.displayName) || 'Local Developer',
|
|
197
|
-
email: (user && user.email) || null,
|
|
198
|
-
timezone: (user && user.timezone) || null
|
|
199
|
-
};
|
|
228
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore, platform = devPlatform(), appId = null }) {
|
|
229
|
+
// `@user/${request.user.username}` on the server names the channel the
|
|
230
|
+
// page subscribes to.
|
|
231
|
+
const devUser = buildDevUser(user);
|
|
200
232
|
const serverDir = join(projectRoot, 'server');
|
|
201
233
|
const streams = streamStore || createStreamStore();
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
async function query(sql, params) {
|
|
205
|
-
if (!devWorkspaceId) {
|
|
206
|
-
throw new Error('query() requires INFORMER_DEV_WORKSPACE. Run: npx informer-workspace init');
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const resp = await globalThis.fetch(`${serverOrigin}/api/datasources/${devWorkspaceId}/_sql`, {
|
|
210
|
-
method: 'POST',
|
|
211
|
-
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
|
212
|
-
body: JSON.stringify({ sql, params: params || [] })
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
if (!resp.ok) {
|
|
216
|
-
const err = await resp.json().catch(() => ({}));
|
|
217
|
-
const detail = err.message || resp.statusText;
|
|
218
|
-
throw new Error(`query() failed: ${resp.status} ${detail} (${serverOrigin}/api/datasources/${devWorkspaceId}/_sql)`);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const data = await resp.json();
|
|
222
|
-
return data.rows;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// fetch() implementation — proxies API calls to the Informer server
|
|
226
|
-
async function fetchAs(auth, path, opts = {}) {
|
|
227
|
-
const method = (opts.method || 'GET').toUpperCase();
|
|
228
|
-
// Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath);
|
|
229
|
-
// reject non-canonical shapes here instead of silently accepting them.
|
|
230
|
-
const apiPath = normalizeFetchPath(path);
|
|
231
|
-
if (!apiPath) {
|
|
232
|
-
return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` }, contentType: 'application/json' };
|
|
233
|
-
}
|
|
234
|
-
const url = `${serverOrigin}${apiPath}`;
|
|
235
|
-
const fetchOpts = {
|
|
236
|
-
method,
|
|
237
|
-
headers: { Authorization: auth, 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
if (opts.body && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
241
|
-
fetchOpts.body = JSON.stringify(opts.body);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Read the stream exactly once — `.json()` consumes/locks the body, so a
|
|
245
|
-
// `.text()` fallback would throw "Body is unusable" on any non-JSON
|
|
246
|
-
// response (auth-bounce HTML, proxy error page). Parse in memory
|
|
247
|
-
// instead — same as prod's unwrapInject.
|
|
248
|
-
let status, contentType, text;
|
|
249
|
-
try {
|
|
250
|
-
const resp = await globalThis.fetch(url, { ...fetchOpts, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
251
|
-
status = resp.status;
|
|
252
|
-
contentType = resp.headers.get('content-type') || '';
|
|
253
|
-
text = await resp.text();
|
|
254
|
-
} catch (err) {
|
|
255
|
-
// Transport failure or timeout — fetch throws (TypeError 'fetch failed'
|
|
256
|
-
// with the real reason on err.cause, or a TimeoutError). Return a
|
|
257
|
-
// synthetic 502 so the dependency layer names it (dep + url + cause)
|
|
258
|
-
// rather than a bare unhandled "fetch failed".
|
|
259
|
-
const reason = (err.cause && err.cause.message) || err.message;
|
|
260
|
-
return { status: 502, body: { message: `fetch to ${url} failed: ${reason}` }, contentType: 'application/json' };
|
|
261
|
-
}
|
|
262
|
-
let body;
|
|
263
|
-
try { body = JSON.parse(text); } catch { body = text; }
|
|
264
|
-
return { status, body, contentType };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function apiFetch(path, opts = {}) {
|
|
268
|
-
return await fetchAs(authHeader, path, opts);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Cross-app request() targets /api/apps/<id>/view/_/<path>, whose auth accepts
|
|
272
|
-
// only the token/session strategies — NOT basic auth. In API-key mode the
|
|
273
|
-
// INFORMER_API_KEY Bearer already satisfies that, so reuse it; under basic
|
|
274
|
-
// auth a separate API token (INFORMER_APP_TOKEN) is required, and without one
|
|
275
|
-
// the app proxy's request() throws a pointed error instead of a bare 401.
|
|
276
|
-
// appFetch also stamps x-informer-app-depth:1 so the target runs one hop deep
|
|
277
|
-
// and enforces the same one-hop guard it does in production.
|
|
278
|
-
const appAuth = appToken
|
|
279
|
-
? `Bearer ${appToken}`
|
|
280
|
-
: (authHeader && authHeader.startsWith('Bearer ') ? authHeader : null);
|
|
281
|
-
const appFetch = appAuth
|
|
282
|
-
? async (path, opts = {}) => await fetchAs(appAuth, path, { ...opts, headers: { ...(opts.headers || {}), 'x-informer-app-depth': '1' } })
|
|
283
|
-
: null;
|
|
234
|
+
const bagBuilder = createDevBagBuilder({ serverOrigin, authHeader, devWorkspaceId, projectRoot, devBindings, appToken, channels, logPrefix: '[app]', platform, appId });
|
|
235
|
+
const { query } = bagBuilder;
|
|
284
236
|
|
|
285
237
|
return async function serverRoutesMiddleware(req, res, next) {
|
|
286
238
|
try {
|
|
@@ -325,34 +277,6 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
325
277
|
try { body = JSON.parse(rawBody); } catch { body = rawBody; }
|
|
326
278
|
}
|
|
327
279
|
|
|
328
|
-
// crypto helper — mirrors the prod sandbox crypto surface
|
|
329
|
-
const cryptoHelper = buildDevCrypto();
|
|
330
|
-
|
|
331
|
-
// log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
|
|
332
|
-
const logCall = (level, message, data) => {
|
|
333
|
-
const msg = typeof message === 'string' ? message : JSON.stringify(message);
|
|
334
|
-
const args = [`[app-log] [${level}] ${msg}`];
|
|
335
|
-
if (data) args.push(data);
|
|
336
|
-
console.log(...args);
|
|
337
|
-
};
|
|
338
|
-
const log = Object.assign(
|
|
339
|
-
(message, data) => logCall('info', message, data),
|
|
340
|
-
{
|
|
341
|
-
debug: (message, data) => logCall('debug', message, data),
|
|
342
|
-
info: (message, data) => logCall('info', message, data),
|
|
343
|
-
warn: (message, data) => logCall('warn', message, data),
|
|
344
|
-
error: (message, data) => logCall('error', message, data)
|
|
345
|
-
}
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
// markdown helper — passthrough in dev (production uses `marked`)
|
|
349
|
-
const markdown = (text) => text;
|
|
350
|
-
|
|
351
|
-
// notify/email — delivery is a console-logged no-op in dev, but the
|
|
352
|
-
// required-field validation mirrors prod so an app that passes here
|
|
353
|
-
// won't 500 in production.
|
|
354
|
-
const { notify, email } = buildDevMessaging('[app]');
|
|
355
|
-
|
|
356
280
|
// Build request context
|
|
357
281
|
const request = {
|
|
358
282
|
method: req.method.toUpperCase(),
|
|
@@ -387,8 +311,11 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
387
311
|
serveDevDownload(streams, item, res);
|
|
388
312
|
}
|
|
389
313
|
|
|
390
|
-
// respond() — sends early response, handler continues in
|
|
391
|
-
//
|
|
314
|
+
// respond() — sends the early response, the handler continues in
|
|
315
|
+
// the background. A `{ status, headers?, body?, encoding? }`
|
|
316
|
+
// descriptor is honored as such (app-sandbox.js respondCallback);
|
|
317
|
+
// anything else is wrapped as 200 JSON; a download handle streams
|
|
318
|
+
// the staged bytes.
|
|
392
319
|
let responded = false;
|
|
393
320
|
async function respond(earlyBody) {
|
|
394
321
|
if (responded) return;
|
|
@@ -401,29 +328,16 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
401
328
|
return serveDownloadHandle(earlyBody);
|
|
402
329
|
}
|
|
403
330
|
responded = true;
|
|
331
|
+
if (isDescriptor(earlyBody)) return sendDescriptor(res, earlyBody);
|
|
404
332
|
res.statusCode = 200;
|
|
405
333
|
res.setHeader('Content-Type', 'application/json');
|
|
406
334
|
res.end(JSON.stringify(earlyBody));
|
|
407
335
|
}
|
|
408
336
|
|
|
409
|
-
|
|
410
|
-
// sandbox does, so handlers using `await context.myDep.method(...)`
|
|
411
|
-
// work locally. The manifest is parsed once per request (deps, env
|
|
412
|
-
// and channels all come from that one read) so edits to
|
|
413
|
-
// informer.yaml take effect without a dev-server restart.
|
|
414
|
-
const manifest = await loadManifest(projectRoot);
|
|
415
|
-
const deps = manifestBlock(manifest, 'dependencies');
|
|
416
|
-
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
417
|
-
const env = manifestBlock(manifest, 'env');
|
|
418
|
-
|
|
419
|
-
// emit() writes no app_event row in dev, but still relays a listed
|
|
420
|
-
// event to its `channels:` channel; broadcast() publishes a live
|
|
421
|
-
// frame to the page (see dev-channels.js).
|
|
422
|
-
const emit = createDevEmit({ channels, manifestChannels: manifestBlock(manifest, 'channels'), logPrefix: '[app-event]' });
|
|
423
|
-
const { broadcast } = channels;
|
|
337
|
+
const { bag } = await bagBuilder.build({ forwarding: streamServices.forwarding });
|
|
424
338
|
|
|
425
339
|
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|
|
426
|
-
const result = await handler({
|
|
340
|
+
const result = await handler({ ...bag, request, query: streamQuery, respond, uploads: streamServices.uploads, downloads: streamServices.downloads });
|
|
427
341
|
|
|
428
342
|
// Seal any download the handler left open (prod finalizes the same way).
|
|
429
343
|
await streamServices.finalize();
|
|
@@ -434,60 +348,14 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
434
348
|
// A returned download handle streams as the response.
|
|
435
349
|
if (isStreamRef(result, 'download')) return serveDownloadHandle(result);
|
|
436
350
|
|
|
437
|
-
// Normalize
|
|
438
|
-
// The encoding allow-list and contract checks are enforced inside the
|
|
439
|
-
// ivm in production; replicate them here so handlers see the same
|
|
440
|
-
// errors in dev (where there's no isolate to attribute the throw to).
|
|
441
|
-
// If you change this, mirror it in modules/app/routes/view-api.js.
|
|
442
|
-
let status, responseBody, responseHeaders, encoding;
|
|
443
|
-
|
|
351
|
+
// Normalize the return value (mirrors app-sandbox.js buildInvokeScript).
|
|
444
352
|
if (result === undefined || result === null) {
|
|
445
|
-
|
|
446
|
-
responseBody = null;
|
|
447
|
-
responseHeaders = {};
|
|
448
|
-
} else if (typeof result === 'object' && typeof result.status === 'number') {
|
|
449
|
-
encoding = typeof result.encoding === 'string' ? result.encoding : null;
|
|
450
|
-
if (encoding !== null && encoding !== 'base64') {
|
|
451
|
-
throw new Error(`Unknown response encoding: ${JSON.stringify(encoding)} (expected 'base64' or omitted)`);
|
|
452
|
-
}
|
|
453
|
-
if (encoding === 'base64' && typeof result.body !== 'string') {
|
|
454
|
-
throw new Error(`encoding: 'base64' requires body to be a base64-encoded string, got ${typeof result.body}`);
|
|
455
|
-
}
|
|
456
|
-
status = result.status || 200;
|
|
457
|
-
responseBody = result.body !== undefined ? result.body : null;
|
|
458
|
-
responseHeaders = result.headers || {};
|
|
459
|
-
} else {
|
|
460
|
-
status = 200;
|
|
461
|
-
responseBody = result;
|
|
462
|
-
responseHeaders = { 'content-type': 'application/json' };
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
res.statusCode = status;
|
|
466
|
-
for (const [key, value] of Object.entries(responseHeaders)) {
|
|
467
|
-
res.setHeader(key, value);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (responseBody === null) {
|
|
353
|
+
res.statusCode = 204;
|
|
471
354
|
res.end();
|
|
472
|
-
} else if (
|
|
473
|
-
|
|
474
|
-
throw new Error('Handler returned malformed base64 body');
|
|
475
|
-
}
|
|
476
|
-
res.end(Buffer.from(responseBody, 'base64'));
|
|
477
|
-
} else if (typeof responseBody === 'string') {
|
|
478
|
-
// Pre-PR the dev middleware always JSON.stringify'd the body, so
|
|
479
|
-
// a handler returning { body: 'hello' } emitted "hello" (with
|
|
480
|
-
// quotes) — diverging from prod which passed strings verbatim.
|
|
481
|
-
// This branch fixes that parity.
|
|
482
|
-
if (!res.getHeader('content-type')) {
|
|
483
|
-
res.setHeader('Content-Type', 'application/json');
|
|
484
|
-
}
|
|
485
|
-
res.end(responseBody);
|
|
355
|
+
} else if (isDescriptor(result)) {
|
|
356
|
+
sendDescriptor(res, result);
|
|
486
357
|
} else {
|
|
487
|
-
|
|
488
|
-
res.setHeader('Content-Type', 'application/json');
|
|
489
|
-
}
|
|
490
|
-
res.end(JSON.stringify(responseBody));
|
|
358
|
+
sendDescriptor(res, { status: 200, headers: { 'content-type': 'application/json' }, body: result });
|
|
491
359
|
}
|
|
492
360
|
} catch (err) {
|
|
493
361
|
viteServer.ssrFixStacktrace(err);
|
package/src/streams-client.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dev copy of the injected app streams client (I5-12979):
|
|
3
|
-
* `__INFORMER__.upload(file, opts)
|
|
3
|
+
* `__INFORMER__.upload(file, opts)`, `__INFORMER__.downloadUrl(id, filename)` and
|
|
4
|
+
* `__INFORMER__.streams` (I5-13030: status / list / discard, plus upload()'s onEvent
|
|
5
|
+
* and task.created).
|
|
4
6
|
*
|
|
5
7
|
* VERBATIM copy of modules/app/routes/lib/html-utils.js#generateStreamsHelper
|
|
6
8
|
* in its origin-mode form (base "", plain window.fetch / XHR transport — the
|
|
@@ -11,7 +13,7 @@
|
|
|
11
13
|
* that test says exactly what to paste here. The dev page must upload the way
|
|
12
14
|
* a deployed one does.
|
|
13
15
|
*
|
|
14
|
-
* @returns {string} JavaScript expression evaluating to { upload, downloadUrl }
|
|
16
|
+
* @returns {string} JavaScript expression evaluating to { upload, downloadUrl, streams }
|
|
15
17
|
*/
|
|
16
18
|
export function streamsClientSource() {
|
|
17
19
|
return `(function (base, transport) {
|
|
@@ -80,6 +82,24 @@ export function streamsClientSource() {
|
|
|
80
82
|
return [opts.filename || file.name || '', file.size, file.lastModified || 0].join(':');
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
// The transfer's event stream for the page (I5-13030): what the helper
|
|
86
|
+
// already knows at each step, handed out rather than inferred from a
|
|
87
|
+
// percentage. A listener's own throw must not fail the transfer it is
|
|
88
|
+
// watching, so it is rethrown on a fresh tick — still reaching
|
|
89
|
+
// window.onerror and the console, but not the chunk promise chain.
|
|
90
|
+
function notify (opts, event) {
|
|
91
|
+
if (typeof opts.onEvent !== 'function') return;
|
|
92
|
+
try {
|
|
93
|
+
opts.onEvent(event);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// Say so where it happened: the deferred rethrow below reaches
|
|
96
|
+
// window.onerror and nothing else, by which point the event it
|
|
97
|
+
// came from is long gone.
|
|
98
|
+
try { console.error('[informer] upload onEvent listener threw for ' + event.type + '; the transfer continues', err); } catch (e) {}
|
|
99
|
+
setTimeout(function () { throw err; }, 0);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
function upload (file, opts) {
|
|
84
104
|
opts = opts || {};
|
|
85
105
|
if (!file || typeof file.slice !== 'function' || typeof file.size !== 'number') {
|
|
@@ -97,6 +117,20 @@ export function streamsClientSource() {
|
|
|
97
117
|
var task = { id: opts.resume || null, abort: function () { controller.abort(); } };
|
|
98
118
|
var loaded = {};
|
|
99
119
|
|
|
120
|
+
// Settles with the upload's geometry the moment the store has reserved
|
|
121
|
+
// it — before a chunk moves — or with the create-time refusal (a 413
|
|
122
|
+
// over the cap, a resume that does not match). Nobody has to await
|
|
123
|
+
// it: a create failure still surfaces on the task itself.
|
|
124
|
+
var settleCreated;
|
|
125
|
+
var createdSettled = false;
|
|
126
|
+
task.created = new Promise(function (resolve, reject) {
|
|
127
|
+
// failed is its own argument rather than inferred from err:
|
|
128
|
+
// code that throws a falsy value would otherwise settle this
|
|
129
|
+
// as a resolve(undefined) — a failure read as success.
|
|
130
|
+
settleCreated = function (failed, err, value) { createdSettled = true; if (failed) reject(err); else resolve(value); };
|
|
131
|
+
});
|
|
132
|
+
task.created.catch(function () {});
|
|
133
|
+
|
|
100
134
|
function progress () {
|
|
101
135
|
if (typeof opts.onProgress !== 'function') return;
|
|
102
136
|
var sum = 0;
|
|
@@ -105,16 +139,24 @@ export function streamsClientSource() {
|
|
|
105
139
|
}
|
|
106
140
|
function abortError () { var err = new Error('Upload aborted'); err.code = 'aborted'; return err; }
|
|
107
141
|
|
|
142
|
+
// 'attempt' is 1-based in events: the first try is attempt 1, and a
|
|
143
|
+
// 'retry' names the attempt that just failed.
|
|
108
144
|
function sendChunk (meta, n, attempt) {
|
|
109
145
|
if (signal.aborted) return Promise.reject(abortError());
|
|
110
146
|
var start = (n - 1) * meta.chunkSize;
|
|
111
147
|
var blob = file.slice(start, Math.min(file.size, start + meta.chunkSize));
|
|
148
|
+
notify(opts, { type: 'chunk', n: n, state: 'sent', attempt: attempt + 1 });
|
|
112
149
|
return putChunk(base + '/_uploads/' + meta.id + '/' + n, blob, signal, function (sent) { loaded[n] = sent; progress(); })
|
|
113
|
-
.then(function () {
|
|
150
|
+
.then(function () {
|
|
151
|
+
loaded[n] = blob.size;
|
|
152
|
+
progress();
|
|
153
|
+
notify(opts, { type: 'chunk', n: n, state: 'landed', attempt: attempt + 1 });
|
|
154
|
+
})
|
|
114
155
|
.catch(function (err) {
|
|
115
156
|
if (err.code === 'aborted' || signal.aborted) throw err;
|
|
116
157
|
if (err.status === 404) { var gone = new Error('Upload expired before it completed'); gone.code = 'upload_expired'; gone.status = 404; throw gone; }
|
|
117
158
|
if (!retryable(err.status) || attempt >= retries) throw err;
|
|
159
|
+
notify(opts, { type: 'chunk', n: n, state: 'retry', attempt: attempt + 1, status: err.status });
|
|
118
160
|
return delay(backoff(attempt)).then(function () { return sendChunk(meta, n, attempt + 1); });
|
|
119
161
|
});
|
|
120
162
|
}
|
|
@@ -131,6 +173,24 @@ export function streamsClientSource() {
|
|
|
131
173
|
return Promise.all(workers);
|
|
132
174
|
}
|
|
133
175
|
|
|
176
|
+
// Seal. A 412 means the store is missing chunks a PUT reported as
|
|
177
|
+
// landed (a proxy that answered before the bytes were durable, an
|
|
178
|
+
// expiry racing the last write): send exactly those once more and
|
|
179
|
+
// seal again. The server names at most 50 (app-streams.js
|
|
180
|
+
// completeUpload), so a gap wider than that cannot be closed this
|
|
181
|
+
// way — a second 412 is a failure the caller resumes from.
|
|
182
|
+
function seal (meta, resent) {
|
|
183
|
+
return json('POST', base + '/_uploads/' + meta.id + '/_complete').catch(function (err) {
|
|
184
|
+
var missing = err.status === 412 && err.data && err.data.missing;
|
|
185
|
+
if (resent || !missing || !missing.length) throw err;
|
|
186
|
+
for (var i = 0; i < missing.length; i++) notify(opts, { type: 'chunk', n: missing[i], state: 'resend' });
|
|
187
|
+
return sendAll(meta, missing).then(function () {
|
|
188
|
+
if (signal.aborted) throw abortError();
|
|
189
|
+
return seal(meta, true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
134
194
|
var plan = signal.aborted ? Promise.reject(abortError()) : opts.resume
|
|
135
195
|
? json('GET', base + '/_uploads/' + encodeURIComponent(opts.resume)).then(function (meta) {
|
|
136
196
|
// Size alone is not identity: two same-length files would be
|
|
@@ -159,26 +219,45 @@ export function streamsClientSource() {
|
|
|
159
219
|
var promise = plan
|
|
160
220
|
.then(function (p) {
|
|
161
221
|
task.id = p.meta.id;
|
|
222
|
+
var created = {
|
|
223
|
+
id: p.meta.id,
|
|
224
|
+
size: p.meta.size,
|
|
225
|
+
chunks: p.meta.chunks,
|
|
226
|
+
chunkSize: p.meta.chunkSize,
|
|
227
|
+
resumed: !!opts.resume,
|
|
228
|
+
// Chunks a resumed upload already holds; empty for a new one.
|
|
229
|
+
received: opts.resume ? (p.meta.received || []) : []
|
|
230
|
+
};
|
|
231
|
+
settleCreated(false, null, created);
|
|
232
|
+
notify(opts, { type: 'created', id: created.id, size: created.size, chunks: created.chunks, chunkSize: created.chunkSize, resumed: created.resumed, received: created.received });
|
|
162
233
|
progress();
|
|
163
234
|
return sendAll(p.meta, p.chunks).then(function () {
|
|
164
235
|
if (signal.aborted) throw abortError();
|
|
165
|
-
return
|
|
236
|
+
return seal(p.meta, false);
|
|
166
237
|
});
|
|
167
238
|
})
|
|
168
|
-
.then(function (handle) {
|
|
239
|
+
.then(function (handle) {
|
|
240
|
+
progress();
|
|
241
|
+
notify(opts, { type: 'sealed', handle: handle });
|
|
242
|
+
return handle;
|
|
243
|
+
})
|
|
169
244
|
.catch(function (err) {
|
|
170
245
|
var aborted = err.code === 'aborted' || signal.aborted;
|
|
171
246
|
// Promise.all rejects on the first failure but leaves the other
|
|
172
247
|
// workers retrying with backoff; stop them before returning.
|
|
173
248
|
controller.abort();
|
|
249
|
+
if (!createdSettled) settleCreated(true, err);
|
|
174
250
|
if (task.id) {
|
|
175
251
|
// Discard only on an explicit abort — the caller is done
|
|
176
252
|
// with it. Any other failure keeps the staged chunks so
|
|
177
253
|
// upload({ resume: err.uploadId }) can finish the job; the
|
|
178
|
-
// TTL reclaims them if it never does.
|
|
254
|
+
// TTL reclaims them if it never does. An expired upload is
|
|
255
|
+
// the exception: the server already reclaimed it, so
|
|
256
|
+
// offering a resume would send the page into a 404 loop.
|
|
179
257
|
if (aborted) transport.fetch(base + '/_uploads/' + task.id, { method: 'DELETE', credentials: 'same-origin' }).catch(function () {});
|
|
180
|
-
else err.uploadId = task.id;
|
|
258
|
+
else if (err.code !== 'upload_expired') err.uploadId = task.id;
|
|
181
259
|
}
|
|
260
|
+
notify(opts, { type: 'failed', error: err, aborted: aborted, resumable: !!err.uploadId });
|
|
182
261
|
throw err;
|
|
183
262
|
});
|
|
184
263
|
|
|
@@ -192,7 +271,41 @@ export function streamsClientSource() {
|
|
|
192
271
|
return base + '/_downloads/' + encodeURIComponent(id) + (filename ? '/' + encodeURIComponent(filename) : '');
|
|
193
272
|
}
|
|
194
273
|
|
|
195
|
-
|
|
274
|
+
// What the store holds for this user in this app (I5-13030). The helper
|
|
275
|
+
// owns the base URL, so these are the sanctioned way to ask — a page
|
|
276
|
+
// must not derive the prefix from downloadUrl() and hand-build the calls.
|
|
277
|
+
var streams = {
|
|
278
|
+
// Resume state for an upload there is no task for: which chunks
|
|
279
|
+
// landed, which are missing, whether it is sealed.
|
|
280
|
+
status: function (id) {
|
|
281
|
+
return json('GET', base + '/_uploads/' + encodeURIComponent(id));
|
|
282
|
+
},
|
|
283
|
+
// Every upload and download this user has staged for the app,
|
|
284
|
+
// oldest first. What a truthful "n of maxStreamsPerUser" reads.
|
|
285
|
+
list: function () {
|
|
286
|
+
return Promise.all([json('GET', base + '/_uploads'), json('GET', base + '/_downloads')])
|
|
287
|
+
.then(function (r) {
|
|
288
|
+
// A truthy non-array (an auth-bounce page body) would
|
|
289
|
+
// otherwise pass through as an empty-looking listing.
|
|
290
|
+
if (!Array.isArray(r[0]) || !Array.isArray(r[1])) {
|
|
291
|
+
throw new Error('streams.list(): the staging routes did not answer with listings');
|
|
292
|
+
}
|
|
293
|
+
return { uploads: r[0], downloads: r[1] };
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
// Free a staged stream now rather than at expiry. Takes a handle or
|
|
297
|
+
// a bare id; a handle already carries its kind, so only a bare id
|
|
298
|
+
// needs the second argument (which still defaults to 'upload').
|
|
299
|
+
discard: function (idOrHandle, type) {
|
|
300
|
+
var handle = idOrHandle && typeof idOrHandle === 'object' ? idOrHandle : null;
|
|
301
|
+
var id = handle ? handle.id : idOrHandle;
|
|
302
|
+
var kind = type || (handle && handle.__appStream) || 'upload';
|
|
303
|
+
var prefix = kind === 'download' ? '/_downloads/' : '/_uploads/';
|
|
304
|
+
return json('DELETE', base + prefix + encodeURIComponent(id)).then(function () { return true; });
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
return { upload: upload, downloadUrl: downloadUrl, streams: streams };
|
|
196
309
|
})("", {
|
|
197
310
|
fetch: function (url, opts) { return window.fetch(url, opts); },
|
|
198
311
|
open: function (xhr, method, url) { xhr.open(method, url); }
|