@craftthingy-digital-innovation/cty-offline-outbox-sync-web 1.0.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/LICENSE ADDED
@@ -0,0 +1,38 @@
1
+ Public-Source Corporate Royalty License (PSCRL)
2
+
3
+ Copyright (c) 2026 Alif Nurhidayat (alifnurhidayatwork@gmail.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction for non-commercial, personal, educational,
8
+ research, and open-source public use, subject to the following conditions:
9
+
10
+ 1. NON-COMMERCIAL & PUBLIC USE
11
+ The Software is completely free to use, modify, and distribute for individuals,
12
+ educational institutions, personal research, testing, and non-commercial open-source
13
+ projects.
14
+
15
+ 2. CORPORATE & COMMERCIAL BUSINESS USE
16
+ Any use of the Software by for-profit companies, corporations, commercial applications,
17
+ SaaS (Software as a Service) platforms, or for any revenue-generating activity
18
+ requires a commercial licensing agreement.
19
+
20
+ By using this Software for commercial or corporate purposes, you agree to:
21
+ a. Pay a royalty fee of 1% (one percent) of the gross monthly revenue generated by
22
+ any product, service, or platform that incorporates or uses this Software.
23
+ b. Startups or businesses with gross annual revenues of less than $10,000 USD (or equivalent)
24
+ are exempt from royalties until they exceed this threshold.
25
+ c. Provide quarterly reporting of gross revenues generated to the copyright holder.
26
+
27
+ 3. CONTACT & CUSTOM LICENSING
28
+ For royalty payments, reporting, custom enterprise flat-rate licenses, or general inquiries,
29
+ contact the copyright holder directly at:
30
+ alifnurhidayatwork@gmail.com
31
+
32
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
33
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
34
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
35
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
36
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
37
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
38
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @craftthingy-digital-innovation/cty-offline-outbox-sync-web
2
+
3
+ Bilingual documentation: [Bahasa Indonesia](#bahasa-indonesia) | [English](#english)
4
+
5
+ ---
6
+
7
+ ## Bahasa Indonesia
8
+
9
+ Library client-side Javascript untuk menangani pengiriman data otomatis (auto-save) dengan kemampuan penyimpanan lokal sementara (`localStorage` outbox) dan pencobaan ulang otomatis (`auto-retry`) berkala jika koneksi terputus.
10
+
11
+ Sangat berguna untuk aplikasi web yang membutuhkan persistensi tinggi (seperti spreadsheet, form panjang, CRM) agar terhindar dari kehilangan data saat jaringan buruk.
12
+
13
+ ### Instalasi
14
+ ```bash
15
+ npm install @craftthingy-digital-innovation/cty-offline-outbox-sync-web
16
+ ```
17
+
18
+ ### Cara Penggunaan
19
+ ```javascript
20
+ import { OfflineOutbox } from '@craftthingy-digital-innovation/cty-offline-outbox-sync-web';
21
+
22
+ const outbox = new OfflineOutbox({
23
+ storageKey: 'paspor_offline_queue', // Kunci localStorage
24
+ retryInterval: 6000, // Waktu tunggu coba kembali (6 detik)
25
+ onSync: async (item) => {
26
+ // Jalankan pengiriman data asinkron Anda ke server
27
+ const response = await fetch('/api/save', {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify(item.postData)
31
+ });
32
+ return await response.json();
33
+ }
34
+ });
35
+
36
+ // Masukkan perubahan data ke outbox (akan di-merge otomatis jika UUID sama)
37
+ outbox.enqueue('row-uuid-123', { nama_pemohon: 'Alif Nurhidayat' });
38
+
39
+ // Dengarkan perubahan status sinkronisasi untuk update UI badge
40
+ outbox.on('status-change', ({ pendingCount, online }) => {
41
+ if (!online) {
42
+ updateStatusUI(`Koneksi terputus. Menyimpan lokal... (${pendingCount} tertunda)`);
43
+ } else if (pendingCount > 0) {
44
+ updateStatusUI(`Menyinkronkan... (${pendingCount} perubahan)`);
45
+ } else {
46
+ updateStatusUI('Semua data tersimpan otomatis');
47
+ }
48
+ });
49
+ ```
50
+
51
+ ---
52
+
53
+ ## English
54
+
55
+ A client-side JavaScript library to handle automatic form submissions (auto-save) with local cache fallback (`localStorage` outbox) and periodic auto-retry capabilities when connections are disrupted.
56
+
57
+ Extremely useful for sheets, databases, long forms, or CRMs to prevent data loss on unstable networks.
58
+
59
+ ### Installation
60
+ ```bash
61
+ npm install @craftthingy-digital-innovation/cty-offline-outbox-sync-web
62
+ ```
63
+
64
+ ### Usage
65
+ ```javascript
66
+ import { OfflineOutbox } from '@craftthingy-digital-innovation/cty-offline-outbox-sync-web';
67
+
68
+ const outbox = new OfflineOutbox({
69
+ storageKey: 'paspor_offline_queue', // Key in localStorage
70
+ retryInterval: 6000, // Retry interval (6 seconds)
71
+ onSync: async (item) => {
72
+ // Execute your async API save request
73
+ const response = await fetch('/api/save', {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify(item.postData)
77
+ });
78
+ return await response.json();
79
+ }
80
+ });
81
+
82
+ // Enqueue changes (automatically merged if same UUID is modified)
83
+ outbox.enqueue('row-uuid-123', { nama_pemohon: 'Alif Nurhidayat' });
84
+
85
+ // Listen for status changes to update your UI indicators
86
+ outbox.on('status-change', ({ pendingCount, online }) => {
87
+ if (!online) {
88
+ updateStatusUI(`Connection offline. Saved locally (${pendingCount} pending)`);
89
+ } else if (pendingCount > 0) {
90
+ updateStatusUI(`Syncing... (${pendingCount} changes)`);
91
+ } else {
92
+ updateStatusUI('All data saved automatically');
93
+ }
94
+ });
95
+ ```
@@ -0,0 +1,85 @@
1
+ class i extends EventTarget {
2
+ constructor(t = {}) {
3
+ super(), this.storageKey = t.storageKey || "cty_offline_outbox_queue", this.retryInterval = t.retryInterval || 6e3, this.onSync = t.onSync || null, this.queue = JSON.parse(localStorage.getItem(this.storageKey) || "[]"), this.isProcessing = !1, typeof window < "u" && (window.addEventListener("online", () => {
4
+ this.dispatchEvent(new CustomEvent("network-status-change", { detail: { online: !0 } })), this.processQueue();
5
+ }), window.addEventListener("offline", () => {
6
+ this.dispatchEvent(new CustomEvent("network-status-change", { detail: { online: !1 } }));
7
+ }));
8
+ }
9
+ /**
10
+ * Enqueues data for submission. Deduplicates by uuid.
11
+ * @param {string} uuid Unique identifier for the row/entity
12
+ * @param {object} postData Fields and values to update
13
+ */
14
+ enqueue(t, e) {
15
+ const s = this.queue.findIndex((n) => n.uuid === t);
16
+ s !== -1 ? (Object.assign(this.queue[s].postData, e), this.queue[s].timestamp = Date.now()) : this.queue.push({
17
+ uuid: t,
18
+ postData: e,
19
+ timestamp: Date.now(),
20
+ attempts: 0
21
+ }), this.save(), this.notifyStatus(), this.processQueue();
22
+ }
23
+ /**
24
+ * Saves the queue to local storage
25
+ */
26
+ save() {
27
+ localStorage.setItem(this.storageKey, JSON.stringify(this.queue));
28
+ }
29
+ /**
30
+ * Dispatches status update events
31
+ */
32
+ notifyStatus(t = !1) {
33
+ const e = typeof navigator < "u" ? navigator.onLine : !0;
34
+ this.dispatchEvent(new CustomEvent("status-change", {
35
+ detail: {
36
+ pendingCount: this.queue.length,
37
+ online: e && !t,
38
+ queue: [...this.queue]
39
+ }
40
+ }));
41
+ }
42
+ /**
43
+ * Processes the first item in the queue recursively
44
+ */
45
+ async processQueue() {
46
+ if (this.isProcessing || this.queue.length === 0) return;
47
+ if (!(typeof navigator < "u" ? navigator.onLine : !0)) {
48
+ this.notifyStatus(!0);
49
+ return;
50
+ }
51
+ this.isProcessing = !0;
52
+ const e = this.queue[0];
53
+ this.dispatchEvent(new CustomEvent("sync-start", { detail: { item: e } }));
54
+ try {
55
+ if (typeof this.onSync != "function")
56
+ throw new Error("onSync callback is not registered or not a function.");
57
+ const s = await this.onSync(e);
58
+ this.queue.shift(), this.save(), this.dispatchEvent(new CustomEvent("sync-success", { detail: { item: e, result: s } })), this.isProcessing = !1, this.notifyStatus(), this.processQueue();
59
+ } catch (s) {
60
+ console.warn("Outbox sync failed, will retry:", s), e.attempts++, this.save(), this.dispatchEvent(new CustomEvent("sync-failure", { detail: { item: e, error: s } })), this.isProcessing = !1, this.notifyStatus(!0), setTimeout(() => this.processQueue(), this.retryInterval);
61
+ }
62
+ }
63
+ /**
64
+ * Clear all pending items in the queue
65
+ */
66
+ clear() {
67
+ this.queue = [], this.save(), this.notifyStatus();
68
+ }
69
+ /**
70
+ * Get all queued items
71
+ */
72
+ getQueue() {
73
+ return [...this.queue];
74
+ }
75
+ /**
76
+ * Callback binder helper
77
+ */
78
+ on(t, e) {
79
+ this.addEventListener(t, (s) => e(s.detail));
80
+ }
81
+ }
82
+ typeof window < "u" && (window.OfflineOutbox = i);
83
+ export {
84
+ i as OfflineOutbox
85
+ };
@@ -0,0 +1 @@
1
+ (function(n,i){typeof exports=="object"&&typeof module<"u"?i(exports):typeof define=="function"&&define.amd?define(["exports"],i):(n=typeof globalThis<"u"?globalThis:n||self,i(n.OfflineOutbox={}))})(this,function(n){"use strict";class i extends EventTarget{constructor(t={}){super(),this.storageKey=t.storageKey||"cty_offline_outbox_queue",this.retryInterval=t.retryInterval||6e3,this.onSync=t.onSync||null,this.queue=JSON.parse(localStorage.getItem(this.storageKey)||"[]"),this.isProcessing=!1,typeof window<"u"&&(window.addEventListener("online",()=>{this.dispatchEvent(new CustomEvent("network-status-change",{detail:{online:!0}})),this.processQueue()}),window.addEventListener("offline",()=>{this.dispatchEvent(new CustomEvent("network-status-change",{detail:{online:!1}}))}))}enqueue(t,e){const s=this.queue.findIndex(o=>o.uuid===t);s!==-1?(Object.assign(this.queue[s].postData,e),this.queue[s].timestamp=Date.now()):this.queue.push({uuid:t,postData:e,timestamp:Date.now(),attempts:0}),this.save(),this.notifyStatus(),this.processQueue()}save(){localStorage.setItem(this.storageKey,JSON.stringify(this.queue))}notifyStatus(t=!1){const e=typeof navigator<"u"?navigator.onLine:!0;this.dispatchEvent(new CustomEvent("status-change",{detail:{pendingCount:this.queue.length,online:e&&!t,queue:[...this.queue]}}))}async processQueue(){if(this.isProcessing||this.queue.length===0)return;if(!(typeof navigator<"u"?navigator.onLine:!0)){this.notifyStatus(!0);return}this.isProcessing=!0;const e=this.queue[0];this.dispatchEvent(new CustomEvent("sync-start",{detail:{item:e}}));try{if(typeof this.onSync!="function")throw new Error("onSync callback is not registered or not a function.");const s=await this.onSync(e);this.queue.shift(),this.save(),this.dispatchEvent(new CustomEvent("sync-success",{detail:{item:e,result:s}})),this.isProcessing=!1,this.notifyStatus(),this.processQueue()}catch(s){console.warn("Outbox sync failed, will retry:",s),e.attempts++,this.save(),this.dispatchEvent(new CustomEvent("sync-failure",{detail:{item:e,error:s}})),this.isProcessing=!1,this.notifyStatus(!0),setTimeout(()=>this.processQueue(),this.retryInterval)}}clear(){this.queue=[],this.save(),this.notifyStatus()}getQueue(){return[...this.queue]}on(t,e){this.addEventListener(t,s=>e(s.detail))}}typeof window<"u"&&(window.OfflineOutbox=i),n.OfflineOutbox=i,Object.defineProperty(n,Symbol.toStringTag,{value:"Module"})});
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@craftthingy-digital-innovation/cty-offline-outbox-sync-web",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/CraftThingy-Digital-Innovation/cty-offline-outbox-sync-web.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/CraftThingy-Digital-Innovation/cty-offline-outbox-sync-web/issues"
11
+ },
12
+ "homepage": "https://github.com/CraftThingy-Digital-Innovation/cty-offline-outbox-sync-web#readme",
13
+ "main": "./dist/cty-offline-outbox-sync.umd.js",
14
+ "module": "./dist/cty-offline-outbox-sync.es.js",
15
+ "exports": {
16
+ ".": {
17
+ "import": "./dist/cty-offline-outbox-sync.es.js",
18
+ "require": "./dist/cty-offline-outbox-sync.umd.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "scripts": {
26
+ "build": "vite build"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {},
32
+ "devDependencies": {
33
+ "vite": "^5.2.0"
34
+ }
35
+ }
package/src/index.js ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * cty-offline-outbox-sync-web
3
+ * Offline Outbox Auto-Retry Manager
4
+ * Public-Source Corporate Royalty License (PSCRL)
5
+ * Copyright (c) 2026 CraftThingy Digital Innovation & Alif Nurhidayat
6
+ */
7
+
8
+ export class OfflineOutbox extends EventTarget {
9
+ constructor(options = {}) {
10
+ super();
11
+ this.storageKey = options.storageKey || 'cty_offline_outbox_queue';
12
+ this.retryInterval = options.retryInterval || 6000;
13
+ this.onSync = options.onSync || null; // Async function (item) => Promise<any>
14
+
15
+ this.queue = JSON.parse(localStorage.getItem(this.storageKey) || '[]');
16
+ this.isProcessing = false;
17
+
18
+ // Listen to network status changes automatically
19
+ if (typeof window !== 'undefined') {
20
+ window.addEventListener('online', () => {
21
+ this.dispatchEvent(new CustomEvent('network-status-change', { detail: { online: true } }));
22
+ this.processQueue();
23
+ });
24
+ window.addEventListener('offline', () => {
25
+ this.dispatchEvent(new CustomEvent('network-status-change', { detail: { online: false } }));
26
+ });
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Enqueues data for submission. Deduplicates by uuid.
32
+ * @param {string} uuid Unique identifier for the row/entity
33
+ * @param {object} postData Fields and values to update
34
+ */
35
+ enqueue(uuid, postData) {
36
+ const existingIdx = this.queue.findIndex(item => item.uuid === uuid);
37
+
38
+ if (existingIdx !== -1) {
39
+ // Merge updates
40
+ Object.assign(this.queue[existingIdx].postData, postData);
41
+ this.queue[existingIdx].timestamp = Date.now();
42
+ } else {
43
+ this.queue.push({
44
+ uuid: uuid,
45
+ postData: postData,
46
+ timestamp: Date.now(),
47
+ attempts: 0
48
+ });
49
+ }
50
+
51
+ this.save();
52
+ this.notifyStatus();
53
+ this.processQueue();
54
+ }
55
+
56
+ /**
57
+ * Saves the queue to local storage
58
+ */
59
+ save() {
60
+ localStorage.setItem(this.storageKey, JSON.stringify(this.queue));
61
+ }
62
+
63
+ /**
64
+ * Dispatches status update events
65
+ */
66
+ notifyStatus(isOfflineError = false) {
67
+ const online = typeof navigator !== 'undefined' ? navigator.onLine : true;
68
+ this.dispatchEvent(new CustomEvent('status-change', {
69
+ detail: {
70
+ pendingCount: this.queue.length,
71
+ online: online && !isOfflineError,
72
+ queue: [...this.queue]
73
+ }
74
+ }));
75
+ }
76
+
77
+ /**
78
+ * Processes the first item in the queue recursively
79
+ */
80
+ async processQueue() {
81
+ if (this.isProcessing || this.queue.length === 0) return;
82
+
83
+ const online = typeof navigator !== 'undefined' ? navigator.onLine : true;
84
+ if (!online) {
85
+ this.notifyStatus(true);
86
+ return;
87
+ }
88
+
89
+ this.isProcessing = true;
90
+ const item = this.queue[0];
91
+
92
+ this.dispatchEvent(new CustomEvent('sync-start', { detail: { item } }));
93
+
94
+ try {
95
+ if (typeof this.onSync !== 'function') {
96
+ throw new Error("onSync callback is not registered or not a function.");
97
+ }
98
+
99
+ const result = await this.onSync(item);
100
+
101
+ // Success! Remove from outbox
102
+ this.queue.shift();
103
+ this.save();
104
+
105
+ this.dispatchEvent(new CustomEvent('sync-success', { detail: { item, result } }));
106
+
107
+ this.isProcessing = false;
108
+ this.notifyStatus();
109
+
110
+ // Recursively process next item
111
+ this.processQueue();
112
+ } catch (err) {
113
+ console.warn("Outbox sync failed, will retry:", err);
114
+ item.attempts++;
115
+ this.save();
116
+
117
+ this.dispatchEvent(new CustomEvent('sync-failure', { detail: { item, error: err } }));
118
+
119
+ this.isProcessing = false;
120
+ this.notifyStatus(true);
121
+
122
+ // Retry after configured delay
123
+ setTimeout(() => this.processQueue(), this.retryInterval);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Clear all pending items in the queue
129
+ */
130
+ clear() {
131
+ this.queue = [];
132
+ this.save();
133
+ this.notifyStatus();
134
+ }
135
+
136
+ /**
137
+ * Get all queued items
138
+ */
139
+ getQueue() {
140
+ return [...this.queue];
141
+ }
142
+
143
+ /**
144
+ * Callback binder helper
145
+ */
146
+ on(event, callback) {
147
+ this.addEventListener(event, (e) => callback(e.detail));
148
+ }
149
+ }
150
+
151
+ if (typeof window !== 'undefined') {
152
+ window.OfflineOutbox = OfflineOutbox;
153
+ }