Repository: phoenixframework/phoenix Branch: main Commit: eca672ec4bdc Files: 454 Total size: 3.1 MB Directory structure: gitextract_fy0ivb69/ ├── .formatter.exs ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── config.yml │ ├── dependabot.yml │ └── workflows/ │ ├── assets.yml │ ├── ci.yml │ └── npm-publish.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── RELEASE.md ├── SECURITY.md ├── assets/ │ ├── js/ │ │ └── phoenix/ │ │ ├── ajax.js │ │ ├── channel.js │ │ ├── constants.js │ │ ├── index.js │ │ ├── longpoll.js │ │ ├── presence.js │ │ ├── push.js │ │ ├── serializer.js │ │ ├── socket.js │ │ ├── timer.js │ │ └── utils.js │ └── test/ │ ├── channel_test.js │ ├── longpoll_test.js │ ├── presence_test.js │ ├── serializer.js │ ├── serializer_test.js │ ├── socket_http_test.js │ └── socket_test.js ├── babel.config.json ├── config/ │ └── config.exs ├── eslint.config.mjs ├── guides/ │ ├── asset_management.md │ ├── authn_authz/ │ │ ├── api_authentication.md │ │ ├── authn_authz.md │ │ ├── mix_phx_gen_auth.md │ │ └── scopes.md │ ├── cheatsheets/ │ │ └── router.cheatmd │ ├── components.md │ ├── controllers.md │ ├── data_modelling/ │ │ ├── contexts.md │ │ ├── cross_context_boundaries.md │ │ ├── faq.md │ │ ├── in_context_relationships.md │ │ ├── more_examples.md │ │ └── your_first_context.md │ ├── deployment/ │ │ ├── deployment.md │ │ ├── fly.md │ │ ├── gigalixir.md │ │ ├── heroku.md │ │ └── releases.md │ ├── directory_structure.md │ ├── ecto.md │ ├── howto/ │ │ ├── custom_error_pages.md │ │ ├── file_uploads.md │ │ ├── swapping_databases.md │ │ ├── using_ssl.md │ │ └── writing_a_channels_client.md │ ├── introduction/ │ │ ├── community.md │ │ ├── installation.md │ │ ├── overview.md │ │ ├── packages_glossary.md │ │ └── up_and_running.md │ ├── json_and_apis.md │ ├── live_view.md │ ├── plug.md │ ├── real_time/ │ │ ├── channels.md │ │ └── presence.md │ ├── request_lifecycle.md │ ├── routing.md │ ├── security.md │ ├── telemetry.md │ └── testing/ │ ├── testing.md │ ├── testing_channels.md │ ├── testing_contexts.md │ └── testing_controllers.md ├── installer/ │ ├── .gitignore │ ├── README.md │ ├── lib/ │ │ ├── mix/ │ │ │ └── tasks/ │ │ │ ├── local.phx.ex │ │ │ ├── phx.new.ecto.ex │ │ │ ├── phx.new.ex │ │ │ └── phx.new.web.ex │ │ └── phx_new/ │ │ ├── ecto.ex │ │ ├── generator.ex │ │ ├── mailer.ex │ │ ├── project.ex │ │ ├── single.ex │ │ ├── umbrella.ex │ │ └── web.ex │ ├── mix.exs │ ├── recreate_default_css.exs │ ├── templates/ │ │ ├── phx_assets/ │ │ │ ├── app.css.eex │ │ │ ├── app.js.eex │ │ │ ├── daisyui-theme.js.eex │ │ │ ├── daisyui.js.eex │ │ │ ├── heroicons.js.eex │ │ │ ├── logo.svg.eex │ │ │ ├── topbar.js.eex │ │ │ └── tsconfig.json.eex │ │ ├── phx_ecto/ │ │ │ ├── data_case.ex.eex │ │ │ ├── formatter.exs.eex │ │ │ ├── repo.ex.eex │ │ │ └── seeds.exs.eex │ │ ├── phx_gettext/ │ │ │ ├── en/ │ │ │ │ └── LC_MESSAGES/ │ │ │ │ └── errors.po.eex │ │ │ ├── errors.pot.eex │ │ │ └── gettext.ex.eex │ │ ├── phx_mailer/ │ │ │ └── lib/ │ │ │ └── app_name/ │ │ │ └── mailer.ex.eex │ │ ├── phx_single/ │ │ │ ├── README.md.eex │ │ │ ├── config/ │ │ │ │ ├── config.exs.eex │ │ │ │ ├── dev.exs.eex │ │ │ │ ├── prod.exs.eex │ │ │ │ ├── runtime.exs.eex │ │ │ │ └── test.exs.eex │ │ │ ├── formatter.exs.eex │ │ │ ├── gitignore.eex │ │ │ ├── lib/ │ │ │ │ ├── app_name/ │ │ │ │ │ └── application.ex.eex │ │ │ │ ├── app_name.ex.eex │ │ │ │ └── app_name_web.ex.eex │ │ │ ├── mix.exs.eex │ │ │ └── test/ │ │ │ └── test_helper.exs.eex │ │ ├── phx_static/ │ │ │ ├── app.css │ │ │ ├── app.js │ │ │ ├── default.css │ │ │ └── robots.txt │ │ ├── phx_test/ │ │ │ ├── controllers/ │ │ │ │ ├── error_html_test.exs.eex │ │ │ │ ├── error_json_test.exs.eex │ │ │ │ └── page_controller_test.exs.eex │ │ │ └── support/ │ │ │ └── conn_case.ex.eex │ │ ├── phx_umbrella/ │ │ │ ├── README.md.eex │ │ │ ├── apps/ │ │ │ │ ├── app_name/ │ │ │ │ │ ├── README.md.eex │ │ │ │ │ ├── config/ │ │ │ │ │ │ └── config.exs.eex │ │ │ │ │ ├── formatter.exs.eex │ │ │ │ │ ├── gitignore.eex │ │ │ │ │ ├── lib/ │ │ │ │ │ │ ├── app_name/ │ │ │ │ │ │ │ └── application.ex.eex │ │ │ │ │ │ └── app_name.ex.eex │ │ │ │ │ ├── mix.exs.eex │ │ │ │ │ └── test/ │ │ │ │ │ └── test_helper.exs.eex │ │ │ │ └── app_name_web/ │ │ │ │ ├── README.md.eex │ │ │ │ ├── config/ │ │ │ │ │ ├── config.exs.eex │ │ │ │ │ ├── dev.exs.eex │ │ │ │ │ ├── prod.exs.eex │ │ │ │ │ ├── runtime.exs.eex │ │ │ │ │ └── test.exs.eex │ │ │ │ ├── formatter.exs.eex │ │ │ │ ├── gitignore.eex │ │ │ │ ├── lib/ │ │ │ │ │ ├── app_name/ │ │ │ │ │ │ └── application.ex.eex │ │ │ │ │ └── app_name.ex.eex │ │ │ │ ├── mix.exs.eex │ │ │ │ └── test/ │ │ │ │ └── test_helper.exs.eex │ │ │ ├── config/ │ │ │ │ ├── config.exs.eex │ │ │ │ ├── dev.exs.eex │ │ │ │ ├── extra_config.exs.eex │ │ │ │ ├── prod.exs.eex │ │ │ │ ├── runtime.exs.eex │ │ │ │ └── test.exs.eex │ │ │ ├── formatter.exs.eex │ │ │ ├── gitignore.eex │ │ │ └── mix.exs.eex │ │ ├── phx_web/ │ │ │ ├── components/ │ │ │ │ ├── core_components.ex.eex │ │ │ │ ├── layouts/ │ │ │ │ │ └── root.html.heex.eex │ │ │ │ └── layouts.ex.eex │ │ │ ├── controllers/ │ │ │ │ ├── error_html.ex.eex │ │ │ │ ├── error_json.ex.eex │ │ │ │ ├── page_controller.ex.eex │ │ │ │ ├── page_html/ │ │ │ │ │ └── home.html.heex.eex │ │ │ │ └── page_html.ex.eex │ │ │ ├── endpoint.ex.eex │ │ │ ├── router.ex.eex │ │ │ └── telemetry.ex.eex │ │ └── usage-rules/ │ │ ├── assets.md │ │ ├── phoenix.md │ │ └── project.md │ └── test/ │ ├── mix_helper.exs │ ├── phx_new_ecto_test.exs │ ├── phx_new_test.exs │ ├── phx_new_umbrella_test.exs │ ├── phx_new_web_test.exs │ └── test_helper.exs ├── integration_test/ │ ├── README.md │ ├── config/ │ │ └── config.exs │ ├── docker-compose.yml │ ├── docker.sh │ ├── mix.exs │ ├── test/ │ │ ├── code_generation/ │ │ │ ├── app_with_defaults_test.exs │ │ │ ├── app_with_mssql_adapter_test.exs │ │ │ ├── app_with_mysql_adapter_test.exs │ │ │ ├── app_with_no_options_test.exs │ │ │ ├── app_with_scopes_test.exs │ │ │ ├── app_with_sqlite3_adapter_test.exs │ │ │ └── umbrella_app_with_defaults_test.exs │ │ ├── support/ │ │ │ └── code_generator_case.ex │ │ └── test_helper.exs │ └── test.sh ├── jest.config.js ├── lib/ │ ├── mix/ │ │ ├── phoenix/ │ │ │ ├── context.ex │ │ │ ├── schema.ex │ │ │ └── scope.ex │ │ ├── phoenix.ex │ │ └── tasks/ │ │ ├── compile.phoenix.ex │ │ ├── phx.digest.clean.ex │ │ ├── phx.digest.ex │ │ ├── phx.ex │ │ ├── phx.gen.auth/ │ │ │ ├── hashing_library.ex │ │ │ ├── injector.ex │ │ │ └── migration.ex │ │ ├── phx.gen.auth.ex │ │ ├── phx.gen.cert.ex │ │ ├── phx.gen.channel.ex │ │ ├── phx.gen.context.ex │ │ ├── phx.gen.embedded.ex │ │ ├── phx.gen.ex │ │ ├── phx.gen.html.ex │ │ ├── phx.gen.json.ex │ │ ├── phx.gen.live.ex │ │ ├── phx.gen.notifier.ex │ │ ├── phx.gen.presence.ex │ │ ├── phx.gen.release.ex │ │ ├── phx.gen.schema.ex │ │ ├── phx.gen.secret.ex │ │ ├── phx.gen.socket.ex │ │ ├── phx.routes.ex │ │ └── phx.server.ex │ ├── phoenix/ │ │ ├── channel/ │ │ │ └── server.ex │ │ ├── channel.ex │ │ ├── code_reloader/ │ │ │ ├── mix_listener.ex │ │ │ ├── proxy.ex │ │ │ └── server.ex │ │ ├── code_reloader.ex │ │ ├── config.ex │ │ ├── controller/ │ │ │ └── pipeline.ex │ │ ├── controller.ex │ │ ├── debug.ex │ │ ├── digester/ │ │ │ ├── compressor.ex │ │ │ └── gzip.ex │ │ ├── digester.ex │ │ ├── endpoint/ │ │ │ ├── cowboy2_adapter.ex │ │ │ ├── render_errors.ex │ │ │ ├── supervisor.ex │ │ │ ├── sync_code_reload_plug.ex │ │ │ └── watcher.ex │ │ ├── endpoint.ex │ │ ├── exceptions.ex │ │ ├── flash.ex │ │ ├── logger.ex │ │ ├── naming.ex │ │ ├── param.ex │ │ ├── presence.ex │ │ ├── router/ │ │ │ ├── console_formatter.ex │ │ │ ├── helpers.ex │ │ │ ├── resource.ex │ │ │ ├── route.ex │ │ │ └── scope.ex │ │ ├── router.ex │ │ ├── socket/ │ │ │ ├── message.ex │ │ │ ├── pool_supervisor.ex │ │ │ ├── serializer.ex │ │ │ ├── serializers/ │ │ │ │ ├── v1_json_serializer.ex │ │ │ │ └── v2_json_serializer.ex │ │ │ └── transport.ex │ │ ├── socket.ex │ │ ├── test/ │ │ │ ├── channel_test.ex │ │ │ └── conn_test.ex │ │ ├── token.ex │ │ ├── transports/ │ │ │ ├── long_poll.ex │ │ │ ├── long_poll_server.ex │ │ │ └── websocket.ex │ │ └── verified_routes.ex │ └── phoenix.ex ├── mix.exs ├── package.json ├── priv/ │ ├── static/ │ │ ├── phoenix.cjs.js │ │ ├── phoenix.js │ │ └── phoenix.mjs │ └── templates/ │ ├── phx.gen.auth/ │ │ ├── AGENTS.md.eex │ │ ├── auth.ex.eex │ │ ├── auth_test.exs.eex │ │ ├── confirmation_live.ex.eex │ │ ├── confirmation_live_test.exs.eex │ │ ├── conn_case.exs.eex │ │ ├── context_fixtures_functions.ex.eex │ │ ├── context_functions.ex.eex │ │ ├── login_live.ex.eex │ │ ├── login_live_test.exs.eex │ │ ├── migration.ex.eex │ │ ├── notifier.ex.eex │ │ ├── registration_controller.ex.eex │ │ ├── registration_controller_test.exs.eex │ │ ├── registration_html.ex.eex │ │ ├── registration_live.ex.eex │ │ ├── registration_live_test.exs.eex │ │ ├── registration_new.html.heex.eex │ │ ├── routes.ex.eex │ │ ├── schema.ex.eex │ │ ├── schema_token.ex.eex │ │ ├── scope.ex.eex │ │ ├── session_confirm.html.heex.eex │ │ ├── session_controller.ex.eex │ │ ├── session_controller_test.exs.eex │ │ ├── session_html.ex.eex │ │ ├── session_new.html.heex.eex │ │ ├── settings_controller.ex.eex │ │ ├── settings_controller_test.exs.eex │ │ ├── settings_edit.html.heex.eex │ │ ├── settings_html.ex.eex │ │ ├── settings_live.ex.eex │ │ ├── settings_live_test.exs.eex │ │ └── test_cases.exs.eex │ ├── phx.gen.channel/ │ │ ├── channel.ex.eex │ │ ├── channel_case.ex.eex │ │ └── channel_test.exs.eex │ ├── phx.gen.context/ │ │ ├── access_no_schema.ex.eex │ │ ├── access_no_schema_scope.ex.eex │ │ ├── context.ex.eex │ │ ├── context_test.exs.eex │ │ ├── fixtures.ex.eex │ │ ├── fixtures_module.ex.eex │ │ ├── schema_access.ex.eex │ │ ├── schema_access_scope.ex.eex │ │ ├── test_cases.exs.eex │ │ └── test_cases_scope.exs.eex │ ├── phx.gen.embedded/ │ │ └── embedded_schema.ex.eex │ ├── phx.gen.html/ │ │ ├── controller.ex.eex │ │ ├── controller_test.exs.eex │ │ ├── edit.html.heex.eex │ │ ├── html.ex.eex │ │ ├── index.html.heex.eex │ │ ├── new.html.heex.eex │ │ ├── resource_form.html.heex.eex │ │ └── show.html.heex.eex │ ├── phx.gen.json/ │ │ ├── changeset_json.ex.eex │ │ ├── controller.ex.eex │ │ ├── controller_test.exs.eex │ │ ├── fallback_controller.ex.eex │ │ └── json.ex.eex │ ├── phx.gen.live/ │ │ ├── form.ex.eex │ │ ├── index.ex.eex │ │ ├── live_test.exs.eex │ │ └── show.ex.eex │ ├── phx.gen.notifier/ │ │ ├── notifier.ex.eex │ │ └── notifier_test.exs.eex │ ├── phx.gen.presence/ │ │ └── presence.ex.eex │ ├── phx.gen.release/ │ │ ├── Dockerfile.eex │ │ ├── dockerignore.eex │ │ ├── rel/ │ │ │ ├── migrate.bat.eex │ │ │ ├── migrate.sh.eex │ │ │ ├── server.bat.eex │ │ │ └── server.sh.eex │ │ └── release.ex.eex │ ├── phx.gen.schema/ │ │ ├── migration.exs.eex │ │ └── schema.ex.eex │ └── phx.gen.socket/ │ ├── socket.ex.eex │ └── socket.js.eex ├── test/ │ ├── fixtures/ │ │ ├── digest/ │ │ │ ├── cleaner/ │ │ │ │ ├── cache_manifest.json │ │ │ │ └── latest_not_most_recent_cache_manifest.json │ │ │ ├── compile/ │ │ │ │ ├── cache_manifest.json │ │ │ │ └── cache_manifest_upgrade.json │ │ │ └── priv/ │ │ │ ├── output/ │ │ │ │ ├── foo-288ea8c7954498e65663c817382eeac4.css │ │ │ │ └── foo-d978852bea6530fcd197b5445ed008fd.css │ │ │ └── static/ │ │ │ ├── app.js │ │ │ ├── css/ │ │ │ │ └── app.css │ │ │ ├── foo.css │ │ │ ├── manifest.json │ │ │ └── precompressed.js.br │ │ ├── hello.txt │ │ ├── ssl/ │ │ │ ├── cert.pem │ │ │ └── key.pem │ │ ├── templates/ │ │ │ ├── custom.foo │ │ │ ├── layout/ │ │ │ │ ├── app.html.eex │ │ │ │ └── root.html.eex │ │ │ ├── no_trim.text.eex │ │ │ ├── path.html.eex │ │ │ ├── safe.html.eex │ │ │ ├── show.html.eex │ │ │ ├── trim.html.eex │ │ │ └── user/ │ │ │ ├── index.html.eex │ │ │ ├── profiles/ │ │ │ │ └── admin.html.eex │ │ │ ├── render_template.html.eex │ │ │ └── show.json.exs │ │ └── views.exs │ ├── mix/ │ │ ├── phoenix_test.exs │ │ └── tasks/ │ │ ├── phx.digest.clean_test.exs │ │ ├── phx.digest_test.exs │ │ ├── phx.gen.auth/ │ │ │ └── injector_test.exs │ │ ├── phx.gen.auth_test.exs │ │ ├── phx.gen.cert_test.exs │ │ ├── phx.gen.channel_test.exs │ │ ├── phx.gen.context_test.exs │ │ ├── phx.gen.embedded_test.exs │ │ ├── phx.gen.html_test.exs │ │ ├── phx.gen.json_test.exs │ │ ├── phx.gen.live_test.exs │ │ ├── phx.gen.notifier_test.exs │ │ ├── phx.gen.presence_test.exs │ │ ├── phx.gen.release_test.exs │ │ ├── phx.gen.schema_test.exs │ │ ├── phx.gen.secret_test.exs │ │ ├── phx.gen.socket_test.exs │ │ ├── phx.routes_test.exs │ │ └── phx_test.exs │ ├── phoenix/ │ │ ├── channel_test.exs │ │ ├── code_reloader_test.exs │ │ ├── config_test.exs │ │ ├── controller/ │ │ │ ├── controller_test.exs │ │ │ ├── flash_test.exs │ │ │ ├── pipeline_test.exs │ │ │ └── render_test.exs │ │ ├── debug_test.exs │ │ ├── digester/ │ │ │ └── gzip_test.exs │ │ ├── digester_test.exs │ │ ├── endpoint/ │ │ │ ├── endpoint_test.exs │ │ │ ├── render_errors_test.exs │ │ │ ├── supervisor_test.exs │ │ │ └── watcher_test.exs │ │ ├── integration/ │ │ │ ├── endpoint_test.exs │ │ │ ├── long_poll_channels_test.exs │ │ │ ├── long_poll_socket_test.exs │ │ │ ├── websocket_channels_test.exs │ │ │ └── websocket_socket_test.exs │ │ ├── logger_test.exs │ │ ├── naming_test.exs │ │ ├── param_test.exs │ │ ├── presence_test.exs │ │ ├── router/ │ │ │ ├── console_formatter_test.exs │ │ │ ├── forward_test.exs │ │ │ ├── helpers_test.exs │ │ │ ├── pipeline_test.exs │ │ │ ├── resource_test.exs │ │ │ ├── resources_test.exs │ │ │ ├── route_test.exs │ │ │ ├── routing_test.exs │ │ │ └── scope_test.exs │ │ ├── socket/ │ │ │ ├── message_test.exs │ │ │ ├── socket_test.exs │ │ │ ├── transport_test.exs │ │ │ ├── v1_json_serializer_test.exs │ │ │ └── v2_json_serializer_test.exs │ │ ├── test/ │ │ │ ├── channel_test.exs │ │ │ └── conn_test.exs │ │ ├── token_test.exs │ │ └── verified_routes_test.exs │ ├── support/ │ │ ├── endpoint_helper.exs │ │ ├── http_client.exs │ │ ├── router_helper.exs │ │ └── websocket_client.exs │ └── test_helper.exs └── usage-rules/ ├── ecto.md ├── elixir.md ├── html.md ├── liveview.md └── phoenix.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .formatter.exs ================================================ locals_without_parens = [ # Phoenix.Channel intercept: 1, # Phoenix.Router connect: 3, connect: 4, delete: 3, delete: 4, forward: 2, forward: 3, forward: 4, get: 3, get: 4, head: 3, head: 4, match: 4, match: 5, options: 3, options: 4, patch: 3, patch: 4, pipeline: 2, pipe_through: 1, post: 3, post: 4, put: 3, put: 4, resources: 2, resources: 3, resources: 4, trace: 4, # Phoenix.Controller action_fallback: 1, # Phoenix.Endpoint plug: 1, plug: 2, socket: 2, socket: 3, # Phoenix.Socket channel: 2, channel: 3, # Phoenix.ChannelTest assert_broadcast: 2, assert_broadcast: 3, assert_push: 2, assert_push: 3, assert_reply: 2, assert_reply: 3, assert_reply: 4, refute_broadcast: 2, refute_broadcast: 3, refute_push: 2, refute_push: 3, refute_reply: 2, refute_reply: 3, refute_reply: 4, # Phoenix.ConnTest assert_error_sent: 2, # Phoenix.Live{Dashboard,View} attr: 2, attr: 3, embed_templates: 1, embed_templates: 2, live: 2, live: 3, live: 4, live_dashboard: 1, live_dashboard: 2, on_mount: 1, slot: 1, slot: 2, slot: 3, # Phoenix.LiveViewTest assert_patch: 1, assert_patch: 2, assert_patch: 3, assert_patched: 2, assert_push_event: 3, assert_push_event: 4, assert_redirect: 1, assert_redirect: 2, assert_redirect: 3, assert_redirected: 2, assert_reply: 2, assert_reply: 3, refute_redirected: 1, refute_redirected: 2, refute_patched: 1, refute_patched: 2, refute_push_event: 3, refute_push_event: 4 ] [ locals_without_parens: locals_without_parens, export: [locals_without_parens: locals_without_parens] ] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- ### Environment * Elixir version (elixir -v): * Phoenix version (mix deps): * Operating system: ### Actual behavior ### Expected behavior ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ --- blank_issues_enabled: true contact_links: - name: Ask questions, support, and general discussions url: https://elixirforum.com/c/phoenix-forum about: Ask questions, provide support, and more on Elixir Forum - name: Propose new features url: https://elixirforum.com/c/phoenix-forum about: Propose new features on Elixir Forum ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" # Maintain dependencies for NPM - package-ecosystem: "npm" directory: "/" schedule: interval: "monthly" groups: minor-and-patch: applies-to: version-updates update-types: - "minor" - "patch" ================================================ FILE: .github/workflows/assets.yml ================================================ name: Assets on: push: branches: - main - "v*.*" permissions: contents: read jobs: build: runs-on: ubuntu-24.04 env: elixir: 1.18.3 otp: 27.2 permissions: contents: write # for stefanzweifel/git-auto-commit-action to push code in repo steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Elixir uses: erlef/setup-beam@3580539ceec3dc05b0ed51e9e10b08eb7a7c2bb4 # v1.21.0 with: elixir-version: ${{ env.elixir }} otp-version: ${{ env.otp }} - name: Restore deps and _build cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | deps _build key: ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}-${{ hashFiles('**/mix.lock') }}-dev restore-keys: | ${{ runner.os }}-mix-${{ env.elixir }}-${{ env.otp }}- - name: Install Dependencies run: mix deps.get --only dev - name: Set up Node.js 20.x uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20.x - name: Restore npm cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: Install npm dependencies run: npm ci - name: Build assets run: mix assets.build - name: Push updated assets id: push_assets uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 with: commit_message: Update assets file_pattern: priv/static ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request] permissions: contents: read jobs: mix_test: name: mix test (OTP ${{matrix.otp}} | Elixir ${{matrix.elixir}}) env: MIX_ENV: test PHX_CI: true strategy: matrix: include: - elixir: "1.15.8" otp: "25.3.2.9" - elixir: "1.18.4" otp: "27.3" - elixir: "1.19.5" otp: "28.3.3" lint: true installer: true runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Elixir uses: erlef/setup-beam@3580539ceec3dc05b0ed51e9e10b08eb7a7c2bb4 # v1.21.0 with: elixir-version: ${{ matrix.elixir }} otp-version: ${{ matrix.otp }} - name: Restore deps and _build cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | deps _build key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} - name: Install dependencies run: mix deps.get --only test - name: Remove compiled application files run: mix clean - name: Compile & lint dependencies run: mix compile --warnings-as-errors if: ${{ matrix.lint }} - name: Run tests run: mix test - name: Run installer test run: | cd installer mix deps.get mix test if: ${{ matrix.installer }} npm_test: name: npm test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Restore deps and _build cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | deps _build key: deps-${{ runner.os }}-npm-${{ hashFiles('**/mix.lock') }} restore-keys: | deps-${{ runner.os }}-npm - name: Set up Node.js 20.x uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 20.x - name: Restore npm cache uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: npm install and test run: | cd assets npm install npm test integration-test-elixir: runs-on: ubuntu-latest strategy: matrix: include: # look for correct alpine image here: https://hub.docker.com/r/hexpm/elixir/tags - elixir: "1.15.8" otp: "25.3.2.9" suffix: "alpine-3.20.3" - elixir: "1.19.5" otp: "28.3.3" suffix: "alpine-3.20.9" container: image: hexpm/elixir:${{ matrix.elixir }}-erlang-${{ matrix.otp }}-${{ matrix.suffix }} env: ELIXIR_ASSERT_TIMEOUT: 10000 PHX_CI: true services: postgres: image: postgres ports: - 5432:5432 env: POSTGRES_PASSWORD: postgres mysql: image: mysql ports: - 3306:3306 env: MYSQL_ALLOW_EMPTY_PASSWORD: "yes" mssql: image: mcr.microsoft.com/mssql/server:2019-latest env: ACCEPT_EULA: Y SA_PASSWORD: some!Password ports: - 1433:1433 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run test script run: ./integration_test/test.sh ================================================ FILE: .github/workflows/npm-publish.yml ================================================ # https://docs.npmjs.com/trusted-publishers name: NPM Publish on: push: tags: - "v*" permissions: id-token: write # Required for OIDC contents: read jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "24" registry-url: "https://registry.npmjs.org" # Ensure npm 11.5.1 or later is installed - name: Update npm run: npm install -g npm@latest - name: Determine npm tag id: npm-tag run: | TAG=${GITHUB_REF#refs/tags/} # Update this condition when bumping the major version! if [[ $TAG == v1.7.* ]]; then echo "tag=old-version" >> $GITHUB_OUTPUT elif [[ $TAG == *-rc* ]]; then echo "tag=rc" >> $GITHUB_OUTPUT else echo "tag=latest" >> $GITHUB_OUTPUT fi - name: Publish to npm run: npm publish --tag ${{ steps.npm-tag.outputs.tag }} ================================================ FILE: .gitignore ================================================ /_build/ /deps/ /doc/ /node_modules/ /tmp/ /cover/ /assets/node_modules/ /installer/_build/ /installer/assets/ /installer/deps/ /installer/doc/ /installer/phx_new-*.ez /installer/tmp/ /integration_test/_build/ /integration_test/deps/ erl_crash.dump phoenix-*.ez .DS_Store /priv/templates/phx.gen.live/core_components.ex.eex ================================================ FILE: CHANGELOG.md ================================================ # Changelog for v1.8 This release requires Erlang/OTP 25+. ## Streamlined generators * Extend tailwindcss support in new apps with [daisyUI](https://daisyui.com/) for light/dark/system mode support for entire app, including core components * Simplify layout handling for new apps. Now there is only a single `root.html.heex` which wraps the render pipeline. Other dynamic layouts, like `app.html.heex` are called as needed within templates as regular function components * Simplify core components and live generators to more closely match basic `phx.gen.html` crud. This serves as a better base for seasoned devs to start with, and lessens the amount of code newcomers need to get up to speed with on the basics * Introduce magic links (passwordless auth) and "sudo mode" to `mix phx.gen.auth` while simplifying the generated structure * Introduce scopes to Phoenix generators, designed to make secure data access the *default*, not something you remember (or forget) to do later ## `put_secure_browser_headers` `put_secure_browser_headers` has been updated to the latest security practices. In particular, it sets the `content-security-policy` header to `"base-uri 'self'; frame-ancestors 'self';"` if none is set, restricting embedding of your application and the use of `` element to same origin respectively. If you expect your application to be embedded by third-parties, you want to consult the documentation. The headers `x-download-options` and `x-frame-options` are no longer set as they have been deprecated by standards. ## Deprecations This release introduces deprecation warnings for several features that have been soft-deprecated in the past. * `use Phoenix.Controller` must now specify the `:formats` option, which may be set to an empty list if the formats are not known upfront * The `:namespace` and `:put_default_views` options on `use Phoenix.Controller` are deprecated and emit a warning on use * Specifying layouts without modules, such as `put_layout(conn, :print)` or `put_layout(conn, html: :print)` is deprecated * The `:trailing_slash` option in `Phoenix.Router` has been deprecated in favor of using `Phoenix.VerifiedRoutes`. The overall usage of helpers will be deprecated in the future ## Potential breaking changes * The `config` variable is no longer available in `Phoenix.Endpoint`. In the past, it was possible to read your endpoint configuration at compile-time via an injected variable named `config`, which is no longer supported. Use `Application.compile_env/3` instead, which is tracked by the Elixir compiler and lead to a better developer experience. This may also lead to errors on application boot if you were previously incorrectly setting compile time config at runtime. ## 1.8.5 (2026-03-05) ### JavaScript Client Bug Fixes - Fix socket connecting on visibility change when never established ### Enhancements - Fix warnings on Elixir 1.20 ## 1.8.4 (2026-02-23) ### JavaScript Client Bug Fixes - Fix bug reconnecting connections when close was gracefully initiated by server - Fix LongPoll transport name in sessionStorage and logs ### Enhancements - Adds guards support in `assert_push`, `assert_broadcast`, and `assert_reply` - Enable purging in Phoenix code server for Elixir 1.20 ## 1.8.3 (2025-12-08) ### Enhancements - Add top-level phoenix config: `sort_verified_routes_query_params` to enable sorting query params in verified routes during tests ### Bug fixes - Fix endpoint port config in an umbrella application. ([#6549](https://github.com/phoenixframework/phoenix/pull/6549)) - Drop incoming channel messages with stale join refs ## 1.8.2 (2025-11-26) ### Bug fixes - [phoenix.js] fix issue where LongPoll can cause "unmatched topic" errors (observed on iOS only) ([#6538](https://github.com/phoenixframework/phoenix/pull/6538)) - [phx.gen.live] fix tests when schema and table names are equal ([#6477](https://github.com/phoenixframework/phoenix/pull/6477)) - [Verified Routes] do not add path prefixes for static routes - [Phoenix.Endpoint] fix LongPoll being active by default since 1.8.0 ([#6487](https://github.com/phoenixframework/phoenix/pull/6487)) ### Enhancements - [phoenix.js] socket now stops reconnection attempts while the page is hidden ([#6534](https://github.com/phoenixframework/phoenix/pull/6534)) - [phx.new] (re-)add `<.input field={@form[:foo]} type="hidden" />` support in core components - [phx.new] set `force_ssl` in `prod.exs` by default ([#6435](https://github.com/phoenixframework/phoenix/pull/6435)) - [phx.new] change `--docker` base image to debian trixie ([#6521](https://github.com/phoenixframework/phoenix/pull/6521)) - [Phoenix.Socket.assign/2] allow passing a function as second argument `assign(socket, fn _existing_assigns -> %{this_gets: "merged"} end)` ([#6530](https://github.com/phoenixframework/phoenix/pull/6530)) - [Phoenix.Controller.assign/2] allow passing a function as second argument ([#6542](https://github.com/phoenixframework/phoenix/pull/6542)) - [Phoenix.Controller.assign/2] support keyword lists and maps as second argument similar to LiveView ([#6513](https://github.com/phoenixframework/phoenix/pull/6513)) - [Presence] support custom dispatcher for `presence_diff` broadcast ([#6500](https://github.com/phoenixframework/phoenix/pull/6500)) - [AGENTS.md] add short test guidelines to usage rules ## 1.8.1 (2025-08-28) ### Bug fixes - [phx.new] Fix AGENTS.md failing to include CSS and JavaScript sections ## 1.8.0 (2025-08-05) ### Bug fixes - [phx.new] Don't include node_modules override in generated `tsconfig.json` ### Enhancements - [phx.gen.live|html|json] - Make context argument optional. Defaults to the plural name. - [phx.new] Add `mix precommit` alias - [phx.new] Add `AGENTS.md` generation compatible with [`usage_rules`](https://hexdocs.pm/usage_rules/) - [phx.new] Add `usage_rules` folder to installer, allowing to sync generic Phoenix rules into new projects - [phx.new] Use LiveView 1.1 release in generated code - [phx.new] Ensure theme selector and flash closing works without LiveView ## 1.8.0-rc.4 (2025-07-14) ### Bug Fixes - Fix phx.gen.presence PubSub server name for umbrella apps - Fix `phx.gen.live` subscribing to pubsub in disconnected mounts ### Enhancements - [phx.new] Initialize initial git repo when git is installed - [phx.new] Opt-in to HEEx `:debug_tags_location` in development - [phx.gen.live|html|json|context] Make context name optional and inflect based on schema when missing - [phx.gen.*] Use new Ecto 3.13 `Repo.transact/2` in generators - [phx.gen.auth] Warn when using `phx.gen.auth` without esbuild as features assume `phoenix_html.js` in bundle - Add `security.md` guide for security best practices - [phoenix.js] - Add fetch() support to LongPoll when XMLHTTPRequest is not available - Optimize parameter scrubbing by precompiling patterns ## 1.8.0-rc.3 (2025-05-07) ### Enhancements - [phx.gen.auth] Allow configuring the scope's assign key in phx.gen.auth - [phx.new] Do not override theme in root layout if explicitly set ## 1.8.0-rc.2 (2025-04-29) ### Bug Fixes - [phx.gen.live] Only subscribe to pubsub if connected - [phx.gen.auth] Remove unused current_password field - [phx.gen.auth] Use context_app for scopes to fix generated scopes in umbrella apps ## 1.8.0-rc.1 (2025-04-16) ### Enhancements - [phx.new] Support PORT in dev - [phx.gen.auth] Replace `utc_now/0 + truncate/1` with `utc_now/1` - [phx.gen.auth] Make dev mailbox link more obvious ### Bug Fixes - [phx.new] Fix Tailwind custom variants for loading classes (#6194) - [phx.new] Fix heroicons path for umbrella apps - [phx.gen.auth] Fix missing index for scoped resources (#6186) - [phx.gen.live] Fix crash when an open :show page gets a PubSub broadcast for items (#6197) ## 1.8.0-rc.0 (2025-04-01) 🚀 - First release candidate! ## v1.7 The CHANGELOG for v1.7 releases can be found in the [v1.7 branch](https://github.com/phoenixframework/phoenix/blob/v1.7/CHANGELOG.md). ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Code of Conduct As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery * Personal attacks * Trolling or insulting/derogatory comments * Public or private harassment * Publishing other's private information, such as physical or electronic addresses, without explicit permission * Other unethical or unprofessional conduct. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.2.0, available at [https://www.contributor-covenant.org/version/1/2/0/code-of-conduct/](https://www.contributor-covenant.org/version/1/2/0/code-of-conduct/) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Phoenix Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved! Also make sure you read our [Code of Conduct](CODE_OF_CONDUCT.md) that outlines our commitment towards an open and welcoming environment. ## Using the issue tracker Use the issues tracker for: * [Bug reports](#bug-reports) * [Submitting pull requests](#pull-requests) For requesting help and discussing new features: * [The Phoenix subforum on the Elixir Forum](https://elixirforum.com/c/phoenix-forum) * **[#elixir](irc://irc.libera.chat/elixir)** on [Libera](https://libera.chat/) IRC We do our best to keep the issue tracker tidy and organized, making it useful for everyone. For example, we classify open issues per perceived difficulty, making it easier for developers to [contribute to Phoenix](#pull-requests). ## Bug reports A bug is either a _demonstrable problem_ that is caused by the code in the repository, or indicates missing, unclear, or misleading documentation. Good bug reports are extremely helpful - thank you! Guidelines for bug reports: 1. **Use the GitHub issue search** — check if the issue has already been reported. 2. **Check if the issue has been fixed** — try to reproduce it using the `main` branch in the repository. 3. **Isolate and report the problem** — ideally create a reduced test case. Please try to be as detailed as possible in your report. Include information about your Operating System, as well as your Erlang, Elixir and Phoenix versions. Please provide steps to reproduce the issue as well as the outcome you were expecting! All these details will help developers to fix any potential bugs. Example: > Short and descriptive example bug report title > > A summary of the issue and the environment in which it occurs. If suitable, > include the steps required to reproduce the bug. > > 1. This is the first step > 2. This is the second step > 3. Further steps, etc. > > `` - a link to the reduced test case (e.g. a GitHub Gist) > > Any other information you want to share that is relevant to the issue being > reported. This might include the lines of code that you have identified as > causing the bug, and potential solutions (and your opinions on their > merits). ## Feature requests Feature requests are welcome and should be discussed on the [Phoenix subforum](https://elixirforum.com/c/phoenix-forum). But take a moment to find out whether your idea fits with the scope and aims of the project. It's up to *you* to make a strong case to convince the community of the merits of this feature. Please provide as much detail and context as possible. ## Contributing Documentation Code documentation (`@doc`, `@moduledoc`, `@typedoc`) has a special convention: the first paragraph is considered to be a short summary. For functions, macros, and callbacks say what it will do. For example write something like: ```elixir @doc """ Marks the given value as HTML safe. """ def safe({:safe, value}), do: {:safe, value} ``` For modules, protocols, and types say what it is. For example write something like: ```elixir defmodule Phoenix.HTML do @moduledoc """ Conveniences for working HTML strings and templates. ... """ ``` Keep in mind that the first paragraph might show up in a summary somewhere, long texts in the first paragraph create very ugly summaries. As a rule of thumb anything longer than 80 characters is too long. Try to keep unnecessary details out of the first paragraph, it's only there to give a user a quick idea of what the documented "thing" does/is. The rest of the documentation string can contain the details, for example when a value and when `nil` is returned. If possible include examples, preferably in a form that works with doctests. This makes it easy to test the examples so that they don't go stale and examples are often a great help in explaining what a function does. ## Pull requests Good pull requests - patches, improvements, new features - are a fantastic help. They should remain focused in scope and avoid containing unrelated commits. **IMPORTANT**: By submitting a patch, you agree that your work will be licensed under the license used by the project. If you have any large pull request in mind (e.g. implementing features, refactoring code, etc), **please ask first** otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project. Please adhere to the coding conventions in the project (indentation, accurate comments, etc.) and don't forget to add your own tests and documentation. When working with git, we recommend the following process in order to craft an excellent pull request: 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your fork, and configure the remotes: ```bash # Clone your fork of the repo into the current directory git clone https://github.com//phoenix # Navigate to the newly cloned directory cd phoenix # Assign the original repo to a remote called "upstream" git remote add upstream https://github.com/phoenixframework/phoenix ``` 2. If you cloned a while ago, get the latest changes from upstream, and update your fork: ```bash git checkout main git pull upstream main git push ``` 3. Create a new topic branch (off of `main`) to contain your feature, change, or fix. **IMPORTANT**: Making changes in `main` is discouraged. You should always keep your local `main` in sync with upstream `main` and make your changes in topic branches. ```bash git checkout -b ``` 4. Commit your changes in logical chunks. Keep your commit messages organized, with a short description in the first line and more detailed information on the following lines. Feel free to use Git's [interactive rebase](https://help.github.com/articles/about-git-rebase/) feature to tidy up your commits before making them public. 5. Make sure all the tests are still passing. ```bash mix test ``` 6. Push your topic branch up to your fork: ```bash git push origin ``` 7. [Open a Pull Request](https://help.github.com/articles/about-pull-requests/) with a clear title and description. 8. If you haven't updated your pull request for a while, you should consider rebasing on main and resolving any conflicts. **IMPORTANT**: _Never ever_ merge upstream `main` into your branches. You should always `git rebase` on `main` to bring your changes up to date when necessary. ```bash git checkout main git pull upstream main git checkout git rebase main ``` Thank you for your contributions! ## Guides These Guides aim to be inclusive. We use "we" and "our" instead of "you" and "your" to foster this sense of inclusion. Ideally there is something for everybody in each guide, from beginner to expert. This is hard, maybe impossible. When we need to compromise, we do so on behalf of beginning users because expert users have more tools at their disposal to help themselves. The general pattern we use for presenting information is to first introduce a small, discrete topic, then write a small amount of code to demonstrate the concept, then verify that the code worked. In this way, we build from small, easily digestible concepts into more complex ones. The shorter this cycle is, as long as the information is still clear and complete, the better. For formatting the guides: - We use the `elixir` code fence for all module code. - We use the `iex` for IEx sessions. - We use the `console` code fence for shell commands. - We use the `html` code fence for html templates, even if there is elixir code in the template. - We use backticks for filenames and directory paths. - We use backticks for module names, function names, and variable names. - Documentation line length should hard wrapped at around 100 characters if possible. ================================================ FILE: LICENSE.md ================================================ # MIT License Copyright (c) 2014 Chris McCord Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ Phoenix logo > Peace of mind from prototype to production. [![Build Status](https://github.com/phoenixframework/phoenix/workflows/CI/badge.svg)](https://github.com/phoenixframework/phoenix/actions/workflows/ci.yml) [![Hex.pm](https://img.shields.io/hexpm/v/phoenix.svg)](https://hex.pm/packages/phoenix) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/phoenix) ## Getting started See the official site at . Install the latest version of Phoenix by following the instructions at . ## Documentation API documentation is available at . Phoenix.js documentation is available at . ## Contributing We appreciate any contribution to Phoenix. Check our [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) and [CONTRIBUTING.md](CONTRIBUTING.md) guides for more information. We usually keep a list of features and bugs in the [issue tracker][4]. ### Generating a Phoenix project from unreleased versions You can create a new project using the latest Phoenix source installer (the `phx.new` Mix task) with the following steps: 1. Remove any previously installed `phx_new` archives so that Mix will pick up the local source code. This can be done with `mix archive.uninstall phx_new` or by simply deleting the file, which is usually in `~/.mix/archives/`. 2. Copy this repo via `git clone https://github.com/phoenixframework/phoenix` or by downloading it 3. Run the `phx.new` Mix task from within the `installer` directory, for example: ```bash cd phoenix/installer mix phx.new dev_app --dev ``` The `--dev` flag will configure your new project's `:phoenix` dep as a relative path dependency, pointing to your local Phoenix checkout: ```elixir defp deps do [{:phoenix, path: "../..", override: true}, ``` To create projects outside of the `installer/` directory, add the latest archive to your machine by following the instructions in [installer/README.md](https://github.com/phoenixframework/phoenix/blob/main/installer/README.md) ### Building from source To build the documentation: ```bash npm install MIX_ENV=docs mix docs ``` To build Phoenix: ```bash mix deps.get mix compile ``` To build the Phoenix installer: ```bash mix deps.get mix compile mix archive.build ``` To build Phoenix.js: ```bash cd assets npm install ``` ## Important links * [#elixir][1] on [Libera][2] IRC * [elixir-lang Slack channel][3] * [Issues tracker][4] * [Phoenix Forum (questions and proposals)][5] * Visit Phoenix's sponsor, DockYard, for expert [Phoenix Consulting](https://dockyard.com/phoenix-consulting) [1]: https://web.libera.chat/?channels=#elixir [2]: https://libera.chat/ [3]: https://elixir-lang.slack.com/ [4]: https://github.com/phoenixframework/phoenix/issues [5]: https://elixirforum.com/c/phoenix-forum ## Copyright and License Copyright (c) 2014, Chris McCord. Phoenix source code is licensed under the [MIT License](LICENSE.md). ================================================ FILE: RELEASE.md ================================================ # Release Instructions 1. Check related deps for required version bumps and compatibility (`phoenix_ecto`, `phoenix_html`) 2. Bump version in related files below 3. Bump external dependency version in related external files below 4. Run tests: - `mix test` in the root folder - `mix test` in the `installer/` folder 5. Commit, push code 6. Publish `phx_new` and `phoenix` packages and docs after pruning any extraneous uncommitted files 7. Test installer by generating a new app, running `mix deps.get`, and compiling 8. Publish to `npm` with `npm publish` 9. Update Elixir and Erlang/OTP versions on new.phoenixframework.org 10. Start -dev version in related files below ## Files with version * `CHANGELOG` * `mix.exs` * `installer/mix.exs` * `package.json` * `assets/package.json` ## Files with external dependency versions * `priv/templates/phx.gen.release/Docker.eex` (debian) * `priv/templates/phx.gen.release/Docker.eex` (esbuild) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported versions Phoenix applies bug fixes only to the latest minor branch. Security patches are available for the last 4 minor branches: Phoenix version | Support :-------------- | :----------------------------- 1.7 | Bug fixes and security patches 1.6 | Security patches only 1.5 | Security patches only 1.4 | Security patches only ## Announcements [Security advisories will be published on GitHub](https://github.com/phoenixframework/phoenix/security). ## Reporting a vulnerability [Please disclose security vulnerabilities privately via GitHub](https://github.com/phoenixframework/phoenix/security). ================================================ FILE: assets/js/phoenix/ajax.js ================================================ import { global, XHR_STATES } from "./constants" export default class Ajax { static request(method, endPoint, headers, body, timeout, ontimeout, callback){ if(global.XDomainRequest){ let req = new global.XDomainRequest() // IE8, IE9 return this.xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback) } else if(global.XMLHttpRequest){ let req = new global.XMLHttpRequest() // IE7+, Firefox, Chrome, Opera, Safari return this.xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback) } else if(global.fetch && global.AbortController){ // Fetch with AbortController for modern browsers return this.fetchRequest(method, endPoint, headers, body, timeout, ontimeout, callback) } else { throw new Error("No suitable XMLHttpRequest implementation found") } } static fetchRequest(method, endPoint, headers, body, timeout, ontimeout, callback){ let options = { method, headers, body, } let controller = null if(timeout){ controller = new AbortController() const _timeoutId = setTimeout(() => controller.abort(), timeout) options.signal = controller.signal } global.fetch(endPoint, options) .then(response => response.text()) .then(data => this.parseJSON(data)) .then(data => callback && callback(data)) .catch(err => { if(err.name === "AbortError" && ontimeout){ ontimeout() } else { callback && callback(null) } }) return controller } static xdomainRequest(req, method, endPoint, body, timeout, ontimeout, callback){ req.timeout = timeout req.open(method, endPoint) req.onload = () => { let response = this.parseJSON(req.responseText) callback && callback(response) } if(ontimeout){ req.ontimeout = ontimeout } // Work around bug in IE9 that requires an attached onprogress handler req.onprogress = () => { } req.send(body) return req } static xhrRequest(req, method, endPoint, headers, body, timeout, ontimeout, callback){ req.open(method, endPoint, true) req.timeout = timeout for(let [key, value] of Object.entries(headers)){ req.setRequestHeader(key, value) } req.onerror = () => callback && callback(null) req.onreadystatechange = () => { if(req.readyState === XHR_STATES.complete && callback){ let response = this.parseJSON(req.responseText) callback(response) } } if(ontimeout){ req.ontimeout = ontimeout } req.send(body) return req } static parseJSON(resp){ if(!resp || resp === ""){ return null } try { return JSON.parse(resp) } catch { console && console.log("failed to parse JSON response", resp) return null } } static serialize(obj, parentKey){ let queryStr = [] for(var key in obj){ if(!Object.prototype.hasOwnProperty.call(obj, key)){ continue } let paramKey = parentKey ? `${parentKey}[${key}]` : key let paramVal = obj[key] if(typeof paramVal === "object"){ queryStr.push(this.serialize(paramVal, paramKey)) } else { queryStr.push(encodeURIComponent(paramKey) + "=" + encodeURIComponent(paramVal)) } } return queryStr.join("&") } static appendParams(url, params){ if(Object.keys(params).length === 0){ return url } let prefix = url.match(/\?/) ? "&" : "?" return `${url}${prefix}${this.serialize(params)}` } } ================================================ FILE: assets/js/phoenix/channel.js ================================================ import {closure} from "./utils" import { CHANNEL_EVENTS, CHANNEL_STATES, } from "./constants" import Push from "./push" import Timer from "./timer" /** * * @param {string} topic * @param {(Object|function)} params * @param {Socket} socket */ export default class Channel { constructor(topic, params, socket){ this.state = CHANNEL_STATES.closed this.topic = topic this.params = closure(params || {}) this.socket = socket this.bindings = [] this.bindingRef = 0 this.timeout = this.socket.timeout this.joinedOnce = false this.joinPush = new Push(this, CHANNEL_EVENTS.join, this.params, this.timeout) this.pushBuffer = [] this.stateChangeRefs = [] this.rejoinTimer = new Timer(() => { if(this.socket.isConnected()){ this.rejoin() } }, this.socket.rejoinAfterMs) this.stateChangeRefs.push(this.socket.onError(() => this.rejoinTimer.reset())) this.stateChangeRefs.push(this.socket.onOpen(() => { this.rejoinTimer.reset() if(this.isErrored()){ this.rejoin() } }) ) this.joinPush.receive("ok", () => { this.state = CHANNEL_STATES.joined this.rejoinTimer.reset() this.pushBuffer.forEach(pushEvent => pushEvent.send()) this.pushBuffer = [] }) this.joinPush.receive("error", () => { this.state = CHANNEL_STATES.errored if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() } }) this.onClose(() => { this.rejoinTimer.reset() if(this.socket.hasLogger()) this.socket.log("channel", `close ${this.topic} ${this.joinRef()}`) this.state = CHANNEL_STATES.closed this.socket.remove(this) }) this.onError(reason => { if(this.socket.hasLogger()) this.socket.log("channel", `error ${this.topic}`, reason) if(this.isJoining()){ this.joinPush.reset() } this.state = CHANNEL_STATES.errored if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() } }) this.joinPush.receive("timeout", () => { if(this.socket.hasLogger()) this.socket.log("channel", `timeout ${this.topic} (${this.joinRef()})`, this.joinPush.timeout) let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), this.timeout) leavePush.send() this.state = CHANNEL_STATES.errored this.joinPush.reset() if(this.socket.isConnected()){ this.rejoinTimer.scheduleTimeout() } }) this.on(CHANNEL_EVENTS.reply, (payload, ref) => { this.trigger(this.replyEventName(ref), payload) }) } /** * Join the channel * @param {integer} timeout * @returns {Push} */ join(timeout = this.timeout){ if(this.joinedOnce){ throw new Error("tried to join multiple times. 'join' can only be called a single time per channel instance") } else { this.timeout = timeout this.joinedOnce = true this.rejoin() return this.joinPush } } /** * Hook into channel close * @param {Function} callback */ onClose(callback){ this.on(CHANNEL_EVENTS.close, callback) } /** * Hook into channel errors * @param {Function} callback */ onError(callback){ return this.on(CHANNEL_EVENTS.error, reason => callback(reason)) } /** * Subscribes on channel events * * Subscription returns a ref counter, which can be used later to * unsubscribe the exact event listener * * @example * const ref1 = channel.on("event", do_stuff) * const ref2 = channel.on("event", do_other_stuff) * channel.off("event", ref1) * // Since unsubscription, do_stuff won't fire, * // while do_other_stuff will keep firing on the "event" * * @param {string} event * @param {Function} callback * @returns {integer} ref */ on(event, callback){ let ref = this.bindingRef++ this.bindings.push({event, ref, callback}) return ref } /** * Unsubscribes off of channel events * * Use the ref returned from a channel.on() to unsubscribe one * handler, or pass nothing for the ref to unsubscribe all * handlers for the given event. * * @example * // Unsubscribe the do_stuff handler * const ref1 = channel.on("event", do_stuff) * channel.off("event", ref1) * * // Unsubscribe all handlers from event * channel.off("event") * * @param {string} event * @param {integer} ref */ off(event, ref){ this.bindings = this.bindings.filter((bind) => { return !(bind.event === event && (typeof ref === "undefined" || ref === bind.ref)) }) } /** * @private */ canPush(){ return this.socket.isConnected() && this.isJoined() } /** * Sends a message `event` to phoenix with the payload `payload`. * Phoenix receives this in the `handle_in(event, payload, socket)` * function. if phoenix replies or it times out (default 10000ms), * then optionally the reply can be received. * * @example * channel.push("event") * .receive("ok", payload => console.log("phoenix replied:", payload)) * .receive("error", err => console.log("phoenix errored", err)) * .receive("timeout", () => console.log("timed out pushing")) * @param {string} event * @param {Object} payload * @param {number} [timeout] * @returns {Push} */ push(event, payload, timeout = this.timeout){ payload = payload || {} if(!this.joinedOnce){ throw new Error(`tried to push '${event}' to '${this.topic}' before joining. Use channel.join() before pushing events`) } let pushEvent = new Push(this, event, function (){ return payload }, timeout) if(this.canPush()){ pushEvent.send() } else { pushEvent.startTimeout() this.pushBuffer.push(pushEvent) } return pushEvent } /** Leaves the channel * * Unsubscribes from server events, and * instructs channel to terminate on server * * Triggers onClose() hooks * * To receive leave acknowledgements, use the `receive` * hook to bind to the server ack, ie: * * @example * channel.leave().receive("ok", () => alert("left!") ) * * @param {integer} timeout * @returns {Push} */ leave(timeout = this.timeout){ this.rejoinTimer.reset() this.joinPush.cancelTimeout() this.state = CHANNEL_STATES.leaving let onClose = () => { if(this.socket.hasLogger()) this.socket.log("channel", `leave ${this.topic}`) this.trigger(CHANNEL_EVENTS.close, "leave") } let leavePush = new Push(this, CHANNEL_EVENTS.leave, closure({}), timeout) leavePush.receive("ok", () => onClose()) .receive("timeout", () => onClose()) leavePush.send() if(!this.canPush()){ leavePush.trigger("ok", {}) } return leavePush } /** * Overridable message hook * * Receives all events for specialized message handling * before dispatching to the channel callbacks. * * Must return the payload, modified or unmodified * @param {string} event * @param {Object} payload * @param {integer} ref * @returns {Object} */ onMessage(_event, payload, _ref){ return payload } /** * @private */ isMember(topic, event, payload, joinRef){ if(this.topic !== topic){ return false } if(joinRef && joinRef !== this.joinRef()){ if(this.socket.hasLogger()) this.socket.log("channel", "dropping outdated message", {topic, event, payload, joinRef}) return false } else { return true } } /** * @private */ joinRef(){ return this.joinPush.ref } /** * @private */ rejoin(timeout = this.timeout){ if(this.isLeaving()){ return } this.socket.leaveOpenTopic(this.topic) this.state = CHANNEL_STATES.joining this.joinPush.resend(timeout) } /** * @private */ trigger(event, payload, ref, joinRef){ let handledPayload = this.onMessage(event, payload, ref, joinRef) if(payload && !handledPayload){ throw new Error("channel onMessage callbacks must return the payload, modified or unmodified") } let eventBindings = this.bindings.filter(bind => bind.event === event) for(let i = 0; i < eventBindings.length; i++){ let bind = eventBindings[i] bind.callback(handledPayload, ref, joinRef || this.joinRef()) } } /** * @private */ replyEventName(ref){ return `chan_reply_${ref}` } /** * @private */ isClosed(){ return this.state === CHANNEL_STATES.closed } /** * @private */ isErrored(){ return this.state === CHANNEL_STATES.errored } /** * @private */ isJoined(){ return this.state === CHANNEL_STATES.joined } /** * @private */ isJoining(){ return this.state === CHANNEL_STATES.joining } /** * @private */ isLeaving(){ return this.state === CHANNEL_STATES.leaving } } ================================================ FILE: assets/js/phoenix/constants.js ================================================ export const globalSelf = typeof self !== "undefined" ? self : null export const phxWindow = typeof window !== "undefined" ? window : null export const global = globalSelf || phxWindow || globalThis export const DEFAULT_VSN = "2.0.0" export const SOCKET_STATES = {connecting: 0, open: 1, closing: 2, closed: 3} export const DEFAULT_TIMEOUT = 10000 export const WS_CLOSE_NORMAL = 1000 export const CHANNEL_STATES = { closed: "closed", errored: "errored", joined: "joined", joining: "joining", leaving: "leaving", } export const CHANNEL_EVENTS = { close: "phx_close", error: "phx_error", join: "phx_join", reply: "phx_reply", leave: "phx_leave" } export const TRANSPORTS = { longpoll: "longpoll", websocket: "websocket" } export const XHR_STATES = { complete: 4 } export const AUTH_TOKEN_PREFIX = "base64url.bearer.phx." ================================================ FILE: assets/js/phoenix/index.js ================================================ /** * Phoenix Channels JavaScript client * * ## Socket Connection * * A single connection is established to the server and * channels are multiplexed over the connection. * Connect to the server using the `Socket` class: * * ```javascript * let socket = new Socket("/socket", {params: {userToken: "123"}}) * socket.connect() * ``` * * The `Socket` constructor takes the mount point of the socket, * the authentication params, as well as options that can be found in * the Socket docs, such as configuring the `LongPoll` transport, and * heartbeat. * * ## Channels * * Channels are isolated, concurrent processes on the server that * subscribe to topics and broker events between the client and server. * To join a channel, you must provide the topic, and channel params for * authorization. Here's an example chat room example where `"new_msg"` * events are listened for, messages are pushed to the server, and * the channel is joined with ok/error/timeout matches: * * ``` * let channel = socket.channel("room:123", {token: roomToken}) * channel.on("new_msg", msg => console.log("Got message", msg) ) * $input.onEnter( e => { * channel.push("new_msg", {body: e.target.val}, 10000) * .receive("ok", (msg) => console.log("created message", msg) ) * .receive("error", (reasons) => console.log("create failed", reasons) ) * .receive("timeout", () => console.log("Networking issue...") ) * }) * * channel.join() * .receive("ok", ({messages}) => console.log("catching up", messages) ) * .receive("error", ({reason}) => console.log("failed join", reason) ) * .receive("timeout", () => console.log("Networking issue. Still waiting...")) *``` * * ## Joining * * Creating a channel with `socket.channel(topic, params)`, binds the params to * `channel.params`, which are sent up on `channel.join()`. * Subsequent rejoins will send up the modified params for * updating authorization params, or passing up last_message_id information. * Successful joins receive an "ok" status, while unsuccessful joins * receive "error". * * With the default serializers and WebSocket transport, JSON text frames are * used for pushing a JSON object literal. If an `ArrayBuffer` instance is provided, * binary encoding will be used and the message will be sent with the binary * opcode. * * *Note*: binary messages are only supported on the WebSocket transport. * * ## Duplicate Join Subscriptions * * While the client may join any number of topics on any number of channels, * the client may only hold a single subscription for each unique topic at any * given time. When attempting to create a duplicate subscription, * the server will close the existing channel, log a warning, and * spawn a new channel for the topic. The client will have their * `channel.onClose` callbacks fired for the existing channel, and the new * channel join will have its receive hooks processed as normal. * * ## Pushing Messages * * From the previous example, we can see that pushing messages to the server * can be done with `channel.push(eventName, payload)` and we can optionally * receive responses from the push. Additionally, we can use * `receive("timeout", callback)` to abort waiting for our other `receive` hooks * and take action after some period of waiting. The default timeout is 10000ms. * * * ## Socket Hooks * * Lifecycle events of the multiplexed connection can be hooked into via * `socket.onError()` and `socket.onClose()` events, ie: * * ``` * socket.onError( () => console.log("there was an error with the connection!") ) * socket.onClose( () => console.log("the connection dropped") ) * ``` * * * ## Channel Hooks * * For each joined channel, you can bind to `onError` and `onClose` events * to monitor the channel lifecycle, ie: * * ``` * channel.onError( () => console.log("there was an error!") ) * channel.onClose( () => console.log("the channel has gone away gracefully") ) * ``` * * ### onError hooks * * `onError` hooks are invoked if the socket connection drops, or the channel * crashes on the server. In either case, a channel rejoin is attempted * automatically in an exponential backoff manner. * * ### onClose hooks * * `onClose` hooks are invoked only in two cases. 1) the channel explicitly * closed on the server, or 2). The client explicitly closed, by calling * `channel.leave()` * * * ## Presence * * The `Presence` object provides features for syncing presence information * from the server with the client and handling presences joining and leaving. * * ### Syncing state from the server * * To sync presence state from the server, first instantiate an object and * pass your channel in to track lifecycle events: * * ``` * let channel = socket.channel("some:topic") * let presence = new Presence(channel) * ``` * * Next, use the `presence.onSync` callback to react to state changes * from the server. For example, to render the list of users every time * the list changes, you could write: * * ``` * presence.onSync(() => { * myRenderUsersFunction(presence.list()) * }) * ``` * * ### Listing Presences * * `presence.list` is used to return a list of presence information * based on the local state of metadata. By default, all presence * metadata is returned, but a `listBy` function can be supplied to * allow the client to select which metadata to use for a given presence. * For example, you may have a user online from different devices with * a metadata status of "online", but they have set themselves to "away" * on another device. In this case, the app may choose to use the "away" * status for what appears on the UI. The example below defines a `listBy` * function which prioritizes the first metadata which was registered for * each user. This could be the first tab they opened, or the first device * they came online from: * * ``` * let listBy = (id, {metas: [first, ...rest]}) => { * first.count = rest.length + 1 // count of this user's presences * first.id = id * return first * } * let onlineUsers = presence.list(listBy) * ``` * * ### Handling individual presence join and leave events * * The `presence.onJoin` and `presence.onLeave` callbacks can be used to * react to individual presences joining and leaving the app. For example: * * ``` * let presence = new Presence(channel) * * // detect if user has joined for the 1st time or from another tab/device * presence.onJoin((id, current, newPres) => { * if(!current){ * console.log("user has entered for the first time", newPres) * } else { * console.log("user additional presence", newPres) * } * }) * * // detect if user has left from all tabs/devices, or is still present * presence.onLeave((id, current, leftPres) => { * if(current.metas.length === 0){ * console.log("user has left from all devices", leftPres) * } else { * console.log("user left from a device", leftPres) * } * }) * // receive presence data from server * presence.onSync(() => { * displayUsers(presence.list()) * }) * ``` * @module phoenix */ import Channel from "./channel" import LongPoll from "./longpoll" import Presence from "./presence" import Serializer from "./serializer" import Socket from "./socket" export { Channel, LongPoll, Presence, Serializer, Socket } ================================================ FILE: assets/js/phoenix/longpoll.js ================================================ import { SOCKET_STATES, TRANSPORTS, AUTH_TOKEN_PREFIX } from "./constants" import Ajax from "./ajax" let arrayBufferToBase64 = (buffer) => { let binary = "" let bytes = new Uint8Array(buffer) let len = bytes.byteLength for(let i = 0; i < len; i++){ binary += String.fromCharCode(bytes[i]) } return btoa(binary) } export default class LongPoll { constructor(endPoint, protocols){ // we only support subprotocols for authToken // ["phoenix", "base64url.bearer.phx.BASE64_ENCODED_TOKEN"] if(protocols && protocols.length === 2 && protocols[1].startsWith(AUTH_TOKEN_PREFIX)){ this.authToken = atob(protocols[1].slice(AUTH_TOKEN_PREFIX.length)) } this.endPoint = null this.token = null this.skipHeartbeat = true this.reqs = new Set() this.awaitingBatchAck = false this.currentBatch = null this.currentBatchTimer = null this.batchBuffer = [] this.onopen = function (){ } // noop this.onerror = function (){ } // noop this.onmessage = function (){ } // noop this.onclose = function (){ } // noop this.pollEndpoint = this.normalizeEndpoint(endPoint) this.readyState = SOCKET_STATES.connecting // we must wait for the caller to finish setting up our callbacks and timeout properties setTimeout(() => this.poll(), 0) } normalizeEndpoint(endPoint){ return (endPoint .replace("ws://", "http://") .replace("wss://", "https://") .replace(new RegExp("(.*)\/" + TRANSPORTS.websocket), "$1/" + TRANSPORTS.longpoll)) } endpointURL(){ return Ajax.appendParams(this.pollEndpoint, {token: this.token}) } closeAndRetry(code, reason, wasClean){ this.close(code, reason, wasClean) this.readyState = SOCKET_STATES.connecting } ontimeout(){ this.onerror("timeout") this.closeAndRetry(1005, "timeout", false) } isActive(){ return this.readyState === SOCKET_STATES.open || this.readyState === SOCKET_STATES.connecting } poll(){ const headers = {"Accept": "application/json"} if(this.authToken){ headers["X-Phoenix-AuthToken"] = this.authToken } this.ajax("GET", headers, null, () => this.ontimeout(), resp => { if(resp){ var {status, token, messages} = resp if(status === 410 && this.token !== null){ // In case we already have a token, this means that our existing session // is gone. We fail so that the client rejoins its channels. this.onerror(410) this.closeAndRetry(3410, "session_gone", false) return } this.token = token } else { status = 0 } switch(status){ case 200: messages.forEach(msg => { // Tasks are what things like event handlers, setTimeout callbacks, // promise resolves and more are run within. // In modern browsers, there are two different kinds of tasks, // microtasks and macrotasks. // Microtasks are mainly used for Promises, while macrotasks are // used for everything else. // Microtasks always have priority over macrotasks. If the JS engine // is looking for a task to run, it will always try to empty the // microtask queue before attempting to run anything from the // macrotask queue. // // For the WebSocket transport, messages always arrive in their own // event. This means that if any promises are resolved from within, // their callbacks will always finish execution by the time the // next message event handler is run. // // In order to emulate this behaviour, we need to make sure each // onmessage handler is run within its own macrotask. setTimeout(() => this.onmessage({data: msg}), 0) }) this.poll() break case 204: this.poll() break case 410: this.readyState = SOCKET_STATES.open this.onopen({}) this.poll() break case 403: this.onerror(403) this.close(1008, "forbidden", false) break case 0: case 500: this.onerror(500) this.closeAndRetry(1011, "internal server error", 500) break default: throw new Error(`unhandled poll status ${status}`) } }) } // we collect all pushes within the current event loop by // setTimeout 0, which optimizes back-to-back procedural // pushes against an empty buffer send(body){ if(typeof(body) !== "string"){ body = arrayBufferToBase64(body) } if(this.currentBatch){ this.currentBatch.push(body) } else if(this.awaitingBatchAck){ this.batchBuffer.push(body) } else { this.currentBatch = [body] this.currentBatchTimer = setTimeout(() => { this.batchSend(this.currentBatch) this.currentBatch = null }, 0) } } batchSend(messages){ this.awaitingBatchAck = true this.ajax("POST", {"Content-Type": "application/x-ndjson"}, messages.join("\n"), () => this.onerror("timeout"), resp => { this.awaitingBatchAck = false if(!resp || resp.status !== 200){ this.onerror(resp && resp.status) this.closeAndRetry(1011, "internal server error", false) } else if(this.batchBuffer.length > 0){ this.batchSend(this.batchBuffer) this.batchBuffer = [] } }) } close(code, reason, wasClean){ for(let req of this.reqs){ req.abort() } this.readyState = SOCKET_STATES.closed let opts = Object.assign({code: 1000, reason: undefined, wasClean: true}, {code, reason, wasClean}) this.batchBuffer = [] clearTimeout(this.currentBatchTimer) this.currentBatchTimer = null if(typeof(CloseEvent) !== "undefined"){ this.onclose(new CloseEvent("close", opts)) } else { this.onclose(opts) } } ajax(method, headers, body, onCallerTimeout, callback){ let req let ontimeout = () => { this.reqs.delete(req) onCallerTimeout() } req = Ajax.request(method, this.endpointURL(), headers, body, this.timeout, ontimeout, resp => { this.reqs.delete(req) if(this.isActive()){ callback(resp) } }) this.reqs.add(req) } } ================================================ FILE: assets/js/phoenix/presence.js ================================================ /** * Initializes the Presence * @param {Channel} channel - The Channel * @param {Object} opts - The options, * for example `{events: {state: "state", diff: "diff"}}` */ export default class Presence { constructor(channel, opts = {}){ let events = opts.events || {state: "presence_state", diff: "presence_diff"} this.state = {} this.pendingDiffs = [] this.channel = channel this.joinRef = null this.caller = { onJoin: function (){ }, onLeave: function (){ }, onSync: function (){ } } this.channel.on(events.state, newState => { let {onJoin, onLeave, onSync} = this.caller this.joinRef = this.channel.joinRef() this.state = Presence.syncState(this.state, newState, onJoin, onLeave) this.pendingDiffs.forEach(diff => { this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave) }) this.pendingDiffs = [] onSync() }) this.channel.on(events.diff, diff => { let {onJoin, onLeave, onSync} = this.caller if(this.inPendingSyncState()){ this.pendingDiffs.push(diff) } else { this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave) onSync() } }) } onJoin(callback){ this.caller.onJoin = callback } onLeave(callback){ this.caller.onLeave = callback } onSync(callback){ this.caller.onSync = callback } list(by){ return Presence.list(this.state, by) } inPendingSyncState(){ return !this.joinRef || (this.joinRef !== this.channel.joinRef()) } // lower-level public static API /** * Used to sync the list of presences on the server * with the client's state. An optional `onJoin` and `onLeave` callback can * be provided to react to changes in the client's local presences across * disconnects and reconnects with the server. * * @returns {Presence} */ static syncState(currentState, newState, onJoin, onLeave){ let state = this.clone(currentState) let joins = {} let leaves = {} this.map(state, (key, presence) => { if(!newState[key]){ leaves[key] = presence } }) this.map(newState, (key, newPresence) => { let currentPresence = state[key] if(currentPresence){ let newRefs = newPresence.metas.map(m => m.phx_ref) let curRefs = currentPresence.metas.map(m => m.phx_ref) let joinedMetas = newPresence.metas.filter(m => curRefs.indexOf(m.phx_ref) < 0) let leftMetas = currentPresence.metas.filter(m => newRefs.indexOf(m.phx_ref) < 0) if(joinedMetas.length > 0){ joins[key] = newPresence joins[key].metas = joinedMetas } if(leftMetas.length > 0){ leaves[key] = this.clone(currentPresence) leaves[key].metas = leftMetas } } else { joins[key] = newPresence } }) return this.syncDiff(state, {joins: joins, leaves: leaves}, onJoin, onLeave) } /** * * Used to sync a diff of presence join and leave * events from the server, as they happen. Like `syncState`, `syncDiff` * accepts optional `onJoin` and `onLeave` callbacks to react to a user * joining or leaving from a device. * * @returns {Presence} */ static syncDiff(state, diff, onJoin, onLeave){ let {joins, leaves} = this.clone(diff) if(!onJoin){ onJoin = function (){ } } if(!onLeave){ onLeave = function (){ } } this.map(joins, (key, newPresence) => { let currentPresence = state[key] state[key] = this.clone(newPresence) if(currentPresence){ let joinedRefs = state[key].metas.map(m => m.phx_ref) let curMetas = currentPresence.metas.filter(m => joinedRefs.indexOf(m.phx_ref) < 0) state[key].metas.unshift(...curMetas) } onJoin(key, currentPresence, newPresence) }) this.map(leaves, (key, leftPresence) => { let currentPresence = state[key] if(!currentPresence){ return } let refsToRemove = leftPresence.metas.map(m => m.phx_ref) currentPresence.metas = currentPresence.metas.filter(p => { return refsToRemove.indexOf(p.phx_ref) < 0 }) onLeave(key, currentPresence, leftPresence) if(currentPresence.metas.length === 0){ delete state[key] } }) return state } /** * Returns the array of presences, with selected metadata. * * @param {Object} presences * @param {Function} chooser * * @returns {Presence} */ static list(presences, chooser){ if(!chooser){ chooser = function (key, pres){ return pres } } return this.map(presences, (key, presence) => { return chooser(key, presence) }) } // private static map(obj, func){ return Object.getOwnPropertyNames(obj).map(key => func(key, obj[key])) } static clone(obj){ return JSON.parse(JSON.stringify(obj)) } } ================================================ FILE: assets/js/phoenix/push.js ================================================ /** * Initializes the Push * @param {Channel} channel - The Channel * @param {string} event - The event, for example `"phx_join"` * @param {Object} payload - The payload, for example `{user_id: 123}` * @param {number} timeout - The push timeout in milliseconds */ export default class Push { constructor(channel, event, payload, timeout){ this.channel = channel this.event = event this.payload = payload || function (){ return {} } this.receivedResp = null this.timeout = timeout this.timeoutTimer = null this.recHooks = [] this.sent = false } /** * * @param {number} timeout */ resend(timeout){ this.timeout = timeout this.reset() this.send() } /** * */ send(){ if(this.hasReceived("timeout")){ return } this.startTimeout() this.sent = true this.channel.socket.push({ topic: this.channel.topic, event: this.event, payload: this.payload(), ref: this.ref, join_ref: this.channel.joinRef() }) } /** * * @param {*} status * @param {*} callback */ receive(status, callback){ if(this.hasReceived(status)){ callback(this.receivedResp.response) } this.recHooks.push({status, callback}) return this } /** * @private */ reset(){ this.cancelRefEvent() this.ref = null this.refEvent = null this.receivedResp = null this.sent = false } /** * @private */ matchReceive({status, response, _ref}){ this.recHooks.filter(h => h.status === status) .forEach(h => h.callback(response)) } /** * @private */ cancelRefEvent(){ if(!this.refEvent){ return } this.channel.off(this.refEvent) } /** * @private */ cancelTimeout(){ clearTimeout(this.timeoutTimer) this.timeoutTimer = null } /** * @private */ startTimeout(){ if(this.timeoutTimer){ this.cancelTimeout() } this.ref = this.channel.socket.makeRef() this.refEvent = this.channel.replyEventName(this.ref) this.channel.on(this.refEvent, payload => { this.cancelRefEvent() this.cancelTimeout() this.receivedResp = payload this.matchReceive(payload) }) this.timeoutTimer = setTimeout(() => { this.trigger("timeout", {}) }, this.timeout) } /** * @private */ hasReceived(status){ return this.receivedResp && this.receivedResp.status === status } /** * @private */ trigger(status, response){ this.channel.trigger(this.refEvent, {status, response}) } } ================================================ FILE: assets/js/phoenix/serializer.js ================================================ /* The default serializer for encoding and decoding messages */ import { CHANNEL_EVENTS } from "./constants" export default { HEADER_LENGTH: 1, META_LENGTH: 4, KINDS: {push: 0, reply: 1, broadcast: 2}, encode(msg, callback){ if(msg.payload.constructor === ArrayBuffer){ return callback(this.binaryEncode(msg)) } else { let payload = [msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload] return callback(JSON.stringify(payload)) } }, decode(rawPayload, callback){ if(rawPayload.constructor === ArrayBuffer){ return callback(this.binaryDecode(rawPayload)) } else { let [join_ref, ref, topic, event, payload] = JSON.parse(rawPayload) return callback({join_ref, ref, topic, event, payload}) } }, // private binaryEncode(message){ let {join_ref, ref, event, topic, payload} = message let metaLength = this.META_LENGTH + join_ref.length + ref.length + topic.length + event.length let header = new ArrayBuffer(this.HEADER_LENGTH + metaLength) let view = new DataView(header) let offset = 0 view.setUint8(offset++, this.KINDS.push) // kind view.setUint8(offset++, join_ref.length) view.setUint8(offset++, ref.length) view.setUint8(offset++, topic.length) view.setUint8(offset++, event.length) Array.from(join_ref, char => view.setUint8(offset++, char.charCodeAt(0))) Array.from(ref, char => view.setUint8(offset++, char.charCodeAt(0))) Array.from(topic, char => view.setUint8(offset++, char.charCodeAt(0))) Array.from(event, char => view.setUint8(offset++, char.charCodeAt(0))) var combined = new Uint8Array(header.byteLength + payload.byteLength) combined.set(new Uint8Array(header), 0) combined.set(new Uint8Array(payload), header.byteLength) return combined.buffer }, binaryDecode(buffer){ let view = new DataView(buffer) let kind = view.getUint8(0) let decoder = new TextDecoder() switch(kind){ case this.KINDS.push: return this.decodePush(buffer, view, decoder) case this.KINDS.reply: return this.decodeReply(buffer, view, decoder) case this.KINDS.broadcast: return this.decodeBroadcast(buffer, view, decoder) } }, decodePush(buffer, view, decoder){ let joinRefSize = view.getUint8(1) let topicSize = view.getUint8(2) let eventSize = view.getUint8(3) let offset = this.HEADER_LENGTH + this.META_LENGTH - 1 // pushes have no ref let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)) offset = offset + joinRefSize let topic = decoder.decode(buffer.slice(offset, offset + topicSize)) offset = offset + topicSize let event = decoder.decode(buffer.slice(offset, offset + eventSize)) offset = offset + eventSize let data = buffer.slice(offset, buffer.byteLength) return {join_ref: joinRef, ref: null, topic: topic, event: event, payload: data} }, decodeReply(buffer, view, decoder){ let joinRefSize = view.getUint8(1) let refSize = view.getUint8(2) let topicSize = view.getUint8(3) let eventSize = view.getUint8(4) let offset = this.HEADER_LENGTH + this.META_LENGTH let joinRef = decoder.decode(buffer.slice(offset, offset + joinRefSize)) offset = offset + joinRefSize let ref = decoder.decode(buffer.slice(offset, offset + refSize)) offset = offset + refSize let topic = decoder.decode(buffer.slice(offset, offset + topicSize)) offset = offset + topicSize let event = decoder.decode(buffer.slice(offset, offset + eventSize)) offset = offset + eventSize let data = buffer.slice(offset, buffer.byteLength) let payload = {status: event, response: data} return {join_ref: joinRef, ref: ref, topic: topic, event: CHANNEL_EVENTS.reply, payload: payload} }, decodeBroadcast(buffer, view, decoder){ let topicSize = view.getUint8(1) let eventSize = view.getUint8(2) let offset = this.HEADER_LENGTH + 2 let topic = decoder.decode(buffer.slice(offset, offset + topicSize)) offset = offset + topicSize let event = decoder.decode(buffer.slice(offset, offset + eventSize)) offset = offset + eventSize let data = buffer.slice(offset, buffer.byteLength) return {join_ref: null, ref: null, topic: topic, event: event, payload: data} } } ================================================ FILE: assets/js/phoenix/socket.js ================================================ import { global, phxWindow, CHANNEL_EVENTS, DEFAULT_TIMEOUT, DEFAULT_VSN, SOCKET_STATES, TRANSPORTS, WS_CLOSE_NORMAL, AUTH_TOKEN_PREFIX } from "./constants" import { closure } from "./utils" import Ajax from "./ajax" import Channel from "./channel" import LongPoll from "./longpoll" import Serializer from "./serializer" import Timer from "./timer" /** Initializes the Socket * * * For IE8 support use an ES5-shim (https://github.com/es-shims/es5-shim) * * @param {string} endPoint - The string WebSocket endpoint, ie, `"ws://example.com/socket"`, * `"wss://example.com"` * `"/socket"` (inherited host & protocol) * @param {Object} [opts] - Optional configuration * @param {Function} [opts.transport] - The Websocket Transport, for example WebSocket or Phoenix.LongPoll. * * Defaults to WebSocket with automatic LongPoll fallback if WebSocket is not defined. * To fallback to LongPoll when WebSocket attempts fail, use `longPollFallbackMs: 2500`. * * @param {number} [opts.longPollFallbackMs] - The millisecond time to attempt the primary transport * before falling back to the LongPoll transport. Disabled by default. * * @param {boolean} [opts.debug] - When true, enables debug logging. Default false. * * @param {Function} [opts.encode] - The function to encode outgoing messages. * * Defaults to JSON encoder. * * @param {Function} [opts.decode] - The function to decode incoming messages. * * Defaults to JSON: * * ```javascript * (payload, callback) => callback(JSON.parse(payload)) * ``` * * @param {number} [opts.timeout] - The default timeout in milliseconds to trigger push timeouts. * * Defaults `DEFAULT_TIMEOUT` * @param {number} [opts.heartbeatIntervalMs] - The millisec interval to send a heartbeat message * @param {Function} [opts.reconnectAfterMs] - The optional function that returns the * socket reconnect interval, in milliseconds. * * Defaults to stepped backoff of: * * ```javascript * function(tries){ * return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000 * } * ```` * * @param {Function} [opts.rejoinAfterMs] - The optional function that returns the millisec * rejoin interval for individual channels. * * ```javascript * function(tries){ * return [1000, 2000, 5000][tries - 1] || 10000 * } * ```` * * @param {Function} [opts.logger] - The optional function for specialized logging, ie: * * ```javascript * function(kind, msg, data) { * console.log(`${kind}: ${msg}`, data) * } * ``` * * @param {number} [opts.longpollerTimeout] - The maximum timeout of a long poll AJAX request. * * Defaults to 20s (double the server long poll timer). * * @param {(Object|function)} [opts.params] - The optional params to pass when connecting * @param {string} [opts.authToken] - the optional authentication token to be exposed on the server * under the `:auth_token` connect_info key. * @param {string} [opts.binaryType] - The binary type to use for binary WebSocket frames. * * Defaults to "arraybuffer" * * @param {vsn} [opts.vsn] - The serializer's protocol version to send on connect. * * Defaults to DEFAULT_VSN. * * @param {Object} [opts.sessionStorage] - An optional Storage compatible object * Phoenix uses sessionStorage for longpoll fallback history. Overriding the store is * useful when Phoenix won't have access to `sessionStorage`. For example, This could * happen if a site loads a cross-domain channel in an iframe. Example usage: * * class InMemoryStorage { * constructor() { this.storage = {} } * getItem(keyName) { return this.storage[keyName] || null } * removeItem(keyName) { delete this.storage[keyName] } * setItem(keyName, keyValue) { this.storage[keyName] = keyValue } * } * */ export default class Socket { constructor(endPoint, opts = {}){ this.stateChangeCallbacks = {open: [], close: [], error: [], message: []} this.channels = [] this.sendBuffer = [] this.ref = 0 this.fallbackRef = null this.timeout = opts.timeout || DEFAULT_TIMEOUT this.transport = opts.transport || global.WebSocket || LongPoll this.primaryPassedHealthCheck = false this.longPollFallbackMs = opts.longPollFallbackMs this.fallbackTimer = null this.sessionStore = opts.sessionStorage || (global && global.sessionStorage) this.establishedConnections = 0 this.defaultEncoder = Serializer.encode.bind(Serializer) this.defaultDecoder = Serializer.decode.bind(Serializer) // We start with closeWasClean true to avoid the visibility change // logic from connecting if the socket was never connected in the first place. // transportConnect sets it to false on open. this.closeWasClean = true this.disconnecting = false this.binaryType = opts.binaryType || "arraybuffer" this.connectClock = 1 this.pageHidden = false if(this.transport !== LongPoll){ this.encode = opts.encode || this.defaultEncoder this.decode = opts.decode || this.defaultDecoder } else { this.encode = this.defaultEncoder this.decode = this.defaultDecoder } let awaitingConnectionOnPageShow = null if(phxWindow && phxWindow.addEventListener){ phxWindow.addEventListener("pagehide", _e => { if(this.conn){ this.disconnect() awaitingConnectionOnPageShow = this.connectClock } }) phxWindow.addEventListener("pageshow", _e => { if(awaitingConnectionOnPageShow === this.connectClock){ awaitingConnectionOnPageShow = null this.connect() } }) phxWindow.addEventListener("visibilitychange", () => { if(document.visibilityState === "hidden"){ this.pageHidden = true } else { this.pageHidden = false // reconnect immediately if(!this.isConnected() && !this.closeWasClean){ this.teardown(() => this.connect()) } } }) } this.heartbeatIntervalMs = opts.heartbeatIntervalMs || 30000 this.rejoinAfterMs = (tries) => { if(opts.rejoinAfterMs){ return opts.rejoinAfterMs(tries) } else { return [1000, 2000, 5000][tries - 1] || 10000 } } this.reconnectAfterMs = (tries) => { if(opts.reconnectAfterMs){ return opts.reconnectAfterMs(tries) } else { return [10, 50, 100, 150, 200, 250, 500, 1000, 2000][tries - 1] || 5000 } } this.logger = opts.logger || null if(!this.logger && opts.debug){ this.logger = (kind, msg, data) => { console.log(`${kind}: ${msg}`, data) } } this.longpollerTimeout = opts.longpollerTimeout || 20000 this.params = closure(opts.params || {}) this.endPoint = `${endPoint}/${TRANSPORTS.websocket}` this.vsn = opts.vsn || DEFAULT_VSN this.heartbeatTimeoutTimer = null this.heartbeatTimer = null this.pendingHeartbeatRef = null this.reconnectTimer = new Timer(() => { if(this.pageHidden){ this.log("Not reconnecting as page is hidden!") this.teardown() return } this.teardown(() => this.connect()) }, this.reconnectAfterMs) this.authToken = opts.authToken } /** * Returns the LongPoll transport reference */ getLongPollTransport(){ return LongPoll } /** * Disconnects and replaces the active transport * * @param {Function} newTransport - The new transport class to instantiate * */ replaceTransport(newTransport){ this.connectClock++ this.closeWasClean = true clearTimeout(this.fallbackTimer) this.reconnectTimer.reset() if(this.conn){ this.conn.close() this.conn = null } this.transport = newTransport } /** * Returns the socket protocol * * @returns {string} */ protocol(){ return location.protocol.match(/^https/) ? "wss" : "ws" } /** * The fully qualified socket url * * @returns {string} */ endPointURL(){ let uri = Ajax.appendParams( Ajax.appendParams(this.endPoint, this.params()), {vsn: this.vsn}) if(uri.charAt(0) !== "/"){ return uri } if(uri.charAt(1) === "/"){ return `${this.protocol()}:${uri}` } return `${this.protocol()}://${location.host}${uri}` } /** * Disconnects the socket * * See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes for valid status codes. * * @param {Function} callback - Optional callback which is called after socket is disconnected. * @param {integer} code - A status code for disconnection (Optional). * @param {string} reason - A textual description of the reason to disconnect. (Optional) */ disconnect(callback, code, reason){ this.connectClock++ this.disconnecting = true this.closeWasClean = true clearTimeout(this.fallbackTimer) this.reconnectTimer.reset() this.teardown(() => { this.disconnecting = false callback && callback() }, code, reason) } /** * * @param {Object} params - The params to send when connecting, for example `{user_id: userToken}` * * Passing params to connect is deprecated; pass them in the Socket constructor instead: * `new Socket("/socket", {params: {user_id: userToken}})`. */ connect(params){ if(params){ console && console.log("passing params to connect is deprecated. Instead pass :params to the Socket constructor") this.params = closure(params) } if(this.conn && !this.disconnecting){ return } if(this.longPollFallbackMs && this.transport !== LongPoll){ this.connectWithFallback(LongPoll, this.longPollFallbackMs) } else { this.transportConnect() } } /** * Logs the message. Override `this.logger` for specialized logging. noops by default * @param {string} kind * @param {string} msg * @param {Object} data */ log(kind, msg, data){ this.logger && this.logger(kind, msg, data) } /** * Returns true if a logger has been set on this socket. */ hasLogger(){ return this.logger !== null } /** * Registers callbacks for connection open events * * @example socket.onOpen(function(){ console.info("the socket was opened") }) * * @param {Function} callback */ onOpen(callback){ let ref = this.makeRef() this.stateChangeCallbacks.open.push([ref, callback]) return ref } /** * Registers callbacks for connection close events * @param {Function} callback */ onClose(callback){ let ref = this.makeRef() this.stateChangeCallbacks.close.push([ref, callback]) return ref } /** * Registers callbacks for connection error events * * @example socket.onError(function(error){ alert("An error occurred") }) * * @param {Function} callback */ onError(callback){ let ref = this.makeRef() this.stateChangeCallbacks.error.push([ref, callback]) return ref } /** * Registers callbacks for connection message events * @param {Function} callback */ onMessage(callback){ let ref = this.makeRef() this.stateChangeCallbacks.message.push([ref, callback]) return ref } /** * Pings the server and invokes the callback with the RTT in milliseconds * @param {Function} callback * * Returns true if the ping was pushed or false if unable to be pushed. */ ping(callback){ if(!this.isConnected()){ return false } let ref = this.makeRef() let startTime = Date.now() this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: ref}) let onMsgRef = this.onMessage(msg => { if(msg.ref === ref){ this.off([onMsgRef]) callback(Date.now() - startTime) } }) return true } /** * @private * * @param {Function} */ transportName(transport){ // JavaScript minification, enabled by default in production in Phoenix // projects, renames symbols to reduce code size. // See https://esbuild.github.io/api/#keep-names. // This helper ensures we return the correct name for the LongPoll transport // even after minification. The other common transport is WebSocket, which // is native to browsers and does not need special handling. switch(transport){ case LongPoll: return "LongPoll" default: return transport.name } } /** * @private */ transportConnect(){ this.connectClock++ this.closeWasClean = false let protocols = undefined // Sec-WebSocket-Protocol based token // (longpoll uses Authorization header instead) if(this.authToken){ protocols = ["phoenix", `${AUTH_TOKEN_PREFIX}${btoa(this.authToken).replace(/=/g, "")}`] } this.conn = new this.transport(this.endPointURL(), protocols) this.conn.binaryType = this.binaryType this.conn.timeout = this.longpollerTimeout this.conn.onopen = () => this.onConnOpen() this.conn.onerror = error => this.onConnError(error) this.conn.onmessage = event => this.onConnMessage(event) this.conn.onclose = event => this.onConnClose(event) } getSession(key){ return this.sessionStore && this.sessionStore.getItem(key) } storeSession(key, val){ this.sessionStore && this.sessionStore.setItem(key, val) } connectWithFallback(fallbackTransport, fallbackThreshold = 2500){ clearTimeout(this.fallbackTimer) let established = false let primaryTransport = true let openRef, errorRef let fallbackTransportName = this.transportName(fallbackTransport) let fallback = (reason) => { this.log("transport", `falling back to ${fallbackTransportName}...`, reason) this.off([openRef, errorRef]) primaryTransport = false this.replaceTransport(fallbackTransport) this.transportConnect() } if(this.getSession(`phx:fallback:${fallbackTransportName}`)){ return fallback("memorized") } this.fallbackTimer = setTimeout(fallback, fallbackThreshold) errorRef = this.onError(reason => { this.log("transport", "error", reason) if(primaryTransport && !established){ clearTimeout(this.fallbackTimer) fallback(reason) } }) if(this.fallbackRef){ this.off([this.fallbackRef]) } this.fallbackRef = this.onOpen(() => { established = true if(!primaryTransport){ let fallbackTransportName = this.transportName(fallbackTransport) // only memorize LP if we never connected to primary if(!this.primaryPassedHealthCheck){ this.storeSession(`phx:fallback:${fallbackTransportName}`, "true") } return this.log("transport", `established ${fallbackTransportName} fallback`) } // if we've established primary, give the fallback a new period to attempt ping clearTimeout(this.fallbackTimer) this.fallbackTimer = setTimeout(fallback, fallbackThreshold) this.ping(rtt => { this.log("transport", "connected to primary after", rtt) this.primaryPassedHealthCheck = true clearTimeout(this.fallbackTimer) }) }) this.transportConnect() } clearHeartbeats(){ clearTimeout(this.heartbeatTimer) clearTimeout(this.heartbeatTimeoutTimer) } onConnOpen(){ if(this.hasLogger()) this.log("transport", `${this.transportName(this.transport)} connected to ${this.endPointURL()}`) this.closeWasClean = false this.disconnecting = false this.establishedConnections++ this.flushSendBuffer() this.reconnectTimer.reset() this.resetHeartbeat() this.stateChangeCallbacks.open.forEach(([, callback]) => callback()) } /** * @private */ heartbeatTimeout(){ if(this.pendingHeartbeatRef){ this.pendingHeartbeatRef = null if(this.hasLogger()){ this.log("transport", "heartbeat timeout. Attempting to re-establish connection") } this.triggerChanError() this.closeWasClean = false this.teardown(() => this.reconnectTimer.scheduleTimeout(), WS_CLOSE_NORMAL, "heartbeat timeout") } } resetHeartbeat(){ if(this.conn && this.conn.skipHeartbeat){ return } this.pendingHeartbeatRef = null this.clearHeartbeats() this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs) } teardown(callback, code, reason){ if(!this.conn){ return callback && callback() } // If someone calls connect before we finish tearing down, // we create a new connection, but we still want to finish tearing down the old one. const connToClose = this.conn this.waitForBufferDone(connToClose, () => { if(code){ connToClose.close(code, reason || "") } else { connToClose.close() } this.waitForSocketClosed(connToClose, () => { if(this.conn === connToClose){ this.conn.onopen = function (){ } // noop this.conn.onerror = function (){ } // noop this.conn.onmessage = function (){ } // noop this.conn.onclose = function (){ } // noop this.conn = null } callback && callback() }) }) } waitForBufferDone(conn, callback, tries = 1){ if(tries === 5 || !conn.bufferedAmount){ callback() return } setTimeout(() => { this.waitForBufferDone(conn, callback, tries + 1) }, 150 * tries) } waitForSocketClosed(conn, callback, tries = 1){ if(tries === 5 || conn.readyState === SOCKET_STATES.closed){ callback() return } setTimeout(() => { this.waitForSocketClosed(conn, callback, tries + 1) }, 150 * tries) } onConnClose(event){ if(this.conn) this.conn.onclose = () => {} // noop to prevent recursive calls in teardown let closeCode = event && event.code if(this.hasLogger()) this.log("transport", "close", event) this.triggerChanError() this.clearHeartbeats() if(!this.closeWasClean && closeCode !== 1000){ this.reconnectTimer.scheduleTimeout() } this.stateChangeCallbacks.close.forEach(([, callback]) => callback(event)) } /** * @private */ onConnError(error){ if(this.hasLogger()) this.log("transport", error) let transportBefore = this.transport let establishedBefore = this.establishedConnections this.stateChangeCallbacks.error.forEach(([, callback]) => { callback(error, transportBefore, establishedBefore) }) if(transportBefore === this.transport || establishedBefore > 0){ this.triggerChanError() } } /** * @private */ triggerChanError(){ this.channels.forEach(channel => { if(!(channel.isErrored() || channel.isLeaving() || channel.isClosed())){ channel.trigger(CHANNEL_EVENTS.error) } }) } /** * @returns {string} */ connectionState(){ switch(this.conn && this.conn.readyState){ case SOCKET_STATES.connecting: return "connecting" case SOCKET_STATES.open: return "open" case SOCKET_STATES.closing: return "closing" default: return "closed" } } /** * @returns {boolean} */ isConnected(){ return this.connectionState() === "open" } /** * @private * * @param {Channel} */ remove(channel){ this.off(channel.stateChangeRefs) this.channels = this.channels.filter(c => c !== channel) } /** * Removes `onOpen`, `onClose`, `onError,` and `onMessage` registrations. * * @param {refs} - list of refs returned by calls to * `onOpen`, `onClose`, `onError,` and `onMessage` */ off(refs){ for(let key in this.stateChangeCallbacks){ this.stateChangeCallbacks[key] = this.stateChangeCallbacks[key].filter(([ref]) => { return refs.indexOf(ref) === -1 }) } } /** * Initiates a new channel for the given topic * * @param {string} topic * @param {Object} chanParams - Parameters for the channel * @returns {Channel} */ channel(topic, chanParams = {}){ let chan = new Channel(topic, chanParams, this) this.channels.push(chan) return chan } /** * @param {Object} data */ push(data){ if(this.hasLogger()){ let {topic, event, payload, ref, join_ref} = data this.log("push", `${topic} ${event} (${join_ref}, ${ref})`, payload) } if(this.isConnected()){ this.encode(data, result => this.conn.send(result)) } else { this.sendBuffer.push(() => this.encode(data, result => this.conn.send(result))) } } /** * Return the next message ref, accounting for overflows * @returns {string} */ makeRef(){ let newRef = this.ref + 1 if(newRef === this.ref){ this.ref = 0 } else { this.ref = newRef } return this.ref.toString() } sendHeartbeat(){ if(this.pendingHeartbeatRef && !this.isConnected()){ return } this.pendingHeartbeatRef = this.makeRef() this.push({topic: "phoenix", event: "heartbeat", payload: {}, ref: this.pendingHeartbeatRef}) this.heartbeatTimeoutTimer = setTimeout(() => this.heartbeatTimeout(), this.heartbeatIntervalMs) } flushSendBuffer(){ if(this.isConnected() && this.sendBuffer.length > 0){ this.sendBuffer.forEach(callback => callback()) this.sendBuffer = [] } } onConnMessage(rawMessage){ this.decode(rawMessage.data, msg => { let {topic, event, payload, ref, join_ref} = msg if(ref && ref === this.pendingHeartbeatRef){ this.clearHeartbeats() this.pendingHeartbeatRef = null this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs) } if(this.hasLogger()) this.log("receive", `${payload.status || ""} ${topic} ${event} ${ref && "(" + ref + ")" || ""}`, payload) for(let i = 0; i < this.channels.length; i++){ const channel = this.channels[i] if(!channel.isMember(topic, event, payload, join_ref)){ continue } channel.trigger(event, payload, ref, join_ref) } for(let i = 0; i < this.stateChangeCallbacks.message.length; i++){ let [, callback] = this.stateChangeCallbacks.message[i] callback(msg) } }) } leaveOpenTopic(topic){ let dupChannel = this.channels.find(c => c.topic === topic && (c.isJoined() || c.isJoining())) if(dupChannel){ if(this.hasLogger()) this.log("transport", `leaving duplicate topic "${topic}"`) dupChannel.leave() } } } ================================================ FILE: assets/js/phoenix/timer.js ================================================ /** * * Creates a timer that accepts a `timerCalc` function to perform * calculated timeout retries, such as exponential backoff. * * @example * let reconnectTimer = new Timer(() => this.connect(), function(tries){ * return [1000, 5000, 10000][tries - 1] || 10000 * }) * reconnectTimer.scheduleTimeout() // fires after 1000 * reconnectTimer.scheduleTimeout() // fires after 5000 * reconnectTimer.reset() * reconnectTimer.scheduleTimeout() // fires after 1000 * * @param {Function} callback * @param {Function} timerCalc */ export default class Timer { constructor(callback, timerCalc){ this.callback = callback this.timerCalc = timerCalc this.timer = null this.tries = 0 } reset(){ this.tries = 0 clearTimeout(this.timer) } /** * Cancels any previous scheduleTimeout and schedules callback */ scheduleTimeout(){ clearTimeout(this.timer) this.timer = setTimeout(() => { this.tries = this.tries + 1 this.callback() }, this.timerCalc(this.tries + 1)) } } ================================================ FILE: assets/js/phoenix/utils.js ================================================ // wraps value in closure or returns closure export let closure = (value) => { if(typeof value === "function"){ return value } else { let closure = function (){ return value } return closure } } ================================================ FILE: assets/test/channel_test.js ================================================ import {jest} from "@jest/globals" import {Channel, Socket} from "../js/phoenix" let channel, socket const defaultRef = 1 const defaultTimeout = 10000 class WSMock { constructor(url, protocols){ this.url = url this.protocols = protocols } close(){} send(){} } describe("with transport", function (){ beforeAll(function (){ global.WebSocket = WSMock }) afterAll(function (){ global.WebSocket = null }) describe("constructor", function (){ beforeEach(function (){ socket = new Socket("/", {timeout: 1234}) }) it("sets defaults", function (){ channel = new Channel("topic", {one: "two"}, socket) expect(channel.state).toBe("closed") expect(channel.topic).toBe("topic") expect(channel.params()).toEqual({one: "two"}) expect(channel.socket).toBe(socket) expect(channel.timeout).toBe(1234) expect(channel.joinedOnce).toBe(false) expect(channel.joinPush).toBeTruthy() expect(channel.pushBuffer).toEqual([]) }) it("sets up joinPush object with literal params", function (){ channel = new Channel("topic", {one: "two"}, socket) const joinPush = channel.joinPush expect(joinPush.channel).toBe(channel) expect(joinPush.payload()).toEqual({one: "two"}) expect(joinPush.event).toBe("phx_join") expect(joinPush.timeout).toBe(1234) }) it("sets up joinPush object with closure params", function (){ channel = new Channel("topic", () => ({one: "two"}), socket) const joinPush = channel.joinPush expect(joinPush.channel).toBe(channel) expect(joinPush.payload()).toEqual({one: "two"}) expect(joinPush.event).toBe("phx_join") expect(joinPush.timeout).toBe(1234) }) it("sets subprotocols when authToken is provided", function (){ const authToken = "1234" const socket = new Socket("/socket", {authToken}) socket.connect() expect(socket.conn.protocols).toEqual(["phoenix", "base64url.bearer.phx.MTIzNA"]) }) }) describe("updating join params", function (){ it("can update the join params", function (){ let counter = 0 let params = () => ({value: counter}) socket = {timeout: 1234, onError: function (){}, onOpen: function (){}} channel = new Channel("topic", params, socket) const joinPush = channel.joinPush expect(joinPush.channel).toBe(channel) expect(joinPush.payload()).toEqual({value: 0}) expect(joinPush.event).toBe("phx_join") expect(joinPush.timeout).toBe(1234) counter++ expect(joinPush.channel).toBe(channel) expect(joinPush.payload()).toEqual({value: 1}) expect(channel.params()).toEqual({value: 1}) expect(joinPush.event).toBe("phx_join") expect(joinPush.timeout).toBe(1234) }) }) describe("join", function (){ beforeEach(function (){ socket = new Socket("/socket", {timeout: defaultTimeout}) channel = socket.channel("topic", {one: "two"}) }) it("sets state to joining", function (){ channel.join() expect(channel.state).toBe("joining") }) it("sets joinedOnce to true", function (){ expect(channel.joinedOnce).toBe(false) channel.join() expect(channel.joinedOnce).toBe(true) }) it("throws if attempting to join multiple times", function (){ channel.join() expect(() => channel.join()).toThrow(/^tried to join multiple times/) }) it("triggers socket push with channel params", function (){ jest.spyOn(socket, "makeRef").mockReturnValue(defaultRef) const spy = jest.spyOn(socket, "push") channel.join() expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith({ topic: "topic", event: "phx_join", payload: {one: "two"}, ref: defaultRef, join_ref: channel.joinRef(), }) }) it("can set timeout on joinPush", function (){ const newTimeout = 2000 const joinPush = channel.joinPush expect(joinPush.timeout).toBe(defaultTimeout) channel.join(newTimeout) expect(joinPush.timeout).toBe(newTimeout) }) it("leaves existing duplicate topic on new join", function (done){ channel.join().receive("ok", () => { let newChannel = socket.channel("topic") expect(channel.isJoined()).toBe(true) newChannel.join() expect(channel.isJoined()).toBe(false) done() }) channel.joinPush.trigger("ok", {}) }) describe("timeout behavior", function (){ let joinPush const helpers = { receiveSocketOpen(){ jest.spyOn(socket, "isConnected").mockReturnValue(true) socket.onConnOpen() }, } beforeEach(function (){ jest.useFakeTimers() joinPush = channel.joinPush }) afterEach(function (){ jest.useRealTimers() }) it("succeeds before timeout", function (){ const spy = jest.spyOn(socket, "push") const timeout = joinPush.timeout socket.connect() helpers.receiveSocketOpen() channel.join() expect(spy).toHaveBeenCalledTimes(1) expect(channel.timeout).toBe(10000) jest.advanceTimersByTime(100) joinPush.trigger("ok", {}) expect(channel.state).toBe("joined") jest.advanceTimersByTime(timeout) expect(spy).toHaveBeenCalledTimes(1) }) it("retries with backoff after timeout", function (){ const spy = jest.spyOn(socket, "push") const timeoutSpy = jest.fn() const timeout = joinPush.timeout socket.connect() helpers.receiveSocketOpen() channel.join().receive("timeout", timeoutSpy) expect(spy).toHaveBeenCalledTimes(1) expect(timeoutSpy).toHaveBeenCalledTimes(0) jest.advanceTimersByTime(timeout) expect(spy).toHaveBeenCalledTimes(2) // leave pushed to server expect(timeoutSpy).toHaveBeenCalledTimes(1) jest.advanceTimersByTime(timeout + 1000) expect(spy).toHaveBeenCalledTimes(4) // leave + rejoin expect(timeoutSpy).toHaveBeenCalledTimes(2) jest.advanceTimersByTime(10000) joinPush.trigger("ok", {}) expect(spy).toHaveBeenCalledTimes(6) expect(channel.state).toBe("joined") }) it("with socket and join delay", function (){ const spy = jest.spyOn(socket, "push") jest.useFakeTimers() const joinPush = channel.joinPush channel.join() expect(spy).toHaveBeenCalledTimes(1) // open socket after delay jest.advanceTimersByTime(9000) expect(spy).toHaveBeenCalledTimes(1) // join request returns between timeouts jest.advanceTimersByTime(1000) socket.connect() expect(channel.state).toBe("errored") helpers.receiveSocketOpen() joinPush.trigger("ok", {}) // join request succeeds after delay jest.advanceTimersByTime(1000) expect(channel.state).toBe("joined") expect(spy).toHaveBeenCalledTimes(3) // leave pushed to server }) it("with socket delay only", function (){ jest.useFakeTimers() const joinPush = channel.joinPush channel.join() expect(channel.state).toBe("joining") // connect socket after delay jest.advanceTimersByTime(6000) socket.connect() // open socket after delay jest.advanceTimersByTime(5000) helpers.receiveSocketOpen() joinPush.trigger("ok", {}) joinPush.trigger("ok", {}) expect(channel.state).toBe("joined") }) }) }) describe("joinPush", function (){ let joinPush let response const helpers = { receiveOk(){ jest.advanceTimersByTime(joinPush.timeout / 2) // before timeout return joinPush.channel.trigger("phx_reply", {status: "ok", response: response}, joinPush.ref, joinPush.ref) // return joinPush.trigger("ok", response) }, receiveTimeout(){ jest.advanceTimersByTime(joinPush.timeout * 2) // after timeout }, receiveError(){ jest.advanceTimersByTime(joinPush.timeout / 2) // before timeout return joinPush.trigger("error", response) }, getBindings(event){ return channel.bindings.filter(bind => bind.event === event) }, } beforeEach(function (){ jest.useFakeTimers() socket = new Socket("/socket", {timeout: defaultTimeout}) jest.spyOn(socket, "isConnected").mockReturnValue(true) jest.spyOn(socket, "push").mockReturnValue(true) channel = socket.channel("topic", {one: "two"}) joinPush = channel.joinPush channel.join() }) afterEach(function (){ jest.useRealTimers() }) describe("receives 'ok'", function (){ beforeEach(function (){ response = {chan: "reply"} }) it("sets channel state to joined", function (){ expect(channel.state).not.toBe("joined") helpers.receiveOk() expect(channel.state).toBe("joined") }) it("triggers receive('ok') callback after ok response", function (){ const spyOk = jest.fn() joinPush.receive("ok", spyOk) helpers.receiveOk() expect(spyOk).toHaveBeenCalledTimes(1) }) it("triggers receive('ok') callback if ok response already received", function (){ const spyOk = jest.fn() helpers.receiveOk() joinPush.receive("ok", spyOk) expect(spyOk).toHaveBeenCalledTimes(1) }) it("does not trigger other receive callbacks after ok response", function (){ const spyError = jest.fn() const spyTimeout = jest.fn() joinPush.receive("error", spyError).receive("timeout", spyTimeout) helpers.receiveOk() jest.advanceTimersByTime(channel.timeout * 2) // attempt timeout expect(spyError).not.toHaveBeenCalled() expect(spyTimeout).not.toHaveBeenCalled() }) it("clears timeoutTimer", function (){ expect(joinPush.timeoutTimer).toBeTruthy() helpers.receiveOk() expect(joinPush.timeoutTimer).toBeNull() }) it("sets receivedResp", function (){ expect(joinPush.receivedResp).toBeNull() helpers.receiveOk() expect(joinPush.receivedResp).toEqual({status: "ok", response}) }) it("removes channel bindings", function (){ let bindings = helpers.getBindings("chan_reply_3") expect(bindings.length).toBe(1) helpers.receiveOk() bindings = helpers.getBindings("chan_reply_3") expect(bindings.length).toBe(0) }) it("resets channel rejoinTimer", function (){ expect(channel.rejoinTimer).toBeTruthy() const spy = jest.spyOn(channel.rejoinTimer, "reset") helpers.receiveOk() expect(spy).toHaveBeenCalledTimes(1) }) it("sends and empties channel's buffered pushEvents", function (done){ const pushEvent = {send(){}} const spy = jest.spyOn(pushEvent, "send") channel.pushBuffer.push(pushEvent) expect(channel.state).toBe("joining") joinPush.receive("ok", () => { expect(spy).toHaveBeenCalledTimes(1) expect(channel.pushBuffer.length).toBe(0) done() }) helpers.receiveOk() }) }) describe("receives 'timeout'", function (){ it("sets channel state to errored", function (done){ joinPush.receive("timeout", () => { expect(channel.state).toBe("errored") done() }) helpers.receiveTimeout() }) it("triggers receive('timeout') callback after ok response", function (){ const spyTimeout = jest.fn() joinPush.receive("timeout", spyTimeout) helpers.receiveTimeout() expect(spyTimeout).toHaveBeenCalledTimes(1) }) it("does not trigger other receive callbacks after timeout response", function (done){ const spyOk = jest.fn() const spyError = jest.fn() jest.spyOn(channel.rejoinTimer, "scheduleTimeout").mockReturnValue(true) channel.test = true joinPush.receive("ok", spyOk).receive("error", spyError).receive("timeout", () => { expect(spyOk).not.toHaveBeenCalled() expect(spyError).not.toHaveBeenCalled() done() }) helpers.receiveTimeout() helpers.receiveOk() }) it("schedules rejoinTimer timeout", function (){ expect(channel.rejoinTimer).toBeTruthy() const spy = jest.spyOn(channel.rejoinTimer, "scheduleTimeout") helpers.receiveTimeout() expect(spy).toHaveBeenCalled() // TODO why called multiple times? }) }) describe("receives 'error'", function (){ beforeEach(function (){ response = {chan: "fail"} }) it("triggers receive('error') callback after error response", function (){ const spyError = jest.fn() expect(channel.state).toBe("joining") joinPush.receive("error", spyError) helpers.receiveError() joinPush.trigger("error", {}) expect(spyError).toHaveBeenCalledTimes(1) }) it("triggers receive('error') callback if error response already received", function (){ const spyError = jest.fn() helpers.receiveError() joinPush.receive("error", spyError) expect(spyError).toHaveBeenCalledTimes(1) }) it("does not trigger other receive callbacks after error response", function (){ const spyOk = jest.fn() const spyError = jest.fn() const spyTimeout = jest.fn() joinPush.receive("ok", spyOk).receive("error", () => { spyError() channel.leave() }).receive("timeout", spyTimeout) helpers.receiveError() jest.advanceTimersByTime(channel.timeout * 2) // attempt timeout expect(spyError).toHaveBeenCalledTimes(1) expect(spyOk).not.toHaveBeenCalled() expect(spyTimeout).not.toHaveBeenCalled() }) it("clears timeoutTimer", function (){ expect(joinPush.timeoutTimer).toBeTruthy() helpers.receiveError() expect(joinPush.timeoutTimer).toBeNull() }) it("sets receivedResp with error trigger after binding", function (done){ expect(joinPush.receivedResp).toBeNull() joinPush.receive("error", resp => { expect(resp).toEqual(response) done() }) helpers.receiveError() }) it("sets receivedResp with error trigger before binding", function (done){ expect(joinPush.receivedResp).toBeNull() helpers.receiveError() joinPush.receive("error", resp => { expect(resp).toEqual(response) done() }) }) it("does not set channel state to joined", function (){ helpers.receiveError() expect(channel.state).toBe("errored") }) it("does not trigger channel's buffered pushEvents", function (){ const pushEvent = {send: () => {}} const spy = jest.spyOn(pushEvent, "send") channel.pushBuffer.push(pushEvent) helpers.receiveError() expect(spy).not.toHaveBeenCalled() expect(channel.pushBuffer.length).toBe(1) }) }) }) describe("onError", function (){ let joinPush beforeEach(function (){ jest.useFakeTimers() socket = new Socket("/socket", {timeout: defaultTimeout}) jest.spyOn(socket, "isConnected").mockReturnValue(true) jest.spyOn(socket, "push").mockReturnValue(true) channel = socket.channel("topic", {one: "two"}) joinPush = channel.joinPush channel.join() joinPush.trigger("ok", {}) }) afterEach(function (){ jest.useRealTimers() }) it("sets state to 'errored'", function (){ expect(channel.state).not.toBe("errored") channel.trigger("phx_error") expect(channel.state).toBe("errored") }) it("does not trigger redundant errors during backoff", function (){ const spy = jest.spyOn(joinPush, "send").mockImplementation(() => {}) expect(spy).toHaveBeenCalledTimes(0) channel.trigger("phx_error") jest.advanceTimersByTime(1000) expect(spy).toHaveBeenCalledTimes(1) joinPush.trigger("error", {}) jest.advanceTimersByTime(10000) expect(spy).toHaveBeenCalledTimes(1) }) it("does not rejoin if channel leaving", function (){ channel.state = "leaving" const spy = jest.spyOn(joinPush, "send") socket.onConnError({}) jest.advanceTimersByTime(1000) expect(spy).toHaveBeenCalledTimes(0) jest.advanceTimersByTime(2000) expect(spy).toHaveBeenCalledTimes(0) expect(channel.state).toBe("leaving") }) it("does not rejoin if channel closed", function (){ channel.state = "closed" const spy = jest.spyOn(joinPush, "send") socket.onConnError({}) jest.advanceTimersByTime(1000) expect(spy).toHaveBeenCalledTimes(0) jest.advanceTimersByTime(2000) expect(spy).toHaveBeenCalledTimes(0) expect(channel.state).toBe("closed") }) it("triggers additional callbacks after join", function (){ const spy = jest.fn() channel.onError(spy) joinPush.trigger("ok", {}) expect(channel.state).toBe("joined") expect(spy).toHaveBeenCalledTimes(0) channel.trigger("phx_error") expect(spy).toHaveBeenCalledTimes(1) }) }) describe("onClose", function (){ let joinPush beforeEach(function (){ jest.useFakeTimers() socket = new Socket("/socket", {timeout: defaultTimeout}) jest.spyOn(socket, "isConnected").mockReturnValue(true) jest.spyOn(socket, "push").mockReturnValue(true) channel = socket.channel("topic", {one: "two"}) joinPush = channel.joinPush channel.join() }) afterEach(function (){ jest.useRealTimers() }) it("sets state to 'closed'", function (){ expect(channel.state).not.toBe("closed") channel.trigger("phx_close") expect(channel.state).toBe("closed") }) it("does not rejoin", function (){ const spy = jest.spyOn(joinPush, "send") channel.trigger("phx_close") jest.advanceTimersByTime(1000) expect(spy).toHaveBeenCalledTimes(0) jest.advanceTimersByTime(2000) expect(spy).toHaveBeenCalledTimes(0) }) it("triggers additional callbacks", function (){ const spy = jest.fn() channel.onClose(spy) expect(spy).toHaveBeenCalledTimes(0) channel.trigger("phx_close") expect(spy).toHaveBeenCalledTimes(1) }) it("removes channel from socket", function (){ expect(socket.channels.length).toBe(1) expect(socket.channels[0]).toBe(channel) channel.trigger("phx_close") expect(socket.channels.length).toBe(0) }) }) describe("onMessage", function (){ it("returns payload by default", function (){ socket = new Socket("/socket") channel = socket.channel("topic", {one: "two"}) jest.spyOn(socket, "makeRef").mockReturnValue(defaultRef) const payload = channel.onMessage("event", {one: "two"}, defaultRef) expect(payload).toEqual({one: "two"}) }) }) describe("canPush", function (){ beforeEach(function (){ socket = new Socket("/socket") channel = socket.channel("topic", {one: "two"}) }) it("returns true when socket connected and channel joined", function (){ jest.spyOn(socket, "isConnected").mockReturnValue(true) channel.state = "joined" expect(channel.canPush()).toBe(true) }) it("otherwise returns false", function (){ const isConnectedStub = jest.spyOn(socket, "isConnected") isConnectedStub.mockReturnValue(false) channel.state = "joined" expect(channel.canPush()).toBe(false) isConnectedStub.mockReturnValue(true) channel.state = "joining" expect(channel.canPush()).toBe(false) isConnectedStub.mockReturnValue(false) channel.state = "joining" expect(channel.canPush()).toBe(false) }) }) describe("on", function (){ beforeEach(function (){ socket = new Socket("/socket") jest.spyOn(socket, "makeRef").mockReturnValue(defaultRef) channel = socket.channel("topic", {one: "two"}) }) it("sets up callback for event", function (){ const spy = jest.fn() channel.trigger("event", {}, defaultRef) expect(spy).not.toHaveBeenCalled() channel.on("event", spy) channel.trigger("event", {}, defaultRef) expect(spy).toHaveBeenCalled() }) it("other event callbacks are ignored", function (){ const spy = jest.fn() const ignoredSpy = jest.fn() channel.trigger("event", {}, defaultRef) expect(ignoredSpy).not.toHaveBeenCalled() channel.on("event", spy) channel.trigger("event", {}, defaultRef) expect(ignoredSpy).not.toHaveBeenCalled() }) it("generates unique refs for callbacks", function (){ const ref1 = channel.on("event1", () => 0) const ref2 = channel.on("event2", () => 0) expect(ref1 + 1).toBe(ref2) }) it("calls all callbacks for event if they modified during event processing", function (){ const spy = jest.fn() const ref = channel.on("event", () => { channel.off("event", ref) }) channel.on("event", spy) channel.trigger("event", {}, defaultRef) expect(spy).toHaveBeenCalled() }) }) describe("off", function (){ beforeEach(function (){ socket = new Socket("/socket") jest.spyOn(socket, "makeRef").mockReturnValue(defaultRef) channel = socket.channel("topic", {one: "two"}) }) it("removes all callbacks for event", function (){ const spy1 = jest.fn() const spy2 = jest.fn() const spy3 = jest.fn() channel.on("event", spy1) channel.on("event", spy2) channel.on("other", spy3) channel.off("event") channel.trigger("event", {}, defaultRef) channel.trigger("other", {}, defaultRef) expect(spy1).not.toHaveBeenCalled() expect(spy2).not.toHaveBeenCalled() expect(spy3).toHaveBeenCalled() }) it("removes callback by its ref", function (){ const spy1 = jest.fn() const spy2 = jest.fn() const ref1 = channel.on("event", spy1) const _ref2 = channel.on("event", spy2) channel.off("event", ref1) channel.trigger("event", {}, defaultRef) expect(spy1).not.toHaveBeenCalled() expect(spy2).toHaveBeenCalled() }) }) describe("push", function (){ let joinPush let socketSpy const pushParams = (channel) => { return { topic: "topic", event: "event", payload: {foo: "bar"}, join_ref: channel.joinRef(), ref: defaultRef, } } beforeEach(function (){ jest.useFakeTimers() socket = new Socket("/socket", {timeout: defaultTimeout}) jest.spyOn(socket, "makeRef").mockReturnValue(defaultRef) jest.spyOn(socket, "isConnected").mockReturnValue(true) socketSpy = jest.spyOn(socket, "push").mockReturnValue(undefined) channel = socket.channel("topic", {one: "two"}) }) afterEach(function (){ jest.useRealTimers() }) it("sends push event when successfully joined", function (){ channel.join().trigger("ok", {}) channel.push("event", {foo: "bar"}) expect(socketSpy).toHaveBeenCalledWith(pushParams(channel)) }) it("enqueues push event to be sent once join has succeeded", function (){ joinPush = channel.join() channel.push("event", {foo: "bar"}) expect(socketSpy).not.toHaveBeenCalledWith(pushParams(channel)) jest.advanceTimersByTime(channel.timeout / 2) joinPush.trigger("ok", {}) expect(socketSpy).toHaveBeenCalledWith(pushParams(channel)) }) it("does not push if channel join times out", function (){ joinPush = channel.join() channel.push("event", {foo: "bar"}) expect(socketSpy).not.toHaveBeenCalledWith(pushParams(channel)) jest.advanceTimersByTime(channel.timeout * 2) joinPush.trigger("ok", {}) expect(socketSpy).not.toHaveBeenCalledWith(pushParams(channel)) }) it("uses channel timeout by default", function (){ const timeoutSpy = jest.fn() channel.join().trigger("ok", {}) channel.push("event", {foo: "bar"}).receive("timeout", timeoutSpy) jest.advanceTimersByTime(channel.timeout / 2) expect(timeoutSpy).not.toHaveBeenCalled() jest.advanceTimersByTime(channel.timeout) expect(timeoutSpy).toHaveBeenCalled() }) it("accepts timeout arg", function (){ const timeoutSpy = jest.fn() channel.join().trigger("ok", {}) channel.push("event", {foo: "bar"}, channel.timeout * 2).receive("timeout", timeoutSpy) jest.advanceTimersByTime(channel.timeout) expect(timeoutSpy).not.toHaveBeenCalled() jest.advanceTimersByTime(channel.timeout * 2) expect(timeoutSpy).toHaveBeenCalled() }) it("does not time out after receiving 'ok'", function (){ channel.join().trigger("ok", {}) const timeoutSpy = jest.fn() const push = channel.push("event", {foo: "bar"}) push.receive("timeout", timeoutSpy) jest.advanceTimersByTime(push.timeout / 2) expect(timeoutSpy).not.toHaveBeenCalled() push.trigger("ok", {}) jest.advanceTimersByTime(push.timeout) expect(timeoutSpy).not.toHaveBeenCalled() }) it("throws if channel has not been joined", function (){ expect(() => channel.push("event", {})).toThrow(/^tried to push.*before joining/) }) }) describe("leave", function (){ let socketSpy beforeEach(function (){ jest.useFakeTimers() socket = new Socket("/socket", {timeout: defaultTimeout}) jest.spyOn(socket, "isConnected").mockReturnValue(true) socketSpy = jest.spyOn(socket, "push").mockReturnValue(undefined) channel = socket.channel("topic", {one: "two"}) channel.join().trigger("ok", {}) }) afterEach(function (){ jest.useRealTimers() }) it("unsubscribes from server events", function (){ jest.spyOn(socket, "makeRef").mockReturnValue(defaultRef) const joinRef = channel.joinRef() channel.leave() expect(socketSpy).toHaveBeenCalledWith({ topic: "topic", event: "phx_leave", payload: {}, ref: defaultRef, join_ref: joinRef, }) }) it("closes channel on 'ok' from server", function (){ const anotherChannel = socket.channel("another", {three: "four"}) expect(socket.channels.length).toBe(2) channel.leave().trigger("ok", {}) expect(socket.channels.length).toBe(1) expect(socket.channels[0]).toBe(anotherChannel) }) it("sets state to closed on 'ok' event", function (){ expect(channel.state).not.toBe("closed") channel.leave().trigger("ok", {}) expect(channel.state).toBe("closed") }) // TODO - the following tests are skipped until Channel.leave // behavior can be fixed; currently, 'ok' is triggered immediately // within Channel.leave so timeout callbacks are never reached // it.skip("sets state to leaving initially", function (){ expect(channel.state).not.toBe("leaving") channel.leave() expect(channel.state).toBe("leaving") }) it.skip("closes channel on 'timeout'", function (){ channel.leave() jest.advanceTimersByTime(channel.timeout) expect(channel.state).toBe("closed") }) it.skip("accepts timeout arg", function (){ channel.leave(channel.timeout * 2) jest.advanceTimersByTime(channel.timeout) expect(channel.state).toBe("leaving") jest.advanceTimersByTime(channel.timeout * 2) expect(channel.state).toBe("closed") }) }) }) ================================================ FILE: assets/test/longpoll_test.js ================================================ import {jest} from "@jest/globals" import {LongPoll} from "../js/phoenix" import {Socket} from "../js/phoenix" import {AUTH_TOKEN_PREFIX} from "../js/phoenix/constants" import Ajax from "../js/phoenix/ajax" describe("LongPoll", () => { let originalXHR beforeEach(() => { originalXHR = global.XMLHttpRequest // Mock XMLHttpRequest const mockOpen = jest.fn() const mockSend = jest.fn() const mockAbort = jest.fn() const mockSetRequestHeader = jest.fn() global.XMLHttpRequest = jest.fn(() => ({ open: mockOpen, send: mockSend, abort: mockAbort, setRequestHeader: mockSetRequestHeader, readyState: 4, status: 200, responseText: JSON.stringify({status: 200, token: "token123", messages: []}), onreadystatechange: null, })) // Spy on Ajax.request jest.spyOn(Ajax, "request").mockImplementation(() => { return {abort: jest.fn()} }) }) afterEach(() => { global.XMLHttpRequest = originalXHR jest.restoreAllMocks() }) describe("constructor", () => { it("should handle undefined protocols", () => { const longpoll = new LongPoll("http://localhost/socket/longpoll", undefined) // Verify longpoll was initialized correctly without error expect(longpoll.pollEndpoint).toBe("http://localhost/socket/longpoll") expect(longpoll.authToken).toBeUndefined() expect(longpoll.readyState).toBe(0) // connecting }) it("should handle null protocols", () => { const longpoll = new LongPoll("http://localhost/socket/longpoll", null) // Verify longpoll was initialized correctly without error expect(longpoll.pollEndpoint).toBe("http://localhost/socket/longpoll") expect(longpoll.authToken).toBeUndefined() expect(longpoll.readyState).toBe(0) // connecting }) it("should handle empty array protocols", () => { const longpoll = new LongPoll("http://localhost/socket/longpoll", []) // Verify longpoll was initialized correctly without error expect(longpoll.pollEndpoint).toBe("http://localhost/socket/longpoll") expect(longpoll.authToken).toBeUndefined() expect(longpoll.readyState).toBe(0) // connecting }) it("should extract authToken when valid protocols are provided", () => { const authToken = "my-auth-token" const encodedToken = btoa(authToken) const protocols = ["phoenix", `${AUTH_TOKEN_PREFIX}${encodedToken}`] const longpoll = new LongPoll("http://localhost/socket/longpoll", protocols) // Verify auth token was extracted correctly expect(longpoll.authToken).toBe(authToken) }) }) describe("poll", () => { it("should include auth token in headers when present", () => { const authToken = "my-auth-token" const encodedToken = btoa(authToken) const protocols = ["phoenix", `${AUTH_TOKEN_PREFIX}${encodedToken}`] const longpoll = new LongPoll("http://localhost/socket/longpoll", protocols) longpoll.timeout = 1000 longpoll.poll() // Verify Ajax.request was called with the correct headers expect(Ajax.request).toHaveBeenCalledWith( "GET", expect.any(String), {"Accept": "application/json", "X-Phoenix-AuthToken": authToken}, null, expect.any(Number), expect.any(Function), expect.any(Function) ) }) it("should not include auth token in headers when not present", () => { const longpoll = new LongPoll("http://localhost/socket/longpoll", undefined) longpoll.timeout = 1000 longpoll.poll() // Verify Ajax.request was called without auth token header expect(Ajax.request).toHaveBeenCalledWith( "GET", expect.any(String), {"Accept": "application/json"}, null, expect.any(Number), expect.any(Function), expect.any(Function) ) }) it("should treat 410 as error when token already exists", () => { const longpoll = new LongPoll("http://localhost/socket/longpoll", undefined) longpoll.timeout = 1000 longpoll.token = "existing-token" const mockOnerror = jest.fn() const mockCloseAndRetry = jest.fn() longpoll.onerror = mockOnerror longpoll.closeAndRetry = mockCloseAndRetry Ajax.request.mockImplementation((method, url, headers, body, timeout, ontimeout, callback) => { callback({status: 410, token: "new-token", messages: []}) return {abort: jest.fn()} }) longpoll.poll() expect(mockOnerror).toHaveBeenCalledWith(410) expect(mockCloseAndRetry).toHaveBeenCalledWith(3410, "session_gone", false) }) }) describe("batchSend", () => { it("should send with correct content-type header format", () => { const longpoll = new LongPoll("http://localhost/socket/longpoll", undefined) longpoll.timeout = 1000 const messages = ["message1", "message2"] longpoll.batchSend(messages) // Verify Ajax.request was called with correct headers format expect(Ajax.request).toHaveBeenCalledWith( "POST", expect.any(String), {"Content-Type": "application/x-ndjson"}, "message1\nmessage2", expect.any(Number), expect.any(Function), expect.any(Function) ) }) }) }) describe("Socket with LongPoll", () => { describe("transportConnect", () => { it("should initialize with undefined protocols when no auth token", () => { const socket = new Socket("/socket", {transport: LongPoll}) // Mock the transport to capture the protocols argument socket.transport = jest.fn(() => ({ onopen: jest.fn(), onerror: jest.fn(), onmessage: jest.fn(), onclose: jest.fn() })) socket.transportConnect() // Verify that the transport was called with undefined protocols expect(socket.transport).toHaveBeenCalledWith( expect.any(String), undefined ) }) it("should only set protocols array when auth token is present", () => { const authToken = "my-auth-token" const socket = new Socket("/socket", { transport: LongPoll, params: {token: authToken} }) // Set auth token socket.authToken = authToken // Mock the transport to capture the protocols argument socket.transport = jest.fn(() => ({ onopen: jest.fn(), onerror: jest.fn(), onmessage: jest.fn(), onclose: jest.fn() })) socket.transportConnect() // Verify that the transport was called with correct protocols array expect(socket.transport).toHaveBeenCalledWith( expect.any(String), ["phoenix", `${AUTH_TOKEN_PREFIX}${btoa(authToken).replace(/=/g, "")}`] ) }) }) }) describe("Ajax.request", () => { let originalXMLHttpRequest, originalFetch, originalAbortController beforeEach(() => { originalXMLHttpRequest = global.XMLHttpRequest originalFetch = global.fetch originalAbortController = global.AbortController // Mock AbortController global.AbortController = jest.fn(() => ({ abort: jest.fn(), signal: {} })) // Mock XMLHttpRequest global.XMLHttpRequest = jest.fn(() => ({ open: jest.fn(), send: jest.fn(), setRequestHeader: jest.fn(), onreadystatechange: null, readyState: 4, status: 200, responseText: JSON.stringify({success: true}) })) // Mock fetch global.fetch = jest.fn(() => Promise.resolve({ text: () => Promise.resolve(JSON.stringify({success: true})) }) ) }) afterEach(() => { global.XMLHttpRequest = originalXMLHttpRequest global.fetch = originalFetch global.AbortController = originalAbortController jest.restoreAllMocks() }) it("should use XMLHttpRequest by default", () => { Ajax.request("GET", "/test-endpoint", {}, null, 0, null, (response) => { expect(response).toEqual({success: true}) }) expect(global.XMLHttpRequest).toHaveBeenCalled() }) it("should use fetch when XMLHttpRequest is not available", () => { global.XMLHttpRequest = undefined // Simulate it being unavailable Ajax.request("GET", "/test-endpoint", {}, null, 0, null, (response) => { expect(response).toEqual({success: true}) }) expect(global.fetch).toHaveBeenCalledWith( "/test-endpoint", expect.objectContaining({ method: "GET", }) ) }) }) ================================================ FILE: assets/test/presence_test.js ================================================ import {Presence} from "../js/phoenix" const clone = (obj) => { let cloned = JSON.parse(JSON.stringify(obj)) Object.entries(obj).forEach(([key, val]) => { if(val === undefined){ cloned[key] = undefined } }) return cloned } const fixtures = { joins(){ return {u1: {metas: [{id: 1, phx_ref: "1.2"}]}} }, leaves(){ return {u2: {metas: [{id: 2, phx_ref: "2"}]}} }, state(){ return { u1: {metas: [{id: 1, phx_ref: "1"}]}, u2: {metas: [{id: 2, phx_ref: "2"}]}, u3: {metas: [{id: 3, phx_ref: "3"}]}, } }, } const channelStub = { ref: 1, events: {}, on(event, callback){ this.events[event] = callback }, trigger(event, data){ this.events[event](data) }, joinRef(){ return `${this.ref}` }, simulateDisconnectAndReconnect(){ this.ref++ }, } const listByFirst = (id, {metas: [first, ..._rest]}) => first describe("syncState", () => { it("syncs empty state", () => { let newState = {u1: {metas: [{id: 1, phx_ref: "1"}]}} let state = {} let stateBefore = clone(state) Presence.syncState(state, newState) expect(state).toEqual(stateBefore) state = Presence.syncState(state, newState) expect(state).toEqual(newState) }) it("onJoins new presences and onLeave's left presences", () => { let newState = fixtures.state() let state = {u4: {metas: [{id: 4, phx_ref: "4"}]}} let joined = {} let left = {} const onJoin = (key, current, newPres) => { joined[key] = {current, newPres} } const onLeave = (key, current, leftPres) => { left[key] = {current, leftPres} } state = Presence.syncState(state, newState, onJoin, onLeave) expect(state).toEqual(newState) expect(joined).toEqual({ u1: {current: undefined, newPres: {metas: [{id: 1, phx_ref: "1"}]}}, u2: {current: undefined, newPres: {metas: [{id: 2, phx_ref: "2"}]}}, u3: {current: undefined, newPres: {metas: [{id: 3, phx_ref: "3"}]}}, }) expect(left).toEqual({ u4: {current: {metas: []}, leftPres: {metas: [{id: 4, phx_ref: "4"}]}}, }) }) it("onJoins only newly added metas", () => { let newState = {u3: {metas: [{id: 3, phx_ref: "3"}, {id: 3, phx_ref: "3.new"}]}} let state = {u3: {metas: [{id: 3, phx_ref: "3"}]}} let joined = [] let left = [] const onJoin = (key, current, newPres) => { joined.push([key, clone({current, newPres})]) } const onLeave = (key, current, leftPres) => { left.push([key, clone({current, leftPres})]) } state = Presence.syncState(state, clone(newState), onJoin, onLeave) expect(state).toEqual(newState) expect(joined).toEqual([ ["u3", {current: {metas: [{id: 3, phx_ref: "3"}]}, newPres: {metas: [{id: 3, phx_ref: "3.new"}]}}], ]) expect(left).toEqual([]) }) }) describe("syncDiff", () => { it("syncs empty state", () => { let joins = {u1: {metas: [{id: 1, phx_ref: "1"}]}} let state = Presence.syncDiff({}, {joins, leaves: {}}) expect(state).toEqual(joins) }) it("removes presence when meta is empty and adds additional meta", () => { let state = fixtures.state() state = Presence.syncDiff(state, {joins: fixtures.joins(), leaves: fixtures.leaves()}) expect(state).toEqual({ u1: {metas: [{id: 1, phx_ref: "1"}, {id: 1, phx_ref: "1.2"}]}, u3: {metas: [{id: 3, phx_ref: "3"}]}, }) }) it("removes meta while leaving key if other metas exist", () => { let state = {u1: {metas: [{id: 1, phx_ref: "1"}, {id: 1, phx_ref: "1.2"}]}} state = Presence.syncDiff(state, {joins: {}, leaves: {u1: {metas: [{id: 1, phx_ref: "1"}]}}}) expect(state).toEqual({ u1: {metas: [{id: 1, phx_ref: "1.2"}]}, }) }) }) describe("list", () => { it("lists full presence by default", () => { let state = fixtures.state() expect(Presence.list(state)).toEqual([ {metas: [{id: 1, phx_ref: "1"}]}, {metas: [{id: 2, phx_ref: "2"}]}, {metas: [{id: 3, phx_ref: "3"}]}, ]) }) it("lists with custom function", () => { let state = {u1: {metas: [{id: 1, phx_ref: "1.first"}, {id: 1, phx_ref: "1.second"}]}} const listBy = (key, {metas: [first, ..._rest]}) => first expect(Presence.list(state, listBy)).toEqual([{id: 1, phx_ref: "1.first"}]) }) }) describe("instance", () => { it("syncs state and diffs", () => { let presence = new Presence(channelStub) let user1 = {metas: [{id: 1, phx_ref: "1"}]} let user2 = {metas: [{id: 2, phx_ref: "2"}]} let newState = {u1: user1, u2: user2} channelStub.trigger("presence_state", newState) expect(presence.list(listByFirst)).toEqual([{id: 1, phx_ref: "1"}, {id: 2, phx_ref: "2"}]) channelStub.trigger("presence_diff", {joins: {}, leaves: {u1: user1}}) expect(presence.list(listByFirst)).toEqual([{id: 2, phx_ref: "2"}]) }) it("applies pending diff if state is not yet synced", () => { let presence = new Presence(channelStub) let onJoins = [] let onLeaves = [] presence.onJoin((id, current, newPres) => { onJoins.push(clone({id, current, newPres})) }) presence.onLeave((id, current, leftPres) => { onLeaves.push(clone({id, current, leftPres})) }) let user1 = {metas: [{id: 1, phx_ref: "1"}]} let user2 = {metas: [{id: 2, phx_ref: "2"}]} let user3 = {metas: [{id: 3, phx_ref: "3"}]} let newState = {u1: user1, u2: user2} let leaves = {u2: user2} channelStub.trigger("presence_diff", {joins: {}, leaves: leaves}) expect(presence.list(listByFirst)).toEqual([]) expect(presence.pendingDiffs).toEqual([{joins: {}, leaves: leaves}]) channelStub.trigger("presence_state", newState) expect(onLeaves).toEqual([{id: "u2", current: {metas: []}, leftPres: {metas: [{id: 2, phx_ref: "2"}]}}]) expect(presence.list(listByFirst)).toEqual([{id: 1, phx_ref: "1"}]) expect(presence.pendingDiffs).toEqual([]) expect(onJoins).toEqual([ {id: "u1", current: undefined, newPres: {metas: [{id: 1, phx_ref: "1"}]}}, {id: "u2", current: undefined, newPres: {metas: [{id: 2, phx_ref: "2"}]}}, ]) channelStub.simulateDisconnectAndReconnect() expect(presence.inPendingSyncState()).toBe(true) channelStub.trigger("presence_diff", {joins: {}, leaves: {u1: user1}}) expect(presence.list(listByFirst)).toEqual([{id: 1, phx_ref: "1"}]) channelStub.trigger("presence_state", {u1: user1, u3: user3}) expect(presence.list(listByFirst)).toEqual([{id: 3, phx_ref: "3"}]) }) it("allows custom channel events", () => { let presence = new Presence(channelStub, { events: { state: "the_state", diff: "the_diff", }, }) let user1 = {metas: [{id: 1, phx_ref: "1"}]} channelStub.trigger("the_state", {user1}) expect(presence.list(listByFirst)).toEqual([{id: 1, phx_ref: "1"}]) channelStub.trigger("the_diff", {joins: {}, leaves: {user1}}) expect(presence.list(listByFirst)).toEqual([]) }) it("updates existing meta for a presence update (leave + join)", () => { let presence = new Presence(channelStub) let onJoins = [] let onLeaves = [] let user1 = {metas: [{id: 1, phx_ref: "1"}]} let user2 = {metas: [{id: 2, name: "chris", phx_ref: "2"}]} let newState = {u1: user1, u2: user2} channelStub.trigger("presence_state", clone(newState)) presence.onJoin((id, current, newPres) => { onJoins.push(clone({id, current, newPres})) }) presence.onLeave((id, current, leftPres) => { onLeaves.push(clone({id, current, leftPres})) }) expect(presence.list((id, {metas: metas}) => metas)).toEqual([ [{id: 1, phx_ref: "1"}], [{id: 2, name: "chris", phx_ref: "2"}], ]) let leaves = {u2: user2} let joins = {u2: {metas: [{id: 2, name: "chris.2", phx_ref: "2.2", phx_ref_prev: "2"}]}} channelStub.trigger("presence_diff", {joins, leaves}) expect(presence.list((id, {metas: metas}) => metas)).toEqual([ [{id: 1, phx_ref: "1"}], [{id: 2, name: "chris.2", phx_ref: "2.2", phx_ref_prev: "2"}], ]) expect(onJoins).toEqual([ { id: "u2", current: {metas: [{id: 2, name: "chris", phx_ref: "2"}]}, newPres: {metas: [{id: 2, name: "chris.2", phx_ref: "2.2", phx_ref_prev: "2"}]}, }, ]) }) }) ================================================ FILE: assets/test/serializer.js ================================================ export const encode = (msg) => { let payload = [ msg.join_ref, msg.ref, msg.topic, msg.event, msg.payload ] return JSON.stringify(payload) } export const decode = (rawPayload) => { let [join_ref, ref, topic, event, payload] = JSON.parse(rawPayload) return {join_ref, ref, topic, event, payload} } ================================================ FILE: assets/test/serializer_test.js ================================================ /** * @jest-environment node */ import {TextEncoder, TextDecoder} from "util" import {Serializer} from "../js/phoenix" let exampleMsg = {join_ref: "0", ref: "1", topic: "t", event: "e", payload: {foo: 1}} let binPayload = () => { let buffer = new ArrayBuffer(1) new DataView(buffer).setUint8(0, 1) return buffer } describe("JSON", () => { it("encodes general pushes", (done) => { Serializer.encode(exampleMsg, (result) => { expect(result).toBe("[\"0\",\"1\",\"t\",\"e\",{\"foo\":1}]") done() }) }) it("decodes", (done) => { Serializer.decode("[\"0\",\"1\",\"t\",\"e\",{\"foo\":1}]", (result) => { expect(result).toEqual(exampleMsg) done() }) }) }) describe("binary", () => { it("encodes", (done) => { let buffer = binPayload() let bin = "\0\x01\x01\x01\x0101te\x01" let decoder = new TextDecoder() Serializer.encode({join_ref: "0", ref: "1", topic: "t", event: "e", payload: buffer}, (result) => { expect(decoder.decode(result)).toBe(bin) done() }) }) it("encodes variable length segments", (done) => { let buffer = binPayload() let bin = "\0\x02\x01\x03\x02101topev\x01" let decoder = new TextDecoder() Serializer.encode({join_ref: "10", ref: "1", topic: "top", event: "ev", payload: buffer}, (result) => { expect(decoder.decode(result)).toBe(bin) done() }) }) it("decodes push", (done) => { let bin = "\0\x03\x03\n123topsome-event\x01\x01" let buffer = new TextEncoder().encode(bin).buffer let decoder = new TextDecoder() Serializer.decode(buffer, (result) => { expect(result.join_ref).toBe("123") expect(result.ref).toBeNull() expect(result.topic).toBe("top") expect(result.event).toBe("some-event") expect(result.payload.constructor).toBe(ArrayBuffer) expect(decoder.decode(result.payload)).toBe("\x01\x01") done() }) }) it("decodes reply", (done) => { let bin = "\x01\x03\x02\x03\x0210012topok\x01\x01" let buffer = new TextEncoder().encode(bin).buffer let decoder = new TextDecoder() Serializer.decode(buffer, (result) => { expect(result.join_ref).toBe("100") expect(result.ref).toBe("12") expect(result.topic).toBe("top") expect(result.event).toBe("phx_reply") expect(result.payload.status).toBe("ok") expect(result.payload.response.constructor).toBe(ArrayBuffer) expect(decoder.decode(result.payload.response)).toBe("\x01\x01") done() }) }) it("decodes broadcast", (done) => { let bin = "\x02\x03\ntopsome-event\x01\x01" let buffer = new TextEncoder().encode(bin).buffer let decoder = new TextDecoder() Serializer.decode(buffer, (result) => { expect(result.join_ref).toBeNull() expect(result.ref).toBeNull() expect(result.topic).toBe("top") expect(result.event).toBe("some-event") expect(result.payload.constructor).toBe(ArrayBuffer) expect(decoder.decode(result.payload)).toBe("\x01\x01") done() }) }) }) ================================================ FILE: assets/test/socket_http_test.js ================================================ /** * @jest-environment jsdom * @jest-environment-options {"url": "http://example.com/"} */ import {Socket} from "../js/phoenix" // sadly, jsdom can only be configured globally for a file describe("protocol", function (){ it("returns ws when location.protocol is http", function (){ const socket = new Socket("/socket") expect(socket.protocol()).toBe("ws") }) }) describe("endpointURL", function (){ it("returns endpoint for given path on http host", function (){ const socket = new Socket("/socket") expect(socket.endPointURL()).toBe( "ws://example.com/socket/websocket?vsn=2.0.0", ) }) }) ================================================ FILE: assets/test/socket_test.js ================================================ import {jest} from "@jest/globals" import {WebSocket, Server as WebSocketServer} from "mock-socket" import {encode} from "./serializer" import {Socket, LongPoll} from "../js/phoenix" import {SOCKET_STATES} from "../js/phoenix/constants" let socket describe("with transports", function (){ beforeAll(() => { window.WebSocket = WebSocket const mockOpen = jest.fn() const mockSend = jest.fn() const mockAbort = jest.fn() const mockSetRequestHeader = jest.fn() global.XMLHttpRequest = jest.fn(() => ({ open: mockOpen, send: mockSend, abort: mockAbort, setRequestHeader: mockSetRequestHeader, readyState: 4, status: 200, responseText: JSON.stringify({}), onreadystatechange: null, })) }) describe("constructor", function (){ it("sets defaults", function (){ socket = new Socket("/socket") expect(socket.channels.length).toBe(0) expect(socket.sendBuffer.length).toBe(0) expect(socket.ref).toBe(0) expect(socket.endPoint).toBe("/socket/websocket") expect(socket.stateChangeCallbacks).toEqual({open: [], close: [], error: [], message: []}) expect(socket.transport).toBe(WebSocket) expect(socket.timeout).toBe(10000) expect(socket.longpollerTimeout).toBe(20000) expect(socket.heartbeatIntervalMs).toBe(30000) expect(socket.logger).toBeNull() expect(socket.binaryType).toBe("arraybuffer") expect(typeof socket.reconnectAfterMs).toBe("function") }) it("supports closure or literal params", function (){ socket = new Socket("/socket", {params: {one: "two"}}) expect(socket.params()).toEqual({one: "two"}) socket = new Socket("/socket", {params: function (){ return ({three: "four"}) }}) expect(socket.params()).toEqual({three: "four"}) }) it("overrides some defaults with options", function (){ const customTransport = function transport(){ } const customLogger = function logger(){ } const customReconnect = function reconnect(){ } socket = new Socket("/socket", { timeout: 40000, longpollerTimeout: 50000, heartbeatIntervalMs: 60000, transport: customTransport, logger: customLogger, reconnectAfterMs: customReconnect, params: {one: "two"}, }) expect(socket.timeout).toBe(40000) expect(socket.longpollerTimeout).toBe(50000) expect(socket.heartbeatIntervalMs).toBe(60000) expect(socket.transport).toBe(customTransport) expect(socket.logger).toBe(customLogger) expect(socket.params()).toEqual({one: "two"}) }) describe("with Websocket", function (){ it("defaults to Websocket transport if available", function (done){ let mockServer = new WebSocketServer("wss://example.com/") socket = new Socket("/socket") expect(socket.transport).toBe(WebSocket) mockServer.stop(() => done()) }) }) describe("longPollFallbackMs", function (){ it("falls back to longpoll when set after primary transport failure", function (done){ let mockServer socket = new Socket("/socket", {longPollFallbackMs: 20}) const replaceSpy = jest.spyOn(socket, "replaceTransport") mockServer = new WebSocketServer("wss://example.test/") mockServer.stop(() => { expect(socket.transport).toBe(WebSocket) socket.onError((_reason) => { setTimeout(() => { expect(replaceSpy).toHaveBeenCalledWith(LongPoll) done() }, 100) }) socket.connect() }) }) }) }) describe("visibilitychange", function (){ it("does not connect a socket that was never connected", function (){ socket = new Socket("/socket") const teardownSpy = jest.spyOn(socket, "teardown") Object.defineProperty(document, "visibilityState", {value: "hidden", writable: true}) window.dispatchEvent(new Event("visibilitychange")) Object.defineProperty(document, "visibilityState", {value: "visible", writable: true}) window.dispatchEvent(new Event("visibilitychange")) expect(teardownSpy).not.toHaveBeenCalled() }) it("reconnects on visibility change after unclean close", function (){ socket = new Socket("/socket") socket.closeWasClean = false const teardownSpy = jest.spyOn(socket, "teardown") Object.defineProperty(document, "visibilityState", {value: "visible", writable: true}) window.dispatchEvent(new Event("visibilitychange")) expect(teardownSpy).toHaveBeenCalledTimes(1) }) it("does not reconnect on visibility change after clean close", function (){ socket = new Socket("/socket") socket.closeWasClean = true const teardownSpy = jest.spyOn(socket, "teardown") Object.defineProperty(document, "visibilityState", {value: "visible", writable: true}) window.dispatchEvent(new Event("visibilitychange")) expect(teardownSpy).not.toHaveBeenCalled() }) }) describe("protocol", function (){ beforeEach(function (){ socket = new Socket("/socket") }) it("returns wss when location.protocol is https", function (){ expect(socket.protocol()).toBe("wss") }) }) describe("endpointURL", function (){ it("returns endpoint for given full url", function (){ socket = new Socket("wss://example.org/chat") expect(socket.endPointURL()).toBe("wss://example.org/chat/websocket?vsn=2.0.0") }) it("returns endpoint for given protocol-relative url", function (){ socket = new Socket("//example.org/chat") expect(socket.endPointURL()).toBe("wss://example.org/chat/websocket?vsn=2.0.0") }) it("returns endpoint for given path on https host", function (){ socket = new Socket("/socket") expect(socket.endPointURL()).toBe("wss://example.com/socket/websocket?vsn=2.0.0") }) }) describe("connect with WebSocket", function (){ let mockServer beforeAll(function (){ mockServer = new WebSocketServer("wss://example.com/") }) afterAll(function (done){ mockServer.stop(() => done()) }) beforeEach(function (){ socket = new Socket("/socket") }) it("establishes websocket connection with endpoint", function (){ socket.connect() const conn = socket.conn expect(conn instanceof WebSocket).toBeTruthy() expect(conn.url).toBe(socket.endPointURL()) }) it("sets callbacks for connection", function (){ let opens = 0 socket.onOpen(() => ++opens) let closes = 0 socket.onClose(() => ++closes) let lastError socket.onError((error) => lastError = error) let lastMessage socket.onMessage((message) => lastMessage = message.payload) socket.connect() socket.conn.onopen() expect(opens).toBe(1) socket.conn.onclose() expect(closes).toBe(1) socket.conn.onerror("error") expect(lastError).toBe("error") const data = {"topic": "topic", "event": "event", "payload": "payload", "status": "ok"} socket.conn.onmessage({data: encode(data)}) expect(lastMessage).toBe("payload") }) it("is idempotent", function (){ socket.connect() const conn = socket.conn socket.connect() expect(conn).toBe(socket.conn) }) }) describe("connect with long poll", function (){ beforeEach(function (){ socket = new Socket("/socket", {transport: LongPoll}) }) it("establishes long poll connection with endpoint", function (){ socket.connect() const conn = socket.conn expect(conn instanceof LongPoll).toBeTruthy() expect(conn.pollEndpoint).toBe("https://example.com/socket/longpoll?vsn=2.0.0") expect(conn.timeout).toBe(20000) }) it("sets callbacks for connection", function (){ let opens = 0 socket.onOpen(() => ++opens) let closes = 0 socket.onClose(() => ++closes) let lastError socket.onError((error) => lastError = error) let lastMessage socket.onMessage((message) => lastMessage = message.payload) socket.connect() socket.conn.onopen() expect(opens).toBe(1) socket.conn.onclose() expect(closes).toBe(1) socket.conn.onerror("error") expect(lastError).toBe("error") socket.connect() const data = {"topic": "topic", "event": "event", "payload": "payload", "status": "ok"} socket.conn.onmessage({data: encode(data)}) expect(lastMessage).toBe("payload") }) it("is idempotent", function (){ socket.connect() const conn = socket.conn socket.connect() expect(conn).toBe(socket.conn) }) }) describe("disconnect", function (){ let mockServer beforeAll(function (){ mockServer = new WebSocketServer("wss://example.com/") }) afterAll(function (done){ mockServer.stop(() => done()) }) beforeEach(function (){ socket = new Socket("/socket") }) it("removes existing connection", function (done){ socket.connect() socket.disconnect() socket.disconnect(() => { expect(socket.conn).toBeNull() done() }) }) it("calls callback", function (done){ let count = 0 socket.connect() socket.disconnect(() => { count++ expect(count).toBe(1) done() }) }) it("calls connection close callback", function (done){ socket.connect() const closeSpy = jest.spyOn(socket.conn, "close") socket.disconnect(() => { expect(closeSpy).toHaveBeenCalledWith(1000, "reason") done() }, 1000, "reason") }) it("does not throw when no connection", function (){ expect(() => { socket.disconnect() }).not.toThrow() }) it("properly tears down old connection when immediately reconnecting", function (){ const connections = [] const mockWebSocket = function StubWebSocketNoAutoClose(_url){ const conn = { readyState: SOCKET_STATES.open, get bufferedAmount(){ return 1 }, binaryType: "arraybuffer", timeout: 20000, onopen: null, onerror: null, onmessage: null, onclose: null, close(_code, _reason){ this.readyState = SOCKET_STATES.closing setTimeout(() => { this.readyState = SOCKET_STATES.closed }, 1000) }, send(){}, } connections.push(conn) return conn } jest.useFakeTimers() socket = new Socket("/socket", { heartbeatIntervalMs: 30000, heartbeatTimeoutMs: 30000, reconnectAfterMs: () => 10, transport: mockWebSocket }) socket.connect() const originalConn = socket.conn // Disconnect triggers teardown, which waits for bufferedAmount to be zero or 2250ms, // then awaits SOCKET_STATES.closed before calling the callback. const disconnected = jest.fn() socket.disconnect(disconnected) // For now, the conn is still set. expect(socket.conn).toBeTruthy() // Advance time by > 2250ms, which means we are waiting for socket to transition to closed jest.advanceTimersByTime(3000) // Now we call connect, while the teardown is still running socket.connect() // By now, waitForSocketClosed should be done, but now there's a new conn! jest.advanceTimersByTime(3000) expect(socket.conn).not.toBe(originalConn) const openConns = connections.filter(c => c.readyState === SOCKET_STATES.open) expect(openConns.length).toBe(1) // Late teardown must not overwrite this.conn with null when it is already connB expect(socket.conn).not.toBeNull() // the original disconnected should have been called expect(disconnected).toHaveBeenCalled() jest.useRealTimers() }) it("properly tears down old connection when disconnecting twice", function (){ const connections = [] const mockWebSocket = function StubWebSocketNoAutoClose(_url){ const conn = { readyState: SOCKET_STATES.open, get bufferedAmount(){ return 1 }, binaryType: "arraybuffer", timeout: 20000, onopen: null, onerror: null, onmessage: null, onclose: null, close(_code, _reason){ this.readyState = SOCKET_STATES.closing setTimeout(() => { this.readyState = SOCKET_STATES.closed }, 1000) }, send(){}, } connections.push(conn) return conn } jest.useFakeTimers() socket = new Socket("/socket", { heartbeatIntervalMs: 30000, heartbeatTimeoutMs: 30000, reconnectAfterMs: () => 10, transport: mockWebSocket }) socket.connect() const disconnected = jest.fn() socket.disconnect(disconnected) // For now, the conn is still set. expect(socket.conn).toBeTruthy() // Advance time by > 2250ms, which means we are waiting for socket to transition to closed jest.advanceTimersByTime(3000) // Now we call disconnect again, while the teardown is still running const disconnected2 = jest.fn() socket.disconnect(disconnected2) jest.advanceTimersByTime(10000) const openConns = connections.filter(c => c.readyState === SOCKET_STATES.open) expect(openConns.length).toBe(0) expect(socket.conn).toBeNull() // both disconnected functions should have been called expect(disconnected).toHaveBeenCalled() expect(disconnected2).toHaveBeenCalled() jest.useRealTimers() }) }) describe("connectionState", function (){ beforeEach(function (){ socket = new Socket("/socket") }) it("defaults to closed", function (){ expect(socket.connectionState()).toBe("closed") }) it("returns closed if readyState unrecognized", function (){ socket.connect() socket.conn.readyState = 5678 expect(socket.connectionState()).toBe("closed") }) it("returns connecting", function (){ socket.connect() socket.conn.readyState = 0 expect(socket.connectionState()).toBe("connecting") expect(socket.isConnected()).toBe(false) }) it("returns open", function (){ socket.connect() socket.conn.readyState = 1 expect(socket.connectionState()).toBe("open") expect(socket.isConnected()).toBe(true) }) it("returns closing", function (){ socket.connect() socket.conn.readyState = 2 expect(socket.connectionState()).toBe("closing") expect(socket.isConnected()).toBe(false) }) it("returns closed", function (){ socket.connect() socket.conn.readyState = 3 expect(socket.connectionState()).toBe("closed") expect(socket.isConnected()).toBe(false) }) }) describe("channel", function (){ let channel beforeEach(function (){ socket = new Socket("/socket") }) it("returns channel with given topic and params", function (){ channel = socket.channel("topic", {one: "two"}) expect(channel.socket).toBe(socket) expect(channel.topic).toBe("topic") expect(channel.params()).toEqual({one: "two"}) }) it("adds channel to sockets channels list", function (){ expect(socket.channels.length).toBe(0) channel = socket.channel("topic", {one: "two"}) expect(socket.channels.length).toBe(1) const [foundChannel] = socket.channels expect(foundChannel).toBe(channel) }) }) describe("remove", function (){ it("removes given channel from channels", function (){ socket = new Socket("/socket") const channel1 = socket.channel("topic-1") const channel2 = socket.channel("topic-2") jest.spyOn(channel1, "joinRef").mockReturnValue(1) jest.spyOn(channel2, "joinRef").mockReturnValue(2) expect(socket.stateChangeCallbacks.open.length).toBe(2) socket.remove(channel1) expect(socket.stateChangeCallbacks.open.length).toBe(1) expect(socket.channels.length).toBe(1) const [foundChannel] = socket.channels expect(foundChannel).toBe(channel2) }) }) describe("push", function (){ let data, json beforeEach(function (){ data = {topic: "topic", event: "event", payload: "payload", ref: "ref"} json = encode(data) socket = new Socket("/socket") }) it("sends data to connection when connected", function (){ socket.connect() socket.conn.readyState = 1 // open const sendSpy = jest.spyOn(socket.conn, "send") socket.push(data) expect(sendSpy).toHaveBeenCalledWith(json) }) it("buffers data when not connected", function (){ socket.connect() socket.conn.readyState = 0 // connecting const sendSpy = jest.spyOn(socket.conn, "send").mockImplementation(() => {}) expect(socket.sendBuffer.length).toBe(0) socket.push(data) expect(sendSpy).not.toHaveBeenCalledWith(json) expect(socket.sendBuffer.length).toBe(1) const [callback] = socket.sendBuffer callback() expect(sendSpy).toHaveBeenCalledWith(json) }) }) describe("makeRef", function (){ beforeEach(function (){ socket = new Socket("/socket") }) it("returns next message ref", function (){ expect(socket.ref).toBe(0) expect(socket.makeRef()).toBe("1") expect(socket.ref).toBe(1) expect(socket.makeRef()).toBe("2") expect(socket.ref).toBe(2) }) it("restarts for overflow", function (){ socket.ref = Number.MAX_SAFE_INTEGER + 1 expect(socket.makeRef()).toBe("0") expect(socket.ref).toBe(0) }) }) describe("sendHeartbeat", function (){ beforeEach(function (){ socket = new Socket("/socket") socket.connect() }) it("closes socket when heartbeat is not ack'd within heartbeat window", function (done){ jest.useFakeTimers() let closed = false socket.conn.readyState = 1 // open socket.conn.close = () => closed = true socket.sendHeartbeat() expect(closed).toBe(false) jest.advanceTimersByTime(10000) expect(closed).toBe(false) jest.advanceTimersByTime(20010) expect(closed).toBe(true) jest.useRealTimers() done() }) it("pushes heartbeat data when connected", function (){ socket.conn.readyState = 1 // open const sendSpy = jest.spyOn(socket.conn, "send") const data = "[null,\"1\",\"phoenix\",\"heartbeat\",{}]" socket.sendHeartbeat() expect(sendSpy).toHaveBeenCalledWith(data) }) it("no ops when not connected", function (){ socket.conn.readyState = 0 // connecting const sendSpy = jest.spyOn(socket.conn, "send") const data = encode({topic: "phoenix", event: "heartbeat", payload: {}, ref: "1"}) socket.sendHeartbeat() expect(sendSpy).not.toHaveBeenCalledWith(data) }) }) describe("flushSendBuffer", function (){ beforeEach(function (){ socket = new Socket("/socket") socket.connect() }) it("calls callbacks in buffer when connected", function (){ socket.conn.readyState = 1 // open const spy1 = jest.fn() const spy2 = jest.fn() socket.sendBuffer.push(spy1) socket.sendBuffer.push(spy2) socket.flushSendBuffer() expect(spy1).toHaveBeenCalledTimes(1) expect(spy2).toHaveBeenCalledTimes(1) }) it("empties sendBuffer", function (){ socket.conn.readyState = 1 // open socket.sendBuffer.push(() => { }) socket.flushSendBuffer() expect(socket.sendBuffer.length).toBe(0) }) }) describe("onConnOpen", function (){ let mockServer beforeAll(function (){ mockServer = new WebSocketServer("wss://example.com/") }) afterAll(function (done){ mockServer.stop(() => done()) }) beforeEach(function (){ socket = new Socket("/socket", { reconnectAfterMs: () => 100000 }) socket.connect() }) it("flushes the send buffer", function (){ socket.conn.readyState = 1 // open const spy = jest.fn() socket.sendBuffer.push(spy) socket.onConnOpen() expect(spy).toHaveBeenCalledTimes(1) }) it("resets reconnectTimer", function (){ const resetSpy = jest.spyOn(socket.reconnectTimer, "reset") socket.onConnOpen() expect(resetSpy).toHaveBeenCalledTimes(1) }) it("triggers onOpen callback", function (){ const spy = jest.fn() socket.onOpen(spy) socket.onConnOpen() expect(spy).toHaveBeenCalledTimes(1) }) }) describe("onConnClose", function (){ let mockServer beforeAll(function (){ mockServer = new WebSocketServer("wss://example.com/") }) afterAll(function (done){ mockServer.stop(() => done()) }) beforeEach(function (){ socket = new Socket("/socket", { reconnectAfterMs: () => 100000 }) socket.connect() }) it("does not schedule reconnectTimer if normal close", function (){ const scheduleSpy = jest.spyOn(socket.reconnectTimer, "scheduleTimeout") const event = {code: 1000} socket.onConnClose(event) expect(scheduleSpy).not.toHaveBeenCalled() }) it("schedules reconnectTimer timeout if abnormal close", function (){ const scheduleSpy = jest.spyOn(socket.reconnectTimer, "scheduleTimeout") const event = {code: 1006} socket.onConnClose(event) expect(scheduleSpy).toHaveBeenCalledTimes(1) }) it("does not schedule reconnectTimer timeout if normal close after explicit disconnect", function (){ const scheduleSpy = jest.spyOn(socket.reconnectTimer, "scheduleTimeout") socket.disconnect() expect(scheduleSpy).not.toHaveBeenCalled() }) it("schedules reconnectTimer timeout if not normal close", function (){ const scheduleSpy = jest.spyOn(socket.reconnectTimer, "scheduleTimeout") const event = {code: 1001} socket.onConnClose(event) expect(scheduleSpy).toHaveBeenCalledTimes(1) }) it("schedules reconnectTimer timeout if connection cannot be made after a previous clean disconnect", function (done){ const scheduleSpy = jest.spyOn(socket.reconnectTimer, "scheduleTimeout") socket.disconnect(() => { socket.connect() const event = {code: 1001} socket.onConnClose(event) expect(scheduleSpy).toHaveBeenCalledTimes(1) done() }) }) it("triggers onClose callback", function (){ const spy = jest.fn() socket.onClose(spy) socket.onConnClose("event") expect(spy).toHaveBeenCalledWith("event") }) it("triggers channel error if joining", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join() expect(channel.state).toBe("joining") socket.onConnClose() expect(triggerSpy).toHaveBeenCalledWith("phx_error") }) it("triggers channel error if joined", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join().trigger("ok", {}) expect(channel.state).toBe("joined") socket.onConnClose() expect(triggerSpy).toHaveBeenCalledWith("phx_error") }) it("does not trigger channel error after leave", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join().trigger("ok", {}) channel.leave() expect(channel.state).toBe("closed") socket.onConnClose() expect(triggerSpy).not.toHaveBeenCalledWith("phx_error") }) it("does not send heartbeat after explicit disconnect", function (done){ jest.useFakeTimers() const sendHeartbeatSpy = jest.spyOn(socket, "sendHeartbeat") socket.onConnOpen() socket.disconnect() jest.advanceTimersByTime(30000) expect(sendHeartbeatSpy).not.toHaveBeenCalled() jest.useRealTimers() done() }) it("does not timeout the heartbeat after explicit disconnect", function (done){ jest.useFakeTimers() const heartbeatTimeoutSpy = jest.spyOn(socket, "heartbeatTimeout") socket.onConnOpen() socket.disconnect() jest.advanceTimersByTime(60000) expect(heartbeatTimeoutSpy).not.toHaveBeenCalled() jest.useRealTimers() done() }) }) describe("onConnError", function (){ let mockServer beforeAll(function (){ mockServer = new WebSocketServer("wss://example.com/") }) afterAll(function (done){ mockServer.stop(() => done()) }) beforeEach(function (){ socket = new Socket("/socket", { reconnectAfterMs: () => 100000 }) socket.connect() }) it("triggers onClose callback", function (){ const spy = jest.fn() socket.onError(spy) socket.onConnError("error") expect(spy).toHaveBeenCalledWith("error", expect.any(Function), 0) }) it("triggers channel error if joining with open connection", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join() socket.onConnOpen() expect(channel.state).toBe("joining") socket.onConnError("error") expect(triggerSpy).toHaveBeenCalledWith("phx_error") }) it("triggers channel error if joining with no connection", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join() expect(channel.state).toBe("joining") socket.onConnError("error") expect(triggerSpy).toHaveBeenCalledWith("phx_error") }) it("triggers channel error if joined", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join().trigger("ok", {}) socket.onConnOpen() expect(channel.state).toBe("joined") let connectionsCount = null let transport = null socket.onError((error, erroredTransport, conns) => { transport = erroredTransport connectionsCount = conns }) socket.onConnError("error") expect(transport).toBe(WebSocket) expect(connectionsCount).toBe(1) expect(triggerSpy).toHaveBeenCalledWith("phx_error") }) it("does not trigger channel error after leave", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join().trigger("ok", {}) channel.leave() expect(channel.state).toBe("closed") socket.onConnError("error") expect(triggerSpy).not.toHaveBeenCalledWith("phx_error") }) it("does not trigger channel error if transport replaced with no previous connection", function (){ const channel = socket.channel("topic") const triggerSpy = jest.spyOn(channel, "trigger") channel.join() expect(channel.state).toBe("joining") let connectionsCount = null class FakeTransport { } socket.onError((error, transport, conns) => { socket.replaceTransport(FakeTransport) connectionsCount = conns }) socket.onConnError("error") expect(connectionsCount).toBe(0) expect(socket.transport).toBe(FakeTransport) expect(triggerSpy).not.toHaveBeenCalledWith("phx_error") }) }) describe("onConnMessage", function (){ let mockServer beforeAll(function (){ mockServer = new WebSocketServer("wss://example.com/") }) afterAll(function (done){ mockServer.stop(() => done()) }) beforeEach(function (){ socket = new Socket("/socket", { reconnectAfterMs: () => 100000 }) socket.connect() }) it("parses raw message and triggers channel event", function (){ const message = encode({topic: "topic", event: "event", payload: "payload", ref: "ref"}) const data = {data: message} const targetChannel = socket.channel("topic") const otherChannel = socket.channel("off-topic") const targetSpy = jest.spyOn(targetChannel, "trigger") const otherSpy = jest.spyOn(otherChannel, "trigger") socket.onConnMessage(data) expect(targetSpy).toHaveBeenCalledWith("event", "payload", "ref", null) expect(targetSpy).toHaveBeenCalledTimes(1) expect(otherSpy).toHaveBeenCalledTimes(0) }) it("triggers onMessage callback", function (){ const message = {"topic": "topic", "event": "event", "payload": "payload", "ref": "ref"} const spy = jest.fn() socket.onMessage(spy) socket.onConnMessage({data: encode(message)}) expect(spy).toHaveBeenCalledWith({ "topic": "topic", "event": "event", "payload": "payload", "ref": "ref", "join_ref": null }) }) }) describe("ping", function (){ beforeEach(function (){ socket = new Socket("/socket") socket.connect() }) it("pushes when connected", function (done){ let latency = 100 socket.conn.readyState = 1 // open expect(socket.isConnected()).toBe(true) socket.push = (msg) => { setTimeout(() => { socket.onConnMessage({data: encode({topic: "phoenix", event: "phx_reply", ref: msg.ref})}) }, latency) } const result = socket.ping(rtt => { // if we're unlucky we could also receive 99 as rtt, so let's be generous expect(rtt >= (latency - 10)).toBe(true) done() }) expect(result).toBe(true) }) it("returns false when disconnected", function (){ socket.conn.readyState = 0 expect(socket.isConnected()).toBe(false) const result = socket.ping(_rtt => true) expect(result).toBe(false) }) }) describe("custom encoder and decoder", function (){ it("encodes to JSON array by default", function (){ socket = new Socket("/socket") const payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}} socket.encode(payload, encoded => { expect(encoded).toBe("[\"1\",\"2\",\"topic\",\"join\",{\"foo\":\"bar\"}]") }) }) it("allows custom encoding when using WebSocket transport", function (){ const encoder = (payload, callback) => callback("encode works") socket = new Socket("/socket", {transport: WebSocket, encode: encoder}) socket.encode({foo: "bar"}, encoded => { expect(encoded).toBe("encode works") }) }) it("forces JSON encoding when using LongPoll transport", function (){ const encoder = (payload, callback) => callback("encode works") socket = new Socket("/socket", {transport: LongPoll, encode: encoder}) const payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}} socket.encode(payload, encoded => { expect(encoded).toBe("[\"1\",\"2\",\"topic\",\"join\",{\"foo\":\"bar\"}]") }) }) it("decodes JSON by default", function (){ socket = new Socket("/socket") const encoded = "[\"1\",\"2\",\"topic\",\"join\",{\"foo\":\"bar\"}]" socket.decode(encoded, decoded => { expect(decoded).toEqual({topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}}) }) }) it("allows custom decoding when using WebSocket transport", function (){ const decoder = (payload, callback) => callback("decode works") socket = new Socket("/socket", {transport: WebSocket, decode: decoder}) socket.decode("...esoteric format...", decoded => { expect(decoded).toBe("decode works") }) }) it("forces JSON decoding when using LongPoll transport", function (){ const decoder = (payload, callback) => callback("decode works") socket = new Socket("/socket", {transport: LongPoll, decode: decoder}) const payload = {topic: "topic", ref: "2", join_ref: "1", event: "join", payload: {foo: "bar"}} socket.decode("[\"1\",\"2\",\"topic\",\"join\",{\"foo\":\"bar\"}]", decoded => { expect(decoded).toEqual(payload) }) }) }) }) window.XMLHttpRequest = jest.fn() window.WebSocket = WebSocket ================================================ FILE: babel.config.json ================================================ { "presets": [ "@babel/preset-env" ] } ================================================ FILE: config/config.exs ================================================ import Config config :logger, :console, colors: [enabled: false], format: "\n$time $metadata[$level] $message\n" config :phoenix, json_library: Jason, stacktrace_depth: 20, trim_on_html_eex_engine: false, sort_verified_routes_query_params: true if Mix.env() == :dev do esbuild = fn args -> [ args: ~w(./js/phoenix --bundle) ++ args, cd: Path.expand("../assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] end config :esbuild, version: "0.25.4", module: esbuild.(~w(--format=esm --sourcemap --outfile=../priv/static/phoenix.mjs)), main: esbuild.(~w(--format=cjs --sourcemap --outfile=../priv/static/phoenix.cjs.js)), cdn: esbuild.( ~w(--target=es2016 --format=iife --global-name=Phoenix --outfile=../priv/static/phoenix.js) ), cdn_min: esbuild.( ~w(--target=es2016 --format=iife --global-name=Phoenix --minify --outfile=../priv/static/phoenix.min.js) ) end ================================================ FILE: eslint.config.mjs ================================================ import jest from "eslint-plugin-jest" import js from "@eslint/js" import stylistic from "@stylistic/eslint-plugin" export default [ { // eslint config is very unintuitive; they will match an js file in any // directory by default and you can only expand this; // moreover, to have a global ignore, it must be specified without // any other key as a separate object... ignores: [ "integration_test/", "installer/", "doc/", "deps/", "coverage/", "priv/", "tmp/", "test/" ], }, { ...js.configs.recommended, plugins: { jest, "@stylistic": stylistic }, languageOptions: { globals: { ...jest.environments.globals.globals, global: "writable", }, ecmaVersion: 12, sourceType: "module", }, rules: { "@stylistic/indent": ["error", 2, { SwitchCase: 1, }], "@stylistic/linebreak-style": ["error", "unix"], "@stylistic/quotes": ["error", "double"], "@stylistic/semi": ["error", "never"], "@stylistic/object-curly-spacing": ["error", "never", { objectsInObjects: false, arraysInObjects: false, }], "@stylistic/array-bracket-spacing": ["error", "never"], "@stylistic/comma-spacing": ["error", { before: false, after: true, }], "@stylistic/computed-property-spacing": ["error", "never"], "@stylistic/space-before-blocks": ["error", { functions: "never", keywords: "never", classes: "always", }], "@stylistic/keyword-spacing": ["error", { overrides: { if: { after: false, }, for: { after: false, }, while: { after: false, }, switch: { after: false, }, }, }], "@stylistic/eol-last": ["error", "always"], "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", }], "no-useless-escape": "off", "no-cond-assign": "off", "no-case-declarations": "off", }, }] ================================================ FILE: guides/asset_management.md ================================================ # Asset Management Beside producing HTML, most web applications have various assets (JavaScript, CSS, images, fonts and so on). From Phoenix v1.7, new applications use [esbuild](https://esbuild.github.io/) to prepare assets via the [Elixir esbuild wrapper](https://github.com/phoenixframework/esbuild), and [tailwindcss](https://tailwindcss.com) via the [Elixir tailwindcss wrapper](https://github.com/phoenixframework/tailwind) for CSS. The direct integration with `esbuild` and `tailwind` means that newly generated applications do not have dependencies on Node.js or an external build system (e.g. Webpack). Your JavaScript is typically placed at "assets/js/app.js" and `esbuild` will extract it to "priv/static/assets/js/app.js". In development, this is done automatically via the `esbuild` watcher. In production, this is done by running `mix assets.deploy`. `esbuild` can also handle your CSS files, but by default `tailwind` handles all CSS building. Finally, all other assets, that usually don't have to be preprocessed, go directly to "priv/static". ## Third-party JS packages If you want to import JavaScript dependencies, you have at least three options to add them to your application: 1. Vendor those dependencies inside your project and import them in your "assets/js/app.js" using a relative path: ```javascript import topbar from "../vendor/topbar" ``` 2. Call `npm install topbar --prefix assets`, which will create `package.json` and `package-lock.json` inside your assets directory, and `esbuild` will be able to automatically pick them up: ```javascript import topbar from "topbar" ``` To ensure that `npm install` is being run when checking out your project, or when building a release, add a `"cmd --cd assets npm ci"` step in `mix.exs` to the `assets.deploy` and `assets.build` steps: ```elixir "assets.build": ["cmd --cd assets npm ci", "tailwind your_app", "esbuild your_app"], "assets.deploy": [ "cmd --cd assets npm ci", "tailwind your_app --minify", "esbuild your_app --minify", "phx.digest" ] ``` 3. Use Mix to track the dependency from a source repository: ```elixir # mix.exs {:topbar, github: "buunguyen/topbar", app: false, compile: false} ``` Run `mix deps.get` to fetch the dependency and then import it: ```javascript import topbar from "topbar" ``` New applications use this third approach to import icons, such as Heroicons, to avoid vendoring a copy of all icons and to avoid additional system dependencies such as `npm`, while you can still track explicit versions thanks to Mix. It is important to note that git dependencies cannot be used by Hex packages, so if you intend to publish your project to Hex, consider alternatives approaches. Note that if you use third party JS package managers, you might need to adjust your deployment steps to properly include the packages. If you're using `mix phx.gen.release --docker`, have a look at the [documentation](Mix.Tasks.Phx.Gen.Release.html#module-docker) for further details. ## Images, fonts, and external files If you reference an external file in your CSS or JavaScript files, `esbuild` will attempt to validate and manage them, unless told otherwise. For example, imagine you want to reference `priv/static/images/bg.png`, served at `/images/bg.png`, from your CSS file: ```css body { background-image: url(/images/bg.png); } ``` The above may fail with the following message: ```text error: Could not resolve "/images/bg.png" (mark it as external to exclude it from the bundle) ``` Given the images are already managed by Phoenix, you need to mark all resources from `/images` (and also `/fonts`) as external, as the error message says. This is what Phoenix does by default for new apps since v1.6.1+. In your `config/config.exs`, you will find: ```elixir args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*), ``` If you need to reference other directories, you need to update the arguments above accordingly. Note running `mix phx.digest` will create digested files for all of the assets in `priv/static`, so your images and fonts are still cache-busted. ### Ensuring fonts and images from third-party libraries are loaded If you import a Node package that depends on additional fonts or images, you might find them to fail to load. This is because they are referenced in the JS or CSS but by default Esbuild will not touch or process referenced files. You can add arguments to esbuild in `config/config.exs` to ensure that the referenced resources are copied to the output folder. The following example would copy all referenced font files to the output folder and prefix the paths with `/assets/`: ```elixir args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --public-path=/assets/ --loader:.woff=copy --loader:.ttf=copy --loader:.eot=copy --loader:.woff2=copy), ``` For more information, see [the esbuild documentation](https://esbuild.github.io/content-types/#copy). ## Esbuild plugins Phoenix's default configuration of `esbuild` (via the Elixir wrapper) does not allow you to use [esbuild plugins](https://esbuild.github.io/plugins/). If you want to use an esbuild plugin, for example to compile SASS files to CSS, you can replace the default build system with a custom build script. The following is an example of a custom build using esbuild via Node.js. First of all, you'll need to install Node.js in development and make it available for your production build step. Then you'll need to add `esbuild` to your Node.js packages and the Phoenix packages. Inside the `assets` directory, run: ```console $ npm install esbuild --save-dev $ npm install ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view --save ``` or, for Yarn: ```console $ yarn add --dev esbuild $ yarn add ../deps/phoenix ../deps/phoenix_html ../deps/phoenix_live_view ``` Next, add a custom JavaScript build script. We'll call the example `assets/build.js`: ```javascript const esbuild = require("esbuild"); const args = process.argv.slice(2); const watch = args.includes('--watch'); const deploy = args.includes('--deploy'); const loader = { // Add loaders for images/fonts/etc, e.g. { '.svg': 'file' } }; const plugins = [ // Add and configure plugins here ]; // Define esbuild options let opts = { entryPoints: ["js/app.js"], bundle: true, logLevel: "info", target: "es2022", outdir: "../priv/static/assets", external: ["*.css", "fonts/*", "images/*"], nodePaths: ["../deps"], loader: loader, plugins: plugins, }; if (deploy) { opts = { ...opts, minify: true, }; } if (watch) { opts = { ...opts, sourcemap: "inline", }; esbuild .context(opts) .then((ctx) => { ctx.watch(); }) .catch((_error) => { process.exit(1); }); } else { esbuild.build(opts); } ``` This script covers following use cases: - `node build.js`: builds for development & testing (useful on CI) - `node build.js --watch`: like above, but watches for changes continuously - `node build.js --deploy`: builds minified assets for production Modify `config/dev.exs` so that the script runs whenever you change files, replacing the existing `:esbuild` configuration under `watchers`: ```elixir config :hello, HelloWeb.Endpoint, ... watchers: [ node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)] ], ... ``` Modify the `aliases` task in `mix.exs` to install `npm` packages during `mix setup` and use the new `esbuild` on `mix assets.deploy`: ```elixir defp aliases do [ setup: ["deps.get", "ecto.setup", "cmd --cd assets npm install"], ..., "assets.deploy": ["cmd --cd assets node build.js --deploy", "phx.digest"] ] end ``` Finally, remove the `esbuild` configuration from `config/config.exs` and remove the dependency from the `deps` function in your `mix.exs`, and you are done! ## Alternative JS build tools If you are writing an API or you want to use another asset build tool, you may want to remove the `esbuild` Hex package (see steps below). Then you must follow the additional steps required by the third-party tool. ### Remove esbuild 1. Remove the `esbuild` configuration in `config/config.exs` and `config/dev.exs`, 2. Remove the `assets.deploy` task defined in `mix.exs`, 3. Remove the `esbuild` dependency from `mix.exs`, 4. Unlock the `esbuild` dependency: ```console $ mix deps.unlock esbuild ``` ## Alternative CSS frameworks By default, Phoenix generates CSS with the `tailwind` library and its default plugins. If you want to use external `tailwind` plugins or another CSS framework, you should replace the `tailwind` Hex package (see steps below). Then you can use an `esbuild` plugin (as outlined above) or even bring a separate framework altogether. ### Remove tailwind 1. Remove the `tailwind` configuration in `config/config.exs` and `config/dev.exs`, 2. Remove the `assets.deploy` task defined in `mix.exs`, 3. Remove the `tailwind` dependency from `mix.exs`, 4. Unlock the `tailwind` dependency: ```console $ mix deps.unlock tailwind ``` You may optionally remove and delete the `heroicons` dependency as well. ## Alternative icon libraries Phoenix ships with the [Heroicons](https://heroicons.com/) library for icons support. This is done by embedding icons as CSS classes, which guarantees only the icons actually used by your application are sent to the client, thanks to Tailwind. If you prefer to use an alternative icon set, it should be possible to adapt the code that embeds Heroicons to use another library. Let's see exactly how to do that using [Remix Icon](https://remixicon.com/) as an example: First replace the `heroicon` repository in your `mix.exs` by `remixicons`: ```elixir {:remixicons, github: "Remix-Design/RemixIcon", sparse: "icons", tag: "v4.6.0", app: false, compile: false, depth: 1}, ``` Then replace `assets/vendor/heroicons.js`, which traverses the heroicons dependency, by `assets/vendor/remixicons.js`, which traverses remix icons instead: ```js const plugin = require("tailwindcss/plugin") const fs = require("fs") const path = require("path") module.exports = plugin(function({matchComponents, theme}) { let baseDir = path.join(__dirname, "../../deps/remixicons/icons"); let values = {}; let icons = fs .readdirSync(baseDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name); icons.forEach((dir) => { fs.readdirSync(path.join(baseDir, dir)).map((file) => { let name = path.basename(file, ".svg"); values[name] = { name, fullPath: path.join(baseDir, dir, file) }; }); }); matchComponents( { ri: ({ name, fullPath }) => { let content = fs .readFileSync(fullPath) .toString() .replace(/\r?\n|\r/g, ""); return { [`--ri-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, "-webkit-mask": `var(--ri-${name})`, mask: `var(--ri-${name})`, "background-color": "currentColor", "vertical-align": "middle", display: "inline-block", width: theme("spacing.10"), height: theme("spacing.10"), }; }, }, { values }, ); }) ``` And then change `assets/css/app.css` to import your new plugin instead. Finally, update the `icon` function in `lib/my_app_web/components/core_components.ex` to match on `ri-` prefixes instead: ``` @doc """ Renders a [Remix Icon](https://remixicon.com). You can customize the size and colors of the icons by setting width, height, and background color classes. ## Examples <.icon name="ri-github-fill" /> <.icon name="ri-github" class="ml-1 w-3 h-3 animate-spin" /> """ attr :name, :string, required: true attr :class, :any, default: "size-5" def icon(%{name: "ri-" <> _} = assigns) do ~H""" """ end ``` Now replace the Heroicons in your application by Remix ones and you are good to go! The approach above may also work with other libraries, it is a matter of adapting the Tailwind plugin to traverse these libraries and generate the proper classes. Some iconsets may also be available as regular Hex packages too. ================================================ FILE: guides/authn_authz/api_authentication.md ================================================ # API Authentication > **Requirement**: This guide expects that you have gone through the [`mix phx.gen.auth`](mix_phx_gen_auth.html) guide. This guide shows how to add API authentication on top of `mix phx.gen.auth`. Since the authentication generator already includes a token table, we use it to store API tokens too, following the best security practices. We will break this guide in two parts: augmenting the context and the plug implementation. We will assume that the following `mix phx.gen.auth` command was executed: ``` $ mix phx.gen.auth Accounts User users ``` If you ran something else, it should be trivial to adapt the names. ## Adding API functions to the context Our authentication system will require two functions. One to create the API token and another to verify it. Open up `lib/my_app/accounts.ex` and add these two new functions: ```elixir ## API @doc """ Creates a new api token for a user. The token returned must be saved somewhere safe. This token cannot be recovered from the database. """ def create_user_api_token(user) do {encoded_token, user_token} = UserToken.build_email_token(user, "api-token") Repo.insert!(user_token) encoded_token end @doc """ Fetches the user by API token. """ def fetch_user_by_api_token(token) do with {:ok, query} <- UserToken.verify_api_token_query(token), %User{} = user <- Repo.one(query) do {:ok, user} else _ -> :error end end ``` The new functions use the existing `UserToken` functionality to store a new type of token called "api-token". Because this is an email token, if the user changes their email, the tokens will be expired. Also notice we called the second function `fetch_user_by_api_token`, instead of `get_user_by_api_token`. Because we want to render different status codes in our API, depending if a user was found or not, we return `{:ok, user}` or `:error`. Elixir's convention is to call these functions `fetch_*`, instead of `get_*` which would usually return `nil` instead of tuples. To make sure our new functions work, let's write tests. Open up `test/my_app/accounts_test.exs` and add this new describe block: ```elixir describe "create_user_api_token/1 and fetch_user_by_api_token/1" do test "creates and fetches by token" do user = user_fixture() token = Accounts.create_user_api_token(user) assert Accounts.fetch_user_by_api_token(token) == {:ok, user} assert Accounts.fetch_user_by_api_token("invalid") == :error end end ``` If you run the tests, they will actually fail. Something similar to this: ```console 1) test create_user_api_token/1 and fetch_user_by_api_token/1 creates and fetches by token (Demo.AccountsTest) test/demo/accounts_test.exs:380 ** (UndefinedFunctionError) function Demo.Accounts.UserToken.verify_api_token_query/1 is undefined or private. Did you mean: * verify_change_email_token_query/2 * verify_magic_link_token_query/1 * verify_session_token_query/1 code: assert Accounts.fetch_user_by_api_token(token) == {:ok, user} stacktrace: (demo 0.1.0) Demo.Accounts.UserToken.verify_api_token_query("sTpJg7rt-KQ9gZ7xLMtn2keusGk9N2JpPwkXDx7LmHU") (demo 0.1.0) lib/demo/accounts.ex:325: Demo.Accounts.fetch_user_by_api_token/1 test/demo/accounts_test.exs:383: (test) ``` If you prefer, try looking at the error and fixing it yourself. The explanation will come next. The `UserToken` module contains functions for verifying different tokens. Right now, there is no `verify_api_token_query/1`, but we can implement it similar to the existing functions. How long the API token should be valid is going to depend on your application and how sensitive it is in terms of security. For this example, let's say the token is valid for 365 days. Open up `lib/my_app/accounts/user_token.ex`, and add a new function, like this: ```elixir @doc """ Checks if the API token is valid and returns its underlying lookup query. The query returns the user found by the token, if any. The given token is valid if it matches its hashed counterpart in the database and the user email has not changed. This function also checks if the token is being used within 365 days. """ def verify_api_token_query(token) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) query = from token in by_token_and_context_query(hashed_token, "api-token"), join: user in assoc(token, :user), where: token.inserted_at > ago(^@api_token_validity_in_days, "day") and token.sent_to == user.email, select: user {:ok, query} :error -> :error end end ``` Note that we also added a `@api_token_validity_in_days` module attribute at the top of the file: ```diff @magic_link_validity_in_minutes 15 @change_email_validity_in_days 7 @session_validity_in_days 60 + @api_token_validity_in_days 365 ``` Now tests should pass and we are ready to move forward! ## API authentication plug The last part is to add authentication to our API. When we ran `mix phx.gen.auth`, it generated a `MyAppWeb.UserAuth` module with several plugs, which are small functions that receive the `conn` and customize our request/response life-cycle. Open up `lib/my_app_web/user_auth.ex` and add this new function: ```elixir def fetch_current_scope_for_api_user(conn, _opts) do with [<>] <- get_req_header(conn, "authorization"), true <- String.downcase(bearer) == "bearer", {:ok, user} <- Accounts.fetch_user_by_api_token(token) do assign(conn, :current_scope, Scope.for_user(user)) else _ -> conn |> send_resp(:unauthorized, "No access for you") |> halt() end end ``` Our function receives the connection and checks if the "authorization" header has been set with "Bearer TOKEN", where "TOKEN" is the value returned by `Accounts.create_user_api_token/1`. In case the token is not valid or there is no such user, we abort the request. Finally, we need to add this `plug` to our pipeline. Open up `lib/my_app_web/router.ex` and you will find a pipeline for API. Let's add our new plug under it, like this: ```elixir pipeline :api do plug :accepts, ["json"] plug :fetch_current_scope_for_api_user end ``` Now you are ready to receive and validate API requests. Feel free to open up `test/my_app_web/user_auth_test.exs` and write your own test. You can use the tests for other plugs as templates! ## Your turn The overall API authentication flow will depend on your application. If you want to use this token in a JavaScript client, you will need to slightly alter the `UserSessionController` to invoke `Accounts.create_user_api_token/1` and return a JSON response including the token. If you want to provide APIs for 3rd-party users, you will need to allow them to create tokens, and show the result of `Accounts.create_user_api_token/1` to them. They must save these tokens somewhere safe and include them as part of their requests using the "authorization" header. ================================================ FILE: guides/authn_authz/authn_authz.md ================================================ # Introduction to Auth Authentication (authn) and authorization (authz) are two important concepts in security. Authentication is the process of verifying the identity of a user or system, while authorization is the process of granting or denying access to resources based on the user's identity and permissions. Phoenix comes with built-in support for both. Generally speaking, developers use the `mix phx.gen.auth` generator to scaffold their authn and authz. Third-party libraries such as [Ueberauth](https://github.com/ueberauth/ueberauth) can be used either as complementary systems or by itself. Overall we have the following guides: * [mix phx.gen.auth](mix_phx_gen_auth.md) - An introduction to the `mix phx.gen.auth` generator and its security considerations. * [Scopes](scopes.md) - Scopes are the mechanism Phoenix v1.8 introduced to manage access to resources based on the user's identity and permissions. * [API Authentication](api_authentication.md) - An additional guide that shows how to expand `mix phx.gen.auth` code to support token-based API authentication. ================================================ FILE: guides/authn_authz/mix_phx_gen_auth.md ================================================ # mix phx.gen.auth The `mix phx.gen.auth` command generates a flexible, pre-built authentication system into your Phoenix app. This generator allows you to quickly move past the task of adding authentication to your codebase and stay focused on the real-world problem your application is trying to solve. It supports the following features: - User registration with account confirmation by email - Log in with magic links - Opt-in password authentication - "Sudo mode", also known as privileged authentication, where the user must confirm their identity before performing sensitive actions ## Getting started > Before running this command, consider committing your work as it generates multiple files. Let's start by running the following command from the root of our app: ```console $ mix phx.gen.auth Accounts User users An authentication system can be created in two different ways: - Using Phoenix.LiveView (default) - Using Phoenix.Controller only Do you want to create a LiveView based authentication system? [Y/n] Y ``` The first argument is the context module followed by the schema module and its plural name (used as the schema table name). The example above will generate an `Accounts` context module with two schemas inside: `User` and `UserToken`. The context module helps us group all of the different schemas related to authentication. You may name the context and schema according to your preferences. The authentication generators support Phoenix LiveView, for enhanced UX, so you should answer `Y` here. You may also answer `n` for a controller based authentication system. Either approach will create the same context and schemas, using the same table names and route paths. Since this generator installed additional dependencies in `mix.exs`, let's fetch those: ```console $ mix deps.get ``` Now run the pending repository migrations: ```console $ mix ecto.migrate ``` Let's run the tests to make sure our new authentication system works as expected. ```console $ mix test ``` And finally, let's start our Phoenix server and try it out (note the new `Register` and `Log in` links at the top right of the default page). ```console $ mix phx.server ``` ## Developer responsibilities Since Phoenix generates this code into your application instead of building these modules into Phoenix itself, you now have complete freedom to modify the authentication system, so it works best with your use case. The one caveat with using a generated authentication system is it will not be updated after it's been generated. Therefore, as improvements are made to the output of `mix phx.gen.auth`, it becomes your responsibility to determine if these changes need to be ported into your application. Security-related and other important improvements will be explicitly and clearly marked in the `CHANGELOG.md` file and upgrade notes. ## Generated code The following are notes about the generated authentication system. ### Forbidding access The generated code ships with an authentication module with a handful of plugs that fetch the current user, require authentication and so on. For instance, in an app named MyApp which had `mix phx.gen.auth Accounts User users` run on it, you will find a module named `MyAppWeb.UserAuth` with plugs such as: * `fetch_current_scope_for_user` - fetches the current user information if available and stores it as `:current_scope` assign * `require_authenticated_user` - must be invoked after `fetch_current_scope_for_user` and requires that a current user exists and is authenticated * `redirect_if_user_is_authenticated` - used for the few pages that must not be available to authenticated users (only generated for controller based authentication) * `require_sudo_mode` - used for pages that contain sensitive operations and enforces recent authentication ### Scopes The generated code includes a scope module. For an app named MyApp which had `mix phx.gen.auth Accounts User users` run on it, you will find the following module at `lib/my_app/accounts/scope.ex`: ```elixir defmodule MyApp.Accounts.Scope do # ... alias MyApp.Accounts.User defstruct user: nil @doc """ Creates a scope for the given user. Returns nil if no user is given. """ def for_user(%User{} = user) do %__MODULE__{user: user} end def for_user(nil), do: nil end ``` The scope data structure is stored in the assigns and available to your Controllers and LiveViews. As your application grows in complexity, this data structure can store important metadata such as the teams, companies, or organizations the user belongs to, permissions, telemetry information such as IP address and so forth. Furthermore, future Phoenix generator invocations will automatically pass this data structure from your Controllers and LiveViews to most of [your context operations](contexts.md), making sure that future data is scoped to the current user/team/company/organization. Scopes are essential to enforce the user can only access data they own. You can learn more about them in the [Scopes](scopes.md) guide. ### Password hashing The password hashing mechanism defaults to `bcrypt` for Unix systems and `pbkdf2` for Windows systems. Both systems use the [Comeonin interface](https://hexdocs.pm/comeonin/). The password hashing mechanism can be overridden with the `--hashing-lib` option. The following values are supported: * `bcrypt` - [bcrypt_elixir](https://hex.pm/packages/bcrypt_elixir) * `pbkdf2` - [pbkdf2_elixir](https://hex.pm/packages/pbkdf2_elixir) * `argon2` - [argon2_elixir](https://hex.pm/packages/argon2_elixir) We recommend developers to consider using `argon2`, which is the most robust of all 3. The downside is that `argon2` is quite CPU and memory intensive, and you will need more powerful instances to run your applications on. For more information about choosing these libraries, see the [Comeonin project](https://github.com/riverrun/comeonin). There are similar `:on_mount` hooks for LiveView based authentication. ### Notifiers The generated code is not integrated with any system to send SMSes or emails for confirming accounts, resetting passwords, etc. Instead, it simply logs a message to the terminal. It is your responsibility to integrate with the proper system after generation. Note that if you generated your Phoenix project with `mix phx.new`, your project is configured to use [Swoosh](https://hexdocs.pm/swoosh/Swoosh.html) mailer by default. To view notifier emails during development with Swoosh, navigate to `/dev/mailbox`. ### Concurrent tests The generated tests run concurrently if you are using a database that supports concurrent tests, which is the case of PostgreSQL. ### More about `mix phx.gen.auth` Check out `mix phx.gen.auth` for more details, such as using a different password hashing library, customizing the web module namespace, generating binary id type, configuring the default options, and using custom table names. ## Security considerations ### Tracking sessions All sessions and tokens are tracked in a separate table. This allows you to track how many sessions are active for each account. You could even expose this information to users if desired. Note that whenever the password changes (either via reset password or directly), all tokens are deleted, and the user has to log in again on all devices. ### User Enumeration attacks A user enumeration attack allows someone to check if an email is registered in the application. The generated authentication code does not attempt to protect from such attacks. For instance, when you register an account, if the email is already registered, the code will notify the user the email is already registered. If your application is sensitive to enumeration attacks, you need to implement your own workflows, which tends to be very different from most applications, as you need to carefully balance security and user experience. Furthermore, if you are concerned about enumeration attacks, beware of timing attacks too. For example, registering a new account typically involves additional work (such as writing to the database, sending emails, etc) compared to when an account already exists. Someone could measure the time taken to execute those additional tasks to enumerate emails. This applies to all endpoints (registration, login, etc.) that may send email, in-app notifications, etc. ### Confirmation and credential pre-stuffing attacks The generated functionality ships with an account confirmation mechanism, where users have to confirm their account, typically by email. Furthermore, to prevent security issues, the generated code does forbid users from using the application if their accounts have not yet been confirmed. If you want to change this behavior, please refer to the ["Mixing magic link and password registration" section](Mix.Tasks.Phx.Gen.Auth.html#module-mixing-magic-link-and-password-registration) of `mix phx.gen.auth`. ### Case sensitiveness The email lookup is made to be case-insensitive. Case-insensitive lookups are the default in MySQL and MSSQL. In SQLite3 we use [`COLLATE NOCASE`](https://www.sqlite.org/datatype3.html#collating_sequences) in the column definition to support it. In PostgreSQL, we use the [`citext` extension](https://www.postgresql.org/docs/current/citext.html). Note `citext` is part of PostgreSQL itself and is bundled with it in most operating systems and package managers. `mix phx.gen.auth` takes care of creating the extension and no extra work is necessary in the majority of cases. If by any chance your package manager splits `citext` into a separate package, you will get an error while migrating, and you can most likely solve it by installing the `postgres-contrib` package. ## Additional resources ### Migrating to Phoenix v1.8 magic links and sudo mode Phoenix v1.8 added new features and simplified the authentication code. Developers are not required to migrate to the new generators, although we recommend setting up your own scope, as defined in the [Scopes](scopes.md) guide. If you generated your authentication code with `mix phx.gen.auth` in Phoenix v1.7 or earlier and you want to migrate to the new generators, you can use the following pull requests as reference: * [Pull request for migrating LiveView based Phoenix 1.7 `phx.gen.auth` to magic links](https://github.com/SteffenDE/phoenix_gen_auth_magic_link/pull/1) * [Pull request for migrating controller based Phoenix 1.7 `phx.gen.auth` to magic links](https://github.com/SteffenDE/phoenix_gen_auth_magic_link/pull/2) Keep in mind that the new authentication system fully removes registering an account with password, which simplifies both the user experience and the generated code. Therefore, when migrating, you should not change your existing migration files, instead, you must make the `hashed_password` column optional by setting `null: true`. Also, when migrating to the new system and removing features like "Forgot your password?", you must set the `hashed_password` of all accounts that have not been confirmed to `nil`, after making the column nullable, to avoid credential stuffing attacks. For this reason, we recommend deploying the migrated authentication system during low-traffic periods, where ideally no user who has just registered an account would have their password nullified. If those trade-offs are not acceptable, [you can add magic links on top of your existing authentication system without a complete migration, as discussed here](https://github.com/srcrip/phoenix-magic-links). ### Initial implementation The following links describe the original implementation of the authentication system, the default up to Phoenix v1.7: * José Valim's blog post - [An upcoming authentication solution for Phoenix](https://dashbit.co/blog/a-new-authentication-solution-for-phoenix) * Berenice Medel's blog post on generating LiveViews for authentication (rather than conventional Controllers & Views) - [Bringing Phoenix Authentication to Life](https://fly.io/phoenix-files/phx-gen-auth/) * [Original design spec](https://github.com/dashbitco/mix_phx_gen_auth_demo/blob/auth/README.md) * [Pull request on bare Phoenix app](https://github.com/dashbitco/mix_phx_gen_auth_demo/pull/1) ================================================ FILE: guides/authn_authz/scopes.md ================================================ # Scopes A scope is a data structure used to keep information about the current request or session, such as the current user, the user's organization/company, permissions, and so on. Think of a scope as a container with information required by the majority of pages in your application. A scope can also hold request metadata, such as IP addresses. Scopes play an important role in security. [OWASP](https://owasp.org/) lists "Broken access control" as a [top-10 security risk](https://owasp.org/Top10/). Most application data is private for a user, a team, or an organization. Your database CRUD operations must be properly scoped to the current user/team/organization. Phoenix generators such as `mix phx.gen.html`, `mix phx.gen.json`, and `mix phx.gen.live` automatically use your custom scopes. Scopes are flexible. You can have more than one scope in your application and choose the specific scope when invoking a generator. When you run `mix phx.gen.auth`, it will automatically generate a scope for you, but you may also add your own. This guide will: * Show how `mix phx.gen.auth` generates a scope for you * Discuss how generators, such as `mix phx.gen.context`, rely on scopes for security * How to define your own scope from scratch and all valid options * Augment the built-in scope with additional scopes ## phx.gen.auth The task `mix phx.gen.auth` will generate a default scope. This scope ties the generated resources to the currently authenticated user. Let's see it in action: ```console $ mix phx.gen.auth Accounts User users ``` The scope code is the same for the `--live` and `--no-live` variants of the generator. Looking at the generated scope file `lib/my_app/accounts/scope.ex`, we can see that it defines a struct with a single `user` field, and a function `for_user/1` that, if given a `User` struct, returns a new `%Scope{}` for that user. ```elixir defmodule MyApp.Accounts.Scope do alias MyApp.Accounts.User defstruct user: nil def for_user(%User{} = user) do %__MODULE__{user: user} end def for_user(nil), do: nil end ``` The scope is automatically fetched by the `fetch_current_scope_for_user` plug that is injected into the `:browser` pipeline: ```elixir # router.ex ... pipeline :browser do ... plug :fetch_current_scope_for_user end ``` ```elixir # user_auth.ex def fetch_current_scope_for_user(conn, _opts) do {user_token, conn} = ensure_user_token(conn) user = user_token && Accounts.get_user_by_session_token(user_token) assign(conn, :current_scope, Scope.for_user(user)) end ``` Similarly, for LiveViews, there is a pre-defined `mount_current_scope` hook that ensures the scope is available: ```elixir # user_auth.ex def on_mount(:mount_current_scope, _params, session, socket) do {:cont, mount_current_scope(socket, session)} end defp mount_current_scope(socket, session) do Phoenix.Component.assign_new(socket, :current_scope, fn -> user = if user_token = session["user_token"] do Accounts.get_user_by_session_token(user_token) end Scope.for_user(user) end) end ``` ## Integration of scopes in the Phoenix generators If a default scope is defined in your application's config, the generators will build scoped resources by default. The generated LiveViews / Controllers will automatically pass the scope to the context functions. `mix phx.gen.auth` automatically sets its scope as default, if there is not already a default scope defined: ```elixir # config/config.exs config :my_app, :scopes, user: [ default: true, ... ] ``` We will look at the individual options in the next section. Now let's look at the code generated once a default scope is set. We will use `mix phx.gen.live` as an example, but the ideas and the overall code will be similar to `mix phx.gen.html` and `mix phx.gen.json` too: ```console $ mix phx.gen.live Blog Post posts title:string body:text ``` This creates a new `Blog` context, with a `Post` resource. To ensure the scope is available, for LiveViews the routes in your `router.ex` must be added to a `live_session` that ensures the user is authenticated. In this case, within the aptly named `required_authenticated_user` section: ```diff scope "/", MyAppWeb do pipe_through [:browser, :require_authenticated_user] live_session :require_authenticated_user, on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do live "/users/settings", UserLive.Settings, :edit live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email + live "/posts", PostLive.Index, :index + live "/posts/new", PostLive.Form, :new + live "/posts/:id", PostLive.Show, :show + live "/posts/:id/edit", PostLive.Form, :edit end post "/users/update-password", UserSessionController, :update_password end ``` > Although the router has a `scope` macro, the router `scope` and `current_scope` are ultimately distinct features which have similar purposes: to narrow down access to parts of our application, each acting at distinct layers (one at the router, the other at the data layer). Now, let's look at the generated LiveView (`lib/my_app_web/live/post_live/index.ex`): ```elixir defmodule MyAppWeb.PostLive.Index do use MyAppWeb, :live_view alias MyApp.Blog ... @impl true def mount(_params, _session, socket) do Blog.subscribe_posts(socket.assigns.current_scope) {:ok, socket |> assign(:page_title, "Listing Posts") |> stream(:posts, Blog.list_posts(socket.assigns.current_scope))} end @impl true def handle_event("delete", %{"id" => id}, socket) do post = Blog.get_post!(socket.assigns.current_scope, id) {:ok, _} = Blog.delete_post(socket.assigns.current_scope, post) {:noreply, stream_delete(socket, :posts, post)} end @impl true def handle_info({type, %MyApp.Blog.Post{}}, socket) when type in [:created, :updated, :deleted] do {:noreply, stream(socket, :posts, Blog.list_posts(socket.assigns.current_scope), reset: true)} end end ``` Note that every function from the `Blog` context that we call gets the `current_scope` assign passed in as the first argument. The `list_posts/1` function then uses that information to properly filter posts: ```elixir # lib/my_app/blog.ex def list_posts(%Scope{} = scope) do Repo.all(from post in Post, where: post.user_id == ^scope.user.id) end ``` The LiveView even subscribes to scoped PubSub messages and automatically updates the rendered list whenever a new post is created or an existing post is updated or deleted, while ensuring that only messages for the current scope are processed. ## Defining scopes The Phoenix generators use your application's config to discover the available scopes. A scope is defined by the following options: ```elixir config :my_app, :scopes, user: [ default: true, module: MyApp.Accounts.Scope, assign_key: :current_scope, access_path: [:user, :id], schema_key: :user_id, schema_type: :id, schema_table: :users, test_data_fixture: MyApp.AccountsFixtures, test_setup_helper: :register_and_log_in_user ] ``` In this example, the scope is called `user` and it is the `default` scope that is automatically used when running `mix phx.gen.schema`, `mix phx.gen.context`, `mix phx.gen.live`, `mix phx.gen.html` and `mix phx.gen.json`. A scope needs a module that defines a struct, in this case `MyApp.Accounts.Scope`. Those structs are used as first argument to the generated context functions, like `list_posts/1`. * `default` - a boolean that indicates if this scope is the default scope. There can only be one default scope defined. * `module` - the module that defines the struct for this scope. * `assign_key` - the key where the scope struct is assigned to the [socket](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Socket.html#t:t/0) or [conn](https://hexdocs.pm/plug/Plug.Conn.html). * `access_path` - a list of keys that define the path to the identifying field in the scope struct. The generators generate code like `where: schema_key == ^scope.user.id`. * `route_prefix` - (optional) a path template string for how resources should be nested. For example, `/organizations/:org` would generate routes like `/organizations/:org/posts`. The parameter segment (`:org`) will be replaced with the appropriate scope access value in templates and LiveViews. * `route_access_path` - (optional) list of keys that define the path to the field used in route generation (if `route_prefix` is set). This is particularly useful for user-friendly URLs where you might want to use a slug instead of an ID. If not specified, it defaults to `Enum.drop(scope.access_path, -1)` or `access_path` if the former is empty. For example, if the `access_path` is `[:organization, :id]`, it defaults to `[:organization]`, assuming that the value at `scope.organization` implements the `Phoenix.Param` protocol. * `schema_key` - the foreign key that ties the resource to the scope. New scoped schemas are created with a foreign key field named `schema_key` of type `schema_type` to the `schema_table` table. * `schema_type` - the type of the foreign key field in the schema. Typically `:id` or `:binary_id`. * `schema_migration_type` (optional) - the type of the foreign key column in the database. Used for the generated migration. It defaults to the default migration foreign keytype. * `schema_table` - the name of the table where the foreign key points to. * `test_data_fixture` - a module that is automatically imported into the context test file. It must have a `NAME_scope_fixture/0` function that returns a unique scope struct for context tests, in this case `user_scope_fixture/0`. * `test_setup_helper` - the name of a function that is registered as [`setup` callback](https://hexdocs.pm/ex_unit/ExUnit.Callbacks.html#setup/1) in LiveView / Controller tests. The function is expected to be imported in the test file. Usually, this is ensured by putting it into the `MyAppWeb.ConnCase` module. While the `mix phx.gen.auth` automatically generates a scope, scopes can also be defined manually. This can be useful, for example, to retrofit an existing application with scopes or to define scopes that are not tied to a user. For this example, we will implement a custom scope that gives each session its own scope. While this might not be useful in most real-world applications as created resources would be inaccessible as soon as the session ends, it is a good example to understand how scopes work. See the following section for an example on how to augment an existing scope with organizations (teams, companies, or similar). First, let's define our scope module `lib/my_app/scope.ex`: ```elixir defmodule MyApp.Scope do defstruct id: nil def for_id(id) do %MyApp.Scope{id: id} end end ``` Next, we define a plug in our router that assigns a scope to each request: ```diff pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_live_flash plug :put_root_layout, html: {MyAppWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers + plug :assign_scope end + + defp assign_scope(conn, _opts) do + if id = get_session(conn, :scope_id) do + assign(conn, :current_scope, MyApp.Scope.for_id(id)) + else + id = System.unique_integer() + + conn + |> put_session(:scope_id, id) + |> assign(:current_scope, MyApp.Scope.for_id(id)) + end + end ``` For tests, we'll also define a fixture module `test/support/fixtures/scope_fixtures.ex`: ```elixir defmodule MyApp.ScopeFixtures do alias MyApp.Scope def session_scope_fixture(id \\ System.unique_integer()) do %Scope{id: id} end end ``` And then add a `setup` helper to our `test/support/conn_case.ex`: ```elixir defmodule MyAppWeb.ConnCase do ... def put_scope_in_session(%{conn: conn}) do id = System.unique_integer() scope = MyApp.ScopeFixtures.session_scope_fixture(id) conn = conn |> Phoenix.ConnTest.init_test_session(%{}) |> Plug.Conn.put_session(:scope_id, id) %{conn: conn, scope: scope} end end ``` Finally, we configure the scope in our application's `config/config.exs`: ```elixir config :my_app, :scopes, session: [ default: true, module: MyApp.Scope, assign_key: :current_scope, access_path: [:id], schema_key: :session_id, schema_type: :id, schema_migration_type: :bigint, schema_table: nil, test_data_fixture: MyApp.ScopeFixtures, test_setup_helper: :put_scope_in_session ] ``` Setting `schema_table` to `nil` means that the generated resources don't have a foreign key to the scope, but instead a normal `bigint` column that directly stores the scope's id. We can now generate a new resource, for example with `phx.gen.html`: ```console $ mix phx.gen.html Post posts title:string ``` When you now visit [http://localhost:4000/posts](http://localhost:4000/posts), and create a new post, you will see that it is only visible to the current session. If you open a private browser window and visit the same URL, the previously created post is not visible. Similarly, if you create a new post in the private window, it is not visible in the other window. If you try to copy the URL of a post created in one session and access it in another, you will get an `Ecto.NoResultsError` error, which is automatically converted to 404 when the `debug_errors` setting is disabled. ## Augmenting scopes Let's assume that you used `mix phx.gen.auth` to generate a scope tied to users. But now you also create a new `organization` entity, where users can be members of: ```elixir defmodule MyApp.Accounts.Organization do use Ecto.Schema import Ecto.Changeset @derive {Phoenix.Param, key: :slug} schema "organizations" do field :name, :string field :slug, :string ... many_to_many :users, MyApp.Accounts.User, join_through: "organizations_users" timestamps(type: :utc_datetime) end end ``` First, we'd adjust our scope struct to also include the organization: ```diff defmodule MyApp.Accounts.Scope do alias MyApp.Accounts.User alias MyApp.Accounts.Organization - defstruct user: nil + defstruct user: nil, organization: nil def for_user(%User{} = user) do %__MODULE__{user: user} end def for_user(nil), do: nil + + def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do + %{scope | organization: organization} + end end ``` Let's also assume that the current organization is part of the URL path, like `http://localhost:4000/organizations/foo/posts`. Then, we'd adjust our router to fetch the organization from the path and assign it to the scope: ```diff # router.ex pipeline :browser do ... plug :fetch_current_scope_for_user + plug :assign_org_to_scope end ``` ```elixir # user_auth.ex def assign_org_to_scope(conn, _opts) do current_scope = conn.assigns.current_scope if slug = conn.params["org"] do org = MyApp.Accounts.get_organization_by_slug!(current_scope, slug) assign(conn, :current_scope, MyApp.Accounts.Scope.put_organization(current_scope, org)) else conn end end ``` For LiveViews, we'll also need to add a new `:on_mount` hook and add it to `live_session`'s `on_mount` option in the router: ```diff # router.ex scope "/", MyAppWeb do pipe_through [:browser] live_session :current_user, on_mount: [ {MyAppWeb.UserAuth, :mount_current_scope}, + {MyAppWeb.UserAuth, :assign_org_to_scope} ] do ... end end ``` ```elixir # user_auth.ex def on_mount(:assign_org_to_scope, %{"org" => slug}, _session, socket) do socket = case socket.assigns.current_scope do %{organization: nil} = scope -> org = MyApp.Accounts.get_organization_by_slug!(socket.assigns.current_scope, slug) Phoenix.Component.assign(socket, :current_scope, Scope.put_organization(scope, org)) _ -> socket end {:cont, socket} end def on_mount(:assign_org_to_scope, _params, _session, socket), do: {:cont, socket} ``` This way, if a route is defined like `live /organizations/:org/posts`, the `assign_org_to_scope` plug would fetch the organization from the path and assign it to the scope. This code assumes that `get_organization_by_slug!/2` raises an `Ecto.NoResultsError` which would be automatically converted to `404`, but you could also handle the error explicitly and, for example, set an error flash and redirect to another page, like a dashboard. The `get_organization_by_slug!/2` function should also rely on the current scope to filter the organizations to those the user has access to. Then, we are ready to define a new scope in our application's `config/config.exs` to generate resources scoped to the organization: ```elixir config :my_app, :scopes, user: [ ... ], organization: [ module: MyApp.Accounts.Scope, assign_key: :current_scope, access_path: [:organization, :id], route_prefix: "/organizations/:org", route_access_path: [:organization, :slug], schema_key: :org_id, schema_type: :id, schema_table: :organizations, test_data_fixture: MyApp.AccountsFixtures, test_setup_helper: :register_and_log_in_user_with_org ] ``` For the generated tests, we'll also need to define a fixture in `test/support/fixtures/accounts_fixtures.ex` and extend our `test/support/conn_case.ex`: ```elixir defmodule MyApp.AccountsFixtures do ... def organization_scope_fixture(scope \\ user_scope_fixture()) do org = organization_fixture(scope) Scope.put_organization(scope, org) end end ``` ```elixir defmodule MyAppWeb.ConnCase do ... def register_and_log_in_user_with_org(context) do %{conn: conn, user: _user, scope: scope} = register_and_log_in_user(context) %{conn: conn, scope: MyApp.AccountsFixtures.organization_scope_fixture(scope)} end end ``` Now that our scope configuration includes the `route_prefix`, we can generate resources scoped to the organization, and all paths will be automatically generated with the correct organization slug: ```console $ mix phx.gen.live Blog Post posts title:string body:text --scope organization ``` This shows that scopes are quite flexible, allowing you to keep a well-defined data structure, even when your application grows. Most of the time, your application will have a single scope module, like in this example. But sometimes, you might want to create a new scope module, for example to completely separate a user-facing scope from an admin scope, where also the context functions are supposed to only be called by one of the two. ## Scope helpers When working with more complex scopes, it is often useful to create some helper functions, which can conveniently be added to the scope module: ```elixir defmodule MyApp.Accounts.Scope do alias MyApp.Accounts alias MyApp.Accounts.{User, Organization} defstruct user: nil, organization: nil def for_user(%User{} = user) do %__MODULE__{user: user} end def for_user(nil), do: nil def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do %{scope | organization: organization} end def for(opts) when is_list(opts) do cond do opts[:user] && opts[:org] -> user = user(opts[:user]) org = org(opts[:org]) user |> for_user() |> put_organization(org) opts[:user] -> user = user(opts[:user]) for_user(user) opts[:org] -> %__MODULE__{organization: org(opts[:org])} end end defp user(id) when is_integer(id) do Accounts.get_user!(id) end defp user(email) when is_binary(email) do Accounts.get_user_by_email(email) end defp org(id) when is_integer(id) do Accounts.get_organization!(id) end defp org(slug) when is_binary(slug) do Accounts.get_organization_by_slug!(slug) end end ``` Then, you can alias the Scope module in your project's `.iex.exs`: ```elixir alias MyApp.Accounts.Scope ``` And when working with scoped context functions, you can just do: ```elixir iex> MyApp.Blog.list_posts(Scope.for(user: 1, org: "foo")) ... iex> MyApp.Accounts.list_api_tokens(Scope.for(user: "john@doe.com")) ... ``` ================================================ FILE: guides/cheatsheets/router.cheatmd ================================================ # Routing cheatsheet > Those need to be declared in the correct router module and scope. A quick reference to the common routing features' syntax. For an exhaustive overview, refer to the [routing guides](routing.md). ## Routing declaration {: .col-2} ### Single route ```elixir get "/users", UserController, :index patch "/users/:id", UserController, :update ``` ```elixir # Verified Routes ~p"/users" ~p"/users/#{@user}" ``` Also accepts `put`, `patch`, `options`, `delete` and `head`. ### Resources #### Simple ```elixir resources "/users", UserController ``` Generates `:index`, `:edit`, `:new`, `:show`, `:create`, `:update` and `:delete`. #### Options ```elixir resources "/users", UserController, only: [:show] resources "/users", UserController, except: [:create, :delete] ``` #### Nested ```elixir resources "/users", UserController do resources "/posts", PostController end ``` ```elixir # Verified Routes ~p"/users" ~p"/users/new" ~p"/users/#{@user}" ~p"/users/#{@user}/edit" ~p"/users/#{@user}/posts" ~p"/users/#{@user}/posts/new" ~p"/users/#{@user}/posts/#{@post}" ~p"/users/#{@user}/posts/#{@post}/edit" ``` For more info check the [resources docs.](routing.html#resources) ### Scopes #### Simple ```elixir scope "/admin", HelloWeb.Admin do pipe_through :browser resources "/users", UserController end ``` ```elixir # Verified Routes ~p"/admin/users" ~p"/admin/users/new" ~p"/admin/users/#{@user}" ~p"/admin/users/#{@user}/edit" ``` #### Nested ```elixir scope "/api", HelloWeb.Api do pipe_through :api scope "/v1", V1 do resources "/users", UserController end end ``` ```elixir # Verified Routes ~p"/api/v1/users" ~p"/api/v1/users/new" ~p"/api/v1/users/#{@user}" ~p"/api/v1/users/#{@user}/edit" ``` For more info check the [scoped routes](routing.md#scoped-routes) docs. ================================================ FILE: guides/components.md ================================================ # Components and HEEx > **Requirement**: This guide expects that you have gone through the [introductory guides](installation.html) and got a Phoenix application [up and running](up_and_running.html). > **Requirement**: This guide expects that you have gone through the [request life-cycle guide](request_lifecycle.html). The Phoenix endpoint pipeline takes a request, routes it to a controller, and calls a view module to render a template. The view interface from the controller is simple – the controller calls a view function with the connection's assigns, and the function's job is to return a HEEx template. We call any function that accepts an `assigns` parameter and returns a HEEx template a *function component*. > The Phoenix framework is designed for HTML applications, JSON APIs, GraphQL endpoints, etc. For this reason, all of the functionality related to HTML rendering comes as part of two separate packages: > > * [`phoenix_html`](https://hexdocs.pm/phoenix_html) - defines the building blocks for writing HTML safely. In your project, you'll interact primarily with the `Phoenix.HTML` module, which is imported by default in all templates > > * [`phoenix_live_view`](https://hexdocs.pm/phoenix_live_view) - a library for rich, real-time user experiences with server-rendered HTML. While LiveView provides several abstraction for building collaborative and dynamic applications, it also defines the `HEEx` template language, function components, and JS commands, which brings powerful abstractions for all kinds of server-rendered HTML applications In this chapter, we will recap how components are used and dig deeper to discover new use cases. ## Function components Function components are the essential building block for any kind of markup-based template rendering you'll perform in Phoenix. They serve as a shared abstraction for the standard MVC controller-based applications, LiveView applications, layouts, and smaller UI definitions you'll use throughout other templates. Their documentation is available in [the `Phoenix.Component` module](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html). At the end of the Request life-cycle chapter, we created a template at `lib/hello_web/controllers/hello_html/show.html.heex`, let's open it up: ```heex

Hello World, from {@messenger}!

``` `` is a function component defined inside `lib/hello_web/components/layouts.ex`. If you open the file up, you will find: ```elixir def app(assigns) do ~H"""