@entrinsik/vite-plugin-informer 2.7.0 → 2.10.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 +129 -0
- package/bin/ci.js +0 -0
- package/package.json +1 -1
- package/src/agent-dev.js +26 -12
- package/src/assemble.js +5 -1
- package/src/compat.js +252 -0
- package/src/deploy.js +116 -43
- package/src/dev-channel-shim.js +150 -0
- package/src/dev-channels.js +191 -0
- package/src/dev-dependencies.js +44 -17
- package/src/dev-platform.js +41 -0
- package/src/dev-streams.js +660 -0
- package/src/index.js +131 -16
- package/src/server-routes.js +79 -21
- package/src/streams-client.js +200 -0
package/src/index.js
CHANGED
|
@@ -1,13 +1,70 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
2
3
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
4
|
import { resolve } from 'node:path';
|
|
4
5
|
import { createClient } from './client.js';
|
|
5
|
-
import { loadDependencies, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
|
+
import { loadDependencies, loadChannels, validateDependencies, validateDevBindings, resolveAppBinding } from './dev-dependencies.js';
|
|
6
7
|
import { buildDeclarations } from './openapi-to-dts.js';
|
|
7
8
|
import { loadEnv, envWritePath } from './env.js';
|
|
8
9
|
import { createMiddleware as createServerRoutes } from './server-routes.js';
|
|
9
10
|
import { createAgentMiddleware } from './agent-dev.js';
|
|
11
|
+
import { createDevChannels, validateChannels, BROADCAST_EVENT, DEV_CHANNEL_EVENT } from './dev-channels.js';
|
|
12
|
+
import { renderDevChannelScript } from './dev-channel-shim.js';
|
|
13
|
+
import { createStreamStore, createUploadsMiddleware, createDownloadsMiddleware } from './dev-streams.js';
|
|
14
|
+
import { streamsClientSource } from './streams-client.js';
|
|
10
15
|
import { init, migrate } from './workspace.js';
|
|
16
|
+
import { devPlatform } from './dev-platform.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The dev `window.__INFORMER__` context: defaults overlaid with the plugin's
|
|
20
|
+
* `mock` option. Shared by the page injection and the dev channels hub (whose
|
|
21
|
+
* frames carry `report.id` as the app id).
|
|
22
|
+
*/
|
|
23
|
+
function buildMock(options) {
|
|
24
|
+
return {
|
|
25
|
+
report: {
|
|
26
|
+
id: 'dev-local',
|
|
27
|
+
name: 'Local Development'
|
|
28
|
+
},
|
|
29
|
+
theme: 'light',
|
|
30
|
+
roles: [],
|
|
31
|
+
// The viewer identity the server injects (window.__INFORMER__.user);
|
|
32
|
+
// override with mock.user to test @user/<username> channels as someone else.
|
|
33
|
+
user: { username: 'dev', displayName: 'Local Developer' },
|
|
34
|
+
...options.mock,
|
|
35
|
+
// What the platform offers, as the server injects it; a mock
|
|
36
|
+
// override merges into the dev defaults (see dev-platform.js).
|
|
37
|
+
platform: devPlatform(options.mock && options.mock.platform)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Occupied-spot check for the dev /api mount: does informer.yaml's
|
|
43
|
+
* access.apis declare this method+path? Mirrors the server's matchWhitelist
|
|
44
|
+
* semantics — segment-aware, `*` matches exactly one segment.
|
|
45
|
+
*/
|
|
46
|
+
function manifestOccupies (projectRoot, req) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
const raw = parseYaml(readFileSync(resolve(projectRoot, 'informer.yaml'), 'utf8')) || {};
|
|
50
|
+
entries = (raw.access && Array.isArray(raw.access.apis)) ? raw.access.apis : [];
|
|
51
|
+
} catch {
|
|
52
|
+
return false; // no/invalid manifest: nothing is occupied
|
|
53
|
+
}
|
|
54
|
+
// Connect strips the mount prefix, so re-add it for whole-path matching.
|
|
55
|
+
const path = `/api${(req.url || '').split('?')[0]}`;
|
|
56
|
+
const segments = path.split('/').filter(Boolean);
|
|
57
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
58
|
+
return entries.some(entry => {
|
|
59
|
+
const spec = typeof entry === 'string'
|
|
60
|
+
? entry
|
|
61
|
+
: `${(entry && entry.method) || 'GET'} ${(entry && (entry.url || entry.path)) || ''}`;
|
|
62
|
+
const parsed = /^([A-Za-z]+)\s+(\S+)$/.exec(String(spec).trim());
|
|
63
|
+
if (!parsed || parsed[1].toUpperCase() !== method) return false;
|
|
64
|
+
const want = parsed[2].split('/').filter(Boolean);
|
|
65
|
+
return want.length === segments.length && want.every((s, i) => s === '*' || s === segments[i]);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
11
68
|
|
|
12
69
|
/**
|
|
13
70
|
* Write generated app-dependency type declarations under .informer/ and keep the
|
|
@@ -39,7 +96,8 @@ async function writeAppDepTypes (projectRoot, dts) {
|
|
|
39
96
|
*
|
|
40
97
|
* - Proxies /api requests to the Informer server with Basic auth
|
|
41
98
|
* - Runs server/ route handlers locally via ssrLoadModule (if server/ dir exists)
|
|
42
|
-
* - Injects window.__INFORMER__ context mock in dev mode
|
|
99
|
+
* - Injects window.__INFORMER__ context mock in dev mode, including a
|
|
100
|
+
* `channel()` mock fed by handler broadcast() over Vite's dev websocket
|
|
43
101
|
* - Sets base to './' so built assets use relative paths
|
|
44
102
|
*
|
|
45
103
|
* @param {Object} [options]
|
|
@@ -101,7 +159,12 @@ export default function informer(options = {}) {
|
|
|
101
159
|
Authorization: authHeader
|
|
102
160
|
},
|
|
103
161
|
...options.proxy
|
|
104
|
-
}
|
|
162
|
+
},
|
|
163
|
+
// NOTE: no /informer-api mount. The server
|
|
164
|
+
// retired that spelling — /api is ONE namespace
|
|
165
|
+
// with manifest-driven precedence (a whitelisted
|
|
166
|
+
// access.apis path OCCUPIES its spot; everything
|
|
167
|
+
// else is the app's own routes), mirrored below.
|
|
105
168
|
}
|
|
106
169
|
};
|
|
107
170
|
}
|
|
@@ -185,6 +248,27 @@ export default function informer(options = {}) {
|
|
|
185
248
|
for (const message of validateDevBindings(deps, options.devBindings || {})) {
|
|
186
249
|
console.error(`[informer] vite.config.js: ${message}`);
|
|
187
250
|
}
|
|
251
|
+
// Same for the `channels:` relay block — the deploy 400s on a bad
|
|
252
|
+
// channel or event name; say so now instead of dropping relays.
|
|
253
|
+
try {
|
|
254
|
+
for (const message of validateChannels(await loadChannels(projectRoot))) {
|
|
255
|
+
console.error(`[informer] informer.yaml: ${message}`);
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.warn(`[informer] Could not read informer.yaml channels: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// App Channels in dev. Handlers' broadcast() (and the `channels:`
|
|
262
|
+
// relay behind emit()) publish frames on this hub; each frame goes
|
|
263
|
+
// to the page as a custom event on Vite's own dev websocket, which
|
|
264
|
+
// the injected `__INFORMER__.channel` mock listens to via
|
|
265
|
+
// import.meta.hot. Riding the HMR socket means no second websocket,
|
|
266
|
+
// no `ws: true` on the /api proxy and no socket credential to mint
|
|
267
|
+
// — the dev server already owns a live connection to every page.
|
|
268
|
+
const channels = createDevChannels({ appId: buildMock(options).report.id });
|
|
269
|
+
channels.emitter.on(BROADCAST_EVENT, (frame) => {
|
|
270
|
+
server.ws.send({ type: 'custom', event: DEV_CHANNEL_EVENT, data: frame });
|
|
271
|
+
});
|
|
188
272
|
|
|
189
273
|
// Generate .d.ts types for bound `target: app` / `target: pack`
|
|
190
274
|
// deps from their published OpenAPI docs, so server/ handlers get
|
|
@@ -249,6 +333,16 @@ export default function informer(options = {}) {
|
|
|
249
333
|
console.warn(`[informer] app-dependency type generation skipped: ${err.message}`);
|
|
250
334
|
}
|
|
251
335
|
|
|
336
|
+
// App streams (I5-12979): the dev origin's /_uploads and /_downloads
|
|
337
|
+
// — the same protocol the deployed app speaks on its own origin —
|
|
338
|
+
// backed by one in-memory store the server routes' `uploads` /
|
|
339
|
+
// `downloads` services share. Mounted regardless of server/: the
|
|
340
|
+
// client helper is always on __INFORMER__, so a page may stage an
|
|
341
|
+
// upload before any handler exists to consume it.
|
|
342
|
+
const streamStore = createStreamStore();
|
|
343
|
+
server.middlewares.use('/_uploads', createUploadsMiddleware(streamStore));
|
|
344
|
+
server.middlewares.use('/_downloads', createDownloadsMiddleware(streamStore));
|
|
345
|
+
|
|
252
346
|
// Mount server-side route handlers if a server/ directory exists
|
|
253
347
|
const serverDir = resolve(projectRoot, 'server');
|
|
254
348
|
|
|
@@ -259,14 +353,31 @@ export default function informer(options = {}) {
|
|
|
259
353
|
devWorkspaceId,
|
|
260
354
|
projectRoot,
|
|
261
355
|
roles: (options.mock && options.mock.roles) || [],
|
|
356
|
+
user: buildMock(options).user,
|
|
262
357
|
// Dev-only bindings for `target: app` / `target: pack`
|
|
263
358
|
// slots (app: overrides the manifest defaultBinding; pack:
|
|
264
359
|
// names the locally-installed pack app). Shape:
|
|
265
360
|
// devBindings: { kanban: { app: 'admin:kanban' } }
|
|
266
361
|
devBindings: options.devBindings || {},
|
|
267
|
-
appToken
|
|
362
|
+
appToken,
|
|
363
|
+
channels,
|
|
364
|
+
streamStore
|
|
268
365
|
});
|
|
269
366
|
server.middlewares.use('/api/_server', serverRoutes);
|
|
367
|
+
// Occupied-spot precedence (App API v2, matches production
|
|
368
|
+
// dispatch in view-api.js): a platform API declared in
|
|
369
|
+
// informer.yaml's access.apis OCCUPIES its /api path — those
|
|
370
|
+
// requests skip the local handlers (next() falls through to
|
|
371
|
+
// the /api proxy above); every unclaimed path dispatches the
|
|
372
|
+
// app's own routes. The manifest is re-read per request so
|
|
373
|
+
// edits apply without a restart. Dev limitation: only raw
|
|
374
|
+
// access.apis entries are consulted (dependency-derived
|
|
375
|
+
// grants proxy through their typed context slots anyway).
|
|
376
|
+
// /api/_server stays as the legacy exclusive spelling.
|
|
377
|
+
server.middlewares.use('/api', (req, res, next) => {
|
|
378
|
+
if (manifestOccupies(projectRoot, req)) return next();
|
|
379
|
+
return serverRoutes(req, res, next);
|
|
380
|
+
});
|
|
270
381
|
}
|
|
271
382
|
|
|
272
383
|
// Mount agent dev middleware if tools/, mcp/, or informer.yaml agents exist
|
|
@@ -281,7 +392,8 @@ export default function informer(options = {}) {
|
|
|
281
392
|
devWorkspaceId,
|
|
282
393
|
projectRoot,
|
|
283
394
|
devBindings: options.devBindings || {},
|
|
284
|
-
appToken
|
|
395
|
+
appToken,
|
|
396
|
+
channels
|
|
285
397
|
});
|
|
286
398
|
server.middlewares.use('/api/_agent', agentDev);
|
|
287
399
|
}
|
|
@@ -293,22 +405,25 @@ export default function informer(options = {}) {
|
|
|
293
405
|
handler(html) {
|
|
294
406
|
if (!isDev) return html;
|
|
295
407
|
|
|
296
|
-
const mock =
|
|
297
|
-
report: {
|
|
298
|
-
id: 'dev-local',
|
|
299
|
-
name: 'Local Development'
|
|
300
|
-
},
|
|
301
|
-
theme: 'light',
|
|
302
|
-
roles: [],
|
|
303
|
-
...options.mock
|
|
304
|
-
};
|
|
408
|
+
const mock = buildMock(options);
|
|
305
409
|
|
|
410
|
+
// The context is a classic script (synchronous, so it exists
|
|
411
|
+
// before anything else runs). The streams client (upload /
|
|
412
|
+
// downloadUrl) rides inside it: the one piece of the mock that is
|
|
413
|
+
// code, not data, the same helper the deployed page gets,
|
|
414
|
+
// speaking to the /_uploads and /_downloads mounted in
|
|
415
|
+
// configureServer. The channel mock follows as a module script
|
|
416
|
+
// because only a Vite-processed module gets import.meta.hot.
|
|
306
417
|
const script = `<script>
|
|
307
418
|
(function() {
|
|
308
419
|
'use strict';
|
|
309
420
|
window.__INFORMER__ = ${JSON.stringify(mock)};
|
|
421
|
+
var __streams = ${streamsClientSource()};
|
|
422
|
+
window.__INFORMER__.upload = __streams.upload;
|
|
423
|
+
window.__INFORMER__.downloadUrl = __streams.downloadUrl;
|
|
310
424
|
})();
|
|
311
|
-
</script
|
|
425
|
+
</script>
|
|
426
|
+
${renderDevChannelScript({ hub: Boolean(serverOrigin) })}`;
|
|
312
427
|
|
|
313
428
|
// Insert after <head> tag, matching server behavior
|
|
314
429
|
const headIdx = html.indexOf('<head>');
|
package/src/server-routes.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, posix } from 'node:path';
|
|
3
3
|
import { parse as parseUrl } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import { loadManifest, manifestBlock, buildDevContext, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
|
|
5
|
+
import { createDevChannels, createDevEmit } from './dev-channels.js';
|
|
6
|
+
import { devPlatform } from './dev-platform.js';
|
|
7
|
+
import { createStreamStore, createStreamServices, isStreamRef, serveDownload as serveDevDownload } from './dev-streams.js';
|
|
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
|
+
};
|
|
5
16
|
|
|
6
17
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
7
18
|
|
|
@@ -170,10 +181,24 @@ async function walkJsFiles(dir, basePath) {
|
|
|
170
181
|
* @param {string[]} [opts.roles] - dev user roles surfaced on request.roles
|
|
171
182
|
* @param {Object} [opts.devBindings] - dev bindings for `target: app` deps
|
|
172
183
|
* @param {string|null} [opts.appToken] - INFORMER_APP_TOKEN for cross-app request()
|
|
184
|
+
* @param {ReturnType<typeof createDevChannels>} [opts.channels] - the dev channels hub
|
|
185
|
+
* behind `broadcast()` and the `channels:` relay (a private one when omitted)
|
|
186
|
+
* @param {Object} [opts.streamStore] - dev stream store shared with the /_uploads
|
|
187
|
+
* and /_downloads middleware (createStreamStore()); a private one when omitted
|
|
173
188
|
* @returns {Function} Connect middleware
|
|
174
189
|
*/
|
|
175
|
-
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, devBindings, appToken }) {
|
|
190
|
+
export function createMiddleware(viteServer, { serverOrigin, authHeader, devWorkspaceId, projectRoot, roles, user, devBindings, appToken, channels = createDevChannels(), streamStore }) {
|
|
191
|
+
// The viewer identity dev handlers see — the same object the page gets as
|
|
192
|
+
// window.__INFORMER__.user, so `@user/${request.user.username}` on the
|
|
193
|
+
// server names the channel the page subscribes to.
|
|
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
|
+
};
|
|
176
200
|
const serverDir = join(projectRoot, 'server');
|
|
201
|
+
const streams = streamStore || createStreamStore();
|
|
177
202
|
|
|
178
203
|
// query() implementation — proxies to the workspace _sql endpoint
|
|
179
204
|
async function query(sql, params) {
|
|
@@ -323,12 +348,6 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
323
348
|
// markdown helper — passthrough in dev (production uses `marked`)
|
|
324
349
|
const markdown = (text) => text;
|
|
325
350
|
|
|
326
|
-
// emit helper — no-op in dev (logs to console)
|
|
327
|
-
const emit = (event, payload) => {
|
|
328
|
-
console.log(`[app-event] emit("${event}",`, JSON.stringify(payload), ')');
|
|
329
|
-
return { ok: true };
|
|
330
|
-
};
|
|
331
|
-
|
|
332
351
|
// notify/email — delivery is a console-logged no-op in dev, but the
|
|
333
352
|
// required-field validation mirrors prod so an app that passes here
|
|
334
353
|
// won't 500 in production.
|
|
@@ -344,18 +363,43 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
344
363
|
rawBody,
|
|
345
364
|
headers: req.headers,
|
|
346
365
|
roles: roles || [],
|
|
347
|
-
user: {
|
|
348
|
-
username: 'dev-user',
|
|
349
|
-
displayName: 'Dev User',
|
|
350
|
-
email: null,
|
|
351
|
-
timezone: null
|
|
352
|
-
}
|
|
366
|
+
user: { ...devUser }
|
|
353
367
|
};
|
|
354
368
|
|
|
355
|
-
//
|
|
369
|
+
// App streams (I5-12979): per-invocation uploads/downloads services.
|
|
370
|
+
// An upload handle passed as a query() parameter is swapped for a
|
|
371
|
+
// \x bytea literal on the way to the _sql proxy (prod passes the
|
|
372
|
+
// bytes as a Buffer parameter — same column-typed coercion).
|
|
373
|
+
const streamServices = createStreamServices({ store: streams, query: devWorkspaceId ? query : null });
|
|
374
|
+
const streamQuery = async (sql, params) => await query(sql, await streamServices.resolveQueryParams(params || []));
|
|
375
|
+
|
|
376
|
+
// Serve a download handle as the response, like view-api.js does
|
|
377
|
+
// when a handler returns one (single-use, prod headers).
|
|
378
|
+
function serveDownloadHandle(ref) {
|
|
379
|
+
const item = streams.get('download', ref.id);
|
|
380
|
+
if (!item) throw new Error(`Unknown download ${ref.id} — was it discarded?`);
|
|
381
|
+
// Do NOT force complete: production 404s an unsealed download
|
|
382
|
+
// (app-streams.js#serveDownload), so forcing it here would hide
|
|
383
|
+
// a half-written download that fails once deployed.
|
|
384
|
+
if (!item.complete) {
|
|
385
|
+
throw new Error(`Download ${ref.id} was never ended — call end() (or return it) before serving it.`);
|
|
386
|
+
}
|
|
387
|
+
serveDevDownload(streams, item, res);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// respond() — sends early response, handler continues in background.
|
|
391
|
+
// respond(download) streams the staged bytes instead of JSON.
|
|
356
392
|
let responded = false;
|
|
357
|
-
function respond(earlyBody) {
|
|
393
|
+
async function respond(earlyBody) {
|
|
358
394
|
if (responded) return;
|
|
395
|
+
if (isStreamRef(earlyBody, 'download')) {
|
|
396
|
+
// Seal it first, as production does (app-sandbox.js
|
|
397
|
+
// respondCallback), then claim the response — not before,
|
|
398
|
+
// or a failure here would leave the request unanswered.
|
|
399
|
+
await streamServices.endDownload(earlyBody.id);
|
|
400
|
+
responded = true;
|
|
401
|
+
return serveDownloadHandle(earlyBody);
|
|
402
|
+
}
|
|
359
403
|
responded = true;
|
|
360
404
|
res.statusCode = 200;
|
|
361
405
|
res.setHeader('Content-Type', 'application/json');
|
|
@@ -364,18 +408,32 @@ export function createMiddleware(viteServer, { serverOrigin, authHeader, devWork
|
|
|
364
408
|
|
|
365
409
|
// Build the dependency-injection context the same way the prod
|
|
366
410
|
// sandbox does, so handlers using `await context.myDep.method(...)`
|
|
367
|
-
// work locally.
|
|
368
|
-
//
|
|
369
|
-
|
|
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');
|
|
370
416
|
const context = buildDevContext({ deps, apiFetch, devBindings, appFetch });
|
|
371
|
-
const env =
|
|
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;
|
|
372
424
|
|
|
373
425
|
// Call handler — bag mirrors the prod sandbox (see app-sandbox.js).
|
|
374
|
-
const result = await handler({ request, context, query, fetch: apiFetch, respond, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
|
|
426
|
+
const result = await handler({ request, context, query: streamQuery, fetch: apiFetch, respond, emit, broadcast, notify, email, embed, crypto: cryptoHelper, markdown, uploads: streamServices.uploads, downloads: streamServices.downloads, log, env, platform: devPlatform() });
|
|
427
|
+
|
|
428
|
+
// Seal any download the handler left open (prod finalizes the same way).
|
|
429
|
+
await streamServices.finalize();
|
|
375
430
|
|
|
376
431
|
// If respond() was already called, the response is already sent
|
|
377
432
|
if (responded) return;
|
|
378
433
|
|
|
434
|
+
// A returned download handle streams as the response.
|
|
435
|
+
if (isStreamRef(result, 'download')) return serveDownloadHandle(result);
|
|
436
|
+
|
|
379
437
|
// Normalize response (mirrors app-sandbox.js buildInvokeScript logic).
|
|
380
438
|
// The encoding allow-list and contract checks are enforced inside the
|
|
381
439
|
// ivm in production; replicate them here so handlers see the same
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev copy of the injected app streams client (I5-12979):
|
|
3
|
+
* `__INFORMER__.upload(file, opts)` and `__INFORMER__.downloadUrl(id, filename)`.
|
|
4
|
+
*
|
|
5
|
+
* VERBATIM copy of modules/app/routes/lib/html-utils.js#generateStreamsHelper
|
|
6
|
+
* in its origin-mode form (base "", plain window.fetch / XHR transport — the
|
|
7
|
+
* dev server IS the app's origin, so the same-origin /_uploads and /_downloads
|
|
8
|
+
* middleware serves it). The plugin cannot import the server module (different
|
|
9
|
+
* runtime, no dependency), so the copy is pinned byte-for-byte by
|
|
10
|
+
* test/streams-parity.test.js: change the protocol or retry policy there, and
|
|
11
|
+
* that test says exactly what to paste here. The dev page must upload the way
|
|
12
|
+
* a deployed one does.
|
|
13
|
+
*
|
|
14
|
+
* @returns {string} JavaScript expression evaluating to { upload, downloadUrl }
|
|
15
|
+
*/
|
|
16
|
+
export function streamsClientSource() {
|
|
17
|
+
return `(function (base, transport) {
|
|
18
|
+
var DEFAULTS = { chunkSize: 4 * 1024 * 1024, concurrency: 3, retries: 5 };
|
|
19
|
+
|
|
20
|
+
function delay (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
|
|
21
|
+
function backoff (attempt) { return Math.min(8000, 500 * Math.pow(2, attempt)) * (0.5 + Math.random()); }
|
|
22
|
+
function retryable (status) { return status === 0 || status === 408 || status === 429 || status >= 500; }
|
|
23
|
+
function httpError (status, data, fallback) {
|
|
24
|
+
var err = new Error((data && (data.message || data.error)) || fallback || ('HTTP ' + status));
|
|
25
|
+
err.status = status;
|
|
26
|
+
err.data = data && data.data;
|
|
27
|
+
return err;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function json (method, url, body) {
|
|
31
|
+
var opts = { method: method, credentials: 'same-origin', headers: {} };
|
|
32
|
+
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
|
33
|
+
return transport.fetch(url, opts).then(function (res) {
|
|
34
|
+
return res.text().then(function (text) {
|
|
35
|
+
var data = null;
|
|
36
|
+
try { data = text ? JSON.parse(text) : null; } catch (e) { data = text; }
|
|
37
|
+
if (!res.ok) throw httpError(res.status, data);
|
|
38
|
+
return data;
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// XHR rather than fetch for the chunk body: byte-level upload progress
|
|
44
|
+
// events and a synchronous abort.
|
|
45
|
+
function putChunk (url, blob, signal, onProgress) {
|
|
46
|
+
return new Promise(function (resolve, reject) {
|
|
47
|
+
var xhr = new XMLHttpRequest();
|
|
48
|
+
transport.open(xhr, 'PUT', url);
|
|
49
|
+
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
50
|
+
xhr.withCredentials = true;
|
|
51
|
+
// Without this, ontimeout can never fire and a half-open socket
|
|
52
|
+
// (VPN drop, laptop sleep, an idle reap with no RST) leaves the
|
|
53
|
+
// promise pending forever. Allows ~8 KB/s before giving up, so a
|
|
54
|
+
// genuinely slow link retries rather than stalls.
|
|
55
|
+
xhr.timeout = Math.max(60000, Math.ceil(blob.size / 8));
|
|
56
|
+
if (xhr.upload && onProgress) {
|
|
57
|
+
xhr.upload.onprogress = function (e) { if (e.lengthComputable) onProgress(e.loaded); };
|
|
58
|
+
}
|
|
59
|
+
xhr.onload = function () {
|
|
60
|
+
if (xhr.status >= 200 && xhr.status < 300) return resolve();
|
|
61
|
+
var data = null;
|
|
62
|
+
try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (e) { data = null; }
|
|
63
|
+
reject(httpError(xhr.status, data, 'Chunk upload failed: HTTP ' + xhr.status));
|
|
64
|
+
};
|
|
65
|
+
xhr.onerror = function () { reject(httpError(0, null, 'Network error during chunk upload')); };
|
|
66
|
+
xhr.ontimeout = function () { reject(httpError(408, null, 'Chunk upload timed out')); };
|
|
67
|
+
xhr.onabort = function () { var err = new Error('Upload aborted'); err.code = 'aborted'; reject(err); };
|
|
68
|
+
if (signal) {
|
|
69
|
+
if (signal.aborted) { xhr.abort(); return; }
|
|
70
|
+
signal.addEventListener('abort', function () { xhr.abort(); });
|
|
71
|
+
}
|
|
72
|
+
xhr.send(blob);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Name + length + mtime. Enough to stop a different file being spliced
|
|
77
|
+
// into a resumed upload; not a checksum, which would mean reading the
|
|
78
|
+
// whole file in the page before the first byte goes out.
|
|
79
|
+
function fingerprintOf (file, opts) {
|
|
80
|
+
return [opts.filename || file.name || '', file.size, file.lastModified || 0].join(':');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function upload (file, opts) {
|
|
84
|
+
opts = opts || {};
|
|
85
|
+
if (!file || typeof file.slice !== 'function' || typeof file.size !== 'number') {
|
|
86
|
+
return Promise.reject(new Error('upload(file): a File or Blob is required'));
|
|
87
|
+
}
|
|
88
|
+
var controller = new AbortController();
|
|
89
|
+
if (opts.signal) {
|
|
90
|
+
// An already-aborted signal never fires 'abort' — mirror its state now.
|
|
91
|
+
if (opts.signal.aborted) controller.abort();
|
|
92
|
+
else opts.signal.addEventListener('abort', function () { controller.abort(); });
|
|
93
|
+
}
|
|
94
|
+
var signal = controller.signal;
|
|
95
|
+
var retries = opts.retries != null ? opts.retries : DEFAULTS.retries;
|
|
96
|
+
var concurrency = Math.max(1, opts.concurrency || DEFAULTS.concurrency);
|
|
97
|
+
var task = { id: opts.resume || null, abort: function () { controller.abort(); } };
|
|
98
|
+
var loaded = {};
|
|
99
|
+
|
|
100
|
+
function progress () {
|
|
101
|
+
if (typeof opts.onProgress !== 'function') return;
|
|
102
|
+
var sum = 0;
|
|
103
|
+
for (var k in loaded) sum += loaded[k];
|
|
104
|
+
opts.onProgress({ loaded: sum, total: file.size, percent: file.size ? Math.min(100, Math.floor(sum * 100 / file.size)) : 100 });
|
|
105
|
+
}
|
|
106
|
+
function abortError () { var err = new Error('Upload aborted'); err.code = 'aborted'; return err; }
|
|
107
|
+
|
|
108
|
+
function sendChunk (meta, n, attempt) {
|
|
109
|
+
if (signal.aborted) return Promise.reject(abortError());
|
|
110
|
+
var start = (n - 1) * meta.chunkSize;
|
|
111
|
+
var blob = file.slice(start, Math.min(file.size, start + meta.chunkSize));
|
|
112
|
+
return putChunk(base + '/_uploads/' + meta.id + '/' + n, blob, signal, function (sent) { loaded[n] = sent; progress(); })
|
|
113
|
+
.then(function () { loaded[n] = blob.size; progress(); })
|
|
114
|
+
.catch(function (err) {
|
|
115
|
+
if (err.code === 'aborted' || signal.aborted) throw err;
|
|
116
|
+
if (err.status === 404) { var gone = new Error('Upload expired before it completed'); gone.code = 'upload_expired'; gone.status = 404; throw gone; }
|
|
117
|
+
if (!retryable(err.status) || attempt >= retries) throw err;
|
|
118
|
+
return delay(backoff(attempt)).then(function () { return sendChunk(meta, n, attempt + 1); });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function sendAll (meta, chunks) {
|
|
123
|
+
var next = 0;
|
|
124
|
+
function worker () {
|
|
125
|
+
if (next >= chunks.length || signal.aborted) return Promise.resolve();
|
|
126
|
+
var n = chunks[next++];
|
|
127
|
+
return sendChunk(meta, n, 0).then(worker);
|
|
128
|
+
}
|
|
129
|
+
var workers = [];
|
|
130
|
+
for (var i = 0; i < Math.min(concurrency, chunks.length); i++) workers.push(worker());
|
|
131
|
+
return Promise.all(workers);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
var plan = signal.aborted ? Promise.reject(abortError()) : opts.resume
|
|
135
|
+
? json('GET', base + '/_uploads/' + encodeURIComponent(opts.resume)).then(function (meta) {
|
|
136
|
+
// Size alone is not identity: two same-length files would be
|
|
137
|
+
// spliced into one upload that completes without complaint.
|
|
138
|
+
var fp = fingerprintOf(file, opts);
|
|
139
|
+
if (meta.size !== file.size || (meta.fingerprint && meta.fingerprint !== fp)) {
|
|
140
|
+
throw new Error('upload({ resume }): the file does not match the upload being resumed');
|
|
141
|
+
}
|
|
142
|
+
(meta.received || []).forEach(function (n) {
|
|
143
|
+
loaded[n] = n < meta.chunks ? meta.chunkSize : meta.size - meta.chunkSize * (meta.chunks - 1);
|
|
144
|
+
});
|
|
145
|
+
return { meta: meta, chunks: meta.complete ? [] : (meta.missing || []) };
|
|
146
|
+
})
|
|
147
|
+
: json('POST', base + '/_uploads', {
|
|
148
|
+
filename: opts.filename || file.name || 'upload',
|
|
149
|
+
contentType: opts.contentType || file.type || undefined,
|
|
150
|
+
size: file.size,
|
|
151
|
+
fingerprint: fingerprintOf(file, opts),
|
|
152
|
+
chunkSize: opts.chunkSize || DEFAULTS.chunkSize
|
|
153
|
+
}).then(function (meta) {
|
|
154
|
+
var chunks = [];
|
|
155
|
+
for (var n = 1; n <= meta.chunks; n++) chunks.push(n);
|
|
156
|
+
return { meta: meta, chunks: chunks };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
var promise = plan
|
|
160
|
+
.then(function (p) {
|
|
161
|
+
task.id = p.meta.id;
|
|
162
|
+
progress();
|
|
163
|
+
return sendAll(p.meta, p.chunks).then(function () {
|
|
164
|
+
if (signal.aborted) throw abortError();
|
|
165
|
+
return json('POST', base + '/_uploads/' + p.meta.id + '/_complete');
|
|
166
|
+
});
|
|
167
|
+
})
|
|
168
|
+
.then(function (handle) { progress(); return handle; })
|
|
169
|
+
.catch(function (err) {
|
|
170
|
+
var aborted = err.code === 'aborted' || signal.aborted;
|
|
171
|
+
// Promise.all rejects on the first failure but leaves the other
|
|
172
|
+
// workers retrying with backoff; stop them before returning.
|
|
173
|
+
controller.abort();
|
|
174
|
+
if (task.id) {
|
|
175
|
+
// Discard only on an explicit abort — the caller is done
|
|
176
|
+
// with it. Any other failure keeps the staged chunks so
|
|
177
|
+
// upload({ resume: err.uploadId }) can finish the job; the
|
|
178
|
+
// TTL reclaims them if it never does.
|
|
179
|
+
if (aborted) transport.fetch(base + '/_uploads/' + task.id, { method: 'DELETE', credentials: 'same-origin' }).catch(function () {});
|
|
180
|
+
else err.uploadId = task.id;
|
|
181
|
+
}
|
|
182
|
+
throw err;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
task.then = function (onFulfilled, onRejected) { return promise.then(onFulfilled, onRejected); };
|
|
186
|
+
task['catch'] = function (onRejected) { return promise['catch'](onRejected); };
|
|
187
|
+
task['finally'] = function (onFinally) { return promise['finally'](onFinally); };
|
|
188
|
+
return task;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function downloadUrl (id, filename) {
|
|
192
|
+
return base + '/_downloads/' + encodeURIComponent(id) + (filename ? '/' + encodeURIComponent(filename) : '');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { upload: upload, downloadUrl: downloadUrl };
|
|
196
|
+
})("", {
|
|
197
|
+
fetch: function (url, opts) { return window.fetch(url, opts); },
|
|
198
|
+
open: function (xhr, method, url) { xhr.open(method, url); }
|
|
199
|
+
})`;
|
|
200
|
+
}
|