@entrinsik/vite-plugin-informer 2.7.0-beta.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.
@@ -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 { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
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
- // respond() — sends early response, handler continues in background
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. Loaded per request so edits to informer.yaml take
368
- // effect without a dev-server restart.
369
- const deps = await loadDependencies(projectRoot);
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 = await loadAppEnv(projectRoot);
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
+ }