nucleus-core-ts 0.9.975 → 0.9.976

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.
@@ -41,7 +41,7 @@ export declare const config: {
41
41
  * redefined globals the consuming app relies on — `setTimeout` started
42
42
  * reporting "expected 1 argument" in files that had never heard of us.
43
43
  */
44
- readonly transpileDirs: readonly ["./src/Client/PubSub", "./src/Shared"];
44
+ readonly transpileDirs: readonly ["./src/Client/PubSub", "./src/Client/Upload", "./src/Shared"];
45
45
  /** Build Configuration */
46
46
  readonly build: {
47
47
  readonly outDir: "dist";
@@ -0,0 +1,4 @@
1
+ export { buildProgress, createRateWindow, type RateSample, type RateWindow, secondsRemaining, uploadFraction, uploadPercent, } from './progress';
2
+ export type { UploadHandle, UploadOptions, UploadOutcome, UploadProgress, } from './types';
3
+ export { uploadWithProgress } from './upload';
4
+ export { type UseUploadResult, useUpload } from './useUpload';
@@ -0,0 +1,3 @@
1
+ export { buildProgress, createRateWindow, secondsRemaining, uploadFraction, uploadPercent } from './progress';
2
+ export { uploadWithProgress } from './upload';
3
+ export { useUpload } from './useUpload';
@@ -0,0 +1,102 @@
1
+ /**
2
+ * How long is "how long is left".
3
+ *
4
+ * The naive answer — bytes remaining divided by the rate since the last report
5
+ * — is unusable on a real connection. Between two packets the instantaneous
6
+ * rate swings by an order of magnitude, so the countdown reads "8 saniye", then
7
+ * "4 dakika", then "11 saniye", and the person watching learns to ignore it.
8
+ *
9
+ * So the rate is measured across a window instead: the oldest sample still
10
+ * inside WINDOW_MS against the newest. Long enough that one slow packet cannot
11
+ * move it much, short enough that a connection genuinely getting slower is
12
+ * reflected within a couple of seconds rather than being averaged away by a
13
+ * fast start.
14
+ */ const WINDOW_MS = 5000;
15
+ /**
16
+ * Below this, there is no answer worth giving.
17
+ *
18
+ * A rate computed from 80 ms of history is noise wearing a number, and the
19
+ * first estimate is the one people remember. Until the window has this much in
20
+ * it the remaining time is `null` and the screen shows a bar without a
21
+ * countdown — which is honest, and settles within a second on any real upload.
22
+ */ const MIN_SPAN_MS = 700;
23
+ /**
24
+ * A short history of byte counts, from which a believable rate can be drawn.
25
+ *
26
+ * Deliberately a plain object with functions rather than a class: it has to be
27
+ * usable from a hook's ref, a plain script and a test, and it holds nothing
28
+ * that needs a lifecycle.
29
+ */ export function createRateWindow(windowMs = WINDOW_MS) {
30
+ const samples = [];
31
+ return {
32
+ /** Record a byte count. Out-of-order and duplicate times are tolerated. */ push (at, bytes) {
33
+ const last = samples[samples.length - 1];
34
+ // A report that did not advance the clock cannot inform a rate, but it
35
+ // can inform the byte count — so replace rather than append.
36
+ if (last && at <= last.at) {
37
+ last.bytes = Math.max(last.bytes, bytes);
38
+ return;
39
+ }
40
+ samples.push({
41
+ at,
42
+ bytes
43
+ });
44
+ const cutoff = at - windowMs;
45
+ while(samples.length > 2 && samples[0].at < cutoff){
46
+ samples.shift();
47
+ }
48
+ },
49
+ /** Bytes per second across the window, or `null` while it means nothing. */ ratePerSecond () {
50
+ if (samples.length < 2) return null;
51
+ const first = samples[0];
52
+ const last = samples[samples.length - 1];
53
+ const spanMs = last.at - first.at;
54
+ if (spanMs < MIN_SPAN_MS) return null;
55
+ const delta = last.bytes - first.bytes;
56
+ // A window in which nothing arrived is a stall, not a speed of zero: a
57
+ // zero would make the remaining time infinite, which is not an estimate.
58
+ if (delta <= 0) return null;
59
+ return delta * 1000 / spanMs;
60
+ },
61
+ reset () {
62
+ samples.length = 0;
63
+ }
64
+ };
65
+ }
66
+ /** 0..1, or `null` when the total is unknown. Clamped — a server that counts differently must not produce 103%. */ export function uploadFraction(sentBytes, totalBytes) {
67
+ if (totalBytes === null || totalBytes <= 0) return null;
68
+ return Math.min(1, Math.max(0, sentBytes / totalBytes));
69
+ }
70
+ /** 0..100 rounded, or `null`. Floored below 100 so a bar never reads "100%" while bytes are still moving. */ export function uploadPercent(sentBytes, totalBytes) {
71
+ const fraction = uploadFraction(sentBytes, totalBytes);
72
+ if (fraction === null) return null;
73
+ if (fraction >= 1) return 100;
74
+ return Math.min(99, Math.floor(fraction * 100));
75
+ }
76
+ /**
77
+ * Seconds left, or `null` when that cannot be said yet.
78
+ *
79
+ * Returns 0 — not null — once every byte is accounted for, because "done" is
80
+ * something we DO know and a screen should be able to close on it.
81
+ */ export function secondsRemaining(sentBytes, totalBytes, bytesPerSecond) {
82
+ if (totalBytes === null || totalBytes <= 0) return null;
83
+ const left = totalBytes - sentBytes;
84
+ if (left <= 0) return 0;
85
+ if (bytesPerSecond === null || bytesPerSecond <= 0) return null;
86
+ return left / bytesPerSecond;
87
+ }
88
+ /** Assembles one honest progress report from a byte count and a rate window. */ export function buildProgress(params) {
89
+ const { sentBytes, totalBytes, startedAt, now, window, retrying = false } = params;
90
+ window.push(now, sentBytes);
91
+ const bytesPerSecond = window.ratePerSecond();
92
+ return {
93
+ sentBytes,
94
+ totalBytes,
95
+ fraction: uploadFraction(sentBytes, totalBytes),
96
+ percent: uploadPercent(sentBytes, totalBytes),
97
+ bytesPerSecond,
98
+ secondsRemaining: secondsRemaining(sentBytes, totalBytes, bytesPerSecond),
99
+ elapsedMs: Math.max(0, now - startedAt),
100
+ retrying
101
+ };
102
+ }
@@ -74,4 +74,20 @@ export interface UploadOptions<TData> {
74
74
  * timer is how "sonsuz boyut" quietly becomes "as long as this number".
75
75
  */
76
76
  timeoutMs?: number;
77
+ /**
78
+ * Where the request object comes from. Defaults to the global
79
+ * `XMLHttpRequest`.
80
+ *
81
+ * This exists because the byte counting is a BROWSER guarantee and nothing
82
+ * else implements it. Bun has no `XMLHttpRequest` at all, and happy-dom's
83
+ * has an `upload` object that never fires a single progress event —
84
+ * measured: zero, on an 8 MB body. A test written against that would be a
85
+ * test that cannot fail, which is worse than no test.
86
+ *
87
+ * So the transport's own logic — when it reports, what it does with each
88
+ * outcome, that it never rejects — is exercised against a stand-in that
89
+ * emits the events a browser emits, and the byte counting itself is proven
90
+ * in a browser. Anything else would be proving the stand-in.
91
+ */
92
+ createRequest?: () => XMLHttpRequest;
77
93
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What an upload can honestly say about itself while it is happening.
3
+ *
4
+ * Every field that can be unknown is `null` rather than 0 or a guess. That is
5
+ * the whole design: a bar drawn at 0% that never moves, or a countdown that
6
+ * says "3 seconds" for four minutes, is worse than saying nothing — the person
7
+ * stops believing the screen, and then they do not believe it when it is right.
8
+ */ export { };
@@ -0,0 +1,10 @@
1
+ import type { UploadHandle, UploadOptions, UploadProgress } from './types';
2
+ /**
3
+ * Posts a body and reports honest progress while it goes.
4
+ *
5
+ * Resolves either way and never rejects, because a caller that has to wrap
6
+ * every upload in try/catch ends up with one that does not, and then a failed
7
+ * upload looks like a successful one.
8
+ */
9
+ export declare function uploadWithProgress<TData = unknown>(body: FormData | Blob | ArrayBufferView | ArrayBuffer | string, options: UploadOptions<TData>): UploadHandle<TData>;
10
+ export type { UploadProgress };
@@ -0,0 +1,156 @@
1
+ import { buildProgress, createRateWindow } from './progress';
2
+ /**
3
+ * An upload the browser can actually count.
4
+ *
5
+ * `XMLHttpRequest` and not `fetch`, and the reason is not preference: fetch
6
+ * reports NO upload progress at all. A request body stream would need
7
+ * `duplex: 'half'`, which is not supported across the browsers this has to run
8
+ * on, and even then it reports what the stream produced, not what the socket
9
+ * accepted. `xhr.upload.onprogress` is the only mechanism a browser gives that
10
+ * counts bytes handed to the network.
11
+ *
12
+ * It is also the reason a framework server action cannot be used here. A server
13
+ * action is a request the framework owns; it exposes nothing about its
14
+ * progress, it cannot be aborted, and it buffers the whole body in the web
15
+ * process before a byte reaches storage. Measured on a live install: a large
16
+ * video appeared to upload and then simply did not arrive.
17
+ */ /** How often to report, at most. Every progress event would re-render for nothing. */ const MIN_REPORT_INTERVAL_MS = 120;
18
+ /**
19
+ * Posts a body and reports honest progress while it goes.
20
+ *
21
+ * Resolves either way and never rejects, because a caller that has to wrap
22
+ * every upload in try/catch ends up with one that does not, and then a failed
23
+ * upload looks like a successful one.
24
+ */ export function uploadWithProgress(body, options) {
25
+ const xhr = options.createRequest ? options.createRequest() : new XMLHttpRequest();
26
+ const window = createRateWindow();
27
+ const startedAt = Date.now();
28
+ let lastReportAt = 0;
29
+ let settled = false;
30
+ /** The last totals the browser reported, so completion can be stated exactly. */ let lastSent = 0;
31
+ let lastTotal = null;
32
+ const report = (sentBytes, totalBytes, force)=>{
33
+ if (!options.onProgress) return;
34
+ const now = Date.now();
35
+ if (!force && now - lastReportAt < MIN_REPORT_INTERVAL_MS) return;
36
+ lastReportAt = now;
37
+ options.onProgress(buildProgress({
38
+ sentBytes,
39
+ totalBytes,
40
+ startedAt,
41
+ now,
42
+ window
43
+ }));
44
+ };
45
+ const done = new Promise((resolve)=>{
46
+ const settle = (outcome)=>{
47
+ if (settled) return;
48
+ settled = true;
49
+ resolve(outcome);
50
+ };
51
+ xhr.open('POST', options.url, true);
52
+ // The session cookie is what authenticates an upload; the proxy in front
53
+ // turns it into whatever bearer the API wants.
54
+ xhr.withCredentials = options.withCredentials !== false;
55
+ for (const [key, value] of Object.entries(options.headers ?? {})){
56
+ xhr.setRequestHeader(key, value);
57
+ }
58
+ // Deliberately unset by default. An upload bounded by disk rather than
59
+ // memory can legitimately run for a very long time, and a timer is how
60
+ // "no size limit" quietly becomes "as long as this number".
61
+ if (options.timeoutMs) xhr.timeout = options.timeoutMs;
62
+ xhr.upload.onprogress = (event)=>{
63
+ lastSent = event.loaded;
64
+ // `lengthComputable` false means the browser will not total this body.
65
+ // Saying `null` is the whole design — see UploadProgress.
66
+ lastTotal = event.lengthComputable ? event.total : null;
67
+ report(lastSent, lastTotal, false);
68
+ };
69
+ xhr.upload.onload = ()=>{
70
+ // Every byte is on the socket now. The server has not answered yet, but
71
+ // the part this can measure IS finished, and a bar frozen at 99% while a
72
+ // large file is committed reads as a hang. Reported from the browser's
73
+ // own last numbers rather than a guess about what "all of it" means.
74
+ report(lastTotal ?? lastSent, lastTotal, true);
75
+ };
76
+ xhr.onload = ()=>{
77
+ let parsed = null;
78
+ try {
79
+ parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null;
80
+ } catch {
81
+ parsed = null;
82
+ }
83
+ if (xhr.status < 200 || xhr.status >= 300) {
84
+ settle({
85
+ ok: false,
86
+ status: xhr.status,
87
+ message: serverMessage(parsed) || xhr.statusText || '',
88
+ cancelled: false
89
+ });
90
+ return;
91
+ }
92
+ const data = options.parse ? options.parse(parsed) : parsed;
93
+ if (data === null || data === undefined) {
94
+ // A 2xx whose body the caller does not recognise is not a success. An
95
+ // endpoint with its own envelope stays the caller's business.
96
+ settle({
97
+ ok: false,
98
+ status: xhr.status,
99
+ message: serverMessage(parsed) || 'The server accepted the upload but returned nothing usable',
100
+ cancelled: false
101
+ });
102
+ return;
103
+ }
104
+ settle({
105
+ ok: true,
106
+ data
107
+ });
108
+ };
109
+ xhr.onerror = ()=>{
110
+ settle({
111
+ ok: false,
112
+ status: null,
113
+ message: 'The upload could not reach the server',
114
+ cancelled: false
115
+ });
116
+ };
117
+ xhr.ontimeout = ()=>{
118
+ settle({
119
+ ok: false,
120
+ status: null,
121
+ message: 'The upload timed out',
122
+ cancelled: false
123
+ });
124
+ };
125
+ xhr.onabort = ()=>{
126
+ // Cancelled is carried separately so a screen can stay quiet about it.
127
+ // Somebody who pressed cancel does not need to be told it failed.
128
+ settle({
129
+ ok: false,
130
+ status: null,
131
+ message: '',
132
+ cancelled: true
133
+ });
134
+ };
135
+ xhr.send(body);
136
+ });
137
+ return {
138
+ done,
139
+ abort: ()=>{
140
+ xhr.abort();
141
+ }
142
+ };
143
+ }
144
+ /** Digs the server's own sentence out of whatever envelope it used. */ function serverMessage(parsed) {
145
+ if (!parsed || typeof parsed !== 'object') return '';
146
+ const body = parsed;
147
+ for (const key of [
148
+ 'message',
149
+ 'error',
150
+ 'detail'
151
+ ]){
152
+ const value = body[key];
153
+ if (typeof value === 'string' && value) return value;
154
+ }
155
+ return '';
156
+ }
@@ -0,0 +1,31 @@
1
+ import type { UploadOptions, UploadOutcome, UploadProgress } from './types';
2
+ /**
3
+ * One file at a time, with something honest to draw while it goes.
4
+ *
5
+ * The state is deliberately the progress object itself rather than a spread of
6
+ * separate numbers: a screen that reads `percent` and `secondsRemaining` from
7
+ * two different pieces of state can render a moment where they disagree, and
8
+ * the disagreement is what a person notices.
9
+ *
10
+ * `null` progress means nothing is in flight. That is a different statement
11
+ * from 0%, and the difference is what lets a screen show nothing at all rather
12
+ * than an empty bar sitting at zero.
13
+ */
14
+ export interface UseUploadResult<TData> {
15
+ /** Live progress, or `null` when nothing is uploading. */
16
+ progress: UploadProgress | null;
17
+ /** True from the moment `start` is called until the outcome is known. */
18
+ uploading: boolean;
19
+ /** The last outcome, or `null` before the first attempt. */
20
+ outcome: UploadOutcome<TData> | null;
21
+ /**
22
+ * Begins an upload, cancelling any earlier one first. Resolves with the
23
+ * outcome, so a caller can `await` it without watching state.
24
+ */
25
+ start: (body: FormData | Blob | ArrayBufferView | ArrayBuffer | string, overrides?: Partial<UploadOptions<TData>>) => Promise<UploadOutcome<TData>>;
26
+ /** Stops the transfer for real. */
27
+ cancel: () => void;
28
+ /** Clears the last outcome and progress, e.g. when the form is reopened. */
29
+ reset: () => void;
30
+ }
31
+ export declare function useUpload<TData = unknown>(options: UploadOptions<TData>): UseUploadResult<TData>;
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import { uploadWithProgress } from './upload';
4
+ export function useUpload(options) {
5
+ const [progress, setProgress] = useState(null);
6
+ const [uploading, setUploading] = useState(false);
7
+ const [outcome, setOutcome] = useState(null);
8
+ const handleRef = useRef(null);
9
+ // Read inside a callback that outlives the render it was created in, so the
10
+ // latest options are used rather than the ones captured at mount.
11
+ const optionsRef = useRef(options);
12
+ optionsRef.current = options;
13
+ // Guards a slow upload from writing state after a newer one started, which
14
+ // is how a finished upload gets overwritten by a cancelled one's outcome.
15
+ const runId = useRef(0);
16
+ const cancel = useCallback(()=>{
17
+ handleRef.current?.abort();
18
+ handleRef.current = null;
19
+ }, []);
20
+ const reset = useCallback(()=>{
21
+ setProgress(null);
22
+ setOutcome(null);
23
+ }, []);
24
+ const start = useCallback(async (body, overrides)=>{
25
+ handleRef.current?.abort();
26
+ const id = ++runId.current;
27
+ setUploading(true);
28
+ setOutcome(null);
29
+ setProgress(null);
30
+ const merged = {
31
+ ...optionsRef.current,
32
+ ...overrides
33
+ };
34
+ const handle = uploadWithProgress(body, {
35
+ ...merged,
36
+ onProgress: (p)=>{
37
+ if (id !== runId.current) return;
38
+ setProgress(p);
39
+ merged.onProgress?.(p);
40
+ }
41
+ });
42
+ handleRef.current = handle;
43
+ const result = await handle.done;
44
+ if (id !== runId.current) return result;
45
+ handleRef.current = null;
46
+ setUploading(false);
47
+ setOutcome(result);
48
+ return result;
49
+ }, []);
50
+ return {
51
+ progress,
52
+ uploading,
53
+ outcome,
54
+ start,
55
+ cancel,
56
+ reset
57
+ };
58
+ }
@@ -2,3 +2,4 @@ export * from './ApiCaller';
2
2
  export * from './PubSub';
3
3
  export { ServerFetch, serverFetch } from './ServerFetch';
4
4
  export type { BaseError, HttpMethod, ServerFetchConfig, ServerFetchOptions, ServerFetchResponse, } from './ServerFetch/types';
5
+ export * from './Upload';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nucleus-core-ts",
3
- "version": "0.9.975",
3
+ "version": "0.9.976",
4
4
  "description": "Production-ready, enterprise-grade TypeScript framework for building multi-tenant APIs",
5
5
  "author": "Hidayet Can Özcan <hidayetcan@gmail.com>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -77,6 +77,7 @@
77
77
  "@types/bun": "latest",
78
78
  "@types/pg": "latest",
79
79
  "@types/react": "latest",
80
+ "@happy-dom/global-registrator": "^20.11.2",
80
81
  "@types/react-dom": "latest",
81
82
  "bun-plugin-tailwind": "latest",
82
83
  "drizzle-kit": "latest",