pyview-web 0.7.2__tar.gz

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.
Files changed (65) hide show
  1. pyview_web-0.7.2/LICENSE +21 -0
  2. pyview_web-0.7.2/PKG-INFO +163 -0
  3. pyview_web-0.7.2/pyproject.toml +136 -0
  4. pyview_web-0.7.2/pyview/__init__.py +24 -0
  5. pyview_web-0.7.2/pyview/assets/js/app.js +79 -0
  6. pyview_web-0.7.2/pyview/assets/js/uploaders.js +221 -0
  7. pyview_web-0.7.2/pyview/assets/package-lock.json +57 -0
  8. pyview_web-0.7.2/pyview/assets/package.json +8 -0
  9. pyview_web-0.7.2/pyview/async_stream_runner.py +66 -0
  10. pyview_web-0.7.2/pyview/auth/__init__.py +4 -0
  11. pyview_web-0.7.2/pyview/auth/provider.py +32 -0
  12. pyview_web-0.7.2/pyview/auth/required.py +40 -0
  13. pyview_web-0.7.2/pyview/changesets/__init__.py +3 -0
  14. pyview_web-0.7.2/pyview/changesets/changesets.py +63 -0
  15. pyview_web-0.7.2/pyview/cli/__init__.py +0 -0
  16. pyview_web-0.7.2/pyview/cli/commands/__init__.py +0 -0
  17. pyview_web-0.7.2/pyview/cli/commands/create_view.py +200 -0
  18. pyview_web-0.7.2/pyview/cli/main.py +17 -0
  19. pyview_web-0.7.2/pyview/csrf.py +30 -0
  20. pyview_web-0.7.2/pyview/events/AutoEventDispatch.py +98 -0
  21. pyview_web-0.7.2/pyview/events/BaseEventHandler.py +84 -0
  22. pyview_web-0.7.2/pyview/events/__init__.py +5 -0
  23. pyview_web-0.7.2/pyview/events/info_event.py +16 -0
  24. pyview_web-0.7.2/pyview/instrumentation/__init__.py +21 -0
  25. pyview_web-0.7.2/pyview/instrumentation/interfaces.py +206 -0
  26. pyview_web-0.7.2/pyview/instrumentation/noop.py +100 -0
  27. pyview_web-0.7.2/pyview/js.py +117 -0
  28. pyview_web-0.7.2/pyview/live_routes.py +49 -0
  29. pyview_web-0.7.2/pyview/live_socket.py +280 -0
  30. pyview_web-0.7.2/pyview/live_view.py +53 -0
  31. pyview_web-0.7.2/pyview/meta.py +6 -0
  32. pyview_web-0.7.2/pyview/phx_message.py +54 -0
  33. pyview_web-0.7.2/pyview/playground/__init__.py +10 -0
  34. pyview_web-0.7.2/pyview/playground/builder.py +118 -0
  35. pyview_web-0.7.2/pyview/playground/favicon.py +39 -0
  36. pyview_web-0.7.2/pyview/pyview.py +116 -0
  37. pyview_web-0.7.2/pyview/secret.py +18 -0
  38. pyview_web-0.7.2/pyview/session.py +15 -0
  39. pyview_web-0.7.2/pyview/static/assets/app.js +5484 -0
  40. pyview_web-0.7.2/pyview/static/assets/uploaders.js +221 -0
  41. pyview_web-0.7.2/pyview/template/__init__.py +29 -0
  42. pyview_web-0.7.2/pyview/template/context_processor.py +17 -0
  43. pyview_web-0.7.2/pyview/template/live_template.py +85 -0
  44. pyview_web-0.7.2/pyview/template/live_view_template.py +158 -0
  45. pyview_web-0.7.2/pyview/template/render_diff.py +35 -0
  46. pyview_web-0.7.2/pyview/template/root_template.py +87 -0
  47. pyview_web-0.7.2/pyview/template/serializer.py +28 -0
  48. pyview_web-0.7.2/pyview/template/template_view.py +120 -0
  49. pyview_web-0.7.2/pyview/template/utils.py +25 -0
  50. pyview_web-0.7.2/pyview/uploads.py +586 -0
  51. pyview_web-0.7.2/pyview/vendor/__init__.py +0 -0
  52. pyview_web-0.7.2/pyview/vendor/flet/pubsub/__init__.py +3 -0
  53. pyview_web-0.7.2/pyview/vendor/flet/pubsub/pub_sub.py +232 -0
  54. pyview_web-0.7.2/pyview/vendor/ibis/__init__.py +13 -0
  55. pyview_web-0.7.2/pyview/vendor/ibis/compiler.py +184 -0
  56. pyview_web-0.7.2/pyview/vendor/ibis/context.py +136 -0
  57. pyview_web-0.7.2/pyview/vendor/ibis/errors.py +46 -0
  58. pyview_web-0.7.2/pyview/vendor/ibis/filters.py +292 -0
  59. pyview_web-0.7.2/pyview/vendor/ibis/loaders.py +95 -0
  60. pyview_web-0.7.2/pyview/vendor/ibis/nodes.py +743 -0
  61. pyview_web-0.7.2/pyview/vendor/ibis/template.py +55 -0
  62. pyview_web-0.7.2/pyview/vendor/ibis/tree.py +92 -0
  63. pyview_web-0.7.2/pyview/vendor/ibis/utils.py +91 -0
  64. pyview_web-0.7.2/pyview/ws_handler.py +394 -0
  65. pyview_web-0.7.2/readme.md +117 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Larry Ogrodnek
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, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyview-web
3
+ Version: 0.7.2
4
+ Summary: LiveView in Python
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: web,api,LiveView
8
+ Author: Larry Ogrodnek
9
+ Author-email: ogrodnek@gmail.com
10
+ Requires-Python: >=3.11,<3.15
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Framework :: Pydantic
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Information Technology
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Programming Language :: Python :: 3 :: Only
27
+ Classifier: Topic :: Internet
28
+ Classifier: Topic :: Internet :: WWW/HTTP
29
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
30
+ Classifier: Topic :: Software Development
31
+ Classifier: Topic :: Software Development :: Libraries
32
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
33
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
34
+ Classifier: Typing :: Typed
35
+ Requires-Dist: APScheduler (>=3.11.0,<4.0.0)
36
+ Requires-Dist: click (>=8.1.7,<9.0.0)
37
+ Requires-Dist: itsdangerous (>=2.2.0,<3.0.0)
38
+ Requires-Dist: markupsafe (>=3.0.2,<4.0.0)
39
+ Requires-Dist: pydantic (>=2.11,<3.0)
40
+ Requires-Dist: starlette (>=0.50.0,<0.51.0)
41
+ Requires-Dist: wsproto (>=1.3.0,<2.0.0)
42
+ Project-URL: Homepage, https://pyview.rocks
43
+ Project-URL: Repository, https://github.com/ogrodnek/pyview
44
+ Description-Content-Type: text/markdown
45
+
46
+ <img src="https://pyview.rocks/images/pyview_logo_512.png" width="128px" align="right" />
47
+
48
+ # PyView
49
+
50
+ > A Python implementation of Phoenix LiveView
51
+
52
+ PyView enables dynamic, real-time web apps, using server-rendered HTML.
53
+
54
+ **Source Code**: <a href="https://github.com/ogrodnek/pyview" target="_blank">https://github.com/ogrodnek/pyview</a>
55
+
56
+ # Installation
57
+
58
+ `pip install pyview-web`
59
+
60
+ ## Quickstart
61
+
62
+ There's a [cookiecutter](https://github.com/cookiecutter/cookiecutter) template available
63
+
64
+ ```
65
+ cookiecutter gh:ogrodnek/pyview-cookiecutter
66
+ ```
67
+
68
+ # Live Examples
69
+
70
+ [https://examples.pyview.rocks/](https://examples.pyview.rocks/)
71
+
72
+ ## Other Examples
73
+
74
+ - [pyview AI chat](https://github.com/pyview/pyview-example-ai-chat)
75
+ - [pyview auth example](https://github.com/pyview/pyview-example-auth) (using [authlib](https://docs.authlib.org/en/latest/))
76
+
77
+ ## Simple Counter
78
+
79
+ [See it live!](https://examples.pyview.rocks/count)
80
+
81
+ count.py:
82
+
83
+ ```python
84
+ from pyview import LiveView, LiveViewSocket
85
+ from typing import TypedDict
86
+
87
+
88
+ class CountContext(TypedDict):
89
+ count: int
90
+
91
+
92
+ class CountLiveView(LiveView[CountContext]):
93
+ async def mount(self, socket: LiveViewSocket[CountContext], _session):
94
+ socket.context = {"count": 0}
95
+
96
+ async def handle_event(self, event, payload, socket: LiveViewSocket[CountContext]):
97
+ if event == "decrement":
98
+ socket.context["count"] -= 1
99
+
100
+ if event == "increment":
101
+ socket.context["count"] += 1
102
+
103
+ async def handle_params(self, url, params, socket: LiveViewSocket[CountContext]):
104
+ if "c" in params:
105
+ socket.context["count"] = int(params["c"][0])
106
+ ```
107
+
108
+ count.html:
109
+
110
+ ```html
111
+ <div>
112
+ <h1>Count is {{count}}</h1>
113
+ <button phx-click="decrement">-</button>
114
+ <button phx-click="increment">+</button>
115
+ </div>
116
+ ```
117
+
118
+ # Acknowledgements
119
+
120
+ - Obviously this project wouldn't exist without [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view), which is a wonderful paradigm and implementation. Besides using their ideas, we also directly use the LiveView JavaScript code.
121
+ - Thanks to [Donnie Flood](https://github.com/floodfx) for the encouragement, inspiration, help, and even pull requests to get this project started! Check out [LiveViewJS](https://github.com/floodfx/liveviewjs) for a TypeScript implementation of LiveView (that's much more mature than this one!)
122
+
123
+ - Thanks to [Darren Mulholland](https://github.com/dmulholl) for both his [Let's Build a Template Language](https://www.dmulholl.com/lets-build/a-template-language.html) tutorial, as well as his [ibis template engine](https://github.com/dmulholl/ibis), which he very generously released into the public domain, and forms the basis of templating in PyView.
124
+
125
+ ## Additional Thanks
126
+
127
+ - We're using the [pubsub implementation from flet](https://github.com/flet-dev/flet)
128
+ - PyView is built on top of [Starlette](https://www.starlette.io/).
129
+
130
+ # Status
131
+
132
+ PyView is in the very early stages of active development. Please check it out and give feedback! Note that the API is likely to change, and there are many features that are not yet implemented.
133
+
134
+ # Running the included Examples
135
+
136
+ ## Setup
137
+
138
+ ```
139
+ poetry install
140
+ ```
141
+
142
+ ## Running
143
+
144
+ ```
145
+ poetry run uvicorn examples.app:app --reload
146
+ ```
147
+
148
+ Then go to http://localhost:8000/
149
+
150
+ ### Poetry Install
151
+
152
+ ```
153
+ brew install pipx
154
+ pipx install poetry
155
+ pipx ensurepath
156
+ ```
157
+
158
+ (see https://python-poetry.org/docs/#installation for more details)
159
+
160
+ # License
161
+
162
+ PyView is licensed under the [MIT License](LICENSE).
163
+
@@ -0,0 +1,136 @@
1
+ [tool.poetry]
2
+ name = "pyview-web"
3
+
4
+ packages = [
5
+ { include = "pyview" },
6
+ ]
7
+
8
+ version = "0.7.2"
9
+ description = "LiveView in Python"
10
+ authors = ["Larry Ogrodnek <ogrodnek@gmail.com>"]
11
+ license = "MIT"
12
+ readme = "readme.md"
13
+ homepage = "https://pyview.rocks"
14
+ repository = "https://github.com/ogrodnek/pyview"
15
+ keywords = ["web", "api", "LiveView"]
16
+
17
+ classifiers = [
18
+ "Intended Audience :: Information Technology",
19
+ "Intended Audience :: System Administrators",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python",
23
+ "Topic :: Internet",
24
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Topic :: Software Development :: Libraries",
27
+ "Topic :: Software Development",
28
+ "Typing :: Typed",
29
+ "Development Status :: 4 - Beta",
30
+ "Environment :: Web Environment",
31
+ "Framework :: AsyncIO",
32
+ "Framework :: Pydantic",
33
+ "Intended Audience :: Developers",
34
+ "License :: OSI Approved :: MIT License",
35
+ "Programming Language :: Python :: 3 :: Only",
36
+ "Programming Language :: Python :: 3.11",
37
+ "Programming Language :: Python :: 3.12",
38
+ "Programming Language :: Python :: 3.13",
39
+ "Programming Language :: Python :: 3.14",
40
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
41
+ "Topic :: Internet :: WWW/HTTP",
42
+ ]
43
+
44
+ [tool.poetry.dependencies]
45
+ python = ">=3.11,<3.15"
46
+ starlette = "^0.50.0"
47
+ wsproto = "^1.3.0"
48
+ APScheduler = "^3.11.0"
49
+ markupsafe = "^3.0.2"
50
+ itsdangerous = "^2.2.0"
51
+ pydantic = "^2.11"
52
+ click = "^8.1.7"
53
+
54
+ [tool.poetry.scripts]
55
+ pv = "pyview.cli.main:cli"
56
+
57
+ [tool.poetry.group.dev.dependencies]
58
+ pytest = "^8.2.0"
59
+ ruff = "^0.14.0"
60
+ pyright = "^1.1.403"
61
+ pytest-cov = "^6.1.1"
62
+ pytest-asyncio = "^1.3.0"
63
+
64
+ [tool.poetry.group.profiling.dependencies]
65
+ scalene = {version = "^1.5.51", python = "!=3.11.0,>=3.11"}
66
+
67
+
68
+ [build-system]
69
+ requires = ["poetry-core"]
70
+ build-backend = "poetry.core.masonry.api"
71
+
72
+ [tool.pyright]
73
+ exclude = [
74
+ ".venv",
75
+ "examples",
76
+ "examples/.venv",
77
+ "**/vendor",
78
+ "**/node_modules",
79
+ "**/__pycache__",
80
+ # T-string files require Python 3.14+
81
+ "tests/test_live_view_template.py",
82
+ "tests/test_template_view.py",
83
+ ]
84
+
85
+ [tool.pytest.ini_options]
86
+ asyncio_mode = "auto"
87
+
88
+ [tool.ruff]
89
+ line-length = 100
90
+ target-version = "py311"
91
+ exclude = [
92
+ # T-string files require Python 3.14+ syntax
93
+ "pyview/template/__init__.py",
94
+ "pyview/template/template_view.py",
95
+ "pyview/template/live_view_template.py",
96
+ "examples/views/count/count_tstring.py",
97
+ "tests/test_live_view_template.py",
98
+ "tests/test_template_view.py",
99
+ ]
100
+
101
+ [tool.ruff.lint]
102
+ select = [
103
+ "E", # pycodestyle errors
104
+ "W", # pycodestyle warnings
105
+ "F", # pyflakes
106
+ "I", # isort
107
+ "N", # pep8-naming
108
+ "UP", # pyupgrade
109
+ "B", # flake8-bugbear
110
+ "C4", # flake8-comprehensions
111
+ "SIM", # flake8-simplify
112
+ ]
113
+ ignore = [
114
+ "E501", # line too long (handled by formatter)
115
+ "UP007", # use-pep604-annotation (Union[X, Y] -> X | Y)
116
+ "UP035", # deprecated-import (keep typing.Iterator instead of collections.abc.Iterator)
117
+ "UP045", # non-pep604-annotation (keep Optional[X] instead of X | None)
118
+ "N802", # mixed case function names
119
+ "N803", # mixed case variable
120
+ "N806", # mixed case variable in function
121
+ "N815", # mixed case attributes
122
+ "N818", # exception naming (keep AuthException, not AuthError)
123
+ "N999", # invalid module name (allow PascalCase module names)
124
+ "SIM108", # prefer if-else blocks over ternary operators for readability
125
+ ]
126
+
127
+ [tool.ruff.lint.isort]
128
+ known-first-party = ["pyview"]
129
+
130
+ [tool.ruff.lint.per-file-ignores]
131
+ "pyview/vendor/**" = ["E722", "F811", "N818", "SIM102", "SIM103", "SIM118", "UP031"] # Ignore vendored code issues
132
+
133
+ [tool.ruff.format]
134
+ quote-style = "double"
135
+ indent-style = "space"
136
+ line-ending = "auto"
@@ -0,0 +1,24 @@
1
+ from pyview.js import JsCommand
2
+ from pyview.live_socket import (
3
+ ConnectedLiveViewSocket,
4
+ LiveViewSocket,
5
+ UnconnectedSocket,
6
+ is_connected,
7
+ )
8
+ from pyview.live_view import LiveView
9
+ from pyview.playground import playground
10
+ from pyview.pyview import PyView, RootTemplate, RootTemplateContext, defaultRootTemplate
11
+
12
+ __all__ = [
13
+ "LiveView",
14
+ "LiveViewSocket",
15
+ "PyView",
16
+ "defaultRootTemplate",
17
+ "JsCommand",
18
+ "RootTemplateContext",
19
+ "RootTemplate",
20
+ "is_connected",
21
+ "ConnectedLiveViewSocket",
22
+ "UnconnectedSocket",
23
+ "playground",
24
+ ]
@@ -0,0 +1,79 @@
1
+ // We import the CSS which is extracted to its own file by esbuild.
2
+ // Remove this line if you add a your own CSS build pipeline (e.g postcss).
3
+ //import "../css/app.css";
4
+
5
+ // If you want to use Phoenix channels, run `mix help phx.gen.channel`
6
+ // to get started and then uncomment the line below.
7
+ // import "./user_socket.js"
8
+
9
+ // You can include dependencies in two ways.
10
+ //
11
+ // The simplest option is to put them in assets/vendor and
12
+ // import them using relative paths:
13
+ //
14
+ // import "./vendor/some-package.js"
15
+ //
16
+ // Alternatively, you can `npm install some-package` and import
17
+ // them using a path starting with the package name:
18
+ //
19
+ // import "some-package"
20
+ //
21
+
22
+ // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
23
+ import "phoenix_html";
24
+ // Establish Phoenix Socket and LiveView configuration.
25
+ import { Socket } from "phoenix";
26
+ import { LiveSocket } from "phoenix_live_view";
27
+ import NProgress from "nprogress";
28
+
29
+ let Hooks = window.Hooks ?? {};
30
+
31
+ let scrollAt = () => {
32
+ let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
33
+ let scrollHeight =
34
+ document.documentElement.scrollHeight || document.body.scrollHeight;
35
+ let clientHeight = document.documentElement.clientHeight;
36
+
37
+ return (scrollTop / (scrollHeight - clientHeight)) * 100;
38
+ };
39
+
40
+ Hooks.InfiniteScroll = {
41
+ page() {
42
+ return this.el.dataset.page;
43
+ },
44
+ mounted() {
45
+ this.pending = this.page();
46
+ window.addEventListener("scroll", (e) => {
47
+ if (this.pending == this.page() && scrollAt() > 90) {
48
+ this.pending = this.page() + 1;
49
+ this.pushEvent("load-more", {});
50
+ }
51
+ });
52
+ },
53
+ updated() {
54
+ this.pending = this.page();
55
+ },
56
+ };
57
+
58
+ let csrfToken = document
59
+ .querySelector("meta[name='csrf-token']")
60
+ .getAttribute("content");
61
+ let liveSocket = new LiveSocket("/live", Socket, {
62
+ hooks: Hooks,
63
+ params: { _csrf_token: csrfToken },
64
+ uploaders: window.Uploaders || {},
65
+ });
66
+
67
+ // Show progress bar on live navigation and form submits
68
+ //topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
69
+ window.addEventListener("phx:page-loading-start", (info) => NProgress.start());
70
+ window.addEventListener("phx:page-loading-stop", (info) => NProgress.done());
71
+
72
+ // connect if there are any LiveViews on the page
73
+ liveSocket.connect();
74
+
75
+ // expose liveSocket on window for web console debug logs and latency simulation:
76
+ // >> liveSocket.enableDebug()
77
+ // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
78
+ // >> liveSocket.disableLatencySim()
79
+ window.liveSocket = liveSocket;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * PyView External S3 Uploaders
3
+ *
4
+ * Client-side uploaders for external S3 uploads.
5
+ *
6
+ * Available uploaders:
7
+ * - S3: Simple POST upload to S3 using presigned POST URLs
8
+ * - S3Multipart: Multipart upload for large files (>5GB)
9
+ */
10
+
11
+ window.Uploaders = window.Uploaders || {};
12
+
13
+ // S3 Simple POST uploader
14
+ // Uses presigned POST URLs for direct upload to S3
15
+ // Works for files up to ~5GB
16
+ if (!window.Uploaders.S3) {
17
+ window.Uploaders.S3 = function (entries, onViewError) {
18
+ entries.forEach((entry) => {
19
+ let formData = new FormData();
20
+ let { url, fields } = entry.meta;
21
+
22
+ // Add all fields from presigned POST
23
+ Object.entries(fields).forEach(([key, val]) =>
24
+ formData.append(key, val)
25
+ );
26
+ formData.append("file", entry.file);
27
+
28
+ let xhr = new XMLHttpRequest();
29
+ onViewError(() => xhr.abort());
30
+
31
+ xhr.onload = () => {
32
+ if (xhr.status === 204 || xhr.status === 200) {
33
+ entry.progress(100);
34
+ } else {
35
+ entry.error(`S3 upload failed with status ${xhr.status}`);
36
+ }
37
+ };
38
+ xhr.onerror = () => entry.error("Network error during upload");
39
+
40
+ xhr.upload.addEventListener("progress", (event) => {
41
+ if (event.lengthComputable) {
42
+ let percent = Math.round((event.loaded / event.total) * 100);
43
+ if (percent < 100) {
44
+ entry.progress(percent);
45
+ }
46
+ }
47
+ });
48
+
49
+ xhr.open("POST", url, true);
50
+ xhr.send(formData);
51
+ });
52
+ };
53
+ }
54
+
55
+ // S3 Multipart uploader for large files
56
+ // Uploads file in chunks with retry logic and concurrency control
57
+ //
58
+ // - Exponential backoff retry (max 3 attempts per part)
59
+ // - Concurrency limit (max 6 parallel uploads)
60
+ // - Automatic cleanup on fatal errors
61
+ //
62
+ // Based on AWS best practices:
63
+ // https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
64
+ //
65
+ // Server must:
66
+ // 1. Return metadata with: uploader="S3Multipart", upload_id, part_urls, chunk_size
67
+ // 2. Provide entry_complete callback to finalize the upload
68
+ if (!window.Uploaders.S3Multipart) {
69
+ window.Uploaders.S3Multipart = function (entries, onViewError) {
70
+ entries.forEach((entry) => {
71
+ const { upload_id, part_urls, chunk_size, key } = entry.meta;
72
+ const file = entry.file;
73
+ const parts = []; // Store {PartNumber, ETag} for each uploaded part
74
+
75
+ const MAX_RETRIES = 3;
76
+ const MAX_CONCURRENT = 6;
77
+ let uploadedParts = 0;
78
+ let activeUploads = 0;
79
+ let partIndex = 0;
80
+ let hasError = false;
81
+ const totalParts = part_urls.length;
82
+
83
+ console.log(`[S3Multipart] Starting upload for ${entry.file.name}`);
84
+ console.log(`[S3Multipart] Total parts: ${totalParts}, chunk size: ${chunk_size}`);
85
+ console.log(`[S3Multipart] Max concurrent uploads: ${MAX_CONCURRENT}, max retries: ${MAX_RETRIES}`);
86
+
87
+ // Add a custom method to send completion data directly
88
+ // This bypasses entry.progress() which only handles numbers
89
+ entry.complete = function(completionData) {
90
+ console.log(`[S3Multipart] Calling entry.complete with:`, completionData);
91
+ // Call pushFileProgress directly with the completion data
92
+ entry.view.pushFileProgress(entry.fileEl, entry.ref, completionData);
93
+ };
94
+
95
+ // Upload a single part with retry logic
96
+ const uploadPart = (index, retryCount = 0) => {
97
+ if (hasError) return; // Stop if we've hit a fatal error
98
+
99
+ const partNumber = index + 1;
100
+ const url = part_urls[index];
101
+ const start = index * chunk_size;
102
+ const end = Math.min(start + chunk_size, file.size);
103
+ const chunk = file.slice(start, end);
104
+
105
+ console.log(`[S3Multipart] Starting part ${partNumber}/${totalParts}, size: ${chunk.size} bytes, attempt ${retryCount + 1}`);
106
+
107
+ const xhr = new XMLHttpRequest();
108
+ onViewError(() => xhr.abort());
109
+
110
+ // Track upload progress within this chunk
111
+ xhr.upload.addEventListener("progress", (event) => {
112
+ if (event.lengthComputable) {
113
+ // Calculate overall progress: completed parts + current part's progress
114
+ const completedBytes = uploadedParts * chunk_size;
115
+ const currentPartBytes = event.loaded;
116
+ const totalBytes = file.size;
117
+ const overallPercent = Math.round(((completedBytes + currentPartBytes) / totalBytes) * 100);
118
+
119
+ // Don't report 100% until all parts complete and we send completion data
120
+ if (overallPercent < 100) {
121
+ entry.progress(overallPercent);
122
+ }
123
+ }
124
+ });
125
+
126
+ xhr.onload = () => {
127
+ activeUploads--;
128
+
129
+ if (xhr.status === 200) {
130
+ const etag = xhr.getResponseHeader('ETag');
131
+ console.log(`[S3Multipart] Part ${partNumber} succeeded, ETag: ${etag}`);
132
+
133
+ if (!etag) {
134
+ console.error(`[S3Multipart] Part ${partNumber} missing ETag!`);
135
+ entry.error(`Part ${partNumber} upload succeeded but no ETag returned`);
136
+ hasError = true;
137
+ return;
138
+ }
139
+
140
+ // Store the part with its ETag
141
+ parts.push({
142
+ PartNumber: partNumber,
143
+ ETag: etag.replace(/"/g, '')
144
+ });
145
+ uploadedParts++;
146
+
147
+ // Update progress
148
+ const progressPercent = Math.round((uploadedParts / totalParts) * 100);
149
+ console.log(`[S3Multipart] Progress: ${uploadedParts}/${totalParts} parts (${progressPercent}%)`);
150
+
151
+ if (uploadedParts < totalParts) {
152
+ entry.progress(progressPercent < 100 ? progressPercent : 99);
153
+ uploadNextPart(); // Start next part
154
+ } else {
155
+ // All parts complete!
156
+ const completionData = {
157
+ complete: true,
158
+ upload_id: upload_id,
159
+ key: key,
160
+ parts: parts.sort((a, b) => a.PartNumber - b.PartNumber)
161
+ };
162
+ console.log(`[S3Multipart] All parts complete! Sending completion data`);
163
+ entry.complete(completionData);
164
+ }
165
+ } else {
166
+ // Upload failed - retry with exponential backoff
167
+ console.error(`[S3Multipart] Part ${partNumber} failed with status ${xhr.status}, attempt ${retryCount + 1}`);
168
+
169
+ if (retryCount < MAX_RETRIES) {
170
+ // Exponential backoff: 1s, 2s, 4s, max 10s
171
+ const delay = Math.min(1000 * (2 ** retryCount), 10000);
172
+ console.log(`[S3Multipart] Retrying part ${partNumber} in ${delay}ms...`);
173
+
174
+ setTimeout(() => {
175
+ uploadPart(index, retryCount + 1);
176
+ }, delay);
177
+ } else {
178
+ // Max retries exceeded - fatal error
179
+ console.error(`[S3Multipart] Part ${partNumber} failed after ${MAX_RETRIES} retries, aborting upload`);
180
+ entry.error(`Part ${partNumber} failed after ${MAX_RETRIES} attempts. Upload aborted.`);
181
+ hasError = true;
182
+ }
183
+ }
184
+ };
185
+
186
+ xhr.onerror = () => {
187
+ activeUploads--;
188
+ console.error(`[S3Multipart] Network error on part ${partNumber}, attempt ${retryCount + 1}`);
189
+
190
+ if (retryCount < MAX_RETRIES) {
191
+ const delay = Math.min(1000 * (2 ** retryCount), 10000);
192
+ console.log(`[S3Multipart] Retrying part ${partNumber} after network error in ${delay}ms...`);
193
+
194
+ setTimeout(() => {
195
+ uploadPart(index, retryCount + 1);
196
+ }, delay);
197
+ } else {
198
+ console.error(`[S3Multipart] Part ${partNumber} network error after ${MAX_RETRIES} retries, aborting upload`);
199
+ entry.error(`Part ${partNumber} network error after ${MAX_RETRIES} attempts. Upload aborted.`);
200
+ hasError = true;
201
+ }
202
+ };
203
+
204
+ xhr.open('PUT', url, true);
205
+ xhr.send(chunk);
206
+ activeUploads++;
207
+ };
208
+
209
+ // Upload next part if we haven't hit the concurrency limit
210
+ const uploadNextPart = () => {
211
+ while (partIndex < totalParts && activeUploads < MAX_CONCURRENT && !hasError) {
212
+ uploadPart(partIndex);
213
+ partIndex++;
214
+ }
215
+ };
216
+
217
+ // Start initial batch of uploads
218
+ uploadNextPart();
219
+ });
220
+ };
221
+ }