Repository: remix-run/remix Branch: main Commit: b1741d7dbf36 Files: 1168 Total size: 4.8 MB Directory structure: gitextract_a33wb0o_/ ├── .agents/ │ └── skills/ │ ├── add-package/ │ │ ├── SKILL.md │ │ └── agents/ │ │ └── openai.yaml │ ├── make-change-file/ │ │ ├── SKILL.md │ │ └── agents/ │ │ └── openai.yaml │ ├── make-demo/ │ │ ├── SKILL.md │ │ └── agents/ │ │ └── openai.yaml │ ├── make-pr/ │ │ ├── SKILL.md │ │ └── agents/ │ │ └── openai.yaml │ ├── publish-placeholder-package/ │ │ ├── SKILL.md │ │ └── agents/ │ │ └── openai.yaml │ ├── supersede-pr/ │ │ ├── SKILL.md │ │ ├── agents/ │ │ │ └── openai.yaml │ │ ├── scripts/ │ │ │ └── close_superseded_pr.ts │ │ └── tsconfig.json │ ├── update-pr/ │ │ ├── SKILL.md │ │ └── agents/ │ │ └── openai.yaml │ ├── write-api-docs/ │ │ └── SKILL.md │ └── write-readme/ │ ├── SKILL.md │ └── agents/ │ └── openai.yaml ├── .github/ │ └── workflows/ │ ├── build.yaml │ ├── check.yaml │ ├── data-table-integration.yaml │ ├── file-storage-integration.yaml │ ├── format.yml │ ├── generate-remix.yaml │ ├── preview.yml │ ├── publish.yaml │ ├── release-pr.yaml │ ├── session-integration.yaml │ └── test.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── settings.json │ └── task.json ├── AGENTS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── cspell.yml ├── decisions/ │ ├── 001-route-pattern-vs-url-pattern.md │ └── 002-branching-and-releasing.md ├── demos/ │ ├── bookstore/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── app/ │ │ │ ├── account.test.ts │ │ │ ├── account.tsx │ │ │ ├── admin.books.test.ts │ │ │ ├── admin.books.tsx │ │ │ ├── admin.orders.tsx │ │ │ ├── admin.test.ts │ │ │ ├── admin.tsx │ │ │ ├── admin.users.tsx │ │ │ ├── assets/ │ │ │ │ ├── cart-button.tsx │ │ │ │ ├── cart-items.tsx │ │ │ │ ├── entry.tsx │ │ │ │ └── image-carousel.tsx │ │ │ ├── auth.test.ts │ │ │ ├── auth.tsx │ │ │ ├── books.test.ts │ │ │ ├── books.tsx │ │ │ ├── cart.test.ts │ │ │ ├── cart.tsx │ │ │ ├── checkout.test.ts │ │ │ ├── checkout.tsx │ │ │ ├── components/ │ │ │ │ ├── book-card.tsx │ │ │ │ └── restful-form.tsx │ │ │ ├── data/ │ │ │ │ ├── cart.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── setup.test.ts │ │ │ │ └── setup.ts │ │ │ ├── fragments.tsx │ │ │ ├── layout.tsx │ │ │ ├── marketing.test.ts │ │ │ ├── marketing.tsx │ │ │ ├── middleware/ │ │ │ │ ├── admin.ts │ │ │ │ ├── auth.ts │ │ │ │ └── database.ts │ │ │ ├── router.test.ts │ │ │ ├── router.ts │ │ │ ├── routes.ts │ │ │ ├── uploads.test.ts │ │ │ ├── uploads.tsx │ │ │ └── utils/ │ │ │ ├── context.ts │ │ │ ├── ids.ts │ │ │ ├── render.ts │ │ │ ├── session.ts │ │ │ └── uploads.ts │ │ ├── data/ │ │ │ └── migrations/ │ │ │ └── 20260228090000_create_bookstore_schema.ts │ │ ├── package.json │ │ ├── public/ │ │ │ └── app.css │ │ ├── server.ts │ │ ├── test/ │ │ │ └── helpers.ts │ │ └── tsconfig.json │ ├── frame-navigation/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── assets/ │ │ │ │ ├── dashboard-stat-grid.tsx │ │ │ │ ├── entry.tsx │ │ │ │ └── fake.tsx │ │ │ ├── auth/ │ │ │ │ ├── controller.tsx │ │ │ │ └── session.ts │ │ │ ├── lib/ │ │ │ │ ├── Layout.tsx │ │ │ │ └── NavLink.tsx │ │ │ ├── main/ │ │ │ │ ├── account.tsx │ │ │ │ ├── calendar.tsx │ │ │ │ ├── controller.tsx │ │ │ │ ├── courses.tsx │ │ │ │ └── index.tsx │ │ │ └── settings/ │ │ │ ├── controller.tsx │ │ │ ├── grading.tsx │ │ │ ├── index.tsx │ │ │ ├── integrations.tsx │ │ │ ├── layout.tsx │ │ │ ├── notifications.tsx │ │ │ ├── privacy.tsx │ │ │ └── profile.tsx │ │ ├── config/ │ │ │ ├── render.tsx │ │ │ ├── router.tsx │ │ │ ├── routes.ts │ │ │ └── server.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── frames/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── assets/ │ │ │ │ ├── client-frame-example.tsx │ │ │ │ ├── client-mounted-page-example.tsx │ │ │ │ ├── counter.tsx │ │ │ │ ├── entry.tsx │ │ │ │ ├── reload-scope.tsx │ │ │ │ ├── reload-time.tsx │ │ │ │ └── state-search-page.tsx │ │ │ ├── router.tsx │ │ │ ├── routes.ts │ │ │ └── us-states.ts │ │ ├── package.json │ │ ├── server.ts │ │ └── tsconfig.json │ ├── sse/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── assets/ │ │ │ │ ├── entry.tsx │ │ │ │ └── message-stream.tsx │ │ │ ├── layout.tsx │ │ │ ├── router.test.ts │ │ │ ├── router.tsx │ │ │ ├── routes.ts │ │ │ └── utils/ │ │ │ └── render.ts │ │ ├── package.json │ │ ├── server.ts │ │ └── tsconfig.json │ └── unpkg/ │ ├── README.md │ ├── app/ │ │ ├── breadcrumb.ts │ │ ├── directory.ts │ │ ├── error.ts │ │ ├── file-content.ts │ │ ├── router.test.ts │ │ ├── router.ts │ │ ├── routes.ts │ │ └── utils/ │ │ ├── cache.ts │ │ ├── npm.test.ts │ │ ├── npm.ts │ │ ├── render.test.ts │ │ └── render.ts │ ├── package.json │ ├── server.ts │ ├── test/ │ │ ├── fixtures/ │ │ │ ├── is-number-7.0.0.tgz │ │ │ └── is-number-metadata.json │ │ └── mock-fetch.ts │ └── tsconfig.json ├── docs/ │ ├── .gitignore │ ├── package.json │ ├── public/ │ │ └── docs.css │ ├── src/ │ │ ├── client/ │ │ │ └── entry.tsx │ │ ├── generate/ │ │ │ ├── documented-api.ts │ │ │ ├── index.ts │ │ │ ├── markdown.ts │ │ │ ├── symbols.ts │ │ │ ├── typedoc.ts │ │ │ └── utils.ts │ │ └── server/ │ │ ├── components.tsx │ │ ├── index.ts │ │ ├── markdown.ts │ │ ├── prerender.ts │ │ ├── router.tsx │ │ └── routes.ts │ └── tsconfig.json ├── eslint.config.js ├── package.json ├── packages/ │ ├── async-context-middleware/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── async-context.test.ts │ │ │ └── async-context.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── component/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.01-add-mixin-system-and-core-helpers.md │ │ │ ├── minor.02-add-press-and-keyboard-mixins.md │ │ │ ├── minor.03-add-animation-mixins.md │ │ │ ├── minor.04-remove-legacy-on-prop.md │ │ │ ├── minor.05-remove-legacy-css-prop.md │ │ │ ├── minor.06-remove-legacy-animate-prop.md │ │ │ ├── minor.07-remove-legacy-connect-prop.md │ │ │ ├── minor.08-interaction-package-removed.md │ │ │ ├── minor.09-allow-single-mix-values.md │ │ │ ├── minor.13-allow-remix-node-frame-content.md │ │ │ ├── minor.frame-navigation-link-attributes.md │ │ │ ├── minor.frame-navigation-runtime.md │ │ │ ├── minor.remove-head-hoisting.md │ │ │ ├── minor.resolve-frame-target.md │ │ │ ├── minor.ssr-frame-src-context.md │ │ │ ├── patch.10-preserve-live-dom-state.md │ │ │ ├── patch.11-forward-client-entry-root-errors.md │ │ │ ├── patch.defer-mixin-lifecycle-events.md │ │ │ ├── patch.fix-adjacent-hydration-markers.md │ │ │ ├── patch.fix-svg-classname-mapping.md │ │ │ ├── patch.resolve-svg-link-targets.md │ │ │ └── patch.skip-download-link-interception.md │ │ ├── AGENTS.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bench/ │ │ │ ├── .gitignore │ │ │ ├── frameworks/ │ │ │ │ ├── preact/ │ │ │ │ │ ├── index.html │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── package.json │ │ │ │ │ └── tsconfig.json │ │ │ │ ├── preact-signals/ │ │ │ │ │ ├── index.html │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── package.json │ │ │ │ │ └── tsconfig.json │ │ │ │ ├── react/ │ │ │ │ │ ├── index.html │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── package.json │ │ │ │ │ └── tsconfig.json │ │ │ │ ├── remix/ │ │ │ │ │ ├── index.html │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── package.json │ │ │ │ │ └── tsconfig.json │ │ │ │ ├── shared.ts │ │ │ │ ├── solid/ │ │ │ │ │ ├── build.js │ │ │ │ │ ├── index.html │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── package.json │ │ │ │ │ └── tsconfig.json │ │ │ │ └── styles.css │ │ │ ├── package.json │ │ │ ├── runner.ts │ │ │ ├── server.ts │ │ │ └── tsconfig.json │ │ ├── demos/ │ │ │ ├── .gitignore │ │ │ ├── animation/ │ │ │ │ ├── aspect-ratio.tsx │ │ │ │ ├── bouncy-switch.tsx │ │ │ │ ├── color-interpolation.tsx │ │ │ │ ├── cube.tsx │ │ │ │ ├── default-animate.tsx │ │ │ │ ├── enter.tsx │ │ │ │ ├── entry.tsx │ │ │ │ ├── exit.tsx │ │ │ │ ├── flip-toggle.tsx │ │ │ │ ├── hold-to-confirm.tsx │ │ │ │ ├── html-content.tsx │ │ │ │ ├── index.html │ │ │ │ ├── interruptible-keyframes.tsx │ │ │ │ ├── keyframes.tsx │ │ │ │ ├── material-ripple.tsx │ │ │ │ ├── mixin-presence-list.tsx │ │ │ │ ├── mixin-reclaim.tsx │ │ │ │ ├── multi-state-badge.tsx │ │ │ │ ├── press.tsx │ │ │ │ ├── reordering.tsx │ │ │ │ ├── rolling-square.tsx │ │ │ │ ├── rotate.tsx │ │ │ │ ├── shared-layout.tsx │ │ │ │ └── transition-options.tsx │ │ │ ├── basic/ │ │ │ │ ├── entry.tsx │ │ │ │ └── index.html │ │ │ ├── controlled-uncontrolled-values/ │ │ │ │ ├── entry.tsx │ │ │ │ └── index.html │ │ │ ├── draggable/ │ │ │ │ ├── draggable.tsx │ │ │ │ ├── entry.tsx │ │ │ │ └── index.html │ │ │ ├── drummer/ │ │ │ │ ├── app.tsx │ │ │ │ ├── components.tsx │ │ │ │ ├── drummer.ts │ │ │ │ ├── entry.tsx │ │ │ │ ├── index.html │ │ │ │ ├── tempo-interaction.tsx │ │ │ │ └── voice-looper.ts │ │ │ ├── keyed-list/ │ │ │ │ ├── entry.tsx │ │ │ │ └── index.html │ │ │ ├── package.json │ │ │ ├── readme/ │ │ │ │ ├── entry.tsx │ │ │ │ └── index.html │ │ │ ├── server.ts │ │ │ ├── spring/ │ │ │ │ ├── drag-release.ts │ │ │ │ ├── entry.tsx │ │ │ │ └── index.html │ │ │ └── tsconfig.json │ │ ├── docs/ │ │ │ ├── components.md │ │ │ ├── composition.md │ │ │ ├── context.md │ │ │ ├── events.md │ │ │ ├── frames.md │ │ │ ├── getting-started.md │ │ │ ├── handle.md │ │ │ ├── hydration.md │ │ │ ├── interactions.md │ │ │ ├── patterns.md │ │ │ ├── server-rendering.md │ │ │ ├── spring.md │ │ │ ├── styling.md │ │ │ ├── testing.md │ │ │ └── tween.md │ │ ├── package.json │ │ ├── skills/ │ │ │ ├── animate-elements/ │ │ │ │ └── SKILL.md │ │ │ └── create-mixins/ │ │ │ └── SKILL.md │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── jsx-dev-runtime.ts │ │ │ ├── jsx-runtime.ts │ │ │ ├── lib/ │ │ │ │ ├── client-entries.ts │ │ │ │ ├── component.ts │ │ │ │ ├── create-element.ts │ │ │ │ ├── diff-dom.ts │ │ │ │ ├── diff-props.ts │ │ │ │ ├── document-state.ts │ │ │ │ ├── dom.ts │ │ │ │ ├── error-event.ts │ │ │ │ ├── event-listeners.ts │ │ │ │ ├── frame.ts │ │ │ │ ├── invariant.ts │ │ │ │ ├── jsx.ts │ │ │ │ ├── mixin.ts │ │ │ │ ├── mixins/ │ │ │ │ │ ├── animate-layout-mixin.test.tsx │ │ │ │ │ ├── animate-layout-mixin.tsx │ │ │ │ │ ├── animate-mixins.test.tsx │ │ │ │ │ ├── animate-mixins.tsx │ │ │ │ │ ├── css-mixin.test.tsx │ │ │ │ │ ├── css-mixin.tsx │ │ │ │ │ ├── keys-mixin.test.tsx │ │ │ │ │ ├── keys-mixin.tsx │ │ │ │ │ ├── link-mixin.test.tsx │ │ │ │ │ ├── link-mixin.tsx │ │ │ │ │ ├── on-mixin.test.tsx │ │ │ │ │ ├── on-mixin.tsx │ │ │ │ │ ├── press-mixin.test.tsx │ │ │ │ │ ├── press-mixin.tsx │ │ │ │ │ ├── ref-mixin.test.tsx │ │ │ │ │ └── ref-mixin.tsx │ │ │ │ ├── navigation.ts │ │ │ │ ├── reconcile.ts │ │ │ │ ├── run.ts │ │ │ │ ├── scheduler.ts │ │ │ │ ├── spring.ts │ │ │ │ ├── stream.ts │ │ │ │ ├── style/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lib/ │ │ │ │ │ ├── style.ts │ │ │ │ │ └── stylesheet.ts │ │ │ │ ├── svg-attributes.ts │ │ │ │ ├── to-vnode.ts │ │ │ │ ├── tween.ts │ │ │ │ ├── typed-event-target.ts │ │ │ │ ├── vdom.ts │ │ │ │ └── vnode.ts │ │ │ ├── server.ts │ │ │ └── test/ │ │ │ ├── client-entry.test.tsx │ │ │ ├── create-element.test.ts │ │ │ ├── diff-dom.test.tsx │ │ │ ├── document-state.test.ts │ │ │ ├── event-listeners.test.tsx │ │ │ ├── frame.test.tsx │ │ │ ├── hydration.attributes.test.tsx │ │ │ ├── hydration.boolean-attrs.test.tsx │ │ │ ├── hydration.components.test.tsx │ │ │ ├── hydration.css.test.tsx │ │ │ ├── hydration.extra-nodes.test.tsx │ │ │ ├── hydration.forms.test.tsx │ │ │ ├── hydration.mismatch.test.tsx │ │ │ ├── hydration.text.test.tsx │ │ │ ├── hydration.void-elements.test.tsx │ │ │ ├── jsx.test.tsx │ │ │ ├── navigation.test.ts │ │ │ ├── spring.test.ts │ │ │ ├── stream.test.tsx │ │ │ ├── style.test.ts │ │ │ ├── stylesheet.test.ts │ │ │ ├── utils.ts │ │ │ ├── vdom.components.test.tsx │ │ │ ├── vdom.connect.test.tsx │ │ │ ├── vdom.context.test.tsx │ │ │ ├── vdom.controlled-props.test.tsx │ │ │ ├── vdom.dom-order.test.tsx │ │ │ ├── vdom.elements-fragments.test.tsx │ │ │ ├── vdom.errors.test.tsx │ │ │ ├── vdom.events.test.tsx │ │ │ ├── vdom.insert-remove.test.tsx │ │ │ ├── vdom.keys.test.tsx │ │ │ ├── vdom.mixins.test.tsx │ │ │ ├── vdom.props.test.tsx │ │ │ ├── vdom.range-root.test.tsx │ │ │ ├── vdom.replacements.test.tsx │ │ │ ├── vdom.scheduler.test.tsx │ │ │ ├── vdom.signals.test.tsx │ │ │ ├── vdom.svg.test.tsx │ │ │ └── vdom.tasks.test.tsx │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── compression-middleware/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── global.d.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── compression.test.ts │ │ │ └── compression.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── cookie/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── cookie-signing.ts │ │ │ ├── cookie.test.ts │ │ │ └── cookie.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── cop-middleware/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ └── minor.initial-release.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── cop.test.ts │ │ │ └── cop.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── cors-middleware/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ └── minor.initial-release.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── cors.test.ts │ │ │ └── cors.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── csrf-middleware/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ └── minor.initial-release.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── csrf.test.ts │ │ │ └── csrf.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── data-schema/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.form-data.md │ │ │ └── patch.remove-unnecessary-as-const-from-enum.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── checks.ts │ │ │ ├── coerce.ts │ │ │ ├── form-data.ts │ │ │ ├── index.ts │ │ │ ├── lazy.ts │ │ │ └── lib/ │ │ │ ├── checks.test.ts │ │ │ ├── checks.ts │ │ │ ├── coerce.test.ts │ │ │ ├── coerce.ts │ │ │ ├── form-data.test.ts │ │ │ ├── form-data.ts │ │ │ ├── lazy.test.ts │ │ │ ├── lazy.ts │ │ │ ├── parse.test.ts │ │ │ ├── pipe.test.ts │ │ │ ├── schema.test.ts │ │ │ ├── schema.ts │ │ │ └── variant.test.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── data-table/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.database-class-export.md │ │ │ ├── minor.migration-system-features.md │ │ │ ├── minor.operation-contract-split.md │ │ │ ├── minor.query-object-api.md │ │ │ ├── minor.sql-root-api.md │ │ │ ├── minor.table-column-cutover.md │ │ │ └── minor.table-lifecycle-callbacks.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── adapter.ts │ │ │ │ ├── column.ts │ │ │ │ ├── database/ │ │ │ │ │ ├── execution-context.ts │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── query-execution.ts │ │ │ │ │ ├── relations.ts │ │ │ │ │ └── write-lifecycle.ts │ │ │ │ ├── database.test.ts │ │ │ │ ├── database.ts │ │ │ │ ├── errors.test.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── inflection.test.ts │ │ │ │ ├── inflection.ts │ │ │ │ ├── migrations/ │ │ │ │ │ ├── filename.ts │ │ │ │ │ ├── helpers.ts │ │ │ │ │ ├── journal-store.ts │ │ │ │ │ ├── registry.ts │ │ │ │ │ ├── runner.ts │ │ │ │ │ └── schema-api.ts │ │ │ │ ├── migrations-node.test.ts │ │ │ │ ├── migrations-node.ts │ │ │ │ ├── migrations.test.ts │ │ │ │ ├── migrations.ts │ │ │ │ ├── operators.test.ts │ │ │ │ ├── operators.ts │ │ │ │ ├── query.ts │ │ │ │ ├── references.test.ts │ │ │ │ ├── references.ts │ │ │ │ ├── sql-helpers.ts │ │ │ │ ├── sql.test.ts │ │ │ │ ├── sql.ts │ │ │ │ ├── table.test.ts │ │ │ │ ├── table.ts │ │ │ │ ├── type-safety.test.ts │ │ │ │ └── types.ts │ │ │ ├── migrations/ │ │ │ │ └── node.ts │ │ │ ├── migrations.ts │ │ │ ├── operators.ts │ │ │ └── sql-helpers.ts │ │ ├── test/ │ │ │ ├── adapter-integration-contract.ts │ │ │ ├── adapter-integration-schema.ts │ │ │ ├── sqlite-adapter.ts │ │ │ └── sqlite-test-database.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── data-table-mysql/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.ddl-migration-contract.md │ │ │ └── minor.introspection-migration-transaction-tokens.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── adapter.integration.test.ts │ │ │ ├── adapter.test.ts │ │ │ ├── adapter.ts │ │ │ ├── sql-compiler.test.ts │ │ │ └── sql-compiler.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── data-table-postgres/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.ddl-migration-contract.md │ │ │ └── minor.introspection-migration-transaction-tokens.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── adapter.integration.test.ts │ │ │ ├── adapter.test.ts │ │ │ ├── adapter.ts │ │ │ ├── sql-compiler.test.ts │ │ │ └── sql-compiler.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── data-table-sqlite/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.ddl-migration-contract.md │ │ │ └── minor.introspection-migration-transaction-tokens.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── adapter.integration.test.ts │ │ │ ├── adapter.test.ts │ │ │ ├── adapter.ts │ │ │ ├── sql-compiler.test.ts │ │ │ └── sql-compiler.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── fetch-proxy/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── fetch-proxy.test.ts │ │ │ └── fetch-proxy.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── fetch-router/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.request-context-storage-methods.md │ │ │ ├── minor.simplify-controller-shape.md │ │ │ └── patch.optional-action-middleware.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── demos/ │ │ │ ├── bun/ │ │ │ │ ├── README.md │ │ │ │ ├── app/ │ │ │ │ │ ├── data.ts │ │ │ │ │ ├── router.ts │ │ │ │ │ └── routes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── package.json │ │ │ │ └── tsconfig.json │ │ │ ├── cf-workers/ │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── app/ │ │ │ │ │ ├── data.ts │ │ │ │ │ ├── router.ts │ │ │ │ │ └── routes.ts │ │ │ │ ├── migrations/ │ │ │ │ │ └── 0001_initial.sql │ │ │ │ ├── package.json │ │ │ │ ├── tsconfig.json │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ ├── worker.ts │ │ │ │ └── wrangler.jsonc │ │ │ └── node/ │ │ │ ├── README.md │ │ │ ├── app/ │ │ │ │ ├── data.ts │ │ │ │ ├── router.ts │ │ │ │ └── routes.ts │ │ │ ├── package.json │ │ │ ├── server.ts │ │ │ └── tsconfig.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── controller.ts │ │ │ │ ├── middleware.test.ts │ │ │ │ ├── middleware.ts │ │ │ │ ├── request-abort.test.ts │ │ │ │ ├── request-abort.ts │ │ │ │ ├── request-context.test.ts │ │ │ │ ├── request-context.ts │ │ │ │ ├── request-methods.ts │ │ │ │ ├── route-helpers/ │ │ │ │ │ ├── form.test.ts │ │ │ │ │ ├── form.ts │ │ │ │ │ ├── method.test.ts │ │ │ │ │ ├── method.ts │ │ │ │ │ ├── resource.test.ts │ │ │ │ │ ├── resource.ts │ │ │ │ │ ├── resources.test.ts │ │ │ │ │ └── resources.ts │ │ │ │ ├── route-map.test.ts │ │ │ │ ├── route-map.ts │ │ │ │ ├── router-abort.test.ts │ │ │ │ ├── router.test.ts │ │ │ │ ├── router.ts │ │ │ │ └── type-utils.ts │ │ │ └── routes.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── file-storage/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── fs.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── backends/ │ │ │ │ │ ├── fs.test.ts │ │ │ │ │ ├── fs.ts │ │ │ │ │ ├── memory.test.ts │ │ │ │ │ └── memory.ts │ │ │ │ └── file-storage.ts │ │ │ └── memory.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── file-storage-s3/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── s3.integration.test.ts │ │ │ ├── s3.test.ts │ │ │ └── s3.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── form-data-middleware/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.context-form-data-key.md │ │ │ └── patch.no-op-if-already-parsed.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── form-data.test.ts │ │ │ └── form-data.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── form-data-parser/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ └── minor.aggregate-multipart-limits.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── demos/ │ │ │ └── node/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── tsconfig.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── form-data.test.ts │ │ │ └── form-data.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── fs/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── fs.test.ts │ │ │ └── fs.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── headers/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── accept-encoding.test.ts │ │ │ ├── accept-encoding.ts │ │ │ ├── accept-language.test.ts │ │ │ ├── accept-language.ts │ │ │ ├── accept.test.ts │ │ │ ├── accept.ts │ │ │ ├── cache-control.test.ts │ │ │ ├── cache-control.ts │ │ │ ├── content-disposition.test.ts │ │ │ ├── content-disposition.ts │ │ │ ├── content-range.test.ts │ │ │ ├── content-range.ts │ │ │ ├── content-type.test.ts │ │ │ ├── content-type.ts │ │ │ ├── cookie.test.ts │ │ │ ├── cookie.ts │ │ │ ├── header-names.test.ts │ │ │ ├── header-names.ts │ │ │ ├── header-value.ts │ │ │ ├── if-match.test.ts │ │ │ ├── if-match.ts │ │ │ ├── if-none-match.test.ts │ │ │ ├── if-none-match.ts │ │ │ ├── if-range.test.ts │ │ │ ├── if-range.ts │ │ │ ├── param-values.test.ts │ │ │ ├── param-values.ts │ │ │ ├── range.test.ts │ │ │ ├── range.ts │ │ │ ├── raw-headers.test.ts │ │ │ ├── raw-headers.ts │ │ │ ├── set-cookie.test.ts │ │ │ ├── set-cookie.ts │ │ │ ├── utils.ts │ │ │ ├── vary.test.ts │ │ │ └── vary.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── html-template/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── safe-html.test.ts │ │ │ └── safe-html.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── lazy-file/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── globals.ts │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── byte-range.test.ts │ │ │ ├── byte-range.ts │ │ │ ├── lazy-file.test.ts │ │ │ └── lazy-file.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── logger-middleware/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── logger.test.ts │ │ │ └── logger.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── method-override-middleware/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── method-override.test.ts │ │ │ └── method-override.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── mime/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── codegen.test.ts │ │ │ └── codegen.ts │ │ ├── src/ │ │ │ ├── generated/ │ │ │ │ ├── compressible-mime-types.ts │ │ │ │ └── mime-types.ts │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── define-mime-type.test.ts │ │ │ ├── define-mime-type.ts │ │ │ ├── detect-content-type.test.ts │ │ │ ├── detect-content-type.ts │ │ │ ├── detect-mime-type.test.ts │ │ │ ├── detect-mime-type.ts │ │ │ ├── is-compressible-mime-type.test.ts │ │ │ ├── is-compressible-mime-type.ts │ │ │ ├── mime-type-to-content-type.test.ts │ │ │ └── mime-type-to-content-type.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── multipart-parser/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ └── minor.aggregate-multipart-limits.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bench/ │ │ │ ├── messages.ts │ │ │ ├── package.json │ │ │ ├── parsers/ │ │ │ │ ├── busboy.ts │ │ │ │ ├── fastify-busboy.ts │ │ │ │ ├── multipart-parser.ts │ │ │ │ └── multipasta.ts │ │ │ ├── runner.ts │ │ │ └── utils.ts │ │ ├── demos/ │ │ │ ├── bun/ │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── package.json │ │ │ │ ├── server.ts │ │ │ │ └── tsconfig.json │ │ │ ├── cf-workers/ │ │ │ │ ├── .gitignore │ │ │ │ ├── README.md │ │ │ │ ├── package.json │ │ │ │ ├── src/ │ │ │ │ │ └── index.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.toml │ │ │ ├── deno/ │ │ │ │ ├── README.md │ │ │ │ ├── main.ts │ │ │ │ └── package.json │ │ │ └── node/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── tsconfig.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── buffer-search.test.ts │ │ │ │ ├── buffer-search.ts │ │ │ │ ├── multipart-request.test.ts │ │ │ │ ├── multipart-request.ts │ │ │ │ ├── multipart.node.test.ts │ │ │ │ ├── multipart.node.ts │ │ │ │ ├── multipart.test.ts │ │ │ │ ├── multipart.ts │ │ │ │ └── read-stream.ts │ │ │ └── node.ts │ │ ├── test/ │ │ │ ├── utils.node.ts │ │ │ └── utils.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── node-fetch-server/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bench/ │ │ │ ├── package.json │ │ │ ├── runner.sh │ │ │ └── servers/ │ │ │ ├── express.ts │ │ │ ├── node-fetch-server.ts │ │ │ └── node-http.ts │ │ ├── demos/ │ │ │ └── http2/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── server.crt │ │ │ ├── server.csr │ │ │ ├── server.js │ │ │ ├── server.key │ │ │ └── tsconfig.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── fetch-handler.ts │ │ │ ├── read-stream.ts │ │ │ ├── request-listener.test.ts │ │ │ └── request-listener.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── remix/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── config.json │ │ │ ├── minor.remix.add-cors-middleware-export.md │ │ │ ├── minor.remix.component-exports.md │ │ │ ├── minor.remix.update-exports.md │ │ │ └── minor.request-protection-middlewares.md │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── async-context-middleware.ts │ │ │ ├── component/ │ │ │ │ ├── jsx-dev-runtime.ts │ │ │ │ ├── jsx-runtime.ts │ │ │ │ └── server.ts │ │ │ ├── component.ts │ │ │ ├── compression-middleware.ts │ │ │ ├── cookie.ts │ │ │ ├── cop-middleware.ts │ │ │ ├── cors-middleware.ts │ │ │ ├── csrf-middleware.ts │ │ │ ├── data-schema/ │ │ │ │ ├── checks.ts │ │ │ │ ├── coerce.ts │ │ │ │ ├── form-data.ts │ │ │ │ └── lazy.ts │ │ │ ├── data-schema.ts │ │ │ ├── data-table/ │ │ │ │ ├── migrations/ │ │ │ │ │ └── node.ts │ │ │ │ ├── migrations.ts │ │ │ │ ├── operators.ts │ │ │ │ └── sql-helpers.ts │ │ │ ├── data-table-mysql.ts │ │ │ ├── data-table-postgres.ts │ │ │ ├── data-table-sqlite.ts │ │ │ ├── data-table.ts │ │ │ ├── fetch-proxy.ts │ │ │ ├── fetch-router/ │ │ │ │ └── routes.ts │ │ │ ├── fetch-router.ts │ │ │ ├── file-storage/ │ │ │ │ ├── fs.ts │ │ │ │ └── memory.ts │ │ │ ├── file-storage-s3.ts │ │ │ ├── file-storage.ts │ │ │ ├── form-data-middleware.ts │ │ │ ├── form-data-parser.ts │ │ │ ├── fs.ts │ │ │ ├── headers.ts │ │ │ ├── html-template.ts │ │ │ ├── lazy-file.ts │ │ │ ├── logger-middleware.ts │ │ │ ├── method-override-middleware.ts │ │ │ ├── mime.ts │ │ │ ├── multipart-parser/ │ │ │ │ └── node.ts │ │ │ ├── multipart-parser.ts │ │ │ ├── node-fetch-server.ts │ │ │ ├── response/ │ │ │ │ ├── compress.ts │ │ │ │ ├── file.ts │ │ │ │ ├── html.ts │ │ │ │ └── redirect.ts │ │ │ ├── route-pattern/ │ │ │ │ └── specificity.ts │ │ │ ├── route-pattern.ts │ │ │ ├── session/ │ │ │ │ ├── cookie-storage.ts │ │ │ │ ├── fs-storage.ts │ │ │ │ └── memory-storage.ts │ │ │ ├── session-middleware.ts │ │ │ ├── session-storage-memcache.ts │ │ │ ├── session-storage-redis.ts │ │ │ ├── session.ts │ │ │ ├── static-middleware.ts │ │ │ └── tar-parser.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── response/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── compress.ts │ │ │ ├── file.ts │ │ │ ├── html.ts │ │ │ ├── lib/ │ │ │ │ ├── compress.test.ts │ │ │ │ ├── compress.ts │ │ │ │ ├── file.test.ts │ │ │ │ ├── file.ts │ │ │ │ ├── html.test.ts │ │ │ │ ├── html.ts │ │ │ │ ├── redirect.test.ts │ │ │ │ └── redirect.ts │ │ │ └── redirect.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── route-pattern/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ ├── minor.readonly-ast.md │ │ │ └── patch.type-inference-perf.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bench/ │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── patterns/ │ │ │ │ ├── mediarss.ts │ │ │ │ └── shopify.ts │ │ │ ├── src/ │ │ │ │ ├── comparison.bench.ts │ │ │ │ ├── href.bench.ts │ │ │ │ ├── pathological.bench.ts │ │ │ │ ├── shopify.bench.ts │ │ │ │ └── simple.bench.ts │ │ │ └── types/ │ │ │ ├── href.ts │ │ │ ├── join.ts │ │ │ ├── match.ts │ │ │ ├── new.ts │ │ │ └── params.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── array-matcher.ts │ │ │ │ ├── matcher.test.ts │ │ │ │ ├── matcher.ts │ │ │ │ ├── regexp.ts │ │ │ │ ├── route-pattern/ │ │ │ │ │ ├── AGENTS.md │ │ │ │ │ ├── href.test.ts │ │ │ │ │ ├── href.ts │ │ │ │ │ ├── join.ts │ │ │ │ │ ├── match.ts │ │ │ │ │ ├── params.test.ts │ │ │ │ │ ├── params.ts │ │ │ │ │ ├── parse.test.ts │ │ │ │ │ ├── parse.ts │ │ │ │ │ ├── part-pattern.test.ts │ │ │ │ │ ├── part-pattern.ts │ │ │ │ │ ├── serialize.ts │ │ │ │ │ ├── split.test.ts │ │ │ │ │ └── split.ts │ │ │ │ ├── route-pattern.test.ts │ │ │ │ ├── route-pattern.ts │ │ │ │ ├── specificity.test.ts │ │ │ │ ├── specificity.ts │ │ │ │ ├── trie-matcher/ │ │ │ │ │ ├── variant.test.ts │ │ │ │ │ └── variant.ts │ │ │ │ ├── trie-matcher.ts │ │ │ │ ├── types/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── join.test.ts │ │ │ │ │ ├── join.ts │ │ │ │ │ ├── parse.test.ts │ │ │ │ │ ├── parse.ts │ │ │ │ │ ├── split.test.ts │ │ │ │ │ ├── split.ts │ │ │ │ │ ├── stringify.test.ts │ │ │ │ │ ├── stringify.ts │ │ │ │ │ └── utils.ts │ │ │ │ └── unreachable.ts │ │ │ └── specificity.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── session/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cookie-storage.ts │ │ │ ├── fs-storage.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── session-storage/ │ │ │ │ │ ├── cookie.test.ts │ │ │ │ │ ├── cookie.ts │ │ │ │ │ ├── fs.test.ts │ │ │ │ │ ├── fs.ts │ │ │ │ │ ├── memory.test.ts │ │ │ │ │ └── memory.ts │ │ │ │ ├── session-storage.ts │ │ │ │ ├── session.test.ts │ │ │ │ └── session.ts │ │ │ └── memory-storage.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── session-middleware/ │ │ ├── .changes/ │ │ │ ├── README.md │ │ │ └── minor.session-context-key.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── session.test.ts │ │ │ └── session.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── session-storage-memcache/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── memcache-client.test.ts │ │ │ ├── memcache-client.ts │ │ │ ├── memcache-storage.integration.test.ts │ │ │ ├── memcache-storage.test.ts │ │ │ └── memcache-storage.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── session-storage-redis/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── redis-storage.integration.test.ts │ │ │ ├── redis-storage.test.ts │ │ │ └── redis-storage.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── static-middleware/ │ │ ├── .changes/ │ │ │ └── README.md │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── demos/ │ │ │ └── list-files/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── server.js │ │ │ └── tsconfig.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── directory-listing.ts │ │ │ ├── static.test.ts │ │ │ └── static.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ └── tar-parser/ │ ├── .changes/ │ │ └── README.md │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── bench/ │ │ ├── package.json │ │ ├── parsers/ │ │ │ ├── node-tar.ts │ │ │ ├── tar-parser.ts │ │ │ └── tar-stream.ts │ │ └── runner.ts │ ├── package.json │ ├── src/ │ │ ├── globals.ts │ │ ├── index.ts │ │ └── lib/ │ │ ├── read-stream.ts │ │ ├── tar.test.ts │ │ ├── tar.ts │ │ └── utils.ts │ ├── test/ │ │ ├── fixtures/ │ │ │ ├── express-4.21.1.tgz │ │ │ ├── lodash-4.17.21.tgz │ │ │ └── npm-11.0.0.tgz │ │ └── utils.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── pnpm-workspace.yaml └── scripts/ ├── changes-preview.ts ├── changes-validate.ts ├── changes-version.ts ├── detect-changed-packages.ts ├── generate-remix.ts ├── package.json ├── pr-preview.ts ├── publish.ts ├── release-pr.ts ├── setup-installable-branch.ts ├── tsconfig.json └── utils/ ├── changes.test.ts ├── changes.ts ├── color.ts ├── fs.ts ├── git.test.ts ├── git.ts ├── github.ts ├── packages.ts ├── process.ts ├── release-pr.test.ts ├── release-pr.ts └── semver.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/skills/add-package/SKILL.md ================================================ --- name: add-package description: Create or align a package in the Remix monorepo to match existing package conventions. Use when adding a brand new package under packages/, or when fixing an existing package's structure, test setup, TypeScript/build config, code style, and README layout to match the rest of Remix 3. --- # Add Package ## Overview Use this skill to scaffold and standardize packages so they look and behave like the existing `@remix-run/*` packages. Follow this exactly when creating package files, public exports, tests, and docs. ## Workflow 1. Create the package directory and baseline files. - Create `packages//`. - Add: - `package.json` - `tsconfig.json` - `tsconfig.build.json` - `CHANGELOG.md` - `README.md` - `LICENSE` - `.changes/README.md` - `src/` - For new packages, start `CHANGELOG.md` with `## Unreleased` as the first section to indicate changes are not released yet. 2. Set up `package.json` using monorepo conventions. - Use: - `name`: `@remix-run/` - `version` (for brand-new packages): `"0.0.0"` - `type`: `"module"` - `license`: `"MIT"` - `repository.directory`: `packages/` - `homepage`: `https://github.com/remix-run/remix/tree/main/packages/#readme` - Include `files`: - `LICENSE` - `README.md` - `dist` - `src` - `!src/**/*.test.ts` - Add standard scripts: - `build`: `tsgo -p tsconfig.build.json` - `clean`: `git clean -fdX` - `prepublishOnly`: `pnpm run build` - `test`: `node --disable-warning=ExperimentalWarning --test` - `typecheck`: `tsgo --noEmit` - Use baseline dev dependencies: - `"@types/node": "catalog:"` - `"@typescript/native-preview": "catalog:"` - Add `keywords` like existing packages (short, lowercase, feature-focused). 3. Define exports with `src` entry files only. - In `exports`, map each public subpath to a dedicated file in `src`. - Always include `./package.json`. - Mirror each export in `publishConfig.exports` with `dist` output: - `types`: `./dist/.d.ts` - `default`: `./dist/.js` - Rule: every export must have a `src` file that re-exports from `src/lib`. - Example: export `./foo` -> `src/foo.ts` -> `export { ... } from './lib/foo.ts'` 4. Add TypeScript config files with shared defaults. Use this `tsconfig.json` pattern: ```json { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true } } ``` Use this `tsconfig.build.json` pattern: ```json { "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, "declarationMap": true, "outDir": "./dist" }, "include": ["src"], "exclude": ["src/**/*.test.ts"] } ``` 5. Implement source structure and test setup. - Structure source as: - `src/.ts` for public entry points - `src/lib/*.ts` for implementation - `src/lib/*.test.ts` for tests (colocated with implementation) - Tests use Node's built-in test runner: - `import * as assert from 'node:assert/strict'` - `import { describe, it } from 'node:test'` - Keep tests IDE-friendly: - Do not generate tests with loops/conditionals inside `describe()`. 6. Follow monorepo code style rules while implementing. - Use `import type { ... }` and `export type { ... }` for types. - Include `.ts` extensions in relative imports. - Prefer `let` for locals; use `const` only at module scope. - Never use `var`. - Prefer function declarations/expressions for normal functions. - Use arrow functions for callbacks; use concise callbacks when returning a single expression. - Use object method shorthand (`method() {}`) instead of arrow properties. - Use native class fields and `#private` members. - Avoid Node-specific APIs when Web APIs are available. 7. Write README in the same style and section order as existing packages. - Start with: - `# ` - One short paragraph describing purpose. - Typical section order: - `## Features` - `## Installation` - `## Usage` - Optional deep-dive sections (only if needed) - `## Related Packages` (if applicable) - `## License` - Installation instructions must always include installing the `remix` package. - If using the package requires a peer dependency, installation instructions must also include that peer dependency in the command. - Preferred installation pattern: ```sh npm i remix ``` - Example when a peer dependency is required: ```sh npm i remix ``` - Usage examples must always import from `remix` package exports, not from `@remix-run/` directly. - License section format: - `See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)` 8. Do not manually update the generated `remix` package in PRs. - `packages/remix` is generated automatically in CI. - Do not manually edit `packages/remix/package.json` or `packages/remix/src/*` in new pull requests. - Do not add `packages/remix/.changes/*` change files in new pull requests. - If user asks for full surfacing, you can still update root `README.md` package list when applicable. 9. Validate before finishing. - Run package checks: - `pnpm --filter @remix-run/ run typecheck` - `pnpm --filter @remix-run/ run test` - `pnpm --filter @remix-run/ run build` - Run repo lint (required): - `pnpm run lint` - Add or update a change file under `packages//.changes/` when requested by contribution workflow. - For a brand-new package, the initial change file should use a `minor.` filename (for example, `minor.initial-release.md`) so the first release bumps `0.0.0` to `0.1.0`. - Exception: do not add a change file under `packages/remix/.changes/`; `remix` package updates are CI-generated. ## Templates Use this minimal `src/index.ts` style: ```ts export { createThing, type ThingOptions } from './lib/thing.ts' ``` Use this minimal test style: ```ts import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { createThing } from './thing.ts' describe('createThing', () => { it('returns expected value', () => { let result = createThing() assert.equal(result, 'ok') }) }) ``` ================================================ FILE: .agents/skills/add-package/agents/openai.yaml ================================================ interface: display_name: 'Add Package' short_description: 'Create new Remix monorepo packages consistently' default_prompt: 'Use $add-package to scaffold and wire a new package in the Remix monorepo with the standard layout, scripts, tests, and README structure.' ================================================ FILE: .agents/skills/make-change-file/SKILL.md ================================================ --- name: make-change-file description: Create or update Remix repo change files under `packages/*/.changes`. Use when a user asks for release notes, a change file, a missing changelog entry, a prerelease note, or an update to existing unpublished release notes. --- # Make Change File ## Overview Write release notes that match this repository's `.changes` conventions. Use it for both new change files and edits to existing unpublished ones. ## Workflow 1. Read the package's `package.json`, `.changes/` directory, and any relevant PR diff or commit range before writing anything. 2. Check whether an unpublished change file already exists for the same work. If it does, update it in place instead of creating a duplicate note. 3. Choose the bump type from the package version and the user-facing impact. 4. Write concise, user-facing release notes that describe shipped behavior, APIs, or exports. 5. Run `pnpm changes:preview` to verify the rendered changelog output. 6. Run `pnpm run lint` before finishing. ## Naming - Use `packages//.changes/[major|minor|patch].short-description.md`. - Keep the slug short, specific, and stable. - Reuse existing deterministic names when the repo already has a pattern for that class of note. - For Remix export-only changes, update `packages/remix/.changes/minor.remix.update-exports.md` in place. - For brand-new package releases, prefer `minor.initial-release.md`. ## Bump Rules - For `0.x` packages: use `minor` for new features and breaking changes, `patch` for bug fixes. - Do not use `major` for `0.x` packages unless explicitly instructed. - For `1.x+` packages: use standard semver. - Breaking changes are relative to `main`, not relative to earlier commits in the same PR. - In `0.x`, breaking change notes must start with `BREAKING CHANGE: `. - For `remix` prerelease mode, the bump type mostly controls changelog categorization while the prerelease counter advances. ## Content Rules - Document user-visible behavior, public API changes, exports, migrations, or upgrade work. - Do not write release notes for internal refactors unless they surface as real API or behavior changes. - Prefer a small number of logically grouped notes over many tiny files. - If one package changes internally and another package re-exports the new surface, add notes for both when users can consume the change from both package entrypoints. - Do not manually hard-wrap prose in `.changes/*.md` files. Keep each paragraph or bullet on a single source line and let rendered changelogs wrap naturally. - Use flat bullets only when they add clarity. Short paragraphs are usually better. ## Remix-Specific Rules - `packages/remix/src/*` re-export files are generated. Do not hand-edit them unless the task explicitly requires generated output. - When `packages/remix/package.json` gains or changes public exports, capture that in `minor.remix.update-exports.md` instead of inventing a one-off filename. - If the change exposes another package's new APIs through `remix/...`, describe the surfaced `remix/...` entrypoints, not just the underlying workspace package name. ## Checklist - Did you inspect existing unpublished `.changes` files first? - Is the bump type correct for the package version? - Did you reuse any deterministic filename the repo already expects? - Does the note describe user-facing changes instead of implementation details? - Does each paragraph or bullet stay on one source line without manual hard wrapping? - Did `pnpm changes:preview` render the expected changelog entry? ================================================ FILE: .agents/skills/make-change-file/agents/openai.yaml ================================================ interface: display_name: 'Make Change File' short_description: 'Create or update Remix change files using repo conventions.' default_prompt: 'Use $make-change-file to add or revise package change files for this Remix repo.' ================================================ FILE: .agents/skills/make-demo/SKILL.md ================================================ --- name: make-demo description: Create or revise demos in the Remix repository. Use when adding a new demo under demos/, updating an existing demo, or reviewing demo code to ensure it showcases Remix packages, strong code hygiene, and production-quality patterns. --- # Make Demo ## Overview Demos in this repository are not throwaway prototypes. They are durable code artifacts that should teach people and other agents how to write Remix code well. A good demo should: - exercise Remix framework behavior in a realistic way - push the target APIs through meaningful edge cases and composition points - model clean structure, naming, and accessibility - be code that a reader could adapt into a real application ## Workflow 1. Read the target APIs and at least one or two existing demos before writing new code. 2. Choose a focused scenario that exists to demonstrate Remix behavior, not a generic app shell. 3. Build the demo under `demos//` using the same conventions as the existing demos. 4. Treat the code as a reference artifact, not as temporary sample code. 5. Validate the demo locally before finishing. ## Rules - Use Remix library packages for the demo's framework behavior. Do not introduce unrelated routers, component frameworks, state managers, or middleware stacks that distract from the Remix patterns being demonstrated. - Keep any non-Remix dependency incidental to the runtime environment only. If a database driver, asset bundler, or type package is needed, it should support the demo rather than define its architecture. - Demos should push Remix to its limits in a focused way. Prefer realistic edge cases, composition, streaming, middleware, routing, navigation, forms, or request-handling scenarios over toy examples. - When demos use `remix/component`, prefer idiomatic Remix component patterns. Use normal JSX composition and built-in styling/mixin props such as `css={...}` or `mix={css(...)}` and `mix={[...]}` instead of dropping down to manual DOM mutation or ad hoc class management. - Demo code must have good hygiene. Use clear names, small focused modules, explicit control flow, and accessible markup. Avoid hacks, dead code, unexplained shortcuts, or patterns that would be poor examples for users to copy. - Make the demo teach good patterns. Assume readers and future agents will study it as an example of how Remix code should be written in this repository. - All demo servers should use port `44100`. - Demo servers should handle `SIGINT` and `SIGTERM` cleanly by closing the server and exiting. ## Typical Structure Use only the files the scenario needs, but prefer this shape: - `demos//package.json` - `demos//server.ts` - `demos//README.md` - `demos//app/` - `demos//public/` when serving built assets or other static files ## README Expectations - Explain what the demo proves or teaches. - Document how to run it locally. - Point out the key Remix APIs or patterns being demonstrated. - Keep code examples and imports aligned with repo guidance: use `remix` package exports where available. ## Validation - Run `pnpm -C demos/ typecheck` when the demo defines a typecheck script. - Run `pnpm -C demos/ test` when the demo defines tests. - Smoke-test the demo server locally when behavior depends on live requests or browser interaction. - Run `pnpm run lint` before finishing. ================================================ FILE: .agents/skills/make-demo/agents/openai.yaml ================================================ interface: display_name: 'Make Demo' short_description: 'Create high-quality Remix demos' default_prompt: 'Use $make-demo to add or revise a demo under demos/ that showcases Remix packages with clean, artifact-quality code and strong repo conventions.' ================================================ FILE: .agents/skills/make-pr/SKILL.md ================================================ --- name: make-pr description: Create GitHub pull requests with clear, reviewer-friendly descriptions. Use when asked to open or prepare a PR, especially when the PR needs strong context, related links, and feature usage examples. This skill enforces concise PR structure, avoids redundant sections like validation/testing, and creates the PR with gh CLI. --- # Make PR ## Overview Use this skill to draft and open a PR with consistent, high-signal writing. Keep headings sparse and focus on the problem/feature explanation, context links, and practical code examples. Optimize for the shortest path to a credible PR, not the fullest possible context-gathering pass. ## Workflow 1. Check the fast-path blockers first. - Check `git status --short --branch` and `git branch --show-current` before doing deeper prep. - If the repo is in a detached HEAD or worktree state and the user wants a PR opened, create a branch early. 1. Gather only the context needed to write the PR. - Capture what changed, why it changed, and who it affects. - Find related issues/PRs and include links when relevant. - Prefer `git diff --stat` plus the relevant diff over broad repo archaeology when the change is small. - If the user supplies a report, issue, or related PR, treat that as the primary context source. 1. Get the branch into a PR-ready state quickly. - If changes are still uncommitted and the user wants a PR, branch first, then commit. - Prefer a single clean commit unless the user asks for a different history shape. 1. Check whether this PR also needs a change file. - Do not assume every PR needs one. - Before opening the PR, decide whether the change is user-facing enough to require release notes in `packages/*/.changes`. - If a change file is needed or likely needed, use the `make-change-file` skill instead of re-deriving that workflow here. 1. Draft the PR body with minimal structure. - Start with 1-2 short introductory paragraphs. - After the intro, include clear bullets describing: - the feature and/or issue addressed - key behavior/API changes - expected impact - If the change is extensive, expand to up to 3-4 paragraphs and include background context with related links. 1. Add required usage examples for feature work. - If the PR introduces a new feature, include a comprehensive usage snippet. - If it replaces or improves an older approach, include before/after examples. 1. Exclude redundant sections. - Do not include `Validation`, `Testing`, or other process sections that are already implicit in PR workflow. - Do not add boilerplate sections that do not help review. 1. Create the PR. - Save the body to a temporary file and run: ```bash gh pr create --base main --head --title "" --body-file <file> ``` - If `gh pr create` fails, leave the branch pushed when possible and give the user a ready-to-open compare URL plus the prepared title/body details. ## Body Template Use this as a base and fill with concrete repo-specific details: ````md <One or two short intro paragraphs explaining the change and why it matters.> - <Feature/issue addressed> - <What changed in behavior or API> - <Why this is needed now> <Optional additional context paragraph(s), up to 3-4 total for large changes, including links to related PRs/issues.> ```ts // New feature usage example ``` ```ts // Before ``` ```ts // After ``` ```` ================================================ FILE: .agents/skills/make-pr/agents/openai.yaml ================================================ interface: display_name: 'Make PR' short_description: 'Create high-quality GitHub pull requests' default_prompt: 'Use $make-pr to draft and create a GitHub pull request with clear context and examples.' ================================================ FILE: .agents/skills/publish-placeholder-package/SKILL.md ================================================ --- name: publish-placeholder-package description: Publish a placeholder npm package at version 0.0.0 so package names are reserved and npm OIDC permissions can be configured before CI publishing. Use when creating a brand-new package that is not ready for full release. --- # Publish Placeholder Package ## Overview Use this skill to publish a minimal placeholder package to npm at `0.0.0`. This is used to reserve the package name and unblock npm-side OIDC configuration for CI publishing. ## Workflow 1. Confirm publish target. - Collect: - npm package name (for example, `@remix-run/my-package`) - package directory in repo (for example, `packages/my-package`) - Validate the package does not already exist at `0.0.0`: ```sh npm view <package-name>@0.0.0 version ``` - If it already exists, stop and report that no placeholder publish is needed. 2. Build a temporary placeholder package outside the repo. - Always publish from a temp directory to avoid shipping real package files by mistake. - Create the temp directory and write a minimal `package.json`: ```sh tmp_dir="$(mktemp -d)" cd "$tmp_dir" cat > package.json <<'JSON' { "name": "<package-name>", "version": "0.0.0", "description": "Placeholder package for Remix CI/OIDC setup", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", "directory": "<repo-package-dir>" }, "publishConfig": { "access": "public" } } JSON ``` - Add a short README: ```sh cat > README.md <<'MD' # Placeholder Package This package is a placeholder published at `0.0.0` to reserve the npm name and configure CI publish permissions. MD ``` 3. Ensure npm auth is valid (expect re-auth/OTP). - Check session: ```sh npm whoami ``` - If not authenticated, run: ```sh npm login ``` - Expect npm to require a fresh login and/or one-time password. If prompted for OTP, request it from the user and continue. 4. Publish the placeholder. - Publish with public access: ```sh npm publish --access public ``` - If the account enforces 2FA for writes, publish with OTP: ```sh npm publish --access public --otp <code> ``` 5. Verify and report. - Verify the published version: ```sh npm view <package-name>@0.0.0 version ``` - Report: - package name - published version (`0.0.0`) - confirmation that npm package exists for OIDC permission setup 6. Clean up temp files. ```sh rm -rf "$tmp_dir" ``` ## Notes - Keep placeholder publish minimal. Do not publish full source code for this step. - This is a one-time bootstrap step. Normal releases should continue through CI. ================================================ FILE: .agents/skills/publish-placeholder-package/agents/openai.yaml ================================================ interface: display_name: 'Publish Placeholder Package' short_description: 'Publish npm placeholder package at 0.0.0' default_prompt: 'Use $publish-placeholder-package to publish a minimal npm placeholder package at 0.0.0 so we can configure npm OIDC permissions for CI publishing.' ================================================ FILE: .agents/skills/supersede-pr/SKILL.md ================================================ --- name: supersede-pr description: Safely replace one GitHub pull request with another. Use when a user says a PR supersedes/replaces an older PR, asks to auto-close a superseded PR, or needs guaranteed closure behavior after merge. This skill explicitly closes the superseded PR with gh CLI and verifies final PR states instead of relying on closing keywords. --- # Supersede PR ## Overview Use this skill to handle PR supersession end-to-end. Do not rely on `Closes #<number>` to close another PR. GitHub closing keywords close issues, not pull requests. ## Workflow 1. Identify PR numbers and target repo. - Capture `old_pr` (the superseded PR) and `new_pr` (the replacement PR). - Resolve the repo with `gh repo view --json nameWithOwner -q .nameWithOwner` when not provided. 1. Create or update the replacement PR first. - Open/push the replacement branch. - Open the new PR. - Include a traceable link in the PR body such as `Supersedes #<old_pr>`. 1. Close the superseded PR explicitly. - Run: ```bash scripts/close_superseded_pr.ts <old_pr> <new_pr> ``` - This adds a comment (`Superseded by #<new_pr>.`) and closes the old PR. 1. Verify states. - Confirm the superseded PR is closed: ```bash gh pr view <old_pr> --json state,url ``` - Confirm the replacement PR status/checks: ```bash gh pr checks <new_pr> ``` ## Rules 1. Do not use `Closes #<old_pr>` when `<old_pr>` is a pull request. - Use `Closes/Fixes` only for issues. - Use `Supersedes #<old_pr>` or `Refs #<old_pr>` for PR-to-PR linkage. 1. Prefer explicit closure over implied automation. - Always run the close command when the user asks to supersede a PR. - Treat closure as incomplete until `gh pr view <old_pr>` returns `CLOSED`. ## Script Use the bundled script for deterministic closure: - `scripts/close_superseded_pr.ts` ================================================ FILE: .agents/skills/supersede-pr/agents/openai.yaml ================================================ interface: display_name: 'Supersede PR' short_description: 'Close superseded pull requests safely' default_prompt: 'Use $supersede-pr to replace a PR and close the superseded PR safely.' ================================================ FILE: .agents/skills/supersede-pr/scripts/close_superseded_pr.ts ================================================ #!/usr/bin/env node import { spawnSync } from 'node:child_process' import * as process from 'node:process' type ParsedArgs = { dryRun: boolean newPr: string oldPr: string repo: string | null } function main(): void { let parsed = parseArgs(process.argv.slice(2)) ensureNumericPrNumber(parsed.oldPr, 'old_pr') ensureNumericPrNumber(parsed.newPr, 'new_pr') if (parsed.oldPr === parsed.newPr) { fail('old_pr and new_pr must be different.') } let repo = parsed.repo ?? ghCapture(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']) let oldState = ghCapture([ 'pr', 'view', parsed.oldPr, '--repo', repo, '--json', 'state', '-q', '.state', ]) let newState = ghCapture([ 'pr', 'view', parsed.newPr, '--repo', repo, '--json', 'state', '-q', '.state', ]) if (newState !== 'OPEN' && newState !== 'MERGED') { fail(`Replacement PR #${parsed.newPr} is in state '${newState}'. Expected OPEN or MERGED.`) } if (oldState !== 'OPEN') { process.stdout.write(`Superseded PR #${parsed.oldPr} is already ${oldState}. Nothing to do.\n`) return } let comment = `Superseded by #${parsed.newPr}.` process.stdout.write(`Repo: ${repo}\n`) process.stdout.write(`Closing PR #${parsed.oldPr} with comment: ${comment}\n`) if (parsed.dryRun) { process.stdout.write( `[dry-run] gh pr close "${parsed.oldPr}" --repo "${repo}" --comment "${comment}"\n`, ) return } ghInherit(['pr', 'close', parsed.oldPr, '--repo', repo, '--comment', comment]) let finalState = ghCapture([ 'pr', 'view', parsed.oldPr, '--repo', repo, '--json', 'state', '-q', '.state', ]) if (finalState !== 'CLOSED') { fail(`Failed to close PR #${parsed.oldPr}. Final state: ${finalState}`) } process.stdout.write(`Closed PR #${parsed.oldPr} successfully.\n`) } function parseArgs(argv: string[]): ParsedArgs { if (argv.includes('-h') || argv.includes('--help')) { printUsage() process.exit(0) } if (argv.length < 2) { printUsage() process.exit(1) } let oldPr = argv[0] let newPr = argv[1] let repo: string | null = null let dryRun = false let index = 2 while (index < argv.length) { let arg = argv[index] if (arg === '--repo') { let next = argv[index + 1] if (!next) { fail('--repo requires a value like owner/repo') } repo = next index += 2 continue } if (arg === '--dry-run') { dryRun = true index++ continue } fail(`Unknown argument: ${arg}`) } return { dryRun, newPr, oldPr, repo } } function printUsage(): void { process.stdout.write(`Usage: close_superseded_pr.ts <old_pr> <new_pr> [--repo <owner/repo>] [--dry-run] Examples: close_superseded_pr.ts 11085 11087 close_superseded_pr.ts 11085 11087 --repo remix-run/remix close_superseded_pr.ts 11085 11087 --dry-run `) } function ensureNumericPrNumber(value: string, label: string): void { if (!/^[0-9]+$/.test(value)) { fail(`${label} must be a numeric pull request number.`) } } function ghCapture(args: string[]): string { let result = spawnSync('gh', args, { encoding: 'utf8' }) if (result.status !== 0) { let stderr = (result.stderr ?? '').trim() let details = stderr ? `\n${stderr}` : '' fail(`gh ${args.join(' ')} failed.${details}`) } return (result.stdout ?? '').trim() } function ghInherit(args: string[]): void { let result = spawnSync('gh', args, { stdio: 'inherit' }) if (result.status !== 0) { fail(`gh ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}.`) } } function fail(message: string): never { process.stderr.write(`${message}\n`) process.exit(1) } main() ================================================ FILE: .agents/skills/supersede-pr/tsconfig.json ================================================ { "compilerOptions": { "allowJs": false, "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true, "strict": true, "target": "ESNext", "verbatimModuleSyntax": true }, "include": ["scripts/**/*.ts"] } ================================================ FILE: .agents/skills/update-pr/SKILL.md ================================================ --- name: update-pr description: Update an existing GitHub pull request title and description so they accurately describe the pull request as it exists now. Use when the user asks to update, rewrite, refresh, fix, or tighten a PR title/body, or when the PR scope has changed and the metadata needs to be brought back in sync. --- # Update PR ## Overview Rewrite pull request metadata as if drafting it from scratch for the current diff. Treat the title and body as a current reviewer-facing summary of the PR, not as commentary about prior versions of the PR. ## Workflow 1. Read the current PR title/body and the current branch diff before drafting. 2. Identify the PR's current scope, APIs, behavior changes, and reviewer-relevant context. 3. Rewrite the body from scratch so it describes the PR as it exists now. 4. Review the title at the same time and update it whenever the body is updated. 5. Apply the update with `gh pr edit`. ## Rules - Never write the description as an update to itself. Do not say things like "this expands the original PR", "this PR now also", or similar process narration unless the user explicitly wants history called out. - Always evaluate the title when updating a PR. If the scope or emphasis changed, rewrite the title too. - Write in terms of the present PR contents, using concise reviewer-facing language. - Keep the structure minimal: one short introductory paragraph plus flat bullets is usually enough. - Include usage examples when the PR introduces or materially changes a feature API. - Preserve still-relevant issue links or context, but drop stale framing. ## Applying The Update - Draft the new title and body in a temporary file. - Use `gh pr edit <number> --title "<title>" --body-file <file>`. - Re-read the PR after editing to confirm the final title/body match the intended framing. ================================================ FILE: .agents/skills/update-pr/agents/openai.yaml ================================================ interface: display_name: 'Update PR' short_description: 'Refresh an existing pull request title and body.' default_prompt: 'Use $update-pr to rewrite this pull request title and description so they accurately describe the PR as it exists now.' ================================================ FILE: .agents/skills/write-api-docs/SKILL.md ================================================ --- name: write-api-docs description: Write or audit public API docs for Remix packages. Use when adding or tightening JSDoc on exported functions, classes, interfaces, type aliases, or option objects. --- # Write API Docs ## Overview Use this skill when documenting public APIs in Remix packages. The goal is to document the API users can actually import, not every helper in `src/lib`. Work from the package exports outward, add concise JSDoc to the public declarations, and make sure the result passes the repo's ESLint JSDoc rules. ## Workflow 1. Identify the package's public exports. 2. Find the `src` entry files that back those exports. 3. Trace those entry files to the declarations they re-export from `src/lib`. 4. Add or tighten JSDoc on the public declarations only. 5. Run package typecheck if appropriate and always run `pnpm run lint`. ## How To Identify Public API The source of truth is the package's `package.json`. - Start with `package.json` `exports`. - Each public export should map to a file directly under `src/`. - Those `src/*.ts` entry files define the public surface by re-exporting symbols from `src/lib`. - A declaration in `src/lib` is public only if it is re-exported by one of those public `src/*.ts` entry files. Rules: - Do not assume everything in `src/lib` is public. - Do not document private helpers just because they are exported within `src/lib`. - If a declaration is not reachable from a package export, it is internal unless the user explicitly asks otherwise. ## What To Document For public API, add JSDoc to: - exported functions - exported classes - exported interfaces - exported type aliases - exported public constants when they are part of the API shape For public interfaces: - add a JSDoc block on the interface itself - add a property-level JSDoc block for every property on the interface, even when the name seems obvious For public object-shaped type aliases: - prefer an `interface` when you are introducing a new public object shape - if an existing public type alias cannot reasonably become an interface, document the object shape as thoroughly as the syntax allows For overloads: - document the public overload signatures or the exported declaration in a way that makes the callable surface clear to users ## JSDoc Style For This Repo Keep comments short, factual, and user-facing. - Describe what the API does, not how the implementation works internally. - Prefer one concise summary sentence, then short `@param` / `@returns` docs as needed. - Do not put TypeScript types in JSDoc tags. ESLint forbids JSDoc type syntax here because the source of truth is the TypeScript signature. - Keep parameter names in JSDoc exactly aligned with the function signature. - Use `@returns` for non-void functions and include a real description. - For `@param`, include descriptions and do not add a hyphen before the description. - Specify `@param` default values in parenthesis at the end of the comment, do not use `@default` tags - Include an `@example` code block when it helps to show a use-case or pattern. Skip `@example` for simple getters, trivial constructors, or APIs whose usage is self-evident. - Use `{@link API}` to link to related Remix APIs when it adds value. Don't link every related API — use discretion to avoid noise. - Use backticks for all other unlinked code references — identifiers, HTTP methods, special values. Good: ```ts /** * Creates an {@link AuthProvider} for direct credentials-based authentication. * * @param options Parsing and verification hooks for submitted credentials. * @returns A provider that can be passed to `login()`. */ export function createCredentialsAuthProvider(...) {} ``` Avoid: ```ts /** * @param {CredentialsOptions} options - options * @returns {CredentialsProvider} */ ``` ## ESLint Expectations The relevant rules live in [`eslint.config.js`](../../eslint.config.js). For `packages/**/*.{ts,tsx}` (excluding tests), ESLint enforces JSDoc on callable declarations such as: - function declarations - function expressions - arrow functions - class declarations - public methods Important enforced details: - `jsdoc/require-param` - `jsdoc/require-param-name` - `jsdoc/require-param-description` - `jsdoc/require-returns` - `jsdoc/require-returns-description` - `jsdoc/no-types` - `jsdoc/check-param-names` - `jsdoc/check-types` - `jsdoc/check-alignment` Practical implication: - if a public function takes parameters, document all of them - if a public function returns a value, document the return value - do not use JSDoc type annotations - keep the block formatted cleanly enough to satisfy alignment checks ## Review Checklist - Did you start from `package.json` exports instead of guessing from `src/lib`? - Are all documented declarations actually reachable from a public `src/*.ts` entry file? - Do all public functions and methods have JSDoc with `@param` and `@returns` where required? - Do public interfaces and type aliases have a concise doc block explaining what they represent? - Does every property on every public interface have its own property-level JSDoc block? - Did you avoid documenting internal helpers that are not exported publicly? - Did `pnpm run lint` pass? ================================================ FILE: .agents/skills/write-readme/SKILL.md ================================================ --- name: write-readme description: Write or rewrite package README files in the style used by the Remix repository. Use when drafting a new package README, revising an existing README, or reviewing README structure, examples, installation instructions, and section ordering for Remix packages. --- # Write Readme ## Overview Draft README files as concise package documentation for real users, not as marketing copy or API dumps. Mirror the structure used across this repository, keep examples production-oriented, and avoid awkward manual line breaks in prose. ## Workflow 1. Read the package API and at least one or two sibling package READMEs before drafting. 2. Document the package as it exists today, not the package you wish existed. 3. Start with a realistic production usage example as soon as the installation section is done. 4. Cover each major feature with a concrete example. 5. Finish with internal ecosystem links, external related work, and license info. ## Structure Use this section order unless there is a strong package-specific reason not to: 1. `# short package-name` (i.e. `fetch-router` instead of `@remix-run/fetch-router`) 2. Intro: one or two sentences explaining what the package does and why it exists 3. `## Features`: a flat bullet list of the main highlights 4. `## Installation` 5. `## Usage`: a production-like example that shows the package in context 6. One section per major feature, each with focused examples 7. `## Related Packages` 8. `## Related Work` 9. `## License` ## Rules - Installation should always start with: ```sh npm i remix ``` - If the package requires a third-party dependency or peer, include it explicitly in the installation section after `remix`. - Usage examples should import from `remix/...`, not `@remix-run/...`. - The first example should look like real application code, not the smallest possible snippet. - Feature sections should show how to use the package's major capabilities in practice, with one example per capability when useful. - Keep prose compact. Do not hard-wrap paragraphs at awkward places in the middle of a sentence just to force a line length. - Prefer flat bullets and short paragraphs over long explanatory blocks. - `Related Packages` should point to relevant Remix packages in the monorepo. - `Related Work` should point to external libraries, specs, standards, or prior art that help readers place the package. - `License` should use the standard repo wording and link. ## Checklist - Does the intro explain the package in one or two sentences? - Does the features list surface the package's main value quickly? - Does the installation section use `npm i remix`? - Does the main usage example show a realistic production scenario? - Does each major feature have an example? - Does the README end with `Related Packages`, `Related Work`, and `License`? - Does the prose read naturally without awkward manual line breaks? ================================================ FILE: .agents/skills/write-readme/agents/openai.yaml ================================================ interface: display_name: 'Write Readme' short_description: 'Write Remix package READMEs in the repo style.' default_prompt: 'Use $write-readme to draft or rewrite this package README in the style used by the Remix repository.' ================================================ FILE: .github/workflows/build.yaml ================================================ name: Build on: push: branches: - main pull_request: branches-ignore: - release-v2 - v2 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: strategy: matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build packages run: pnpm build ================================================ FILE: .github/workflows/check.yaml ================================================ name: Type check, lint, validate change files on: push: branches: - main pull_request: branches-ignore: - release-v2 - v2 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint: name: Lint runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 name: Install pnpm - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint run: pnpm lint typecheck: name: Typecheck runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 name: Install pnpm - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm typecheck change-files: name: Validate change files runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' - name: Check change files run: node ./scripts/changes-validate.ts ================================================ FILE: .github/workflows/data-table-integration.yaml ================================================ name: Data Table Integration Tests on: push: branches: - main paths: - '.github/workflows/data-table-integration.yaml' - 'packages/data-table/**' - 'packages/data-table-postgres/**' - 'packages/data-table-mysql/**' - 'packages/data-table-sqlite/**' - 'pnpm-workspace.yaml' - 'package.json' pull_request: paths: - '.github/workflows/data-table-integration.yaml' - 'packages/data-table/**' - 'packages/data-table-postgres/**' - 'packages/data-table-mysql/**' - 'packages/data-table-sqlite/**' - 'pnpm-workspace.yaml' - 'package.json' jobs: unit: name: Unit and Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run data-table package checks run: | pnpm --filter @remix-run/data-table run typecheck pnpm --filter @remix-run/data-table run test pnpm --filter @remix-run/data-table run test:coverage pnpm --filter @remix-run/data-table run build pnpm --filter @remix-run/data-table-postgres run typecheck pnpm --filter @remix-run/data-table-postgres run test pnpm --filter @remix-run/data-table-postgres run test:coverage pnpm --filter @remix-run/data-table-postgres run build pnpm --filter @remix-run/data-table-mysql run typecheck pnpm --filter @remix-run/data-table-mysql run test pnpm --filter @remix-run/data-table-mysql run test:coverage pnpm --filter @remix-run/data-table-mysql run build pnpm --filter @remix-run/data-table-sqlite run typecheck pnpm --filter @remix-run/data-table-sqlite run test pnpm --filter @remix-run/data-table-sqlite run test:coverage pnpm --filter @remix-run/data-table-sqlite run build postgres-integration: name: Postgres Integration runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: remix ports: - 5432:5432 options: >- --health-cmd="pg_isready -U postgres" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run postgres integration tests env: DATA_TABLE_INTEGRATION: '1' DATA_TABLE_POSTGRES_URL: postgres://postgres:postgres@127.0.0.1:5432/remix run: node --disable-warning=ExperimentalWarning --test './packages/data-table-postgres/src/lib/adapter.integration.test.ts' mysql-integration: name: MySQL Integration runs-on: ubuntu-latest services: mysql: image: mysql:8 env: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: remix ports: - 3306:3306 options: >- --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" --health-interval=10s --health-timeout=5s --health-retries=10 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run mysql integration tests env: DATA_TABLE_INTEGRATION: '1' DATA_TABLE_MYSQL_URL: mysql://root:root@127.0.0.1:3306/remix run: node --disable-warning=ExperimentalWarning --test './packages/data-table-mysql/src/lib/adapter.integration.test.ts' sqlite-integration: name: SQLite Integration runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build sqlite native module run: pnpm rebuild better-sqlite3 - name: Run sqlite adapter tests env: DATA_TABLE_INTEGRATION: '1' run: node --disable-warning=ExperimentalWarning --test './packages/data-table-sqlite/src/lib/adapter.integration.test.ts' ================================================ FILE: .github/workflows/file-storage-integration.yaml ================================================ name: File Storage Integration Tests on: push: branches: - main paths: - '.github/workflows/file-storage-integration.yaml' - 'packages/file-storage/**' - 'packages/file-storage-s3/**' - 'pnpm-workspace.yaml' - 'package.json' pull_request: paths: - '.github/workflows/file-storage-integration.yaml' - 'packages/file-storage/**' - 'packages/file-storage-s3/**' - 'pnpm-workspace.yaml' - 'package.json' jobs: unit: name: Unit and Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run file-storage-s3 package checks run: | pnpm --filter @remix-run/file-storage-s3 run typecheck pnpm --filter @remix-run/file-storage-s3 run test pnpm --filter @remix-run/file-storage-s3 run build s3-integration: name: S3 Integration runs-on: ubuntu-latest services: localstack: image: localstack/localstack:4.4.0 env: SERVICES: s3 AWS_ACCESS_KEY_ID: test AWS_SECRET_ACCESS_KEY: test DEFAULT_REGION: us-east-1 ports: - 4566:4566 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Wait for LocalStack run: | for i in {1..60}; do if curl -fsS http://127.0.0.1:4566/_localstack/health > /dev/null; then exit 0 fi sleep 1 done echo "LocalStack did not become ready in time" exit 1 - name: Run S3 integration tests env: FILE_STORAGE_S3_INTEGRATION: '1' FILE_STORAGE_S3_ENDPOINT: http://127.0.0.1:4566 FILE_STORAGE_S3_BUCKET: remix-file-storage-integration FILE_STORAGE_S3_REGION: us-east-1 FILE_STORAGE_S3_ACCESS_KEY_ID: test FILE_STORAGE_S3_SECRET_ACCESS_KEY: test FILE_STORAGE_S3_FORCE_PATH_STYLE: '1' run: node --disable-warning=ExperimentalWarning --test './packages/file-storage-s3/src/lib/s3.integration.test.ts' ================================================ FILE: .github/workflows/format.yml ================================================ name: Format on: push: branches: - main concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: format: if: "${{ github.repository == 'remix-run/remix' && !startsWith(github.event.head_commit.message, 'chore: format') }}" runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: token: ${{ secrets.FORMAT_PAT }} fetch-depth: 0 - name: Detect formatting-relevant changes id: changes run: | base="${{ github.event.before }}" if [ "$base" = "0000000000000000000000000000000000000000" ]; then files="$(git ls-files)" else files="$(git diff --name-only "$base" "$GITHUB_SHA")" fi if [ -z "$files" ]; then echo "No changed files detected" echo "should_run=false" >> "$GITHUB_OUTPUT" exit 0 fi while IFS= read -r file; do case "$file" in .prettierignore|.prettierrc|.prettierrc.json|.prettierrc.yml|.prettierrc.yaml|.prettierrc.js|.prettierrc.cjs|.prettierrc.mjs|*.js|*.jsx|*.cjs|*.mjs|*.ts|*.tsx|*.cts|*.mts|*.json|*.jsonc|*.md|*.mdx|*.yaml|*.yml|*.css|*.scss|*.html) echo "Formatting-relevant change: $file" echo "should_run=true" >> "$GITHUB_OUTPUT" exit 0 ;; esac done <<EOF $files EOF echo "No formatting-relevant files changed" echo "should_run=false" >> "$GITHUB_OUTPUT" - name: Install pnpm if: steps.changes.outputs.should_run == 'true' uses: pnpm/action-setup@v4 - name: Install Node.js if: steps.changes.outputs.should_run == 'true' uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies if: steps.changes.outputs.should_run == 'true' run: pnpm install --frozen-lockfile - name: Format if: steps.changes.outputs.should_run == 'true' run: pnpm format - name: Commit if: steps.changes.outputs.should_run == 'true' run: | git config --local user.email "hello@remix.run" git config --local user.name "Remix Run Bot" git add . git restore .github/workflows # PAT doesn't have permission to push workflow changes if [ -z "$(git status --porcelain)" ]; then echo "No formatting changes" exit 0 fi git commit -m "chore: format" git push echo "Pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" ================================================ FILE: .github/workflows/generate-remix.yaml ================================================ # Update the `remix` package by auto-generating from the `@remix-run/*` packages in the repo # runs on any pushes/PRs to `main` that touch any `package.json` files since that would # potentially alter the sub-exports that need to be reflected in the `remix` package. # Note: Does not currently run on PRs from forked repos. name: Update Remix package on: push: branches: - 'main' paths: - 'packages/**/package.json' pull_request: branches-ignore: - release-v2 - v2 paths: - 'packages/**/package.json' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: generate-remix: if: | github.repository == 'remix-run/remix' && ( github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository ) runs-on: ubuntu-latest steps: # Normal checkout of the trigger branch on pushes - name: Checkout if: github.event_name == 'push' uses: actions/checkout@v4 with: # Use a PAT because using the default `GITHUB_TOKEN`/`github.token` will not # trigger workflow runs, so use a PAT to ensure new commits will run CI checks. # See: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow token: ${{ secrets.GH_REMIX_PAT }} # Checkout the PR branch when running on PRs - name: Checkout if: github.event_name == 'pull_request' uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} # Use a PAT because using the default `GITHUB_TOKEN`/`github.token` will not # trigger workflow runs, so use a PAT to ensure new commits will run CI checks. # See: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow token: ${{ secrets.GH_REMIX_PAT }} - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Generate remix package run: pnpm run generate-remix - name: Commit and push changes id: commit run: | if [ -z "$(git status --porcelain)" ]; then echo "💿 no updates to the remix package needed" exit 0 fi # Check for unintentional changes OUTSIDE_CHANGES=$(git status --porcelain | awk '{print $2}' | grep -v "^packages/remix/" || true) if [ -n "$OUTSIDE_CHANGES" ]; then echo "Refusing to commit changes outside of packages/remix/:" echo "$OUTSIDE_CHANGES" exit 1 fi # Re-install to ensure any new remix peerDependencies are reflected pnpm install --no-frozen-lockfile git config --local user.email "hello@remix.run" git config --local user.name "Remix Run Bot" git add . git commit -a -m "build: update remix package" git push echo "💿 pushed updates: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" echo "new_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - name: Comment on PR if: github.event_name == 'pull_request' && steps.commit.outputs.new_sha != '' env: # `GH_TOKEN` is required to use the `gh` CLI to add comments to PRs. GH_TOKEN: ${{ secrets.GH_REMIX_PAT }} run: gh pr comment ${{ github.event.pull_request.number }} --body "Changes in this PR resulted in updates to the auto-generated \`remix\` package in ${{ steps.commit.outputs.new_sha }}. Please review those changes prior to merging." ================================================ FILE: .github/workflows/preview.yml ================================================ # Create "installable" preview branches # # Commits to `main` push builds to a `preview/main` branch: # pnpm install "remix-run/remix#preview/main&path:packages/remix" # # Pull Requests create `preview/pr-{number}` branches: # pnpm install "remix-run/remix#preview/pr-12345&path:packages/remix" # # Can also be dispatched manually with base/installable branches to provide # `experimental` branches from PRs or otherwise. name: Preview Build on: push: branches: - main workflow_dispatch: inputs: baseBranch: description: Base Branch required: true installableBranch: description: Installable Branch required: true pull_request: types: [opened, synchronize, reopened, closed] concurrency: # Include `event_name` here because when a pull_request is merged (closed), the # `github.ref` goes back to `ref/heads/main` which will conflict with the run on # `main` from the merged PR group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true jobs: preview: # Don't run on PRs from forked repos if: github.repository == 'remix-run/remix' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest steps: - name: Checkout (push) if: github.event_name == 'push' uses: actions/checkout@v4 - name: Checkout (pull_request) if: github.event_name == 'pull_request' uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Checkout (workflow_dispatch) if: github.event_name == 'workflow_dispatch' uses: actions/checkout@v4 with: ref: ${{ inputs.baseBranch }} - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup git run: | git config --local user.email "hello@remix.run" git config --local user.name "Remix Run Bot" # Build and force push over the preview/main branch - name: Build/push branch (push) if: github.event_name == 'push' run: | pnpm run setup-installable-branch preview/main git push --force --set-upstream origin preview/main echo "💿 pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" # Build and force push over the PR preview/pr-{number} branch + comment on the PR - name: Build/push branch (pull_request) if: github.event_name == 'pull_request' && github.event.pull_request.state == 'open' env: GITHUB_TOKEN: ${{ github.token }} run: | pnpm run setup-installable-branch preview/pr-${{ github.event.pull_request.number }} git push --force --set-upstream origin preview/pr-${{ github.event.pull_request.number }} echo "pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" pnpm run pr-preview comment ${{ github.event.pull_request.number }} preview/pr-${{ github.event.pull_request.number }} # Build and normal push for experimental releases to avoid unintended force # pushes over remote branches in case of a branch name collision - name: Build/push branch (workflow_dispatch) if: github.event_name == 'workflow_dispatch' run: | pnpm run setup-installable-branch ${{ inputs.installableBranch }} git push --set-upstream origin ${{ inputs.installableBranch }} echo "💿 pushed installable branch: https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" # Cleanup PR preview/pr-{number} branches when the PR is closed - name: Cleanup preview branch if: github.event_name == 'pull_request' && github.event.pull_request.state == 'closed' env: GITHUB_TOKEN: ${{ github.token }} run: | pnpm run pr-preview cleanup ${{ github.event.pull_request.number }} preview/pr-${{ github.event.pull_request.number }} ================================================ FILE: .github/workflows/publish.yaml ================================================ name: Publish on: push: branches: - main concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: check: name: Check release readiness runs-on: ubuntu-latest outputs: has_change_files: ${{ steps.check.outputs.has_change_files }} steps: - name: Checkout uses: actions/checkout@v4 - name: Check for change files id: check run: | # Look for change files in any package's .changes directory (excluding README.md) change_files=$(find packages/*/.changes -name "*.md" ! -name "README.md" 2>/dev/null) if [ -n "$change_files" ]; then echo "Change files found (blocking publish):" echo "$change_files" echo "has_change_files=true" >> $GITHUB_OUTPUT else echo "No change files found, proceeding to publish." echo "has_change_files=false" >> $GITHUB_OUTPUT fi publish: name: Publish any unreleased packages needs: check if: needs.check.outputs.has_change_files == 'false' runs-on: ubuntu-latest permissions: contents: write # Required for creating tags and GitHub releases id-token: write # OIDC ID token is used for authentication with npm/jsr steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Get Playwright Version id: playwright-version run: echo "version=$(pnpm --filter @remix-run/component exec playwright --version | cut -d ' ' -f2)" >> $GITHUB_OUTPUT - name: Cache Playwright Browsers uses: actions/cache@v4 id: cache-browsers with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright Browsers if: steps.cache-browsers.outputs.cache-hit != 'true' run: pnpm --filter @remix-run/component exec playwright install --with-deps - name: Run tests run: pnpm test - name: Build packages run: pnpm build - name: Publish to npm and create releases run: node ./scripts/publish.ts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Output tag run: echo "tag=$(git tag --points-at HEAD | grep -e '^remix@3')" >> $GITHUB_OUTPUT docs: name: Update API Docs needs: publish runs-on: ubuntu-latest steps: - name: Trigger remix-api-docs uses: actions/github-script@v8 with: github-token: ${{ secrets.REMIX_API_DOCS_PAT }} script: | await github.rest.repos.createDispatchEvent({ owner: 'remix-run', repo: 'remix-api-docs', event_type: 'update-docs', client_payload: { tag: '${{ needs.publish.outputs.tag }}' } }); ================================================ FILE: .github/workflows/release-pr.yaml ================================================ name: Update "Release" PR on: push: branches: - main concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: update: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Checkout uses: actions/checkout@v4 with: token: ${{ secrets.GH_REMIX_PAT }} - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Update PR run: node scripts/release-pr.ts env: GITHUB_TOKEN: ${{ secrets.GH_REMIX_PAT }} ================================================ FILE: .github/workflows/session-integration.yaml ================================================ name: Session Integration Tests on: push: branches: - main paths: - '.github/workflows/session-integration.yaml' - 'packages/session/**' - 'packages/session-storage-memcache/**' - 'packages/session-storage-redis/**' - 'pnpm-workspace.yaml' - 'package.json' pull_request: paths: - '.github/workflows/session-integration.yaml' - 'packages/session/**' - 'packages/session-storage-memcache/**' - 'packages/session-storage-redis/**' - 'pnpm-workspace.yaml' - 'package.json' jobs: memcache-integration: name: Memcache Integration runs-on: ubuntu-latest services: memcached: image: memcached:1.6 ports: - 11211:11211 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run memcache integration tests env: SESSION_MEMCACHE_INTEGRATION: '1' SESSION_MEMCACHE_SERVER: 127.0.0.1:11211 run: node --disable-warning=ExperimentalWarning --test './packages/session-storage-memcache/src/lib/memcache-storage.integration.test.ts' redis-integration: name: Redis Integration runs-on: ubuntu-latest services: redis: image: redis:7 ports: - 6379:6379 options: >- --health-cmd="redis-cli ping || exit 1" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run redis integration tests env: SESSION_REDIS_INTEGRATION: '1' SESSION_REDIS_URL: redis://127.0.0.1:6379 run: node --disable-warning=ExperimentalWarning --test './packages/session-storage-redis/src/lib/redis-storage.integration.test.ts' ================================================ FILE: .github/workflows/test.yaml ================================================ name: Test on: push: branches: - main pull_request: branches-ignore: - release-v2 - v2 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test-ubuntu: name: test (ubuntu-latest) runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Get Playwright Version id: playwright-version shell: bash run: echo "version=$(pnpm --filter @remix-run/component exec playwright --version | cut -d ' ' -f2)" >> $GITHUB_OUTPUT - name: Cache Playwright Browsers uses: actions/cache@v4 id: cache-browsers with: path: | ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright Browsers if: steps.cache-browsers.outputs.cache-hit != 'true' run: pnpm --filter @remix-run/component exec playwright install --with-deps - name: Run tests run: pnpm test test-windows-pr: name: test (windows-latest, changed packages) if: github.event_name == 'pull_request' runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Get Playwright Version id: playwright-version shell: bash run: echo "version=$(pnpm --filter @remix-run/component exec playwright --version | cut -d ' ' -f2)" >> $GITHUB_OUTPUT - name: Cache Playwright Browsers uses: actions/cache@v4 id: cache-browsers with: path: ~/AppData/Local/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright Browsers if: steps.cache-browsers.outputs.cache-hit != 'true' run: pnpm --filter @remix-run/component exec playwright install --with-deps - name: Run changed package tests run: node ./scripts/detect-changed-packages.ts origin/${{ github.base_ref }} test-windows-main: name: test (windows-latest) if: github.event_name == 'push' runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Install Node.js uses: actions/setup-node@v4 with: node-version-file: 'package.json' cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Get Playwright Version id: playwright-version shell: bash run: echo "version=$(pnpm --filter @remix-run/component exec playwright --version | cut -d ' ' -f2)" >> $GITHUB_OUTPUT - name: Cache Playwright Browsers uses: actions/cache@v4 id: cache-browsers with: path: ~/AppData/Local/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - name: Install Playwright Browsers if: steps.cache-browsers.outputs.cache-hit != 'true' run: pnpm --filter @remix-run/component exec playwright install --with-deps - name: Run tests run: pnpm test ================================================ FILE: .gitignore ================================================ dist/ node_modules/ pnpm-publish-summary.json reference/ .tmp/ /demos/tmp/ *.db *.db-* *.sqlite *.sqlite-* *.sqlite3 *.sqlite3-* .env /reference ================================================ FILE: .prettierignore ================================================ node_modules/ dist/ tmp/ reference/ **/*.bundled.* **/public/assets/ **/test/fixtures/ **/worker-configuration.d.ts pnpm-lock.yaml ================================================ FILE: .prettierrc ================================================ printWidth: 100 semi: false singleQuote: true useTabs: false ================================================ FILE: .vscode/settings.json ================================================ { "deno.enablePaths": ["./packages/multipart-parser/examples/deno"], "nodejs-testing.extensions": [ { "extensions": ["ts", "tsx"], "parameters": ["--import", "tsx"] } ], "typescript.tsdk": "./node_modules/typescript/lib" } ================================================ FILE: .vscode/task.json ================================================ { "version": "0.1.0", "command": "./node_modules/.bin/tsc", "args": ["-v"], "echoCommand": true } ================================================ FILE: AGENTS.md ================================================ # Remix 3 Development Guide ## Commands - **Build**: `pnpm run build` (all packages) or `pnpm --filter @remix-run/<package> run build` (single package) - **Test**: `pnpm test` (all packages) or `pnpm --filter @remix-run/<package> run test` (single package) - **Single test file**: `node --test './packages/<package>/src/**/<filename>.test.ts'` - **Typecheck**: `pnpm run typecheck` (all packages) or `pnpm --filter @remix-run/<package> run typecheck` - **Lint**: `pnpm run lint` (check) or `pnpm run lint:fix` (auto-fix) - **Before finishing work**: Run `pnpm run lint` and resolve any lint errors before reporting completion. - **Format**: `pnpm run format` (auto-fix) or `pnpm run format:check` (check only) - **Clean**: `pnpm run clean` (git clean -fdX) ## Architecture - **Monorepo**: pnpm workspace with packages in `packages/` directory - **Key packages**: headers, fetch-proxy, fetch-router, file-storage, form-data-parser, lazy-file, multipart-parser, node-fetch-server, route-pattern, tar-parser - **Package exports**: All `exports` in `package.json` have a dedicated file in `src` that defines the public API by re-exporting from within `src/lib` - **Lib module boundaries**: Files in `src/lib` are implementation files. Do not add barrel-style re-exports or thin pass-through wrapper APIs between `src/lib` files. Re-exporting belongs only in top-level `src` barrel files that map to package exports. - **Cross-package boundaries**: Avoid re-exporting APIs/types from other packages. Consumers should import from the owning package directly. Reuse shared concepts from sibling packages internally instead of creating bespoke duplicate implementations. - **Documentation imports/install**: In package READMEs, documentation, and pull request code examples, installation instructions should always include `npm i remix`, usage examples should import from `remix` package exports (not `@remix-run/*`), and any required peer dependency should be included in the installation command. - **Philosophy**: Web standards-first, runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers). Use Web Streams API, Uint8Array, Web Crypto API, Blob/File instead of Node.js APIs - **Tests run from source** (no build required), using Node.js test runner ## Code Style - **Imports**: Always use `import type { X }` for types (separate from value imports); use `export type { X }` for type exports; include `.ts` extensions - **One-off scripts**: Write one-off scripts in this repo as TypeScript and make them executable natively with modern Node.js (for example, executable `.ts` files) - **Node runtime assumption**: Assume a modern Node.js runtime that supports running TypeScript files natively; prefer `node path/to/script.ts` in examples and instructions. - **Variables**: Prefer `let` for locals, `const` only at module scope; never use `var` - **Functions**: Use regular function declarations/expressions by default. For callback-based APIs (array methods, Promise callbacks, test callbacks, transaction callbacks, etc.), prefer arrow functions over `function` expressions. When an arrow callback only returns a single expression, use a concise body (`value => expression`) instead of braces/`return` - **Object methods**: When defining functions in object literals, use shorthand method syntax (`{ method() {} }`) instead of arrow functions (`{ method: () => {} }`) - **Classes**: Use native fields (omit `public`), `#private` for private members (no TypeScript accessibility modifiers) - **Formatting**: Prettier (printWidth: 100, no semicolons, single quotes, spaces not tabs) - **TypeScript**: Strict mode, ESNext target, ES2022 modules, bundler resolution, verbatimModuleSyntax - **Generics**: Use descriptive lowercase names for type parameters (e.g., `source`, `method`, `pattern`) instead of single uppercase letters like `T`, `P`, or `K` - **Comments**: Only add non-JSDoc comments when the code is doing something surprising or non-obvious ## Test Structure - **No loops or conditionals in test suites**: Do not use `for` loops or conditional statements (`if`, `switch`, etc.) to generate test cases within `describe()` blocks. This breaks the Node.js test runner's ability to run individual tests via IDE features (like clicking test icons in the sidebar). ## Demos - All demo servers should use port **44100** for consistency across the monorepo - **Accessible navigation**: Always use proper `<a>` elements for navigation links. Never use JavaScript `onclick` handlers on non-interactive elements like `<tr>`, `<div>`, or `<span>` for navigation. Links should be keyboard accessible and work with screen readers. - **Clean shutdown**: Demo servers should handle `SIGINT` and `SIGTERM` signals to exit cleanly when Ctrl+C is pressed. Close the server and call `process.exit(0)`. ## Documentation - API documentation is handled by scripts in the docs/ directory - We use `typedoc` to process the source code, and then generate markdown files from the typedoc output - Markdown API documentation files be generated via `pnpm run docs` in the docs/ directory ## Changes and Releases - **Automated releases**: When changes are pushed to `main`, the [release-pr workflow](/.github/workflows/release-pr.yaml) automatically opens/updates a "Release" PR. The [publish workflow](/.github/workflows/publish.yaml) runs on every push to `main` and publishes when no change files are present (i.e., after merging the Release PR). - **Manual releases**: `pnpm changes:version` updates package.json, CHANGELOG.md, and creates a git commit. Push to `main` and the publish workflow will handle the rest (including tags and GitHub releases). - **How publishing works**: The publish workflow checks for change files. If none exist, it runs `pnpm publish --recursive --report-summary`, reads the summary JSON to see what was published, then creates git tags and GitHub releases for each published package. - **Test change/release code with preview scripts**: When modifying any change/release code, run `pnpm changes:preview` to test locally. For the release PR script, run `node ./scripts/release-pr.ts --preview`. For the publish script, run `node ./scripts/publish.ts --dry-run` to see what commands would be executed without actually publishing. ## Skills A skill is a reusable local instruction set stored in a `SKILL.md` file. ### Available skills - **add-package**: Create or align a package in the Remix monorepo to match existing package conventions. Use when adding a brand new package under packages/, or when fixing an existing package's structure, test setup, TypeScript/build config, code style, and README layout to match the rest of Remix 3. (file: `./.agents/skills/add-package/SKILL.md`) - **make-change-file**: Create or update package change files using Remix repo conventions, deterministic naming, and release-note style. (file: `./.agents/skills/make-change-file/SKILL.md`) - **make-demo**: Create or revise demos in the Remix repository so they stay focused on Remix packages, strong code hygiene, and production-quality patterns. (file: `./.agents/skills/make-demo/SKILL.md`) - **make-pr**: Create GitHub pull requests with clear context, issue/feature bullets, and required usage examples for new or changed APIs. (file: `./.agents/skills/make-pr/SKILL.md`) - **publish-placeholder-package**: Publish a minimal npm package at `0.0.0` to reserve the name and enable npm OIDC setup before CI-based publishing. (file: `./.agents/skills/publish-placeholder-package/SKILL.md`) - **supersede-pr**: Replace one GitHub PR with another and explicitly close the superseded PR (instead of relying on `Closes #...` keywords). (file: `./.agents/skills/supersede-pr/SKILL.md`) - **update-pr**: Rewrite GitHub PR titles and descriptions from scratch so they match the PR as it exists now, and always review the title when updating the body. (file: `./.agents/skills/update-pr/SKILL.md`) - **write-api-docs**: Write or audit public API docs for Remix packages. Use when adding or tightening JSDoc on exported functions, classes, interfaces, type aliases, or option objects. (file: `./.agents/skills/write-api-docs/SKILL.md`) - **write-readme**: Write or rewrite Remix package READMEs using this repo's structure, installation conventions, production-style examples, and section ordering. (file: `./.agents/skills/write-readme/SKILL.md`) ================================================ FILE: CONTRIBUTING.md ================================================ Welcome to Remix! We're excited to have you contribute. This guide will help you get started. ## Setting Up Your Environment We develop Remix using [pnpm](https://pnpm.io) on Node 24.3+. If you're using [VS Code](https://code.visualstudio.com/), we recommend installing the [`node:test runner` extension](https://marketplace.visualstudio.com/items?itemName=connor4312.nodejs-testing) for a smooth testing experience. Once that's set up, run `pnpm install` to get all the project dependencies. ## Testing All tests run directly from source. This makes it easy to use breakpoint debugging when running tests. This also means you should not need to run a build before running the tests. ```sh # Run all tests $ pnpm test # Run the tests for a specific package $ pnpm --filter @remix-run/headers run test ``` ## Building All packages are built using a combination of tsc and esbuild. ```sh # Build all packages $ pnpm run build # Build a specific package $ pnpm --filter @remix-run/headers run build ``` All packages are published with TypeScript types along with both ESM and CJS module formats. ## Making Changes Packages live in the [`packages` directory](https://github.com/remix-run/remix/tree/v3/packages). At a minimum, each package includes: - `.changes/`: Directory containing change files for the next release - `CHANGELOG.md`: A log of what's changed - `package.json`: Package metadata and dependencies - `README.md`: Information about the package - `src/`: The package's source code When you make changes to a package, please make sure you add a few relevant tests and run the whole test suite to make sure everything still works. Then, [add a change file](#adding-a-change-file) describing your changes and [make a pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request). We will take a look at it as soon as we can. ### Adding a Change File When making changes to a package, create a markdown file in the package's `.changes/` directory following this naming convention: ``` [major|minor|patch].short-description.md ``` - `major` - Breaking changes for v1.x+ packages - `minor` - Breaking changes for v0.x packages, new features - `patch` - Bug fixes #### Examples - `major.change-something.md` - Breaking change for v1.x+ packages - `minor.change-something.md` - Breaking change for v0.x packages - `minor.add-something.md` - New feature - `patch.fix-something.md` - Bug fix #### Content Format Write your change as a bullet point (without the leading `-` or `*`). This content will be added to the CHANGELOG during release. ```markdown Add support for X feature This is an optional longer explanation that will be indented under the main bullet point in the CHANGELOG. ``` For breaking changes in v0.x packages, any change files that begin with `BREAKING CHANGE: ` will be hoisted to the top of the release notes: ```markdown BREAKING CHANGE: Renamed `foo` option to `bar` Migration: Update your config to use `bar` instead of `foo`. ``` #### Validation Change files are automatically validated in CI. You can also validate them locally: ```sh pnpm changes:validate ``` ## Releases Releases are automated via the [release-pr workflow](/.github/workflows/release-pr.yaml) and [publish workflow](/.github/workflows/publish.yaml). 1. **You push changes to `main`** with change files in `packages/*/.changes/` 2. **A "Release" PR is automatically opened** (or updated if one exists) The PR contains: - Updated `package.json` versions - Updated `CHANGELOG.md` files - Deleted change files This PR should not be edited manually. If you need to make changes, modify the change files and/or scripts in `main` to trigger an update to the PR. 3. **When you merge the PR**, the publish workflow runs (it runs on every push to `main` and checks for change files). Since the change files have been deleted, it publishes all unpublished packages to npm, then creates git tags and GitHub releases based on what was actually published. ### Manual Versioning The "Release" PR simply automates the `pnpm changes:version` command. If needed, you can run this command manually. This will update the `package.json` versions, `CHANGELOG.md` files, and delete the change files. It will then commit the result. ```sh pnpm changes:version ``` You can skip committing the changes by using the `--no-commit` flag. This will leave the changes in a staged state for you to review and commit manually. The command will also output the commit message that would have been used. ```sh pnpm changes:version --no-commit ``` Tags and GitHub releases are created automatically by the publish workflow after successful npm publish. ### Prerelease Mode for `remix` The `remix` package supports prerelease mode via an optional `.changes/config.json` file: ```json { "prereleaseChannel": "alpha" } ``` The `prereleaseChannel` field determines the version suffix (e.g. `alpha`, `beta`, `rc`), while prereleases are always published to npm with the `next` tag. This is only supported for `remix` because it's the only package that needs to publish prereleases alongside an existing stable version on npm. All other packages in this monorepo are new and publish directly as `latest`. #### Bumping `remix` prerelease versions While in prerelease mode, add change files as normal. The prerelease counter increments (e.g. `3.0.0-alpha.1` → `3.0.0-alpha.2`). Changelog entries still get proper "Major Changes" / "Minor Changes" / "Patch Changes" sections, but the bump type is otherwise ignored—only the prerelease counter is bumped. #### Transitioning between `remix` prerelease channels To transition between channels (e.g. `alpha` → `beta`): 1. Update `prereleaseChannel` in `.changes/config.json` to the new channel 2. Add a change file describing the transition Version resets to the new channel (e.g. `3.0.0-alpha.7` → `3.0.0-beta.0`). The bump type is for changelog categorization only—by convention, use `patch`. #### Graduating `remix` to stable To release the stable version: 1. Remove `prereleaseChannel` from `.changes/config.json` (or delete the file) 2. Add a change file describing the stable release The prerelease suffix is stripped (e.g. `3.0.0-rc.7` → `3.0.0`). The bump type is for changelog categorization only—by convention, use `major` for a major release announcement. ## Preview builds We maintain installable builds of `main` in a `preview/main` branch as a way for folks to test out the latest `main` branch without needing to publish releases to npm and clutter up the npm registry and version history UI. This is managed via the [`preview` workflow](/.github/workflows/preview.yaml) which uses the [`setup-installable-branch.ts`](./scripts/setup-installable-branch.ts) script to build and commit the build and required `package.json` changes to the `preview/main` branch on every new commit to `main`. The `preview/main` branch build can be [installed directly](https://pnpm.io/package-sources#install-from-a-git-repository-combining-different-parameters) with `pnpm` (version 9+): ```sh pnpm install "remix-run/remix#preview/main&path:packages/remix" # Or, just install a single package pnpm install "remix-run/remix#preview/main&path:packages/fetch-router" ``` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Shopify Inc. 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 ================================================ # Welcome to Remix 3! This is the source repository for Remix 3. It is under active development. We published [a blog post](https://remix.run/blog/wake-up-remix) earlier this year with some of our thoughts around Remix 3. It explains our philosophy for web development and why we think the time is right for something new. When working on Remix 3, we follow these principles: 1. **Model-First Development**. AI fundamentally shifts the human-computer interaction model for both user experience and developer workflows. Optimize the source code, documentation, tooling, and abstractions for LLMs. Additionally, develop abstractions for applications to use models in the product itself, not just as a tool to develop it. 2. **Build on Web APIs**. Sharing abstractions across the stack greatly reduces the amount of context switching, both for humans and machines. Build on the foundation of Web APIs and JavaScript because it is the only full stack ecosystem. 3. **Religiously Runtime**. Designing for bundlers/compilers/typegen (and any pre-runtime static analysis) leads to poor API design that eventually pollutes the entire system. All packages must be designed with no expectation of static analysis and all tests must run without bundling. Because browsers are involved, `--import` loaders for simple transformations like TypeScript and JSX are permissible. 4. **Avoid Dependencies**. Dependencies lock you into somebody else's roadmap. Choose them wisely, wrap them completely, and expect to replace most of them with our own package eventually. The goal is zero. 5. **Demand Composition**. Abstractions should be single-purpose and replaceable. A composable abstraction is easy to add and remove from an existing program. Every package must be useful and documented independent of any other context. New features should first be attempted as a new package. If impossible, attempt to break up the existing package to make it more composable. However, tightly coupled modules that almost always change together in both directions should be moved to the same package. 6. **Distribute Cohesively**. Extremely composable ecosystems are difficult to learn and use. Remix will be distributed as a single `remix` package for both distribution and documentation. ## Goals Although we recommend the `remix` package for ease of use, all packages that make up Remix should be usable standalone as well. This forces us to consider package boundaries and helps us define public interfaces that are portable and interoperable. Each package in Remix: - Has a [single responsibility](https://en.wikipedia.org/wiki/Single-responsibility_principle) - Prioritizes web standards to ensure maximum interoperability and portability across JavaScript runtimes - Augments standards unobtrusively where they are missing or incomplete, minimizing incompatibility risks This means Remix code is **portable by default**. Remix packages work seamlessly across [Node.js](https://nodejs.org/), [Bun](https://bun.sh/), [Deno](https://deno.com/), [Cloudflare Workers](https://workers.cloudflare.com/), and other environments. We leverage server-side web APIs when they are available: - [The Web Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API) instead of `node:stream` - [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) instead of Node.js `Buffer`s - [The Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) instead of `node:crypto` - [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) and [`File`](https://developer.mozilla.org/en-US/docs/Web/API/File) instead of some bespoke runtime-specific API The benefit is code that's not just reusable, but **future-proof**. ## Packages We currently publish the following packages: - [async-context-middleware](packages/async-context-middleware): Middleware for storing request context in AsyncLocalStorage - [component](packages/component): UI components for Remix - [compression-middleware](packages/compression-middleware): Middleware for compressing HTTP responses - [cop-middleware](packages/cop-middleware): Middleware for tokenless cross-origin protection in Fetch API servers - [cors-middleware](packages/cors-middleware): Middleware for handling CORS in Fetch API servers - [csrf-middleware](packages/csrf-middleware): Middleware for CSRF protection in Fetch API servers - [cookie](packages/cookie): A toolkit for working with cookies in JavaScript - [data-schema](packages/data-schema): Tiny, standards-aligned schema validation - [data-table](packages/data-table): A typed, relational query toolkit for Remix - [data-table-mysql](packages/data-table-mysql): MySQL adapter for remix/data-table - [data-table-postgres](packages/data-table-postgres): PostgreSQL adapter for remix/data-table - [data-table-sqlite](packages/data-table-sqlite): SQLite adapter for remix/data-table - [fetch-proxy](packages/fetch-proxy): An HTTP proxy for the web Fetch API - [fetch-router](packages/fetch-router): A minimal, composable router for the web Fetch API - [file-storage](packages/file-storage): Key/value storage for JavaScript File objects - [file-storage-s3](packages/file-storage-s3): S3 backend for remix/file-storage - [form-data-middleware](packages/form-data-middleware): Middleware for parsing FormData from request bodies - [form-data-parser](packages/form-data-parser): A request.formData() wrapper with streaming file upload handling - [fs](packages/fs): Filesystem utilities using the Web File API - [headers](packages/headers): A toolkit for working with HTTP headers in JavaScript - [html-template](packages/html-template): HTML template tag with auto-escaping for JavaScript - [lazy-file](packages/lazy-file): Lazy, streaming files for JavaScript - [logger-middleware](packages/logger-middleware): Middleware for logging HTTP requests and responses - [method-override-middleware](packages/method-override-middleware): Middleware for overriding HTTP request methods from form data - [mime](packages/mime): Utilities for working with MIME types - [multipart-parser](packages/multipart-parser): A fast, efficient parser for multipart streams in any JavaScript environment - [node-fetch-server](packages/node-fetch-server): Build servers for Node.js using the web fetch API - [remix](packages/remix): Remix Web Framework - [response](packages/response): Response helpers for the web Fetch API - [route-pattern](packages/route-pattern): Match and generate URLs with strong typing - [session](packages/session): Session management for JavaScript - [session-middleware](packages/session-middleware): Middleware for managing sessions with cookie-based storage - [session-storage-memcache](packages/session-storage-memcache): Memcache session storage for remix/session - [session-storage-redis](packages/session-storage-redis): Redis session storage for remix/session - [static-middleware](packages/static-middleware): Middleware for serving static files from the filesystem - [tar-parser](packages/tar-parser): A fast, efficient parser for tar streams in any JavaScript environment ## Installation To try the current Remix alpha, install the `next` dist-tag: ```sh npm install remix@next ``` If you want to play around with the bleeding edge, we also build the latest `main` branch into a `preview/main` branch which can be [installed directly](https://pnpm.io/package-sources#install-from-a-git-repository-combining-different-parameters) with `pnpm` (version 9+): ```sh pnpm install "remix-run/remix#preview/main&path:packages/remix" # Or, just install a single package pnpm install "remix-run/remix#preview/main&path:packages/fetch-router" ``` ## Contributing We welcome contributions! If you'd like to contribute, please feel free to open an issue or submit a pull request. See [CONTRIBUTING](https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md) for more information. ## License See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE) ================================================ FILE: cspell.yml ================================================ version: '0.2' language: en words: - accentunder - actiontype - activedescendant - aftertoggle - arcrole - Arcrole - autocorrect - backlink - bbox - biblioentry - biblioref - Booleanish - braillelabel - brailleroledescription - closedby - closerequest - colcount - colindex - colindextext - columnalign - columnlines - columnspacing - columnspan - commandfor - contenteditable - contentinfo - controlslist - denomalign - describedby - desync - disableremoteplayback - displaystyle - DOMCSS - dropeffect - elementtiming - enterkeyhint - exportparts - fetchpriority - fixpoint - flowto - focusin - focusout - fontstyle - fontweight - formaction - formenctype - formmethod - formnovalidate - formtarget - FOUC - framespacing - glossref - haspopup - healthcheck - horiz - hsba - inlist - inputmode - itemprop - itemref - itemscope - itemtype - jsxs - keybind - keyshortcuts - keyup - labelledby - largeop - linethickness - lquote - lspace - maction - mathbackground - mathcolor - mathsize - mathvariant - maxlength - maxsize - menclose - menuitemcheckbox - menuitemradio - merror - mfenced - mfrac - Microdata - minsize - Mmulti - mmultiscripts - movablelimits - mpadded - mpath - mphantom - mprescripts - mroot - mrow - mspace - msqrt - mstyle - msub - msubsup - msup - mtable - mtext - multiselectable - munder - munderover - noteref - novalidate - numalign - outerclick - pagebreak - pagelist - panose - playsinline - pointerdown - pointerenter - pointerleave - pointermove - pointerup - popovertarget - popovertargetaction - posinset - pullquote - referrerpolicy - renderable - roledescription - roletype - rowalign - rowcount - rowindex - rowindextext - rowlines - rowspacing - rowspan - rquote - rspace - scriptlevel - scriptminsize - scriptsizemultiplier - sectionhead - setsize - Signalish - spinbutton - statusline - stemh - stemv - subscriptshift - Subsup - superscriptshift - SVGM - treegrid - treeitems - unitless - unkeyed - valuenow - valuetext - vdom - visibilitychange - vnode - VNODE - vnodes - voffset - wmode - xlink ignorePaths: - node_modules/** - dist/** - build/** - pnpm-lock.yaml ================================================ FILE: decisions/001-route-pattern-vs-url-pattern.md ================================================ # RoutePattern vs. URLPattern The web has a built-in URL matcher called [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URLPattern). Why don't we use it instead of creating our own thing with [`RoutePattern`](../packages/route-pattern)? ## Main Differences There are a number of major differences between `RoutePattern` and `URLPattern`. Here are the main ones: - **Generating URLs**: `RoutePattern` comes with an "href builder" for building URLs from route patterns. This is the logical bookend to "matching" or parsing a URL. - **Easier pathname-only Matching**: `RoutePattern` allows matching only a URL pathname without resorting to the object syntax, or beginning with a leading `/` ```tsx let pattern = new RoutePattern('products/:id') // matches <protocol>://<host>/products/:id // vs. URLPattern, requires object syntax and leading slash let pattern = new URLPattern({ pathname: '/products/:id' }) ``` - **Non-exhaustive Search Matching**: `RoutePattern` does not treat the pattern's search string as exhaustive. This allows matching URLs that contain additional query parameters, which is important for allowing traffic that comes from sources where you don't have full control over the search string. ```tsx let pattern = new RoutePattern('?q=remix') pattern.match('https://remix.run/?q=remix') // match pattern.match('https://remix.run/?q=remix&utm_source') // also match! let pattern = new URLPattern({ search: '?q=remix' }) pattern.exec('https://remix.run/?q=remix') // match pattern.exec('https://remix.run/?q=remix&utm_source') // null :( ``` - **More Intuitive Optionals**: `RoutePattern` expresses optionals using parentheses, similar to Rails. These read like English instead of using `?` to indicate optional groups as in regular expressions. It also makes the start and end positions of an optional group immediately obvious. ```tsx // An "optional group" using URLPattern let pattern = new URLPattern('/books/:id?', 'https://example.com') pattern.test('https://example.com/books/123') // true pattern.test('https://example.com/books') // true // This behavior is unintuitive. Is the ":id" optional? Or the "/:id"? // There's no way to know when you only have a single group modifier character (the `?`) pattern.test('https://example.com/books/') // false // An optional using RoutePattern let pattern = new RoutePattern('/books(/:id)') pattern.test('https://example.com/books/123') // true pattern.test('https://example.com/books') // true // This result is more intuitive because the () surround the optional // portion of the pattern, indicating both start and end characters pattern.test('https://example.com/books/') // false ``` - **Uniform Param Access**: `RoutePattern` does not support "unnamed groups" that must be accessed by index in the match result. Instead, all variables (groups) must have names and are accessed by that name at `match.params[name]`. - **No RegExp Syntax**: `RoutePattern` does not allow regex syntax. This means route patterns are statically analyzable without parsing RegExp grammar, which makes it easier to provide type safety. Also, the whole point of `RoutePattern` is to provide a syntax that is sufficient for matching URLs without resorting to some other syntax. ================================================ FILE: decisions/002-branching-and-releasing.md ================================================ # Branching and Releasing in Remix 3 Beginning in Remix 3 the `remix` package is an umbrella package for everything in Remix. This includes a number of "sub-packages" that are all published under the `@remix-run/*` scope. The `remix` package re-exports everything from all sub-packages as a transparent pass-thru. This means users only have to install `remix` to get everything we publish, and they can import everything from some `remix/*` export. We anticipate the majority of Remix development will happen directly on the `main` branch. `main` should always be publishable. We will publish minor/patch changes from `main` often for both `remix` and any sub-package that needs it. ## Breaking Changes When it comes to breaking changes (that require a major version bump), we have 2 goals: - Be slow and deliberate about cutting major `remix` releases so we don't stress people out by releasing majors too often - Release breaking changes in sub-packages as soon as they are ready so people can play with them Major `remix` releases will happen on a predetermined schedule so that users may plan upgrades into their development lifecycle. Breaking changes will accumulate on a `future` branch. The `future` branch is a preview of what the next major version of Remix will look like. If someone wants to play with the latest stuff, they can build directly from `future`. We don't make any guarantees about the [stability][^stability] of `future`, which is why users must build from source. We will publish new majors of sub-packages as soon as they are ready from the `future` branch. When it's time to cut the next major `remix` release, we will merge `future` into `main`. Of course, this means that `main` should be merged into `future` periodically to make this easier. [^stability]: By "stable" we mean "won't break between releases". Both `main` and `future` should always pass all tests and be usable, but on `main` we have versions and stability guarantees between them. On `future`, we don't. ================================================ FILE: demos/bookstore/.gitignore ================================================ public/assets/ tmp/ data/*.sqlite ================================================ FILE: demos/bookstore/README.md ================================================ # Bookstore Demo A full-featured e-commerce bookstore demonstrating the most powerful patterns and features of Remix. This demo showcases authentication, shopping cart, admin CRUD operations, file uploads, progressive enhancement, and much more. ## Running the Demo ```bash cd demos/bookstore pnpm install pnpm start ``` Then visit http://localhost:44100 ### Demo Accounts - **Admin**: admin@bookstore.com / admin123 - **Customer**: customer@example.com / password123 ## Database and Migrations - The SQLite file is stored at `data/bookstore.sqlite` - Migration files live in `data/migrations` - On startup, the app loads migrations from `data/migrations` and runs pending migrations before seeding demo data ## Code Highlights - [`app/routes.ts`](app/routes.ts) shows declarative route definitions using `route()`, `form()`, and `resources()` helpers. All route URLs are generated with full type safety, so `routes.admin.books.edit.href({ bookId: '123' })` ensures you never have broken links. - [`app/router.ts`](app/router.ts) demonstrates how to compose middleware for cross-cutting concerns: static file serving, form data parsing, method override, sessions, and async context. Each middleware is independent and reusable. - [`data/migrations/20260228090000_create_bookstore_schema.ts`](data/migrations/20260228090000_create_bookstore_schema.ts) defines the schema using `remix/data-table/migrations`. - [`app/middleware/database.ts`](app/middleware/database.ts) stores the bookstore database on request context with `context.set(Database, db)`, and request handlers read it back with `get(Database)` just like they do for `Session` and `FormData`. - [`app/middleware/auth.ts`](app/middleware/auth.ts) provides two patterns: - **`loadAuth()`** - Optionally loads the current user without requiring authentication - **`requireAuth()`** - Redirects to login with a `returnTo` parameter for post-login redirect - [`app/middleware/admin.ts`](app/middleware/admin.ts) shows role-based authorization that returns 403 for non-admin users. - [`app/utils/context.ts`](app/utils/context.ts) demonstrates sharing data across the request lifecycle without prop drilling. Any code can call `getCurrentUser()` to access the authenticated user set by middleware earlier in the chain. - [`app/utils/session.ts`](app/utils/session.ts) configures signed cookies and filesystem-based session storage. - [`app/utils/uploads.ts`](app/utils/uploads.ts) handles file uploads with `@remix-run/form-data-middleware`. The upload handler stores files and returns public URLs. [`app/uploads.tsx`](app/uploads.tsx) serves uploaded files with appropriate caching headers. - HTML forms only support GET and POST. [`app/components/restful-form.tsx`](app/components/restful-form.tsx) adds a hidden `_method` field for PUT and DELETE, which the `methodOverride()` middleware translates back to the original method. - [`app/assets/cart-button.tsx`](app/assets/cart-button.tsx) shows a button that works without JavaScript (full form submission) but upgrades to fetch-based updates when JS is available. Notice how `hydrated()` wraps a component that maintains local state (`updating`) and calls `this.update()` to re-render. - [`app/assets/image-carousel.tsx`](app/assets/image-carousel.tsx) demonstrates a similar pattern for an interactive image carousel. - [`app/books.tsx`](app/books.tsx) uses `<Frame>` to render book cards that can be loaded independently. The frame URLs point to [`app/fragments.tsx`](app/fragments.tsx), and [`app/utils/frame.tsx`](app/utils/frame.tsx) shows how frames are resolved server-side during initial render. - [`app/admin.books.tsx`](app/admin.books.tsx) demonstrates complete CRUD operations with file uploads, using the RESTful routes generated by `resources('books')`. ================================================ FILE: demos/bookstore/app/account.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { loginAsCustomer, requestWithSession, assertContains } from '../test/helpers.ts' describe('account handlers', () => { it('GET /account redirects to login when not authenticated', async () => { let response = await router.fetch('https://remix.run/account') assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/login?returnTo=%2Faccount') }) it('GET /account returns account page when authenticated', async () => { let sessionId = await loginAsCustomer(router) // Now access account page with session let request = requestWithSession('https://remix.run/account', sessionId) let response = await router.fetch(request) assert.equal(response.status, 200) let html = await response.text() assertContains(html, 'My Account') assertContains(html, 'Account Information') assertContains(html, 'John Doe') }) it('GET /account/orders/:orderId shows order for authenticated user', async () => { let sessionId = await loginAsCustomer(router) // Access existing order let request = requestWithSession('https://remix.run/account/orders/1001', sessionId) let response = await router.fetch(request) assert.equal(response.status, 200) let html = await response.text() assertContains(html, 'Order #1001') assertContains(html, 'Ash & Smoke') }) it('GET /account/orders shows item counts from normalized order items', async () => { let sessionId = await loginAsCustomer(router) let request = requestWithSession('https://remix.run/account/orders', sessionId) let response = await router.fetch(request) assert.equal(response.status, 200) let html = await response.text() assertContains(html, '2 item(s)') assertContains(html, '1 item(s)') }) }) ================================================ FILE: demos/bookstore/app/account.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import * as s from 'remix/data-schema' import * as f from 'remix/data-schema/form-data' import { Database } from 'remix/data-table' import { redirect } from 'remix/response/redirect' import { routes } from './routes.ts' import { Layout } from './layout.tsx' import { requireAuth } from './middleware/auth.ts' import { orders, orderItemsWithBook, users } from './data/schema.ts' import { getCurrentUser } from './utils/context.ts' import { parseId } from './utils/ids.ts' import { render } from './utils/render.ts' import { RestfulForm } from './components/restful-form.tsx' const textField = f.field(s.defaulted(s.string(), '')) const accountSettingsSchema = f.object({ name: textField, email: textField, password: textField, }) export default { middleware: [requireAuth()], actions: { index() { let user = getCurrentUser() return render( <Layout> <h1>My Account</h1> <div class="card"> <h2>Account Information</h2> <p> <strong>Name:</strong> {user.name} </p> <p> <strong>Email:</strong> {user.email} </p> <p> <strong>Role:</strong> {user.role} </p> <p> <strong>Member Since:</strong> {new Date(user.created_at).toLocaleDateString()} </p> <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.account.settings.index.href()} class="btn"> Edit Settings </a> </p> </div> <div class="card" mix={[css({ marginTop: '1.5rem' })]}> <h2>Quick Links</h2> <p> <a href={routes.account.orders.index.href()} class="btn btn-secondary"> View Orders </a> <a href={routes.books.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Browse Books </a> </p> </div> </Layout>, ) }, settings: { actions: { index() { let user = getCurrentUser() return render( <Layout> <h1>Account Settings</h1> <div class="card"> <RestfulForm method="PUT" action={routes.account.settings.update.href()}> <div class="form-group"> <label for="name">Name</label> <input type="text" id="name" name="name" value={user.name} required /> </div> <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" value={user.email} required /> </div> <div class="form-group"> <label for="password">New Password (leave blank to keep current)</label> <input type="password" id="password" name="password" autoComplete="new-password" /> </div> <button type="submit" class="btn"> Update Settings </button> <a href={routes.account.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Cancel </a> </RestfulForm> </div> </Layout>, ) }, async update({ get }) { let db = get(Database) let formData = get(FormData) let user = getCurrentUser() let { email, name, password } = s.parse(accountSettingsSchema, formData) let updateData: any = { name, email } if (password) { updateData.password = password } await db.update(users, user.id, updateData) return redirect(routes.account.index.href()) }, }, }, orders: { actions: { async index({ get }) { let db = get(Database) let user = getCurrentUser() let userOrders = await db.findMany(orders, { where: { user_id: user.id }, orderBy: ['created_at', 'asc'], with: { items: orderItemsWithBook }, }) return render( <Layout> <h1>My Orders</h1> <div class="card"> {userOrders.length > 0 ? ( <table> <thead> <tr> <th>Order ID</th> <th>Date</th> <th>Items</th> <th>Total</th> <th>Status</th> <th>Actions</th> </tr> </thead> <tbody> {userOrders.map((order) => ( <tr> <td>#{order.id}</td> <td>{new Date(order.created_at).toLocaleDateString()}</td> <td>{order.items.length} item(s)</td> <td>${order.total.toFixed(2)}</td> <td> <span class="badge badge-info">{order.status}</span> </td> <td> <a href={routes.account.orders.show.href({ orderId: order.id })} class="btn btn-secondary" mix={[css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem' })]} > View </a> </td> </tr> ))} </tbody> </table> ) : ( <p>You have no orders yet.</p> )} </div> <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.account.index.href()} class="btn btn-secondary"> Back to Account </a> </p> </Layout>, ) }, async show({ get, params }) { let db = get(Database) let user = getCurrentUser() let orderId = parseId(params.orderId) let order = orderId === undefined ? undefined : await db.find(orders, orderId, { with: { items: orderItemsWithBook }, }) if (!order || order.user_id !== user.id) { return render( <Layout> <div class="card"> <h1>Order Not Found</h1> <p> <a href={routes.account.orders.index.href()} class="btn"> Back to Orders </a> </p> </div> </Layout>, { status: 404 }, ) } let shippingAddress = JSON.parse(order.shipping_address_json) as { street: string city: string state: string zip: string } return render( <Layout> <h1>Order #{order.id}</h1> <div class="card"> <p> <strong>Order Date:</strong> {new Date(order.created_at).toLocaleDateString()} </p> <p> <strong>Status:</strong> <span class="badge badge-info">{order.status}</span> </p> <h2 mix={[css({ marginTop: '2rem' })]}>Items</h2> <table mix={[css({ marginTop: '1rem' })]}> <thead> <tr> <th>Book</th> <th>Quantity</th> <th>Price</th> <th>Subtotal</th> </tr> </thead> <tbody> {order.items.map((item) => ( <tr> <td>{item.title}</td> <td>{item.quantity}</td> <td>${item.unit_price.toFixed(2)}</td> <td>${(item.unit_price * item.quantity).toFixed(2)}</td> </tr> ))} </tbody> <tfoot> <tr> <td colSpan={3} mix={[css({ textAlign: 'right', fontWeight: 'bold' })]}> Total: </td> <td mix={[css({ fontWeight: 'bold' })]}>${order.total.toFixed(2)}</td> </tr> </tfoot> </table> <h2 mix={[css({ marginTop: '2rem' })]}>Shipping Address</h2> <p>{shippingAddress.street}</p> <p> {shippingAddress.city}, {shippingAddress.state} {shippingAddress.zip} </p> </div> <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.account.orders.index.href()} class="btn btn-secondary"> Back to Orders </a> </p> </Layout>, ) }, }, }, }, } satisfies Controller<typeof routes.account> ================================================ FILE: demos/bookstore/app/admin.books.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { loginAsAdmin, requestWithSession } from '../test/helpers.ts' describe('admin books handlers', () => { it('POST /admin/books creates new book when admin', async () => { let sessionId = await loginAsAdmin(router) // Create new book let createRequest = requestWithSession('https://remix.run/admin/books', sessionId, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ slug: 'test-book', title: 'Test Book', author: 'Test Author', description: 'Test description', price: '29.99', genre: 'test', isbn: '978-0000000000', publishedYear: '2024', inStock: 'true', }), }) let response = await router.fetch(createRequest) assert.equal(response.status, 302) assert.ok(response.headers.get('Location')?.includes('/admin/books')) }) }) ================================================ FILE: demos/bookstore/app/admin.books.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import * as s from 'remix/data-schema' import * as f from 'remix/data-schema/form-data' import * as coerce from 'remix/data-schema/coerce' import { Database } from 'remix/data-table' import { redirect } from 'remix/response/redirect' import { routes } from './routes.ts' import { books } from './data/schema.ts' import { Layout } from './layout.tsx' import { parseId } from './utils/ids.ts' import { render } from './utils/render.ts' import { RestfulForm } from './components/restful-form.tsx' const textField = f.field(s.defaulted(s.string(), '')) const optionalTextField = f.field(s.optional(s.string())) const priceField = f.field(s.defaulted(s.string(), '0')) const publishedYearField = f.field(s.defaulted(s.string(), '2024'), { name: 'publishedYear', }) const inStockField = f.field(s.defaulted(coerce.boolean(), false), { name: 'inStock', }) const bookSchema = f.object({ slug: textField, title: textField, author: textField, description: textField, price: priceField, genre: textField, cover: optionalTextField, isbn: textField, publishedYear: publishedYearField, inStock: inStockField, }) export default { actions: { async index({ get }) { let db = get(Database) let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] }) return render( <Layout> <h1>Manage Books</h1> <p mix={[css({ marginBottom: '1rem' })]}> <a href={routes.admin.books.new.href()} class="btn"> Add New Book </a> <a href={routes.admin.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Back to Dashboard </a> </p> <div class="card"> <table> <thead> <tr> <th>Title</th> <th>Author</th> <th>Genre</th> <th>Price</th> <th>Stock</th> <th>Actions</th> </tr> </thead> <tbody> {allBooks.map((book) => ( <tr> <td>{book.title}</td> <td>{book.author}</td> <td>{book.genre}</td> <td>${book.price.toFixed(2)}</td> <td> <span class={`badge ${book.in_stock ? 'badge-success' : 'badge-warning'}`}> {book.in_stock ? 'Yes' : 'No'} </span> </td> <td class="actions"> <a href={routes.admin.books.edit.href({ bookId: book.id })} class="btn btn-secondary" mix={[css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem' })]} > Edit </a> <RestfulForm method="DELETE" action={routes.admin.books.destroy.href({ bookId: book.id })} mix={[css({ display: 'inline' })]} > <button type="submit" class="btn btn-danger" mix={[css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem' })]} > Delete </button> </RestfulForm> </td> </tr> ))} </tbody> </table> </div> </Layout>, ) }, async show({ get, params }) { let db = get(Database) let bookId = parseId(params.bookId) let book = bookId === undefined ? undefined : await db.find(books, bookId) if (!book) { return render( <Layout> <div class="card"> <h1>Book Not Found</h1> </div> </Layout>, { status: 404 }, ) } return render( <Layout> <h1>Book Details</h1> <div class="card"> <p> <strong>Title:</strong> {book.title} </p> <p> <strong>Author:</strong> {book.author} </p> <p> <strong>Slug:</strong> {book.slug} </p> <p> <strong>Description:</strong> {book.description} </p> <p> <strong>Price:</strong> ${book.price.toFixed(2)} </p> <p> <strong>Genre:</strong> {book.genre} </p> <p> <strong>ISBN:</strong> {book.isbn} </p> <p> <strong>Published:</strong> {book.published_year} </p> <p> <strong>In Stock:</strong>{' '} <span class={`badge ${book.in_stock ? 'badge-success' : 'badge-warning'}`}> {book.in_stock ? 'Yes' : 'No'} </span> </p> <div mix={[css({ marginTop: '2rem' })]}> <a href={routes.admin.books.edit.href({ bookId: book.id })} class="btn"> Edit </a> <a href={routes.admin.books.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Back to List </a> </div> </div> </Layout>, ) }, new() { return render( <Layout> <h1>Add New Book</h1> <div class="card"> <form method="POST" action={routes.admin.books.create.href()} encType="multipart/form-data" > <div class="form-group"> <label for="title">Title</label> <input type="text" id="title" name="title" required /> </div> <div class="form-group"> <label for="author">Author</label> <input type="text" id="author" name="author" required /> </div> <div class="form-group"> <label for="slug">Slug (URL-friendly name)</label> <input type="text" id="slug" name="slug" required /> </div> <div class="form-group"> <label for="description">Description</label> <textarea id="description" name="description" required></textarea> </div> <div class="form-group"> <label for="price">Price</label> <input type="number" id="price" name="price" step="0.01" required /> </div> <div class="form-group"> <label for="genre">Genre</label> <input type="text" id="genre" name="genre" required /> </div> <div class="form-group"> <label for="isbn">ISBN</label> <input type="text" id="isbn" name="isbn" required /> </div> <div class="form-group"> <label for="publishedYear">Published Year</label> <input type="number" id="publishedYear" name="publishedYear" required /> </div> <div class="form-group"> <label for="inStock">In Stock</label> <select id="inStock" name="inStock"> <option value="true">Yes</option> <option value="false">No</option> </select> </div> <div class="form-group"> <label for="cover">Book Cover Image</label> <input type="file" id="cover" name="cover" accept="image/*" /> <small mix={[css({ color: '#666' })]}> Optional. Upload a cover image for this book. </small> </div> <button type="submit" class="btn"> Create Book </button> <a href={routes.admin.books.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Cancel </a> </form> </div> </Layout>, ) }, async create({ get }) { let db = get(Database) let formData = get(FormData) let { author, cover, description, genre, inStock, isbn, price, publishedYear, slug, title } = s.parse(bookSchema, formData) await db.create(books, { slug, title, author, description, price: parseFloat(price), genre, cover_url: cover ?? '/images/placeholder.jpg', image_urls: JSON.stringify([]), isbn, published_year: parseInt(publishedYear, 10), in_stock: inStock, }) return redirect(routes.admin.books.index.href()) }, async edit({ get, params }) { let db = get(Database) let bookId = parseId(params.bookId) let book = bookId === undefined ? undefined : await db.find(books, bookId) if (!book) { return render( <Layout> <div class="card"> <h1>Book Not Found</h1> </div> </Layout>, { status: 404 }, ) } return render( <Layout> <h1>Edit Book</h1> <div class="card"> <RestfulForm method="PUT" action={routes.admin.books.update.href({ bookId: book.id })} encType="multipart/form-data" > <div class="form-group"> <label for="title">Title</label> <input type="text" id="title" name="title" value={book.title} required /> </div> <div class="form-group"> <label for="author">Author</label> <input type="text" id="author" name="author" value={book.author} required /> </div> <div class="form-group"> <label for="slug">Slug (URL-friendly name)</label> <input type="text" id="slug" name="slug" value={book.slug} required /> </div> <div class="form-group"> <label for="description">Description</label> <textarea id="description" name="description" required> {book.description} </textarea> </div> <div class="form-group"> <label for="price">Price</label> <input type="number" id="price" name="price" step="0.01" value={book.price} required /> </div> <div class="form-group"> <label for="genre">Genre</label> <input type="text" id="genre" name="genre" value={book.genre} required /> </div> <div class="form-group"> <label for="isbn">ISBN</label> <input type="text" id="isbn" name="isbn" value={book.isbn} required /> </div> <div class="form-group"> <label for="publishedYear">Published Year</label> <input type="number" id="publishedYear" name="publishedYear" value={book.published_year} required /> </div> <div class="form-group"> <label for="inStock">In Stock</label> <select id="inStock" name="inStock"> <option value="true" selected={book.in_stock}> Yes </option> <option value="false" selected={!book.in_stock}> No </option> </select> </div> <div class="form-group"> <label for="cover">Book Cover Image</label> {book.cover_url !== '/images/placeholder.jpg' && ( <div mix={[css({ marginBottom: '0.5rem' })]}> <img src={book.cover_url} alt={book.title} mix={[css({ maxWidth: '200px', height: 'auto', borderRadius: '4px' })]} /> <p mix={[css({ fontSize: '0.875rem', color: '#666' })]}>Current cover image</p> </div> )} <input type="file" id="cover" name="cover" accept="image/*" /> <small mix={[css({ color: '#666' })]}> Optional. Upload a new cover image to replace the current one. </small> </div> <button type="submit" class="btn"> Update Book </button> <a href={routes.admin.books.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Cancel </a> </RestfulForm> </div> </Layout>, ) }, async update({ get, params }) { let db = get(Database) let formData = get(FormData) let bookId = parseId(params.bookId) let book = bookId === undefined ? undefined : await db.find(books, bookId) if (!book) { return new Response('Book not found', { status: 404 }) } let { author, cover, description, genre, inStock, isbn, price, publishedYear, slug, title } = s.parse(bookSchema, formData) // The uploadHandler automatically saves the file and returns the URL path // If no file was uploaded, keep the existing cover_url let cover_url = cover || book.cover_url await db.update(books, book.id, { slug, title, author, description, price: parseFloat(price), genre, cover_url, isbn, published_year: parseInt(publishedYear, 10), in_stock: inStock, }) return redirect(routes.admin.books.index.href()) }, async destroy({ get, params }) { let db = get(Database) let bookId = parseId(params.bookId) let book = bookId === undefined ? undefined : await db.find(books, bookId) if (book) { await db.delete(books, book.id) } return redirect(routes.admin.books.index.href()) }, }, } satisfies Controller<typeof routes.admin.books> ================================================ FILE: demos/bookstore/app/admin.orders.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import { Database } from 'remix/data-table' import { routes } from './routes.ts' import { orders, orderItemsWithBook } from './data/schema.ts' import { Layout } from './layout.tsx' import { parseId } from './utils/ids.ts' import { render } from './utils/render.ts' export default { actions: { async index({ get }) { let db = get(Database) let allOrders = await db.findMany(orders, { orderBy: ['created_at', 'asc'], with: { items: orderItemsWithBook }, }) return render( <Layout> <h1>Manage Orders</h1> <p mix={[css({ marginBottom: '1rem' })]}> <a href={routes.admin.index.href()} class="btn btn-secondary"> Back to Dashboard </a> </p> <div class="card"> <table> <thead> <tr> <th>Order ID</th> <th>Date</th> <th>Items</th> <th>Total</th> <th>Status</th> <th>Actions</th> </tr> </thead> <tbody> {allOrders.map((order) => ( <tr> <td>#{order.id}</td> <td>{new Date(order.created_at).toLocaleDateString()}</td> <td>{order.items.length} item(s)</td> <td>${order.total.toFixed(2)}</td> <td> <span class="badge badge-info">{order.status}</span> </td> <td> <a href={routes.admin.orders.show.href({ orderId: order.id })} class="btn btn-secondary" mix={[css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem' })]} > View </a> </td> </tr> ))} </tbody> </table> </div> </Layout>, ) }, async show({ get, params }) { let db = get(Database) let orderId = parseId(params.orderId) let order = orderId === undefined ? undefined : await db.find(orders, orderId, { with: { items: orderItemsWithBook }, }) if (!order) { return render( <Layout> <div class="card"> <h1>Order Not Found</h1> </div> </Layout>, { status: 404 }, ) } let shippingAddress = JSON.parse(order.shipping_address_json) as { street: string city: string state: string zip: string } return render( <Layout> <h1>Order #{order.id}</h1> <div class="card"> <p> <strong>Order Date:</strong> {new Date(order.created_at).toLocaleDateString()} </p> <p> <strong>User ID:</strong> {order.user_id} </p> <p> <strong>Status:</strong> <span class="badge badge-info">{order.status}</span> </p> <h2 mix={[css({ marginTop: '2rem' })]}>Items</h2> <table mix={[css({ marginTop: '1rem' })]}> <thead> <tr> <th>Book</th> <th>Quantity</th> <th>Price</th> <th>Subtotal</th> </tr> </thead> <tbody> {order.items.map((item) => ( <tr> <td>{item.title}</td> <td>{item.quantity}</td> <td>${item.unit_price.toFixed(2)}</td> <td>${(item.unit_price * item.quantity).toFixed(2)}</td> </tr> ))} </tbody> <tfoot> <tr> <td colSpan={3} mix={[css({ textAlign: 'right', fontWeight: 'bold' })]}> Total: </td> <td mix={[css({ fontWeight: 'bold' })]}>${order.total.toFixed(2)}</td> </tr> </tfoot> </table> <h2 mix={[css({ marginTop: '2rem' })]}>Shipping Address</h2> <p>{shippingAddress.street}</p> <p> {shippingAddress.city}, {shippingAddress.state} {shippingAddress.zip} </p> </div> <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.admin.orders.index.href()} class="btn btn-secondary"> Back to Orders </a> </p> </Layout>, ) }, }, } satisfies Controller<typeof routes.admin.orders> ================================================ FILE: demos/bookstore/app/admin.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { loginAsCustomer, requestWithSession } from '../test/helpers.ts' describe('admin handlers', () => { it('GET /admin redirects when not authenticated', async () => { let response = await router.fetch('https://remix.run/admin') assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/login?returnTo=%2Fadmin') }) it('GET /admin returns 403 for non-admin users', async () => { let sessionId = await loginAsCustomer(router) // Try to access admin let request = requestWithSession('https://remix.run/admin', sessionId) let response = await router.fetch(request) assert.equal(response.status, 403) }) }) ================================================ FILE: demos/bookstore/app/admin.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import { routes } from './routes.ts' import { Layout } from './layout.tsx' import { requireAuth } from './middleware/auth.ts' import { requireAdmin } from './middleware/admin.ts' import { render } from './utils/render.ts' import adminBooksController from './admin.books.tsx' import adminOrdersController from './admin.orders.tsx' import adminUsersController from './admin.users.tsx' export default { middleware: [requireAuth(), requireAdmin()], actions: { index() { return render( <Layout> <h1>Admin Dashboard</h1> <div mix={[ css({ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '1.5rem', }), ]} > <div class="card"> <h2>Manage Books</h2> <p>Add, edit, or remove books from the catalog.</p> <a href={routes.admin.books.index.href()} class="btn" mix={[css({ marginTop: '1rem' })]} > View Books </a> </div> <div class="card"> <h2>Manage Users</h2> <p>View and manage user accounts.</p> <a href={routes.admin.users.index.href()} class="btn" mix={[css({ marginTop: '1rem' })]} > View Users </a> </div> <div class="card"> <h2>View Orders</h2> <p>Monitor and manage customer orders.</p> <a href={routes.admin.orders.index.href()} class="btn" mix={[css({ marginTop: '1rem' })]} > View Orders </a> </div> </div> </Layout>, ) }, books: adminBooksController, users: adminUsersController, orders: adminOrdersController, }, } satisfies Controller<typeof routes.admin> ================================================ FILE: demos/bookstore/app/admin.users.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import * as s from 'remix/data-schema' import * as f from 'remix/data-schema/form-data' import { Database } from 'remix/data-table' import { redirect } from 'remix/response/redirect' import { routes } from './routes.ts' import { users } from './data/schema.ts' import { Layout } from './layout.tsx' import { render } from './utils/render.ts' import { getCurrentUser } from './utils/context.ts' import { parseId } from './utils/ids.ts' import { RestfulForm } from './components/restful-form.tsx' const textField = f.field(s.defaulted(s.string(), '')) const roleField = f.field( s.defaulted(s.union([s.literal('customer'), s.literal('admin')]), 'customer'), ) const userSchema = f.object({ name: textField, email: textField, role: roleField, }) export default { actions: { async index({ get }) { let db = get(Database) let user = getCurrentUser() let allUsers = await db.findMany(users, { orderBy: ['id', 'asc'] }) return render( <Layout> <h1>Manage Users</h1> <p mix={[css({ marginBottom: '1rem' })]}> <a href={routes.admin.index.href()} class="btn btn-secondary"> Back to Dashboard </a> </p> <div class="card"> <table> <thead> <tr> <th>Name</th> <th>Email</th> <th>Role</th> <th>Created</th> <th>Actions</th> </tr> </thead> <tbody> {allUsers.map((u) => ( <tr> <td>{u.name}</td> <td>{u.email}</td> <td> <span class={`badge ${u.role === 'admin' ? 'badge-info' : 'badge-success'}`}> {u.role} </span> </td> <td>{new Date(u.created_at).toLocaleDateString()}</td> <td class="actions"> <a href={routes.admin.users.edit.href({ userId: u.id })} class="btn btn-secondary" mix={[css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem' })]} > Edit </a> {u.id !== user.id ? ( <RestfulForm method="DELETE" action={routes.admin.users.destroy.href({ userId: u.id })} mix={[css({ display: 'inline' })]} > <button type="submit" class="btn btn-danger" mix={[css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem' })]} > Delete </button> </RestfulForm> ) : null} </td> </tr> ))} </tbody> </table> </div> </Layout>, ) }, async show({ get, params }) { let db = get(Database) let userId = parseId(params.userId) let targetUser = userId === undefined ? undefined : await db.find(users, userId) if (!targetUser) { return render( <Layout> <div class="card"> <h1>User Not Found</h1> </div> </Layout>, { status: 404 }, ) } return render( <Layout> <h1>User Details</h1> <div class="card"> <p> <strong>Name:</strong> {targetUser.name} </p> <p> <strong>Email:</strong> {targetUser.email} </p> <p> <strong>Role:</strong>{' '} <span class={`badge ${targetUser.role === 'admin' ? 'badge-info' : 'badge-success'}`}> {targetUser.role} </span> </p> <p> <strong>Created:</strong> {new Date(targetUser.created_at).toLocaleDateString()} </p> <div mix={[css({ marginTop: '2rem' })]}> <a href={routes.admin.users.edit.href({ userId: targetUser.id })} class="btn"> Edit </a> <a href={routes.admin.users.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Back to List </a> </div> </div> </Layout>, ) }, async edit({ get, params }) { let db = get(Database) let userId = parseId(params.userId) let targetUser = userId === undefined ? undefined : await db.find(users, userId) if (!targetUser) { return render( <Layout> <div class="card"> <h1>User Not Found</h1> </div> </Layout>, { status: 404 }, ) } return render( <Layout> <h1>Edit User</h1> <div class="card"> <RestfulForm method="PUT" action={routes.admin.users.update.href({ userId: targetUser.id })} > <div class="form-group"> <label for="name">Name</label> <input type="text" id="name" name="name" value={targetUser.name} required /> </div> <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" value={targetUser.email} required /> </div> <div class="form-group"> <label for="role">Role</label> <select id="role" name="role"> <option value="customer" selected={targetUser.role === 'customer'}> Customer </option> <option value="admin" selected={targetUser.role === 'admin'}> Admin </option> </select> </div> <button type="submit" class="btn"> Update User </button> <a href={routes.admin.users.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Cancel </a> </RestfulForm> </div> </Layout>, ) }, async update({ get, params }) { let db = get(Database) let formData = get(FormData) let userId = parseId(params.userId) let targetUser = userId === undefined ? undefined : await db.find(users, userId) let { email, name, role } = s.parse(userSchema, formData) if (targetUser) { await db.update(users, targetUser.id, { name, email, role, }) } return redirect(routes.admin.users.index.href()) }, async destroy({ get, params }) { let db = get(Database) let userId = parseId(params.userId) let targetUser = userId === undefined ? undefined : await db.find(users, userId) if (targetUser) { await db.delete(users, targetUser.id) } return redirect(routes.admin.users.index.href()) }, }, } satisfies Controller<typeof routes.admin.users> ================================================ FILE: demos/bookstore/app/assets/cart-button.tsx ================================================ import { type Handle, clientEntry, on } from 'remix/component' import { routes } from '../routes.ts' let moduleUrl = routes.assets.href({ path: 'cart-button.js#CartButton' }) export const CartButton = clientEntry(moduleUrl, (handle: Handle) => { let pending = false return ({ inCart, id, slug }: { inCart: boolean; id: string | number; slug: string }) => ( <button type="button" mix={[ on('click', async (_event, signal) => { pending = true handle.update() let formData = new FormData() formData.set('bookId', String(id)) formData.set('slug', slug) await fetch(routes.api.cartToggle.href(), { method: 'POST', body: formData, signal, }) await handle.frame.reload() await new Promise((resolve) => setTimeout(resolve, 500)) if (signal.aborted) return pending = false handle.update() }), ]} class="btn" > {pending ? 'Saving...' : inCart ? 'Remove from Cart' : 'Add to Cart'} </button> ) }) ================================================ FILE: demos/bookstore/app/assets/cart-items.tsx ================================================ import { css, type Handle, clientEntry, on } from 'remix/component' import { routes } from '../routes.ts' let moduleUrl = routes.assets.href({ path: 'cart-items.js#CartItems' }) type CartItem = { bookId: number slug: string title: string price: number quantity: number } type CartItemsProps = { items: CartItem[] total: number canCheckout: boolean } type PendingAction = { type: 'update' | 'remove' bookId: number } | null export let CartItems = clientEntry(moduleUrl, (handle: Handle) => { let pendingAction: PendingAction = null let submit = async (form: HTMLFormElement, signal: AbortSignal, nextAction: PendingAction) => { if (pendingAction) return pendingAction = nextAction handle.update() try { let formData = new FormData(form) formData.set('redirect', 'none') await fetch(form.action, { method: 'POST', body: formData, signal, }) if (signal.aborted) return await handle.frame.reload() } finally { pendingAction = null handle.update() } } return ({ items, total, canCheckout }: CartItemsProps) => { let isPending = pendingAction !== null let totalLabel = isPending ? '---' : `$${total.toFixed(2)}` return ( <> {isPending ? ( <p mix={[css({ marginBottom: '1rem', fontSize: '0.9rem', color: '#666' })]}> Updating your cart... </p> ) : null} <table> <thead> <tr> <th>Book</th> <th>Price</th> <th>Quantity</th> <th>Subtotal</th> <th>Actions</th> </tr> </thead> <tbody> {items.map((item) => { let isUpdating = pendingAction?.type === 'update' && pendingAction.bookId === item.bookId let isRemoving = pendingAction?.type === 'remove' && pendingAction.bookId === item.bookId return ( <tr key={item.bookId}> <td> <a href={routes.books.show.href({ slug: item.slug })}>{item.title}</a> </td> <td>${item.price.toFixed(2)}</td> <td> <form method="POST" action={routes.cart.api.update.href()} mix={[ on('submit', async (event, signal) => { event.preventDefault() await submit(event.currentTarget, signal, { type: 'update', bookId: item.bookId, }) }), css({ display: 'inline-flex', gap: '0.5rem', alignItems: 'center' }), ]} > <input type="hidden" name="_method" value="PUT" /> <input type="hidden" name="bookId" value={item.bookId} /> <input type="number" name="quantity" defaultValue={item.quantity} min="1" disabled={isPending} mix={[css({ width: '70px' })]} /> <button type="submit" disabled={isPending} class="btn btn-secondary" mix={[ css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem', minWidth: '6.25rem', textAlign: 'center', }), ]} > {isUpdating ? 'Saving...' : 'Update'} </button> </form> </td> <td>${(item.price * item.quantity).toFixed(2)}</td> <td> <form method="POST" action={routes.cart.api.remove.href()} mix={[ on('submit', async (event, signal) => { event.preventDefault() await submit(event.currentTarget, signal, { type: 'remove', bookId: item.bookId, }) }), css({ display: 'inline' }), ]} > <input type="hidden" name="_method" value="DELETE" /> <input type="hidden" name="bookId" value={item.bookId} /> <button type="submit" disabled={isPending} class="btn btn-danger" mix={[ css({ fontSize: '0.875rem', padding: '0.25rem 0.5rem', minWidth: '7rem', textAlign: 'center', }), ]} > {isRemoving ? 'Removing...' : 'Remove'} </button> </form> </td> </tr> ) })} </tbody> </table> <div mix={[css({ marginTop: '2rem', display: 'flex', alignItems: 'center', gap: '1rem' })]}> <p mix={[css({ margin: 0, fontSize: '1.25rem', fontWeight: 'bold', marginRight: 'auto' })]} > Total: {totalLabel} </p> <a href={routes.books.index.href()} class="btn btn-secondary"> Continue Shopping </a> {canCheckout ? ( <a href={routes.checkout.index.href()} class="btn"> Proceed to Checkout </a> ) : ( <a href={routes.auth.login.index.href()} class="btn"> Login to Checkout </a> )} </div> </> ) } }) ================================================ FILE: demos/bookstore/app/assets/entry.tsx ================================================ import { run } from 'remix/component' let app = run({ async loadModule(moduleUrl: string, exportName: string) { let mod = await import(moduleUrl) let Component = (mod as any)[exportName] if (!Component) { throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) } return Component }, async resolveFrame(src, signal) { let response = await fetch(src, { headers: { accept: 'text/html' }, signal }) if (!response.ok) { return `<pre>Frame error: ${response.status} ${response.statusText}</pre>` } // let text = await response.text() // console.log(text) // return text if (response.body) return response.body return response.text() }, }) app.ready().catch((error: unknown) => { console.error('Frame adoption failed:', error) }) ================================================ FILE: demos/bookstore/app/assets/image-carousel.tsx ================================================ import { css, type Handle, clientEntry, on } from 'remix/component' import { routes } from '../routes.ts' export const ImageCarousel = clientEntry( routes.assets.href({ path: 'image-carousel.js#ImageCarousel' }), function ImageCarousel(handle: Handle, setup?: { startIndex?: number }) { let index = setup?.startIndex ?? 0 let goPrev = (total: number) => { if (index <= 0) return index = index - 1 handle.update() } let goNext = (total: number) => { if (index >= total - 1) return index = index + 1 handle.update() } return ({ images }: { images: string[] }) => { let total = images.length if (total === 0) return null if (index > total - 1) index = total - 1 if (index < 0) index = 0 return ( <div mix={[ css({ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', backgroundColor: '#f5f5f5', }), ]} > <div mix={[ css({ display: 'flex', height: '100%', width: '100%', transition: 'transform 350ms cubic-bezier(0.22, 1, 0.36, 1)', willChange: 'transform', }), ]} style={{ transform: `translateX(-${index * 100}%)`, }} > {images.map((src, i) => ( <div key={src + i} mix={[ css({ minWidth: '100%', height: '100%', position: 'relative', }), ]} > <img src={src} alt={`Image ${i + 1} of ${total}`} mix={[ css({ width: '100%', height: '100%', objectFit: 'cover', display: 'block', userSelect: 'none', pointerEvents: 'none', }), ]} draggable={false} /> </div> ))} </div> <button aria-label="Previous image" disabled={index === 0} mix={[ on('click', () => goPrev(total)), css({ position: 'absolute', top: '50%', left: '8px', transform: 'translateY(-50%)', width: '40px', height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: 'white', border: 'none', borderRadius: '999px', cursor: 'pointer', outline: 'none', transition: 'background-color 150ms ease, opacity 150ms ease', }), ]} style={{ opacity: index === 0 ? 0.4 : 0.9, }} > <span mix={[css({ fontSize: '22px', lineHeight: '1' })]}>{'‹'}</span> </button> <button aria-label="Next image" disabled={index === total - 1} mix={[ on('click', () => goNext(total)), css({ position: 'absolute', top: '50%', right: '8px', transform: 'translateY(-50%)', width: '40px', height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', color: 'white', border: 'none', borderRadius: '999px', cursor: 'pointer', outline: 'none', transition: 'background-color 150ms ease, opacity 150ms ease', }), ]} style={{ opacity: index === total - 1 ? 0.4 : 0.9, }} > <span mix={[css({ fontSize: '22px', lineHeight: '1' })]}>{'›'}</span> </button> </div> ) } }, ) ================================================ FILE: demos/bookstore/app/auth.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { getSessionCookie, assertContains } from '../test/helpers.ts' describe('auth handlers', () => { it('POST /login with valid credentials sets session cookie and redirects', async () => { let response = await router.fetch('https://remix.run/login', { method: 'POST', body: new URLSearchParams({ email: 'admin@bookstore.com', password: 'admin123', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/account') let sessionId = getSessionCookie(response) assert.ok(sessionId, 'Expected session cookie to be set') }) it('POST /login with invalid credentials redirects back to login with error', async () => { let response = await router.fetch('https://remix.run/login', { method: 'POST', body: new URLSearchParams({ email: 'wrong@example.com', password: 'wrongpassword', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/login') // Follow redirect to see the error message let sessionCookie = getSessionCookie(response) let followUpResponse = await router.fetch('https://remix.run/login', { headers: { Cookie: `session=${sessionCookie}`, }, }) let html = await followUpResponse.text() assertContains(html, 'Invalid email or password') }) it('POST /login does not treat wildcard characters as email matches', async () => { let response = await router.fetch('https://remix.run/login', { method: 'POST', body: new URLSearchParams({ email: '%@bookstore.com', password: 'admin123', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/login') }) it('flash error message is cleared after being displayed once', async () => { // POST invalid credentials to trigger flash message let response = await router.fetch('https://remix.run/login', { method: 'POST', body: new URLSearchParams({ email: 'wrong@example.com', password: 'wrongpassword', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/login') // Follow redirect to see the error message (first request) let sessionCookie = getSessionCookie(response) let firstFollowUp = await router.fetch('https://remix.run/login', { headers: { Cookie: `session=${sessionCookie}`, }, }) let firstHtml = await firstFollowUp.text() assertContains(firstHtml, 'Invalid email or password') // Get updated session cookie (session should be updated to clear flash) let updatedSessionCookie = getSessionCookie(firstFollowUp) || sessionCookie // Refresh the page (second request) - error should NOT be shown let secondFollowUp = await router.fetch('https://remix.run/login', { headers: { Cookie: `session=${updatedSessionCookie}`, }, }) let secondHtml = await secondFollowUp.text() assert.ok( !secondHtml.includes('Invalid email or password'), 'Expected flash error to be cleared after first display', ) }) it('POST /register creates new user and sets session', async () => { let uniqueEmail = `newuser-${Date.now()}@example.com` let response = await router.fetch('https://remix.run/register', { method: 'POST', body: new URLSearchParams({ name: 'New User', email: uniqueEmail, password: 'password123', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/account') let sessionId = getSessionCookie(response) assert.ok(sessionId, 'Expected session cookie to be set') }) it('accessing protected route redirects to login with returnTo parameter', async () => { let response = await router.fetch('https://remix.run/checkout', { redirect: 'manual', }) assert.equal(response.status, 302) let location = response.headers.get('Location') assert.ok(location, 'Expected Location header') assert.ok(location.startsWith('/login?returnTo='), 'Expected redirect to login with returnTo') assert.ok( location.includes(encodeURIComponent('/checkout')), 'Expected returnTo to contain /checkout', ) }) it('successful login with returnTo redirects to original destination', async () => { let response = await router.fetch( 'https://remix.run/login?returnTo=' + encodeURIComponent('/checkout'), { method: 'POST', body: new URLSearchParams({ email: 'customer@example.com', password: 'password123', }), redirect: 'manual', }, ) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/checkout') let sessionId = getSessionCookie(response) assert.ok(sessionId, 'Expected session cookie to be set') }) it('failed login with returnTo preserves returnTo parameter', async () => { let response = await router.fetch( 'https://remix.run/login?returnTo=' + encodeURIComponent('/checkout'), { method: 'POST', body: new URLSearchParams({ email: 'wrong@example.com', password: 'wrongpassword', }), redirect: 'manual', }, ) assert.equal(response.status, 302) let location = response.headers.get('Location') assert.ok(location, 'Expected Location header') assert.ok( location.includes('returnTo=' + encodeURIComponent('/checkout')), 'Expected returnTo to be preserved in redirect', ) // Follow redirect to verify error message is shown let sessionCookie = getSessionCookie(response) let followUpResponse = await router.fetch('https://remix.run' + location, { headers: { Cookie: `session=${sessionCookie}`, }, }) let html = await followUpResponse.text() assertContains(html, 'Invalid email or password') assertContains(html, 'returnTo=' + encodeURIComponent('/checkout')) }) it('POST /reset-password with mismatched passwords redirects back with error', async () => { // First, request a password reset to get a token let forgotPasswordResponse = await router.fetch('https://remix.run/forgot-password', { method: 'POST', body: new URLSearchParams({ email: 'customer@example.com', }), }) let html = await forgotPasswordResponse.text() // Extract token from the reset link in the demo response let tokenMatch = html.match(/\/reset-password\/([^"]+)/) assert.ok(tokenMatch, 'Expected to find reset token in response') let token = tokenMatch[1] // Try to reset password with mismatched passwords let response = await router.fetch(`https://remix.run/reset-password/${token}`, { method: 'POST', body: new URLSearchParams({ password: 'newpassword123', confirmPassword: 'differentpassword', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), `/reset-password/${token}`) // Follow redirect to see the error message let sessionCookie = getSessionCookie(response) let followUpResponse = await router.fetch(`https://remix.run/reset-password/${token}`, { headers: { Cookie: `session=${sessionCookie}`, }, }) let errorHtml = await followUpResponse.text() assertContains(errorHtml, 'Passwords do not match') }) it('POST /reset-password with invalid token redirects back with error', async () => { let invalidToken = 'invalid-token-12345' let response = await router.fetch(`https://remix.run/reset-password/${invalidToken}`, { method: 'POST', body: new URLSearchParams({ password: 'newpassword123', confirmPassword: 'newpassword123', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), `/reset-password/${invalidToken}`) // Follow redirect to see the error message let sessionCookie = getSessionCookie(response) let followUpResponse = await router.fetch(`https://remix.run/reset-password/${invalidToken}`, { headers: { Cookie: `session=${sessionCookie}`, }, }) let errorHtml = await followUpResponse.text() assertContains(errorHtml, 'Invalid or expired reset token') }) }) ================================================ FILE: demos/bookstore/app/auth.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import * as s from 'remix/data-schema' import * as f from 'remix/data-schema/form-data' import { Database } from 'remix/data-table' import { redirect } from 'remix/response/redirect' import { routes } from './routes.ts' import { passwordResetTokens, users } from './data/schema.ts' import { Document } from './layout.tsx' import { loadAuth } from './middleware/auth.ts' import { render } from './utils/render.ts' import { Session } from './utils/session.ts' const textField = f.field(s.defaulted(s.string(), '')) const loginSchema = f.object({ email: textField, password: textField, }) const registrationSchema = f.object({ name: textField, email: textField, password: textField, }) const forgotPasswordSchema = f.object({ email: textField, }) const resetPasswordSchema = f.object({ password: textField, confirmPassword: textField, }) export default { middleware: [loadAuth()], actions: { login: { actions: { index({ get, url }) { let session = get(Session) let error = session.get('error') let formAction = routes.auth.login.action.href(undefined, { returnTo: url.searchParams.get('returnTo') ?? undefined, }) return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <h1>Login</h1> {typeof error === 'string' ? ( <div class="alert alert-error" mix={[css({ marginBottom: '1.5rem' })]}> {error} </div> ) : null} <form method="POST" action={formAction}> <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" required autoComplete="email" /> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" id="password" name="password" required autoComplete="current-password" /> </div> <button type="submit" class="btn"> Login </button> </form> <p mix={[css({ marginTop: '1.5rem' })]}> Don't have an account?{' '} <a href={routes.auth.register.index.href()}>Register here</a> </p> <p> <a href={routes.auth.forgotPassword.index.href()}>Forgot password?</a> </p> <div mix={[ css({ marginTop: '2rem', padding: '1rem', background: '#f8f9fa', borderRadius: '4px', }), ]} > <p mix={[css({ fontSize: '0.9rem' })]}> <strong>Demo Accounts:</strong> </p> <p mix={[css({ fontSize: '0.9rem' })]}>Admin: admin@bookstore.com / admin123</p> <p mix={[css({ fontSize: '0.9rem' })]}> Customer: customer@example.com / password123 </p> </div> </div> </Document>, ) }, async action({ get, url }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { email, password } = s.parse(loginSchema, formData) let returnTo = url.searchParams.get('returnTo') ?? undefined let normalizedEmail = normalizeEmail(email) let user = await db.findOne(users, { where: { email: normalizedEmail } }) if (!user || user.password !== password) { session.flash('error', 'Invalid email or password. Please try again.') return redirect(routes.auth.login.index.href(undefined, { returnTo })) } session.regenerateId(true) session.set('userId', user.id) return redirect(returnTo ?? routes.account.index.href()) }, }, }, register: { actions: { index() { return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <h1>Register</h1> <form method="POST" action={routes.auth.register.action.href()}> <div class="form-group"> <label for="name">Name</label> <input type="text" id="name" name="name" required autoComplete="name" /> </div> <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" required autoComplete="email" /> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" id="password" name="password" required autoComplete="new-password" /> </div> <button type="submit" class="btn"> Register </button> </form> <p mix={[css({ marginTop: '1.5rem' })]}> Already have an account? <a href={routes.auth.login.index.href()}>Login here</a> </p> </div> </Document>, ) }, async action({ get }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { email, name, password } = s.parse(registrationSchema, formData) let normalizedEmail = normalizeEmail(email) // Check if user already exists if (await db.findOne(users, { where: { email: normalizedEmail } })) { return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <div class="alert alert-error">An account with this email already exists.</div> <p> <a href={routes.auth.register.index.href()} class="btn"> Back to Register </a> <a href={routes.auth.login.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Login </a> </p> </div> </Document>, { status: 400 }, ) } let user = await db.create( users, { email: normalizedEmail, password, name, }, { returnRow: true }, ) session.set('userId', user.id) return redirect(routes.account.index.href()) }, }, }, logout({ get }) { let session = get(Session) session.destroy() return redirect(routes.home.href()) }, forgotPassword: { actions: { index() { return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <h1>Forgot Password</h1> <p>Enter your email address and we'll send you a link to reset your password.</p> <form method="POST" action={routes.auth.forgotPassword.action.href()}> <div class="form-group"> <label for="email">Email</label> <input type="email" id="email" name="email" required autoComplete="email" /> </div> <button type="submit" class="btn"> Send Reset Link </button> </form> <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.auth.login.index.href()}>Back to Login</a> </p> </div> </Document>, ) }, async action({ get }) { let db = get(Database) let formData = get(FormData) let { email } = s.parse(forgotPasswordSchema, formData) let normalizedEmail = normalizeEmail(email) let user = await db.findOne(users, { where: { email: normalizedEmail } }) let token = undefined as string | undefined if (user) { token = Math.random().toString(36).substring(2, 15) await db.create(passwordResetTokens, { token, user_id: user.id, expires_at: Date.now() + 3600000, }) } return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <div class="alert alert-success">Password reset link sent! Check your email.</div> {token ? ( <div mix={[ css({ marginTop: '1rem', padding: '1rem', background: '#f8f9fa', borderRadius: '4px', }), ]} > <p mix={[css({ fontSize: '0.9rem' })]}> <strong>Demo Mode:</strong> Click the link below to reset your password </p> <p mix={[css({ marginTop: '0.5rem' })]}> <a href={routes.auth.resetPassword.index.href({ token })} class="btn btn-secondary" > Reset Password </a> </p> </div> ) : null} <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.auth.login.index.href()} class="btn"> Back to Login </a> </p> </div> </Document>, ) }, }, }, resetPassword: { actions: { index({ params, get }) { let session = get(Session) let token = params.token let error = session.get('error') return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <h1>Reset Password</h1> <p>Enter your new password below.</p> {typeof error === 'string' ? ( <div class="alert alert-error" mix={[css({ marginBottom: '1.5rem' })]}> {error} </div> ) : null} <form method="POST" action={routes.auth.resetPassword.action.href({ token })}> <div class="form-group"> <label for="password">New Password</label> <input type="password" id="password" name="password" required autoComplete="new-password" /> </div> <div class="form-group"> <label for="confirmPassword">Confirm Password</label> <input type="password" id="confirmPassword" name="confirmPassword" required autoComplete="new-password" /> </div> <button type="submit" class="btn"> Reset Password </button> </form> </div> </Document>, ) }, async action({ get, params }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { confirmPassword, password } = s.parse(resetPasswordSchema, formData) let token = params.token if (!token) { session.flash('error', 'Invalid or expired reset token.') return redirect(routes.auth.forgotPassword.index.href()) } if (password !== confirmPassword) { session.flash('error', 'Passwords do not match.') return redirect(routes.auth.resetPassword.index.href({ token })) } let tokenData = await db.find(passwordResetTokens, { token }) if (!tokenData || tokenData.expires_at < Date.now()) { session.flash('error', 'Invalid or expired reset token.') return redirect(routes.auth.resetPassword.index.href({ token })) } let user = await db.find(users, tokenData.user_id) if (!user) { session.flash('error', 'Invalid or expired reset token.') return redirect(routes.auth.resetPassword.index.href({ token })) } await db.update(users, user.id, { password }) await db.delete(passwordResetTokens, { token }) return render( <Document> <div class="card" mix={[css({ maxWidth: '500px', margin: '2rem auto' })]}> <div class="alert alert-success"> Password reset successfully! You can now login with your new password. </div> <p> <a href={routes.auth.login.index.href()} class="btn"> Login </a> </p> </div> </Document>, ) }, }, }, }, } satisfies Controller<typeof routes.auth> function normalizeEmail(email: string): string { return email.trim().toLowerCase() } ================================================ FILE: demos/bookstore/app/books.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { assertContains } from '../test/helpers.ts' describe('books handlers', () => { it('GET /books returns list of books', async () => { let response = await router.fetch('https://remix.run/books') assert.equal(response.status, 200) let html = await response.text() assertContains(html, 'Browse Books') assertContains(html, 'Ash & Smoke') assertContains(html, 'Heavy Metal Guitar Riffs') assertContains(html, 'Three Ways to Change Your Life') }) it('GET /books/:slug returns book details', async () => { let response = await router.fetch('https://remix.run/books/bbq') assert.equal(response.status, 200) let html = await response.text() assertContains(html, 'Ash & Smoke') assertContains(html, 'Rusty Char-Broil') assertContains(html, 'Add to Cart') }) it('GET /books/:slug returns 404 for non-existent book', async () => { let response = await router.fetch('https://remix.run/books/does-not-exist') assert.equal(response.status, 404) let html = await response.text() assertContains(html, 'Book Not Found') }) }) ================================================ FILE: demos/bookstore/app/books.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { Frame, css } from 'remix/component' import { routes } from './routes.ts' import { Database, ilike } from 'remix/data-table' import { books } from './data/schema.ts' import { BookCard } from './components/book-card.tsx' import { Layout } from './layout.tsx' import { loadAuth } from './middleware/auth.ts' import { render } from './utils/render.ts' import { getCurrentCart } from './utils/context.ts' import { ImageCarousel } from './assets/image-carousel.tsx' export default { middleware: [loadAuth()], actions: { async index({ get }) { let db = get(Database) let allBooks = await db.findMany(books, { orderBy: ['id', 'asc'] }) let genres = await db.query(books).select('genre').distinct().orderBy('genre', 'asc').all() let cart = getCurrentCart() return render( <Layout> <h1>Browse Books</h1> <div class="card" mix={[css({ marginBottom: '2rem' })]}> <form action={routes.search.href()} method="GET" mix={[css({ display: 'flex', gap: '0.5rem' })]} > <input type="search" name="q" placeholder="Search books by title, author, or description..." mix={[css({ flex: 1, padding: '0.5rem' })]} /> <button type="submit" class="btn"> Search </button> </form> </div> <div class="card" mix={[css({ marginBottom: '2rem' })]}> <h3>Browse by Genre</h3> <div mix={[css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' })]} > {genres.map((genreRow) => ( <a href={routes.books.genre.href({ genre: genreRow.genre })} class="btn btn-secondary" > {genreRow.genre} </a> ))} </div> </div> <div class="grid"> {allBooks.map((book) => { let inCart = cart.items.some((item) => item.slug === book.slug) return <BookCard book={book} inCart={inCart} /> })} </div> </Layout>, ) }, async genre({ get, params }) { let db = get(Database) let genre = params.genre let matchingBooks = await db.findMany(books, { where: ilike('genre', genre), orderBy: ['id', 'asc'], }) if (matchingBooks.length === 0) { return render( <Layout> <div class="card"> <h1>Genre Not Found</h1> <p>No books found in the "{genre}" genre.</p> <p mix={[css({ marginTop: '1rem' })]}> <a href={routes.books.index.href()} class="btn"> Browse All Books </a> </p> </div> </Layout>, { status: 404 }, ) } let cart = getCurrentCart() return render( <Layout> <h1>{genre.charAt(0).toUpperCase() + genre.slice(1)} Books</h1> <p mix={[css({ margin: '1rem 0' })]}> <a href={routes.books.index.href()} class="btn btn-secondary"> View All Books </a> </p> <div class="grid" mix={[css({ marginTop: '2rem' })]}> {matchingBooks.map((book) => { let inCart = cart.items.some((item) => item.slug === book.slug) return <BookCard book={book} inCart={inCart} /> })} </div> </Layout>, ) }, async show({ get, params }) { let db = get(Database) let book = await db.findOne(books, { where: { slug: params.slug } }) if (!book) { return render( <Layout> <div class="card"> <h1>Book Not Found</h1> </div> </Layout>, { status: 404 }, ) } let imageUrls = JSON.parse(book.image_urls) as string[] return render( <Layout> <div mix={[css({ display: 'grid', gridTemplateColumns: '300px 1fr', gap: '2rem' })]}> <div mix={[ css({ height: '400px', borderRadius: '8px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', overflow: 'hidden', }), ]} > <ImageCarousel images={imageUrls} /> </div> <div class="card"> <h1>{book.title}</h1> <p class="author" mix={[css({ fontSize: '1.2rem', margin: '0.5rem 0' })]}> by {book.author} </p> <p mix={[css({ margin: '1rem 0' })]}> <span class="badge badge-info">{book.genre}</span> <span class={`badge ${book.in_stock ? 'badge-success' : 'badge-warning'}`} mix={[css({ marginLeft: '0.5rem' })]} > {book.in_stock ? 'In Stock' : 'Out of Stock'} </span> </p> <p class="price" mix={[css({ fontSize: '2rem', margin: '1rem 0' })]}> ${book.price.toFixed(2)} </p> <p mix={[css({ margin: '1.5rem 0', lineHeight: 1.8 })]}>{book.description}</p> <div mix={[ css({ margin: '1.5rem 0', padding: '1rem', background: '#f8f9fa', borderRadius: '4px', }), ]} > <p> <strong>ISBN:</strong> {book.isbn} </p> <p> <strong>Published:</strong> {book.published_year} </p> </div> {book.in_stock ? ( <div mix={[css({ marginTop: '2rem' })]}> <Frame src={routes.fragments.cartButton.href({ bookId: book.id })} /> </div> ) : ( <p mix={[css({ color: '#e74c3c', fontWeight: 500 })]}> This book is currently out of stock. </p> )} <p mix={[css({ marginTop: '1.5rem' })]}> <a href={routes.books.index.href()} class="btn btn-secondary"> Back to Books </a> </p> </div> </div> </Layout>, { headers: { 'Cache-Control': 'no-store' } }, ) }, }, } satisfies Controller<typeof routes.books> ================================================ FILE: demos/bookstore/app/cart.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { getSessionCookie, requestWithSession, assertContains } from '../test/helpers.ts' describe('cart handlers', () => { it('POST /cart/api/add adds book to cart', async () => { let response = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '1', slug: 'bbq', }), redirect: 'manual', }) assert.equal(response.status, 302) assert.ok(response.headers.get('Location')?.includes('/cart')) }) it('GET /cart shows cart items', async () => { // First, add item to cart to get a session let addResponse = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '2', slug: 'heavy-metal', }), redirect: 'manual', }) let sessionId = getSessionCookie(addResponse) assert.ok(sessionId) // Now view cart with session let request = requestWithSession('https://remix.run/cart', sessionId) let response = await router.fetch(request) assert.equal(response.status, 200) let html = await response.text() assertContains(html, 'Shopping Cart') assertContains(html, 'Heavy Metal Guitar Riffs') }) it('cart persists state across requests with same session', async () => { // Add first item let addResponse1 = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '1', slug: 'bbq', }), redirect: 'manual', }) let sessionId = getSessionCookie(addResponse1) assert.ok(sessionId) // Add second item with same session let addRequest2 = requestWithSession('https://remix.run/cart/api/add', sessionId, { method: 'POST', body: new URLSearchParams({ bookId: '3', slug: 'three-ways', }), }) await router.fetch(addRequest2) // View cart - should have both items let cartRequest = requestWithSession('https://remix.run/cart', sessionId) let cartResponse = await router.fetch(cartRequest) let html = await cartResponse.text() assertContains(html, 'Ash & Smoke') assertContains(html, 'Three Ways to Change Your Life') }) it('GET /fragments/cart-items renders table fragment for cart items', async () => { let addResponse = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '2', slug: 'heavy-metal', }), redirect: 'manual', }) let sessionId = getSessionCookie(addResponse) assert.ok(sessionId) let request = requestWithSession('https://remix.run/fragments/cart-items', sessionId) let response = await router.fetch(request) let html = await response.text() assert.equal(response.status, 200) assertContains(html, '<table>') assertContains(html, '<th>Book</th>') assertContains(html, 'Heavy Metal Guitar Riffs') assertContains(html, 'Update') assertContains(html, 'Remove') assertContains(html, 'Total:') }) it('GET /fragments/cart-items renders totals and actions', async () => { let addResponse = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '1', slug: 'bbq', }), redirect: 'manual', }) let sessionId = getSessionCookie(addResponse) assert.ok(sessionId) let request = requestWithSession('https://remix.run/fragments/cart-items', sessionId) let response = await router.fetch(request) let html = await response.text() assert.equal(response.status, 200) assertContains(html, 'Total:') assertContains(html, '$16.99') assertContains(html, 'Continue Shopping') assertContains(html, 'Login to Checkout') }) it('GET /fragments/cart-items renders empty state when cart is empty', async () => { let response = await router.fetch('https://remix.run/fragments/cart-items') let html = await response.text() assert.equal(response.status, 200) assertContains(html, 'Your cart is empty.') assertContains(html, 'Browse Books') }) it('PUT /cart/api/update returns 204 when redirect is none', async () => { let addResponse = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '1', slug: 'bbq', }), redirect: 'manual', }) let sessionId = getSessionCookie(addResponse) assert.ok(sessionId) let request = requestWithSession('https://remix.run/cart/api/update', sessionId, { method: 'PUT', body: new URLSearchParams({ bookId: '1', quantity: '2', redirect: 'none', }), }) let response = await router.fetch(request) assert.equal(response.status, 204) }) it('DELETE /cart/api/remove returns 204 when redirect is none', async () => { let addResponse = await router.fetch('https://remix.run/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '1', slug: 'bbq', }), redirect: 'manual', }) let sessionId = getSessionCookie(addResponse) assert.ok(sessionId) let request = requestWithSession('https://remix.run/cart/api/remove', sessionId, { method: 'DELETE', body: new URLSearchParams({ bookId: '1', redirect: 'none', }), }) let response = await router.fetch(request) assert.equal(response.status, 204) }) }) ================================================ FILE: demos/bookstore/app/cart.tsx ================================================ import type { Controller, RequestContext } from 'remix/fetch-router' import { Frame } from 'remix/component' import { Database } from 'remix/data-table' import * as s from 'remix/data-schema' import * as f from 'remix/data-schema/form-data' import { redirect } from 'remix/response/redirect' import { routes } from './routes.ts' import { books } from './data/schema.ts' import { addToCart, removeFromCart, updateCartItem } from './data/cart.ts' import { Layout } from './layout.tsx' import { loadAuth } from './middleware/auth.ts' import { getCurrentCart } from './utils/context.ts' import { parseId } from './utils/ids.ts' import { render } from './utils/render.ts' import { Session } from './utils/session.ts' const bookIdField = f.field(s.optional(s.string())) const quantityField = f.field(s.defaulted(s.string(), '1')) const redirectField = f.field(s.optional(s.string())) const bookIdSchema = f.object({ bookId: bookIdField, }) const cartActionSchema = f.object({ bookId: bookIdField, redirect: redirectField, }) const cartUpdateSchema = f.object({ bookId: bookIdField, quantity: quantityField, redirect: redirectField, }) export default { middleware: [loadAuth()], actions: { index() { return render( <Layout> <h1>Shopping Cart</h1> <div class="card"> <Frame name="cart" src={routes.fragments.cartItems.href()} /> </div> </Layout>, ) }, api: { actions: { async add({ get }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { bookId, redirect: redirectTo } = s.parse(cartActionSchema, formData) if (process.env.NODE_ENV !== 'test') { // Simulate network latency await new Promise((resolve) => setTimeout(resolve, 1000)) } let parsedBookId = parseId(bookId) let book = parsedBookId === undefined ? undefined : await db.find(books, parsedBookId) if (!book) { return new Response('Book not found', { status: 404 }) } session.set( 'cart', addToCart(getCurrentCart(), book.id, book.slug, book.title, book.price, 1), ) if (redirectTo === 'none') { return new Response(null, { status: 204 }) } return redirect(routes.cart.index.href()) }, async update({ get }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { bookId, quantity, redirect: redirectTo } = s.parse(cartUpdateSchema, formData) await new Promise((resolve) => setTimeout(resolve, 1000)) let parsedBookId = parseId(bookId) let book = parsedBookId === undefined ? undefined : await db.find(books, parsedBookId) if (!book) { return new Response('Book not found', { status: 404 }) } let nextQuantity = parseInt(quantity, 10) session.set('cart', updateCartItem(getCurrentCart(), book.id, nextQuantity)) if (redirectTo === 'none') { return new Response(null, { status: 204 }) } return redirect(routes.cart.index.href()) }, async remove({ get }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { bookId, redirect: redirectTo } = s.parse(cartActionSchema, formData) if (process.env.NODE_ENV !== 'test') { // Simulate network latency await new Promise((resolve) => setTimeout(resolve, 1000)) } let parsedBookId = parseId(bookId) let book = parsedBookId === undefined ? undefined : await db.find(books, parsedBookId) if (!book) { return new Response('Book not found', { status: 404 }) } session.set('cart', removeFromCart(getCurrentCart(), book.id)) if (redirectTo === 'none') { return new Response(null, { status: 204 }) } return redirect(routes.cart.index.href()) }, }, }, }, } satisfies Controller<typeof routes.cart> export async function toggleCart({ get }: RequestContext) { let db = get(Database) let session = get(Session) let formData = get(FormData) let { bookId } = s.parse(bookIdSchema, formData) let parsedBookId = parseId(bookId) let book = parsedBookId === undefined ? undefined : await db.find(books, parsedBookId) if (!book) { return new Response('Book not found', { status: 404 }) } let cart = getCurrentCart() let inCart = cart.items.some((item) => item.bookId === book.id) let next = inCart ? removeFromCart(cart, book.id) : addToCart(cart, book.id, book.slug, book.title, book.price, 1) session.set('cart', next) return new Response(null, { status: 204 }) } ================================================ FILE: demos/bookstore/app/checkout.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' import { assertContains, getSessionCookie, loginAsCustomer, requestWithSession, } from '../test/helpers.ts' describe('checkout handlers', () => { it('GET /checkout redirects when not authenticated', async () => { let response = await router.fetch('https://remix.run/checkout') assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/login?returnTo=%2Fcheckout') }) it('POST /checkout creates order when authenticated with items in cart', async () => { let sessionCookie = await loginAsCustomer(router) // Add item to cart let addRequest = requestWithSession('https://remix.run/cart/api/add', sessionCookie, { method: 'POST', body: new URLSearchParams({ bookId: '1', slug: 'bbq', }), }) let addResponse = await router.fetch(addRequest) // Get updated session cookie after cart modification sessionCookie = getSessionCookie(addResponse) ?? sessionCookie // Submit checkout let checkoutRequest = requestWithSession('https://remix.run/checkout', sessionCookie, { method: 'POST', body: new URLSearchParams({ street: '123 Test St', city: 'Test City', state: 'TS', zip: '12345', }), }) let checkoutResponse = await router.fetch(checkoutRequest) let confirmationUrl = checkoutResponse.headers.get('Location') sessionCookie = getSessionCookie(checkoutResponse) ?? sessionCookie assert.equal(checkoutResponse.status, 302) assert.ok(confirmationUrl?.includes('/checkout/')) assert.ok(confirmationUrl?.includes('/confirmation')) let orderId = confirmationUrl?.match(/\/checkout\/([^/]+)\/confirmation/)?.[1] assert.ok(orderId) let orderDetailsRequest = requestWithSession( `https://remix.run/account/orders/${orderId}`, sessionCookie, ) let orderDetailsResponse = await router.fetch(orderDetailsRequest) assert.equal(orderDetailsResponse.status, 200) let orderDetailsHtml = await orderDetailsResponse.text() assertContains(orderDetailsHtml, 'Ash & Smoke') }) }) ================================================ FILE: demos/bookstore/app/checkout.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import * as s from 'remix/data-schema' import * as f from 'remix/data-schema/form-data' import { Database } from 'remix/data-table' import { redirect } from 'remix/response/redirect' import { routes } from './routes.ts' import { requireAuth } from './middleware/auth.ts' import { clearCart, getCartTotal } from './data/cart.ts' import { itemsByOrder, orders, orderItemsWithBook } from './data/schema.ts' import { Layout } from './layout.tsx' import { render } from './utils/render.ts' import { getCurrentUser, getCurrentCart } from './utils/context.ts' import { parseId } from './utils/ids.ts' import { Session } from './utils/session.ts' const textField = f.field(s.defaulted(s.string(), '')) const shippingAddressSchema = f.object({ street: textField, city: textField, state: textField, zip: textField, }) export default { middleware: [requireAuth()], actions: { index() { let cart = getCurrentCart() let total = getCartTotal(cart) if (cart.items.length === 0) { return render( <Layout> <div class="card"> <h1>Checkout</h1> <p>Your cart is empty. Add some books before checking out.</p> <p mix={[css({ marginTop: '1rem' })]}> <a href={routes.books.index.href()} class="btn"> Browse Books </a> </p> </div> </Layout>, ) } return render( <Layout> <h1>Checkout</h1> <div class="card"> <h2>Order Summary</h2> <table mix={[css({ marginTop: '1rem' })]}> <thead> <tr> <th>Book</th> <th>Quantity</th> <th>Price</th> <th>Subtotal</th> </tr> </thead> <tbody> {cart.items.map((item) => ( <tr> <td>{item.title}</td> <td>{item.quantity}</td> <td>${item.price.toFixed(2)}</td> <td>${(item.price * item.quantity).toFixed(2)}</td> </tr> ))} </tbody> <tfoot> <tr> <td colSpan={3} mix={[css({ textAlign: 'right', fontWeight: 'bold' })]}> Total: </td> <td mix={[css({ fontWeight: 'bold' })]}>${total.toFixed(2)}</td> </tr> </tfoot> </table> </div> <div class="card" mix={[css({ marginTop: '1.5rem' })]}> <h2>Shipping Information</h2> <form method="POST" action={routes.checkout.action.href()}> <div class="form-group"> <label for="street">Street Address</label> <input type="text" id="street" name="street" required /> </div> <div class="form-group"> <label for="city">City</label> <input type="text" id="city" name="city" required /> </div> <div class="form-group"> <label for="state">State</label> <input type="text" id="state" name="state" required /> </div> <div class="form-group"> <label for="zip">ZIP Code</label> <input type="text" id="zip" name="zip" required /> </div> <button type="submit" class="btn"> Place Order </button> <a href={routes.cart.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Back to Cart </a> </form> </div> </Layout>, ) }, async action({ get }) { let db = get(Database) let session = get(Session) let formData = get(FormData) let user = getCurrentUser() let cart = getCurrentCart() if (cart.items.length === 0) { return redirect(routes.cart.index.href()) } let shippingAddress = s.parse(shippingAddressSchema, formData) let total = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0) let order = await db.transaction(async (tx) => { let createdOrder = await tx.create( orders, { user_id: user.id, total, shipping_address_json: JSON.stringify(shippingAddress), }, { returnRow: true }, ) await tx.createMany( itemsByOrder.targetTable, cart.items.map((item) => ({ order_id: createdOrder.id, book_id: item.bookId, title: item.title, unit_price: item.price, quantity: item.quantity, })), ) let created = await tx.find(orders, createdOrder.id, { with: { items: orderItemsWithBook }, }) if (!created) { throw new Error('Failed to load created order') } return created }) session.set('cart', clearCart(cart)) return redirect(routes.checkout.confirmation.href({ orderId: order.id })) }, async confirmation({ get, params }) { let db = get(Database) let user = getCurrentUser() let orderId = parseId(params.orderId) let order = orderId === undefined ? undefined : await db.find(orders, orderId, { with: { items: orderItemsWithBook }, }) if (!order || order.user_id !== user.id) { return render( <Layout> <div class="card"> <h1>Order Not Found</h1> <p> <a href={routes.account.orders.index.href()} class="btn"> View My Orders </a> </p> </div> </Layout>, { status: 404 }, ) } return render( <Layout> <div class="alert alert-success"> <h1 mix={[css({ marginBottom: '0.5rem' })]}>Order Confirmed!</h1> <p>Thank you for your purchase. Your order has been placed successfully.</p> </div> <div class="card"> <h2>Order #{order.id}</h2> <p> <strong>Order Date:</strong> {new Date(order.created_at).toLocaleDateString()} </p> <p> <strong>Total:</strong> ${order.total.toFixed(2)} </p> <p> <strong>Status:</strong> <span class="badge badge-info">{order.status}</span> </p> <p mix={[css({ marginTop: '2rem' })]}> We'll send you a confirmation email shortly. You can track your order status in your account. </p> <div mix={[css({ marginTop: '2rem' })]}> <a href={routes.account.orders.show.href({ orderId: order.id })} class="btn"> View Order Details </a> <a href={routes.books.index.href()} class="btn btn-secondary" mix={[css({ marginLeft: '0.5rem' })]} > Continue Shopping </a> </div> </div> </Layout>, ) }, }, } satisfies Controller<typeof routes.checkout> ================================================ FILE: demos/bookstore/app/components/book-card.tsx ================================================ import { routes } from '../routes.ts' import type { Book } from '../data/schema.ts' import { Frame, css } from 'remix/component' export interface BookCardProps { book: Book inCart: boolean } export function BookCard() { return ({ book }: BookCardProps) => ( <div class="book-card"> <img src={book.cover_url} alt={book.title} /> <div class="book-card-body"> <h3>{book.title}</h3> <p class="author">by {book.author}</p> <p class="price">${book.price.toFixed(2)}</p> <div mix={[css({ display: 'flex', gap: '0.5rem', alignItems: 'center' })]}> <a href={routes.books.show.href({ slug: book.slug })} class="btn"> View Details </a> <Frame src={routes.fragments.cartButton.href({ bookId: book.id })} /> </div> </div> </div> ) } ================================================ FILE: demos/bookstore/app/components/restful-form.tsx ================================================ import type { Props, RemixNode } from 'remix/component' export interface RestfulFormProps extends Props<'form'> { /** * The name of the hidden <input> field that contains the method override value. * Default is `_method`. */ methodOverrideField?: string } /** * A wrapper around the `<form>` element that supports RESTful API methods like `PUT` and `DELETE`. * * When the method is not `GET` or `POST`, a hidden <input> field is added to the form with a * "method override" value that instructs the server to use the specified method when routing * the request. */ export function RestfulForm() { return ({ method = 'GET', methodOverrideField = '_method', ...props }: RestfulFormProps) => { let upperMethod = method.toUpperCase() if (upperMethod === 'GET') { return <form method="GET" {...props} /> } return ( <form method="POST" {...props}> {upperMethod !== 'POST' && ( <input type="hidden" name={methodOverrideField} value={upperMethod} /> )} {props.children} </form> ) } } ================================================ FILE: demos/bookstore/app/data/cart.ts ================================================ export interface CartItem { bookId: number slug: string title: string price: number quantity: number } export interface Cart { items: CartItem[] } export function isCart(value: unknown): value is Cart { return ( typeof value === 'object' && value !== null && 'items' in value && Array.isArray(value.items) ) } export function getCart(value: unknown): Cart { return isCart(value) ? value : { items: [] } } export function addToCart( cart: Cart, bookId: number, slug: string, title: string, price: number, quantity: number = 1, ): Cart { let existingItem = cart.items.find((item) => item.bookId === bookId) if (existingItem) { existingItem.quantity += quantity } else { cart.items.push({ bookId, slug, title, price, quantity }) } return cart } export function updateCartItem(cart: Cart, bookId: number, quantity: number): Cart | undefined { let item = cart.items.find((item) => item.bookId === bookId) if (!item) return undefined if (quantity <= 0) { cart.items = cart.items.filter((item) => item.bookId !== bookId) } else { item.quantity = quantity } return cart } export function removeFromCart(cart: Cart, bookId: number): Cart { cart.items = cart.items.filter((item) => item.bookId !== bookId) return cart } export function clearCart(cart: Cart): Cart { return { items: [] } } export function getCartTotal(cart: Cart): number { return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0) } ================================================ FILE: demos/bookstore/app/data/schema.ts ================================================ import { belongsTo, column as c, table, hasMany } from 'remix/data-table' import type { TableRow, TableRowWith } from 'remix/data-table' export const books = table({ name: 'books', columns: { id: c.integer(), slug: c.text(), title: c.text(), author: c.text(), description: c.text(), price: c.decimal(10, 2), genre: c.text(), image_urls: c.text(), cover_url: c.text(), isbn: c.text(), published_year: c.integer(), in_stock: c.boolean(), }, beforeWrite({ value }) { let next = { ...value } if (typeof next.slug === 'string') { next.slug = normalizeSlug(next.slug) } if (typeof next.title === 'string') { next.title = normalizeText(next.title) } if (typeof next.author === 'string') { next.author = normalizeText(next.author) } if (typeof next.description === 'string') { next.description = normalizeText(next.description) } if (typeof next.genre === 'string') { next.genre = normalizeText(next.genre) } if (typeof next.isbn === 'string') { next.isbn = normalizeText(next.isbn) } if (typeof next.cover_url === 'string' && next.cover_url.trim() === '') { next.cover_url = '/images/placeholder.jpg' } return { value: next } }, validate({ operation, value }) { let issues: Array<{ message: string; path?: Array<string | number> }> = [] let slug = typeof value.slug === 'string' ? normalizeSlug(value.slug) : undefined let title = typeof value.title === 'string' ? normalizeText(value.title) : undefined if (operation === 'create' && !slug) { issues.push({ message: 'Book slug is required.', path: ['slug'] }) } if (slug !== undefined && slug.length === 0) { issues.push({ message: 'Book slug is required.', path: ['slug'] }) } if (operation === 'create' && !title) { issues.push({ message: 'Book title is required.', path: ['title'] }) } if (title !== undefined && title.length === 0) { issues.push({ message: 'Book title is required.', path: ['title'] }) } if (typeof value.price === 'number' && (!Number.isFinite(value.price) || value.price < 0)) { issues.push({ message: 'Price must be a non-negative number.', path: ['price'] }) } if ( typeof value.published_year === 'number' && (!Number.isInteger(value.published_year) || value.published_year < 0) ) { issues.push({ message: 'Published year must be a valid positive integer.', path: ['published_year'], }) } return issues.length > 0 ? { issues } : { value } }, afterRead({ value }) { if (typeof value.cover_url !== 'string' || value.cover_url.trim() !== '') { return { value } } return { value: { ...value, cover_url: '/images/placeholder.jpg', }, } }, }) export const users = table({ name: 'users', columns: { id: c.integer(), email: c.text(), password: c.text(), name: c.text(), role: c.enum(['customer', 'admin']), created_at: c.integer(), }, beforeWrite({ operation, value }) { let next = { ...value } if (typeof next.name === 'string') { next.name = normalizeText(next.name) } if (typeof next.email === 'string') { next.email = normalizeEmail(next.email) } if (typeof next.password === 'string') { next.password = next.password.trim() } if (operation === 'create' && next.role === undefined) { next.role = 'customer' } if (operation === 'create' && next.created_at === undefined) { next.created_at = Date.now() } return { value: next } }, validate({ operation, value }) { let issues: Array<{ message: string; path?: Array<string | number> }> = [] let email = typeof value.email === 'string' ? normalizeEmail(value.email) : undefined let name = typeof value.name === 'string' ? normalizeText(value.name) : undefined if (operation === 'create' && !name) { issues.push({ message: 'Name is required.', path: ['name'] }) } if (name !== undefined && name.length === 0) { issues.push({ message: 'Name is required.', path: ['name'] }) } if (operation === 'create' && !email) { issues.push({ message: 'Email is required.', path: ['email'] }) } if (email !== undefined && !isValidEmail(email)) { issues.push({ message: 'Email address is invalid.', path: ['email'] }) } if ( (operation === 'create' && typeof value.password !== 'string') || (typeof value.password === 'string' && value.password.length < 8) ) { issues.push({ message: 'Password must be at least 8 characters long.', path: ['password'], }) } return issues.length > 0 ? { issues } : { value } }, afterRead({ value }) { if (typeof value.email !== 'string' || typeof value.name !== 'string') { return { value } } let email = normalizeEmail(value.email) let name = normalizeText(value.name) if (email === value.email && name === value.name) { return { value } } return { value: { ...value, email, name, }, } }, }) export const orders = table({ name: 'orders', columns: { id: c.integer(), user_id: c.integer(), total: c.decimal(10, 2), status: c.enum(['pending', 'processing', 'shipped', 'delivered']), shipping_address_json: c.text(), created_at: c.integer(), }, beforeWrite({ operation, value }) { let next = { ...value } if (operation === 'create' && next.status === undefined) { next.status = 'pending' } if (operation === 'create' && next.created_at === undefined) { next.created_at = Date.now() } return { value: next } }, validate({ value }) { let issues: Array<{ message: string; path?: Array<string | number> }> = [] if (typeof value.total === 'number' && (!Number.isFinite(value.total) || value.total < 0)) { issues.push({ message: 'Order total must be a non-negative number.', path: ['total'] }) } if ( typeof value.shipping_address_json === 'string' && !isJsonObject(value.shipping_address_json) ) { issues.push({ message: 'Shipping address must be a valid JSON object string.', path: ['shipping_address_json'], }) } return issues.length > 0 ? { issues } : { value } }, }) export const orderItems = table({ name: 'order_items', primaryKey: ['order_id', 'book_id'], columns: { order_id: c.integer(), book_id: c.integer(), title: c.text(), unit_price: c.decimal(10, 2), quantity: c.integer(), }, beforeWrite({ value }) { let next = { ...value } if (typeof next.title === 'string') { next.title = normalizeText(next.title) } return { value: next } }, validate({ value }) { let issues: Array<{ message: string; path?: Array<string | number> }> = [] if ( typeof value.quantity === 'number' && (!Number.isInteger(value.quantity) || value.quantity < 1) ) { issues.push({ message: 'Quantity must be an integer greater than 0.', path: ['quantity'] }) } if ( typeof value.unit_price === 'number' && (!Number.isFinite(value.unit_price) || value.unit_price < 0) ) { issues.push({ message: 'Unit price must be a non-negative number.', path: ['unit_price'], }) } return issues.length > 0 ? { issues } : { value } }, }) export const itemsByOrder = hasMany(orders, orderItems) export const bookForOrderItem = belongsTo(orderItems, books) export const orderItemsWithBook = itemsByOrder .orderBy('book_id', 'asc') .with({ book: bookForOrderItem }) export const passwordResetTokens = table({ name: 'password_reset_tokens', primaryKey: ['token'], columns: { token: c.text(), user_id: c.integer(), expires_at: c.integer(), }, }) export type Book = TableRow<typeof books> export type User = TableRow<typeof users> export type Order = TableRowWith<typeof orders, { items: OrderItem[] }> export type OrderItem = TableRowWith< typeof itemsByOrder.targetTable, { book: TableRow<typeof bookForOrderItem.targetTable> | null } > function normalizeEmail(email: string): string { return email.trim().toLowerCase() } function normalizeText(value: string): string { return value.trim() } function normalizeSlug(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') } function isValidEmail(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) } function isJsonObject(value: string): boolean { try { let parsed = JSON.parse(value) return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) } catch { return false } } ================================================ FILE: demos/bookstore/app/data/setup.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { fileURLToPath } from 'node:url' import { sql } from 'remix/data-table' import { loadMigrations } from 'remix/data-table/migrations/node' import { db, initializeBookstoreDatabase } from './setup.ts' function getRows(result: { rows?: Record<string, unknown>[] }): Record<string, unknown>[] { return result.rows ?? [] } function readRowString(row: Record<string, unknown>, key: string): string { let value = row[key] if (typeof value !== 'string') { throw new Error('Expected string row value for key "' + key + '"') } return value } function readRowCount(row: Record<string, unknown>, key: string): number { let value = row[key] if (typeof value === 'number') { return value } if (typeof value === 'bigint') { return Number(value) } if (typeof value === 'string') { return Number(value) } throw new Error('Expected numeric row value for key "' + key + '"') } describe('bookstore database setup', () => { it('applies migrations and materializes expected schema artifacts', async () => { await initializeBookstoreDatabase() let migrationsPath = fileURLToPath(new URL('../../data/migrations/', import.meta.url)) let migrations = await loadMigrations(migrationsPath) let journalResult = await db.exec( sql`select id, name from data_table_migrations order by id asc`, ) let journalRows = getRows(journalResult) let journalIds = journalRows.map((row) => readRowString(row, 'id')) let migrationIds = migrations.map((migration) => migration.id) assert.equal(journalRows.length, migrations.length) assert.deepEqual(journalIds, migrationIds) assert.equal(await db.adapter.hasTable({ name: 'books' }), true) assert.equal(await db.adapter.hasTable({ name: 'users' }), true) assert.equal(await db.adapter.hasTable({ name: 'orders' }), true) assert.equal(await db.adapter.hasTable({ name: 'order_items' }), true) assert.equal(await db.adapter.hasTable({ name: 'password_reset_tokens' }), true) assert.equal(await db.adapter.hasColumn({ name: 'books' }, 'slug'), true) assert.equal(await db.adapter.hasColumn({ name: 'users' }, 'email'), true) assert.equal(await db.adapter.hasColumn({ name: 'orders' }, 'user_id'), true) let ordersIndex = await db.exec( sql`select name from sqlite_master where type = 'index' and name = 'orders_user_id_idx'`, ) let orderItemsOrderIndex = await db.exec( sql`select name from sqlite_master where type = 'index' and name = 'order_items_order_id_idx'`, ) assert.equal(getRows(ordersIndex).length, 1) assert.equal(getRows(orderItemsOrderIndex).length, 1) }) it('does not duplicate migration journal entries when initialized more than once', async () => { await initializeBookstoreDatabase() let before = await db.exec(sql`select count(*) as count from data_table_migrations`) let beforeRows = getRows(before) assert.ok(beforeRows.length > 0) let beforeCount = readRowCount(beforeRows[0], 'count') await initializeBookstoreDatabase() let after = await db.exec(sql`select count(*) as count from data_table_migrations`) let afterRows = getRows(after) assert.ok(afterRows.length > 0) let afterCount = readRowCount(afterRows[0], 'count') assert.equal(afterCount, beforeCount) }) }) ================================================ FILE: demos/bookstore/app/data/setup.ts ================================================ import * as fs from 'node:fs' import { fileURLToPath } from 'node:url' import BetterSqlite3 from 'better-sqlite3' import { createDatabase } from 'remix/data-table' import { createMigrationRunner } from 'remix/data-table/migrations' import { loadMigrations } from 'remix/data-table/migrations/node' import { createSqliteDatabaseAdapter } from 'remix/data-table-sqlite' import { books, orderItems, orders, users } from './schema.ts' let dataDirectoryUrl = new URL('../../data/', import.meta.url) let migrationsDirectoryPath = fileURLToPath(new URL('migrations/', dataDirectoryUrl)) let databaseFilePath = getDatabaseFilePath() fs.mkdirSync(fileURLToPath(dataDirectoryUrl), { recursive: true }) if (process.env.NODE_ENV === 'test' && fs.existsSync(databaseFilePath)) { fs.unlinkSync(databaseFilePath) } let sqlite = new BetterSqlite3(databaseFilePath) sqlite.pragma('foreign_keys = ON') let adapter = createSqliteDatabaseAdapter(sqlite) export let db = createDatabase(adapter) let initializePromise: Promise<void> | null = null export async function initializeBookstoreDatabase(): Promise<void> { if (!initializePromise) { initializePromise = initialize() } await initializePromise } async function initialize(): Promise<void> { let migrations = await loadMigrations(migrationsDirectoryPath) let migrationRunner = createMigrationRunner(adapter, migrations) await migrationRunner.up() let booksCount = await db.count(books) if (booksCount === 0) { await db.createMany(books, [ { id: 1, slug: 'bbq', title: 'Ash & Smoke', author: 'Rusty Char-Broil', description: 'The perfect gift for the BBQ enthusiast in your life!', price: 16.99, genre: 'cookbook', image_urls: JSON.stringify(['/images/bbq-1.png', '/images/bbq-2.png', '/images/bbq-3.png']), cover_url: '/images/bbq-1.png', isbn: '978-0525559474', published_year: 2020, in_stock: true, }, { id: 2, slug: 'heavy-metal', title: 'Heavy Metal Guitar Riffs', author: 'Axe Master Krush', description: 'The ultimate guide to heavy metal guitar riffs!', price: 27.0, genre: 'music', image_urls: JSON.stringify([ '/images/heavy-metal-1.png', '/images/heavy-metal-2.png', '/images/heavy-metal-3.png', ]), cover_url: '/images/heavy-metal-1.png', isbn: '978-0735211292', published_year: 2018, in_stock: true, }, { id: 3, slug: 'three-ways', title: 'Three Ways to Change Your Life', author: 'Wisdom Sage', description: 'Life-changing strategies for modern living and personal growth.', price: 28.99, genre: 'self-help', image_urls: JSON.stringify([ '/images/three-ways-1.png', '/images/three-ways-2.png', '/images/three-ways-3.png', ]), cover_url: '/images/three-ways-1.png', isbn: '978-0061120084', published_year: 2021, in_stock: false, }, ]) } let usersCount = await db.count(users) if (usersCount === 0) { await db.createMany(users, [ { id: 1, email: 'admin@bookstore.com', password: 'admin123', name: 'Admin User', role: 'admin', created_at: new Date('2024-01-15').getTime(), }, { id: 2, email: 'customer@example.com', password: 'password123', name: 'John Doe', role: 'customer', created_at: new Date('2024-03-01').getTime(), }, ]) } let ordersCount = await db.count(orders) if (ordersCount === 0) { await db.createMany(orders, [ { id: 1001, user_id: 2, total: 45.98, status: 'delivered', shipping_address_json: JSON.stringify({ street: '123 Main St', city: 'Boston', state: 'MA', zip: '02101', }), created_at: new Date('2024-09-15').getTime(), }, { id: 1002, user_id: 2, total: 54.0, status: 'shipped', shipping_address_json: JSON.stringify({ street: '123 Main St', city: 'Boston', state: 'MA', zip: '02101', }), created_at: new Date('2024-10-01').getTime(), }, ]) } let orderItemsCount = await db.count(orderItems) if (orderItemsCount === 0) { await db.createMany(orderItems, [ { order_id: 1001, book_id: 1, title: 'Ash & Smoke', unit_price: 16.99, quantity: 1, }, { order_id: 1001, book_id: 3, title: 'Three Ways to Change Your Life', unit_price: 28.99, quantity: 1, }, { order_id: 1002, book_id: 2, title: 'Heavy Metal Guitar Riffs', unit_price: 27.0, quantity: 2, }, ]) } } function getDatabaseFilePath(): string { let fileName = process.env.NODE_ENV === 'test' ? `bookstore.test.${process.pid}.${Date.now()}.sqlite` : 'bookstore.sqlite' return fileURLToPath(new URL(fileName, dataDirectoryUrl)) } ================================================ FILE: demos/bookstore/app/fragments.tsx ================================================ import type { Controller } from 'remix/fetch-router' import { css } from 'remix/component' import { Database } from 'remix/data-table' import type { routes } from './routes.ts' import { CartButton } from './assets/cart-button.tsx' import { CartItems } from './assets/cart-items.tsx' import { getCartTotal } from './data/cart.ts' import { books } from './data/schema.ts' import { loadAuth } from './middleware/auth.ts' import { getCurrentCart, getCurrentUserSafely } from './utils/context.ts' import { parseId } from './utils/ids.ts' import { renderFragment } from './utils/render.ts' import { routes as appRoutes } from './routes.ts' export default { middleware: [loadAuth()], actions: { async cartButton({ get, params }) { let db = get(Database) let bookId = parseId(params.bookId) let book = bookId === undefined ? undefined : await db.find(books, bookId) if (!book) { return renderFragment(<p>Book not found</p>, { status: 404 }) } let cart = getCurrentCart() let inCart = cart.items.some((item) => item.bookId === book.id) return renderFragment(<CartButton inCart={inCart} id={book.id} slug={book.slug} />) }, cartItems() { let cart = getCurrentCart() let total = getCartTotal(cart) let user = getCurrentUserSafely() if (cart.items.length === 0) { return renderFragment( <div mix={[css({ marginTop: '2rem' })]}> <p>Your cart is empty.</p> <p mix={[css({ marginTop: '1rem' })]}> <a href={appRoutes.books.index.href()} class="btn"> Browse Books </a> </p> </div>, ) } return renderFragment(<CartItems items={cart.items} total={total} canCheckout={!!user} />) }, }, } satisfies Controller<typeof routes.fragments> ================================================ FILE: demos/bookstore/app/layout.tsx ================================================ import type { RemixNode } from 'remix/component' import { routes } from './routes.ts' import { getCurrentUserSafely } from './utils/context.ts' export function Document() { return ({ title = 'Bookstore', children }: { title?: string; children?: RemixNode }) => ( <html lang="en"> <head> <meta charSet="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{title} ================================================ FILE: packages/component/bench/frameworks/preact/index.tsx ================================================ import { useState, useCallback } from 'preact/hooks' import { get1000Rows, get10000Rows, remove, sortRows, swapRows, updatedEvery10thRow, buildData, } from '../shared.ts' import type { Benchmark, Row } from '../shared.ts' import { render } from 'preact' import { act } from 'preact/test-utils' export const name = 'preact' // Stateful Metric Card Component function MetricCard({ id, label, value, change, }: { id: number label: string value: string change: string }) { let [selected, setSelected] = useState(false) let [hovered, setHovered] = useState(false) return (
setSelected(!selected)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(e: any) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e: any) => { e.currentTarget.style.outline = '' }} tabIndex={0} style={{ backgroundColor: hovered ? '#f5f5f5' : '#fff', transform: hovered && !selected ? 'translateY(-2px)' : 'translateY(0)', transition: 'all 0.2s', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', cursor: 'pointer', boxShadow: selected ? '0 4px 8px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.05)', }} >
{label}
{value}
{change}
) } // Stateful Chart Bar Component function ChartBar({ value, index }: { value: number; index: number }) { let [hovered, setHovered] = useState(false) return (
{}} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(e: any) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e: any) => { e.currentTarget.style.outline = '' }} tabIndex={0} /> ) } // Stateful Activity Item Component function ActivityItem({ id, title, time, icon, }: { id: number title: string time: string icon: string }) { let [read, setRead] = useState(false) let [hovered, setHovered] = useState(false) return (
  • setRead(!read)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ padding: '12px', borderBottom: '1px solid #eee', cursor: 'pointer', backgroundColor: hovered ? '#f5f5f5' : read ? 'rgba(245, 245, 245, 0.6)' : '#fff', display: 'flex', alignItems: 'center', gap: '12px', }} > {icon}
    {title}
    {time}
  • ) } // Stateful Dropdown Menu Component function DropdownMenu({ rowId }: { rowId: number }) { let [open, setOpen] = useState(false) let [hovered, setHovered] = useState(false) let actions = ['View Details', 'Edit', 'Duplicate', 'Archive', 'Delete'] return (
    {open && (
    setOpen(false)} > {actions.map((action, idx) => (
    { e.stopPropagation() setOpen(false) }} onMouseEnter={(e: any) => { e.currentTarget.style.backgroundColor = '#f5f5f5' }} onMouseLeave={(e: any) => { e.currentTarget.style.backgroundColor = '#fff' }} style={{ padding: '8px 12px', cursor: 'pointer', borderBottom: idx < actions.length - 1 ? '1px solid #eee' : 'none', }} > {action}
    ))}
    )}
    ) } // Stateful Dashboard Table Row Component function DashboardTableRow({ row }: { row: Row }) { let [hovered, setHovered] = useState(false) let [selected, setSelected] = useState(false) return ( setSelected(!selected)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ backgroundColor: hovered ? '#f5f5f5' : '#fff', cursor: 'pointer', }} > {row.id} {row.label} Active ${(row.id * 10.5).toFixed(2)} ) } // Stateful Search Input Component function SearchInput() { let [value, setValue] = useState('') let [focused, setFocused] = useState(false) return ( setValue(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} style={{ padding: '8px 12px', border: `1px solid ${focused ? '#337ab7' : '#ddd'}`, borderRadius: '4px', fontSize: '14px', width: '300px', outline: focused ? '2px solid #337ab7' : 'none', outlineOffset: '2px', }} /> ) } // Stateful Form Widgets Component function FormWidgets() { let [selectValue, setSelectValue] = useState('option1') let [checkboxValues, setCheckboxValues] = useState>(new Set()) let [radioValue, setRadioValue] = useState('radio1') let [toggleValue, setToggleValue] = useState(false) let [progressValue, setProgressValue] = useState(45) return (

    Settings

    {['Checkbox 1', 'Checkbox 2', 'Checkbox 3'].map((label, idx) => (
    { let next = new Set(checkboxValues) if (e.target.checked) { next.add(`checkbox-${idx}`) } else { next.delete(`checkbox-${idx}`) } setCheckboxValues(next) }} onFocus={(e: any) => { e.currentTarget.style.outline = '2px solid #337ab7' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e: any) => { e.currentTarget.style.outline = '' }} />
    ))}
    {['Radio 1', 'Radio 2', 'Radio 3'].map((label, idx) => ( ))}
    {progressValue}%
    ) } function Dashboard({ onSwitchToTable }: { onSwitchToTable: () => void }) { let [dashboardRows, setDashboardRows] = useState(() => buildData(300)) let sortDashboardAsc = () => { setDashboardRows((current) => sortRows(current, true)) } let sortDashboardDesc = () => { setDashboardRows((current) => sortRows(current, false)) } let chartData = [65, 45, 78, 52, 89, 34, 67, 91, 43, 56, 72, 38, 55, 82, 47, 63, 71, 39, 58, 84] let activities = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, title: `Activity ${i + 1}: ${['Order placed', 'Payment received', 'Shipment created', 'Customer registered', 'Product updated'][i % 5]}`, time: `${i + 1} ${i === 0 ? 'minute' : 'minutes'} ago`, icon: ['O', 'P', 'S', 'C', 'U'][i % 5], })) return (

    Dashboard

    Sales Performance

    {chartData.map((value, index) => ( ))}

    Recent Activity

      {activities.map((activity) => ( ))}

    Dashboard Items

    {dashboardRows.map((row) => ( ))}
    ID Label Status Value Actions
    ) } function App() { let [rows, setRows] = useState([]) let [selected, setSelected] = useState(null) let [view, setView] = useState<'table' | 'dashboard'>('table') let run = () => { setRows(get1000Rows()) setSelected(null) } let runLots = () => { setRows(get10000Rows()) setSelected(null) } let add = () => { setRows((current) => [...current, ...get1000Rows()]) } let update = () => { setRows((current) => updatedEvery10thRow(current)) } let clear = () => { setRows([]) setSelected(null) } let swap = () => { setRows((current) => swapRows(current)) } let removeRow = (id: number) => { setRows((current) => remove(current, id)) } let sortAsc = () => { setRows((current) => sortRows(current, true)) } let sortDesc = () => { setRows((current) => sortRows(current, false)) } let switchToDashboard = () => { setView('dashboard') } let switchToTable = () => { setView('table') } if (view === 'dashboard') { return } return (

    Preact

    {rows.map((row) => { let rowId = row.id return ( ) })}
    {rowId} { event.preventDefault() setSelected(rowId) }} > {row.label} { event.preventDefault() removeRow(rowId) }} >
    ) } let el = document.getElementById('app')! render(, el) ================================================ FILE: packages/component/bench/frameworks/preact/package.json ================================================ { "name": "component-benchmark-preact", "private": true, "type": "module", "scripts": { "build:prod": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm", "build": "esbuild index.tsx --bundle --outfile=dist/index.js --format=esm", "dev": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm --watch" }, "dependencies": { "esbuild": "^0.27.1", "preact": "^10.28.0" } } ================================================ FILE: packages/component/bench/frameworks/preact/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "jsx": "react-jsx", "jsxImportSource": "preact" }, "exclude": ["dist"] } ================================================ FILE: packages/component/bench/frameworks/preact-signals/index.html ================================================ Preact Signals Benchmark
    ================================================ FILE: packages/component/bench/frameworks/preact-signals/index.tsx ================================================ import { signal, batch, useComputed, type Signal } from '@preact/signals' import { For } from '@preact/signals/utils' import { render } from 'preact' import { buildData as buildPlainData, sortRows as sortPlainRows, get1000Rows } from '../shared.ts' import type { Benchmark, Row as PlainRow } from '../shared.ts' export const name = 'preact-signals' type Row = { id: number; label: Signal } function buildSignalData(count: number): Row[] { let plainData = buildPlainData(count) return plainData.map((row) => ({ id: row.id, label: signal(row.label), })) } // Top-level signals for state let data = signal([]) let selected = signal(null) let view = signal<'table' | 'dashboard'>('table') let run = () => { data.value = buildSignalData(1000) selected.value = null } let runLots = () => { data.value = buildSignalData(10000) selected.value = null } let add = () => { data.value = data.value.concat(buildSignalData(1000)) } let update = () => { batch(() => { for (let i = 0, d = data.value, len = d.length; i < len; i += 10) { d[i].label.value = d[i].label.value + ' !!!' } }) } let clear = () => { data.value = [] selected.value = null } let swap = () => { let d = data.value.slice() if (d.length > 998) { let tmp = d[1] d[1] = d[998] d[998] = tmp data.value = d } } let removeRow = (id: number) => { let idx = data.value.findIndex((d) => d.id === id) data.value = [...data.value.slice(0, idx), ...data.value.slice(idx + 1)] } let selectRow = (id: number) => { selected.value = id } let sortAsc = () => { // Convert signal rows to plain rows, sort, then convert back let plainRows: PlainRow[] = data.value.map((row) => ({ id: row.id, label: row.label.value, })) let sorted = sortPlainRows(plainRows, true) // Rebuild signal rows maintaining the same signal instances where possible let sortedSignalRows: Row[] = sorted.map((plainRow) => { let existing = data.value.find((r) => r.id === plainRow.id) if (existing && existing.label.value === plainRow.label) { return existing } return { id: plainRow.id, label: signal(plainRow.label) } }) data.value = sortedSignalRows } let sortDesc = () => { // Convert signal rows to plain rows, sort, then convert back let plainRows: PlainRow[] = data.value.map((row) => ({ id: row.id, label: row.label.value, })) let sorted = sortPlainRows(plainRows, false) // Rebuild signal rows maintaining the same signal instances where possible let sortedSignalRows: Row[] = sorted.map((plainRow) => { let existing = data.value.find((r) => r.id === plainRow.id) if (existing && existing.label.value === plainRow.label) { return existing } return { id: plainRow.id, label: signal(plainRow.label) } }) data.value = sortedSignalRows } let switchToDashboard = () => { view.value = 'dashboard' } let switchToTable = () => { view.value = 'table' } // Stateful Metric Card Component function MetricCard({ id, label, value, change, }: { id: number label: string value: string change: string }) { let selected = signal(false) let hovered = signal(false) return (
    (selected.value = !selected.value)} onMouseEnter={() => (hovered.value = true)} onMouseLeave={() => (hovered.value = false)} onFocus={(e: any) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e: any) => { e.currentTarget.style.outline = '' }} tabIndex={0} style={{ backgroundColor: hovered.value ? '#f5f5f5' : '#fff', transform: hovered.value && !selected.value ? 'translateY(-2px)' : 'translateY(0)', transition: 'all 0.2s', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', cursor: 'pointer', boxShadow: selected.value ? '0 4px 8px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.05)', }} >
    {label}
    {value}
    {change}
    ) } // Stateful Chart Bar Component function ChartBar({ value, index }: { value: number; index: number }) { let hovered = signal(false) return (
    {}} onMouseEnter={() => (hovered.value = true)} onMouseLeave={() => (hovered.value = false)} onFocus={(e: any) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e: any) => { e.currentTarget.style.outline = '' }} tabIndex={0} /> ) } // Stateful Activity Item Component function ActivityItem({ id, title, time, icon, }: { id: number title: string time: string icon: string }) { let read = signal(false) let hovered = signal(false) return (
  • (read.value = !read.value)} onMouseEnter={() => (hovered.value = true)} onMouseLeave={() => (hovered.value = false)} style={{ padding: '12px', borderBottom: '1px solid #eee', cursor: 'pointer', backgroundColor: hovered.value ? '#f5f5f5' : read.value ? 'rgba(245, 245, 245, 0.6)' : '#fff', display: 'flex', alignItems: 'center', gap: '12px', }} > {icon}
    {title}
    {time}
  • ) } // Stateful Dropdown Menu Component function DropdownMenu({ rowId }: { rowId: number }) { let open = signal(false) let hovered = signal(false) let actions = ['View Details', 'Edit', 'Duplicate', 'Archive', 'Delete'] return (
    {open.value && (
    (open.value = false)} > {actions.map((action, idx) => (
    { e.stopPropagation() open.value = false }} onMouseEnter={(e: any) => { e.currentTarget.style.backgroundColor = '#f5f5f5' }} onMouseLeave={(e: any) => { e.currentTarget.style.backgroundColor = '#fff' }} style={{ padding: '8px 12px', cursor: 'pointer', borderBottom: idx < actions.length - 1 ? '1px solid #eee' : 'none', }} > {action}
    ))}
    )}
    ) } // Stateful Dashboard Table Row Component function DashboardTableRow({ row }: { row: PlainRow }) { let hovered = signal(false) let selected = signal(false) return ( (selected.value = !selected.value)} onMouseEnter={() => (hovered.value = true)} onMouseLeave={() => (hovered.value = false)} style={{ backgroundColor: hovered.value ? '#f5f5f5' : '#fff', cursor: 'pointer', }} > {row.id} {row.label} Active ${(row.id * 10.5).toFixed(2)} ) } // Stateful Search Input Component function SearchInput() { let value = signal('') let focused = signal(false) return ( (value.value = e.target.value)} onFocus={() => (focused.value = true)} onBlur={() => (focused.value = false)} style={{ padding: '8px 12px', border: `1px solid ${focused.value ? '#337ab7' : '#ddd'}`, borderRadius: '4px', fontSize: '14px', width: '300px', outline: focused.value ? '2px solid #337ab7' : 'none', outlineOffset: '2px', }} /> ) } // Stateful Form Widgets Component function FormWidgets() { let selectValue = signal('option1') let checkboxValues = signal>(new Set()) let radioValue = signal('radio1') let toggleValue = signal(false) let progressValue = signal(45) return (

    Settings

    {['Checkbox 1', 'Checkbox 2', 'Checkbox 3'].map((label, idx) => (
    { let next = new Set(checkboxValues.value) if (e.target.checked) { next.add(`checkbox-${idx}`) } else { next.delete(`checkbox-${idx}`) } checkboxValues.value = next }} onFocus={(e: any) => { e.currentTarget.style.outline = '2px solid #337ab7' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e: any) => { e.currentTarget.style.outline = '' }} />
    ))}
    {['Radio 1', 'Radio 2', 'Radio 3'].map((label, idx) => ( ))}
    {progressValue.value}%
    ) } function Row({ id, label }: { id: number; label: Signal }) { let rowClass = useComputed(() => (selected.value === id ? 'danger' : '')) return ( {id} { event.preventDefault() selectRow(id) }} > {label} { event.preventDefault() removeRow(id) }} > ) } function Dashboard({ onSwitchToTable }: { onSwitchToTable: () => void }) { let dashboardRows = signal(buildPlainData(300)) let sortDashboardAsc = () => { dashboardRows.value = sortPlainRows(dashboardRows.value, true) } let sortDashboardDesc = () => { dashboardRows.value = sortPlainRows(dashboardRows.value, false) } let chartData = [65, 45, 78, 52, 89, 34, 67, 91, 43, 56, 72, 38, 55, 82, 47, 63, 71, 39, 58, 84] let activities = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, title: `Activity ${i + 1}: ${['Order placed', 'Payment received', 'Shipment created', 'Customer registered', 'Product updated'][i % 5]}`, time: `${i + 1} ${i === 0 ? 'minute' : 'minutes'} ago`, icon: ['O', 'P', 'S', 'C', 'U'][i % 5], })) return (

    Dashboard

    Sales Performance

    {chartData.map((value, index) => ( ))}

    Recent Activity

      {activities.map((activity) => ( ))}

    Dashboard Items

    {dashboardRows.value.map((row) => ( ))}
    ID Label Status Value Actions
    ) } function App() { let currentView = view.value if (currentView === 'dashboard') { return } return (

    Preact Signals

    {(row) => }
    ) } let el = document.getElementById('app')! render(, el) ================================================ FILE: packages/component/bench/frameworks/preact-signals/package.json ================================================ { "name": "component-benchmark-preact-signals", "private": true, "type": "module", "scripts": { "build": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm", "dev": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm --watch" }, "dependencies": { "@preact/signals": "^2.0.4", "esbuild": "^0.27.1", "preact": "^10.28.0" } } ================================================ FILE: packages/component/bench/frameworks/preact-signals/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "jsx": "react-jsx", "jsxImportSource": "preact" }, "exclude": ["dist"] } ================================================ FILE: packages/component/bench/frameworks/react/index.html ================================================ React Benchmark
    ================================================ FILE: packages/component/bench/frameworks/react/index.tsx ================================================ import { useState } from 'react' import { get1000Rows, get10000Rows, remove, sortRows, swapRows, updatedEvery10thRow, buildData, } from '../shared.ts' import type { Benchmark, Row } from '../shared.ts' import { createRoot, type Root } from 'react-dom/client' import { flushSync } from 'react-dom' export const name = 'react' // Stateful Metric Card Component function MetricCard({ id, label, value, change, }: { id: number label: string value: string change: string }) { let [selected, setSelected] = useState(false) let [hovered, setHovered] = useState(false) return (
    setSelected(!selected)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(e) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e) => { e.currentTarget.style.outline = '' }} tabIndex={0} style={{ backgroundColor: hovered ? '#f5f5f5' : '#fff', transform: hovered && !selected ? 'translateY(-2px)' : 'translateY(0)', transition: 'all 0.2s', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', cursor: 'pointer', boxShadow: selected ? '0 4px 8px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.05)', }} >
    {label}
    {value}
    {change}
    ) } // Stateful Chart Bar Component function ChartBar({ value, index }: { value: number; index: number }) { let [hovered, setHovered] = useState(false) return (
    {}} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(e) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e) => { e.currentTarget.style.outline = '' }} tabIndex={0} /> ) } // Stateful Activity Item Component function ActivityItem({ id, title, time, icon, }: { id: number title: string time: string icon: string }) { let [read, setRead] = useState(false) let [hovered, setHovered] = useState(false) return (
  • setRead(!read)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ padding: '12px', borderBottom: '1px solid #eee', cursor: 'pointer', backgroundColor: hovered ? '#f5f5f5' : read ? 'rgba(245, 245, 245, 0.6)' : '#fff', display: 'flex', alignItems: 'center', gap: '12px', }} > {icon}
    {title}
    {time}
  • ) } // Stateful Dropdown Menu Component function DropdownMenu({ rowId }: { rowId: number }) { let [open, setOpen] = useState(false) let [hovered, setHovered] = useState(false) let actions = ['View Details', 'Edit', 'Duplicate', 'Archive', 'Delete'] return (
    {open && (
    setOpen(false)} > {actions.map((action, idx) => (
    { e.stopPropagation() setOpen(false) }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f5f5f5' }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff' }} style={{ padding: '8px 12px', cursor: 'pointer', borderBottom: idx < actions.length - 1 ? '1px solid #eee' : 'none', }} > {action}
    ))}
    )}
    ) } // Stateful Dashboard Table Row Component function DashboardTableRow({ row }: { row: Row }) { let [hovered, setHovered] = useState(false) let [selected, setSelected] = useState(false) return ( setSelected(!selected)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ backgroundColor: hovered ? '#f5f5f5' : '#fff', cursor: 'pointer', }} > {row.id} {row.label} Active ${(row.id * 10.5).toFixed(2)} ) } // Stateful Search Input Component function SearchInput() { let [value, setValue] = useState('') let [focused, setFocused] = useState(false) return ( setValue(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} style={{ padding: '8px 12px', border: `1px solid ${focused ? '#337ab7' : '#ddd'}`, borderRadius: '4px', fontSize: '14px', width: '300px', outline: focused ? '2px solid #337ab7' : 'none', outlineOffset: '2px', }} /> ) } // Stateful Form Widgets Component function FormWidgets() { let [selectValue, setSelectValue] = useState('option1') let [checkboxValues, setCheckboxValues] = useState>(new Set()) let [radioValue, setRadioValue] = useState('radio1') let [toggleValue, setToggleValue] = useState(false) let [progressValue, setProgressValue] = useState(45) return (

    Settings

    {['Checkbox 1', 'Checkbox 2', 'Checkbox 3'].map((label, idx) => (
    { let next = new Set(checkboxValues) if (e.target.checked) { next.add(`checkbox-${idx}`) } else { next.delete(`checkbox-${idx}`) } setCheckboxValues(next) }} onFocus={(e) => { e.currentTarget.style.outline = '2px solid #337ab7' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e) => { e.currentTarget.style.outline = '' }} />
    ))}
    {['Radio 1', 'Radio 2', 'Radio 3'].map((label, idx) => ( ))}
    {progressValue}%
    ) } function Dashboard({ onSwitchToTable }: { onSwitchToTable: () => void }) { let [dashboardRows, setDashboardRows] = useState(() => buildData(300)) let sortDashboardAsc = () => { setDashboardRows((current) => sortRows(current, true)) } let sortDashboardDesc = () => { setDashboardRows((current) => sortRows(current, false)) } let chartData = [65, 45, 78, 52, 89, 34, 67, 91, 43, 56, 72, 38, 55, 82, 47, 63, 71, 39, 58, 84] let activities = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, title: `Activity ${i + 1}: ${['Order placed', 'Payment received', 'Shipment created', 'Customer registered', 'Product updated'][i % 5]}`, time: `${i + 1} ${i === 0 ? 'minute' : 'minutes'} ago`, icon: ['O', 'P', 'S', 'C', 'U'][i % 5], })) return (

    Dashboard

    Sales Performance

    {chartData.map((value, index) => ( ))}

    Recent Activity

      {activities.map((activity) => ( ))}

    Dashboard Items

    {dashboardRows.map((row) => ( ))}
    ID Label Status Value Actions
    ) } function App() { let [rows, setRows] = useState([]) let [selected, setSelected] = useState(null) let [view, setView] = useState<'table' | 'dashboard'>('table') let run = () => { setRows(get1000Rows()) setSelected(null) } let runLots = () => { setRows(get10000Rows()) setSelected(null) } let add = () => { setRows((current) => [...current, ...get1000Rows()]) } let update = () => { setRows((current) => updatedEvery10thRow(current)) } let clear = () => { setRows([]) setSelected(null) } let swap = () => { setRows((current) => swapRows(current)) } let removeRow = (id: number) => { setRows((current) => remove(current, id)) } let sortAsc = () => { setRows((current) => sortRows(current, true)) } let sortDesc = () => { setRows((current) => sortRows(current, false)) } let switchToDashboard = () => { setView('dashboard') } let switchToTable = () => { setView('table') } if (view === 'dashboard') { return } return (

    React

    {rows.map((row) => { let rowId = row.id return ( ) })}
    {rowId} { event.preventDefault() setSelected(rowId) }} > {row.label} { event.preventDefault() removeRow(rowId) }} >
    ) } let el = document.getElementById('app')! let root = createRoot(el) root.render() ================================================ FILE: packages/component/bench/frameworks/react/package.json ================================================ { "name": "component-benchmark-remix", "private": true, "type": "module", "scripts": { "build": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm", "dev": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm --watch" }, "dependencies": { "esbuild": "^0.27.1", "react": "^19.2.1", "react-dom": "^19.2.1" }, "devDependencies": { "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3" } } ================================================ FILE: packages/component/bench/frameworks/react/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "jsx": "react-jsx" }, "exclude": ["dist"] } ================================================ FILE: packages/component/bench/frameworks/remix/index.html ================================================ Remix Benchmark
    ================================================ FILE: packages/component/bench/frameworks/remix/index.tsx ================================================ import { get1000Rows, get10000Rows, remove, sortRows, swapRows, updatedEvery10thRow, buildData, } from '../shared.ts' import type { Benchmark, Row } from '../shared.ts' import { createRoot, on } from '@remix-run/component' import type { Handle } from '@remix-run/component' export const name = 'remix' function Button() { return ({ id, text, fn }: { id: string; text: string; fn: () => void }) => (
    ) } // Stateful Metric Card Component function MetricCard(handle: Handle) { let selected = false let hovered = false return ({ id, label, value, change, }: { id: number label: string value: string change: string }) => (
    { selected = !selected handle.update() }), on('mouseenter', () => { hovered = true handle.update() }), on('mouseleave', () => { hovered = false handle.update() }), on('focus', (e: any) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }), on('blur', (e: any) => { e.currentTarget.style.outline = '' }), ]} tabIndex={0} style={{ backgroundColor: hovered ? '#f5f5f5' : '#fff', transform: hovered && !selected ? 'translateY(-2px)' : 'translateY(0)', transition: 'all 0.2s', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', cursor: 'pointer', boxShadow: selected ? '0 4px 8px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.05)', }} >
    {label}
    {value}
    {change}
    ) } // Stateful Chart Bar Component function ChartBar(handle: Handle) { let hovered = false return ({ value, index }: { value: number; index: number }) => (
    {}), on('mouseenter', () => { hovered = true handle.update() }), on('mouseleave', () => { hovered = false handle.update() }), on('focus', (e: any) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }), on('blur', (e: any) => { e.currentTarget.style.outline = '' }), ]} style={{ height: `${value}%`, backgroundColor: hovered ? '#286090' : '#337ab7', width: '30px', margin: '0 2px', cursor: 'pointer', transition: 'all 0.2s', opacity: hovered ? 0.9 : 1, transform: hovered ? 'scaleY(1.1)' : 'scaleY(1)', }} tabIndex={0} /> ) } // Stateful Activity Item Component function ActivityItem(handle: Handle) { let read = false let hovered = false return ({ id, title, time, icon }: { id: number; title: string; time: string; icon: string }) => (
  • { read = !read handle.update() }), on('mouseenter', () => { hovered = true handle.update() }), on('mouseleave', () => { hovered = false handle.update() }), ]} style={{ padding: '12px', borderBottom: '1px solid #eee', cursor: 'pointer', backgroundColor: hovered ? '#f5f5f5' : read ? 'rgba(245, 245, 245, 0.6)' : '#fff', display: 'flex', alignItems: 'center', gap: '12px', }} > {icon}
    {title}
    {time}
  • ) } // Stateful Dropdown Menu Component function DropdownMenu(handle: Handle) { let open = false let hovered = false let actions = ['View Details', 'Edit', 'Duplicate', 'Archive', 'Delete'] return ({ rowId }: { rowId: number }) => (
    {open && (
    { open = false handle.update() }), ]} > {actions.map((action, idx) => (
    { e.stopPropagation() open = false handle.update() }), on('mouseenter', (e: any) => { e.currentTarget.style.backgroundColor = '#f5f5f5' }), on('mouseleave', (e: any) => { e.currentTarget.style.backgroundColor = '#fff' }), ]} style={{ padding: '8px 12px', cursor: 'pointer', borderBottom: idx < actions.length - 1 ? '1px solid #eee' : 'none', }} > {action}
    ))}
    )}
    ) } // Stateful Dashboard Table Row Component function DashboardTableRow(handle: Handle) { let hovered = false let selected = false return ({ row }: { row: Row }) => ( { selected = !selected handle.update() }), on('mouseenter', () => { hovered = true handle.update() }), on('mouseleave', () => { hovered = false handle.update() }), ]} style={{ backgroundColor: hovered ? '#f5f5f5' : '#fff', cursor: 'pointer', }} > {row.id} {row.label} Active ${(row.id * 10.5).toFixed(2)} ) } // Stateful Search Input Component function SearchInput(handle: Handle) { let value = '' let focused = false return () => ( { value = e.target.value handle.update() }), on('focus', () => { focused = true handle.update() }), on('blur', () => { focused = false handle.update() }), ]} style={{ padding: '8px 12px', border: `1px solid ${focused ? '#337ab7' : '#ddd'}`, borderRadius: '4px', fontSize: '14px', width: '300px', outline: focused ? '2px solid #337ab7' : 'none', outlineOffset: '2px', }} /> ) } // Stateful Form Widgets Component function FormWidgets(handle: Handle) { let selectValue = 'option1' let checkboxValues = new Set() let radioValue = 'radio1' let toggleValue = false let progressValue = 45 return () => (

    Settings

    {['Checkbox 1', 'Checkbox 2', 'Checkbox 3'].map((label, idx) => (
    { if (e.target.checked) { checkboxValues.add(`checkbox-${idx}`) } else { checkboxValues.delete(`checkbox-${idx}`) } handle.update() }), on('focus', (e: any) => { e.currentTarget.style.outline = '2px solid #337ab7' e.currentTarget.style.outlineOffset = '2px' }), on('blur', (e: any) => { e.currentTarget.style.outline = '' }), ]} />
    ))}
    {['Radio 1', 'Radio 2', 'Radio 3'].map((label, idx) => ( ))}
    {progressValue}%
    ) } function Dashboard(handle: Handle) { let dashboardRows = buildData(300) let sortDashboardAsc = () => { dashboardRows = sortRows(dashboardRows, true) handle.update() } let sortDashboardDesc = () => { dashboardRows = sortRows(dashboardRows, false) handle.update() } let chartData = [65, 45, 78, 52, 89, 34, 67, 91, 43, 56, 72, 38, 55, 82, 47, 63, 71, 39, 58, 84] let activities = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, title: `Activity ${i + 1}: ${['Order placed', 'Payment received', 'Shipment created', 'Customer registered', 'Product updated'][i % 5]}`, time: `${i + 1} ${i === 0 ? 'minute' : 'minutes'} ago`, icon: ['O', 'P', 'S', 'C', 'U'][i % 5], })) return ({ onSwitchToTable }: { onSwitchToTable: () => void }) => (

    Dashboard

    Sales Performance

    {chartData.map((value, index) => ( ))}

    Recent Activity

      {activities.map((activity) => ( ))}

    Dashboard Items

    {dashboardRows.map((row) => ( ))}
    ID Label Status Value Actions
    ) } function App(handle: Handle) { let rows: Row[] = [] let selected: number | null = null let view: 'table' | 'dashboard' = 'table' let setRows = (newRows: Row[]) => { rows = newRows handle.update() } let setSelected = (newSelected: number | null) => { selected = newSelected handle.update() } let switchToDashboard = () => { view = 'dashboard' handle.update() } let switchToTable = () => { view = 'table' handle.update() } return () => { if (view === 'dashboard') { return } return (

    Remix

    {rows.map((row) => { let rowId = row.id return ( ) })}
    {rowId} { setSelected(rowId) }), ]} > {row.label} { setRows(remove(rows, rowId)) }), ]} >
    ) } } let el = document.getElementById('app')! let root = createRoot(el) root.render() ================================================ FILE: packages/component/bench/frameworks/remix/package.json ================================================ { "name": "component-benchmark-remix", "private": true, "type": "module", "scripts": { "build:prod": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm", "build": "esbuild index.tsx --bundle --outfile=dist/index.js --format=esm", "dev": "esbuild index.tsx --bundle --minify --outfile=dist/index.js --format=esm --watch" }, "dependencies": { "@remix-run/component": "workspace:^", "esbuild": "^0.27.1" } } ================================================ FILE: packages/component/bench/frameworks/remix/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "jsx": "react-jsx", "jsxImportSource": "@remix-run/component" }, "exclude": ["dist"] } ================================================ FILE: packages/component/bench/frameworks/shared.ts ================================================ export interface Benchmark { run(): void teardown(): void } export interface Framework { name: string insert: () => Benchmark // swap: Benchmark // update: Benchmark // replace: Benchmark } export type Row = { id: number; label: string } let idCounter = 1 const A = [ 'pretty', 'large', 'big', 'small', 'tall', 'short', 'long', 'handsome', 'plain', 'quaint', 'clean', 'elegant', 'easy', 'angry', 'crazy', 'helpful', 'mushy', 'odd', 'unsightly', 'adorable', 'important', 'inexpensive', 'cheap', 'expensive', 'fancy', ], C = [ 'red', 'yellow', 'blue', 'green', 'pink', 'brown', 'purple', 'brown', 'white', 'black', 'orange', ], N = [ 'table', 'chair', 'house', 'bbq', 'desk', 'car', 'pony', 'cookie', 'sandwich', 'burger', 'pizza', 'mouse', 'keyboard', ] export function buildData(count: number) { let data = new Array(count) for (let i = 0; i < count; i++) { // Use deterministic selection based on index to ensure same data every time data[i] = { id: idCounter++, label: `${A[i % A.length]} ${C[i % C.length]} ${N[i % N.length]}`, } } return data } export function get1000Rows(): Row[] { return buildData(1000) } export function get10000Rows(): Row[] { return buildData(10000) } export function updatedEvery10thRow(data: Row[]): Row[] { let newData = data.slice(0) for (let i = 0, d = data, len = d.length; i < len; i += 10) { newData[i] = { id: data[i].id, label: data[i].label + ' !!!' } } return newData } export function swapRows(data: Row[]): Row[] { let d = data.slice() if (d.length > 998) { let tmp = d[1] d[1] = d[998] d[998] = tmp } return d } export function remove(data: Row[], id: number): Row[] { return data.filter((d) => d.id !== id) } export function sortRows(data: Row[], ascending: boolean = true): Row[] { let sorted = data.slice().sort((a, b) => { if (ascending) { return a.label.localeCompare(b.label) } else { return b.label.localeCompare(a.label) } }) return sorted } ================================================ FILE: packages/component/bench/frameworks/solid/build.js ================================================ import { build, context } from 'esbuild' import { solidPlugin } from 'esbuild-plugin-solid' let isProduction = process.env.NODE_ENV === 'production' let isWatch = process.argv.includes('--watch') let buildOptions = { entryPoints: ['index.tsx'], bundle: true, outfile: 'dist/index.js', format: 'esm', plugins: [solidPlugin()], minify: isProduction, } if (isWatch) { let ctx = await context(buildOptions) await ctx.watch() console.log('Watching...') } else { await build(buildOptions) console.log('Build complete') } ================================================ FILE: packages/component/bench/frameworks/solid/index.html ================================================ SolidJS Benchmark
    ================================================ FILE: packages/component/bench/frameworks/solid/index.tsx ================================================ import { createSignal, createSelector, For } from 'solid-js' import { render } from 'solid-js/web' import { get1000Rows, get10000Rows, remove, sortRows, swapRows, updatedEvery10thRow, buildData, } from '../shared.ts' import type { Benchmark, Row } from '../shared.ts' export const name = 'solid' // Stateful Metric Card Component function MetricCard(props: { id: number; label: string; value: string; change: string }) { let [selected, setSelected] = createSignal(false) let [hovered, setHovered] = createSignal(false) return (
    setSelected(!selected())} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(e) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e) => { e.currentTarget.style.outline = '' }} tabIndex={0} style={{ 'background-color': hovered() ? '#f5f5f5' : '#fff', transform: hovered() && !selected() ? 'translateY(-2px)' : 'translateY(0)', transition: 'all 0.2s', padding: '20px', border: '1px solid #ddd', 'border-radius': '8px', cursor: 'pointer', 'box-shadow': selected() ? '0 4px 8px rgba(0,0,0,0.1)' : '0 2px 4px rgba(0,0,0,0.05)', }} >
    {props.label}
    {props.value}
    {props.change}
    ) } // Stateful Chart Bar Component function ChartBar(props: { value: number; index: number }) { let [hovered, setHovered] = createSignal(false) return (
    {}} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} onFocus={(e) => { e.currentTarget.style.outline = '2px solid #222' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e) => { e.currentTarget.style.outline = '' }} tabIndex={0} /> ) } // Stateful Activity Item Component function ActivityItem(props: { id: number; title: string; time: string; icon: string }) { let [read, setRead] = createSignal(false) let [hovered, setHovered] = createSignal(false) return (
  • setRead(!read())} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ padding: '12px', 'border-bottom': '1px solid #eee', cursor: 'pointer', 'background-color': hovered() ? '#f5f5f5' : read() ? 'rgba(245, 245, 245, 0.6)' : '#fff', display: 'flex', 'align-items': 'center', gap: '12px', }} > {props.icon}
    {props.title}
    {props.time}
  • ) } // Stateful Dropdown Menu Component function DropdownMenu(props: { rowId: number }) { let [open, setOpen] = createSignal(false) let [hovered, setHovered] = createSignal(false) let actions = ['View Details', 'Edit', 'Duplicate', 'Archive', 'Delete'] return (
    {open() && (
    setOpen(false)} > {(action, idx) => (
    { e.stopPropagation() setOpen(false) }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f5f5f5' }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff' }} style={{ padding: '8px 12px', cursor: 'pointer', 'border-bottom': idx() < actions.length - 1 ? '1px solid #eee' : 'none', }} > {action}
    )}
    )}
    ) } // Stateful Dashboard Table Row Component function DashboardTableRow(props: { row: Row }) { let [hovered, setHovered] = createSignal(false) let [selected, setSelected] = createSignal(false) return ( setSelected(!selected())} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ 'background-color': hovered() ? '#f5f5f5' : '#fff', cursor: 'pointer', }} > {props.row.id} {props.row.label} Active ${(props.row.id * 10.5).toFixed(2)} ) } // Stateful Search Input Component function SearchInput() { let [value, setValue] = createSignal('') let [focused, setFocused] = createSignal(false) return ( setValue(e.currentTarget.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} style={{ padding: '8px 12px', border: `1px solid ${focused() ? '#337ab7' : '#ddd'}`, 'border-radius': '4px', 'font-size': '14px', width: '300px', outline: focused() ? '2px solid #337ab7' : 'none', 'outline-offset': '2px', }} /> ) } // Stateful Form Widgets Component function FormWidgets() { let [selectValue, setSelectValue] = createSignal('option1') let [checkboxValues, setCheckboxValues] = createSignal>(new Set()) let [radioValue, setRadioValue] = createSignal('radio1') let [toggleValue, setToggleValue] = createSignal(false) let [progressValue, setProgressValue] = createSignal(45) let checkboxLabels = ['Checkbox 1', 'Checkbox 2', 'Checkbox 3'] let radioLabels = ['Radio 1', 'Radio 2', 'Radio 3'] return (

    Settings

    {(label, idx) => (
    { let next = new Set(checkboxValues()) if (e.target.checked) { next.add(`checkbox-${idx()}`) } else { next.delete(`checkbox-${idx()}`) } setCheckboxValues(next) }} onFocus={(e) => { e.currentTarget.style.outline = '2px solid #337ab7' e.currentTarget.style.outlineOffset = '2px' }} onBlur={(e) => { e.currentTarget.style.outline = '' }} />
    )}
    {(label, idx) => ( )}
    {progressValue()}%
    ) } function Dashboard(props: { onSwitchToTable: () => void }) { let [dashboardRows, setDashboardRows] = createSignal(buildData(300)) let sortDashboardAsc = () => { setDashboardRows((current) => sortRows(current, true)) } let sortDashboardDesc = () => { setDashboardRows((current) => sortRows(current, false)) } let chartData = [65, 45, 78, 52, 89, 34, 67, 91, 43, 56, 72, 38, 55, 82, 47, 63, 71, 39, 58, 84] let activities = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, title: `Activity ${i + 1}: ${['Order placed', 'Payment received', 'Shipment created', 'Customer registered', 'Product updated'][i % 5]}`, time: `${i + 1} ${i === 0 ? 'minute' : 'minutes'} ago`, icon: ['O', 'P', 'S', 'C', 'U'][i % 5], })) return (

    Dashboard

    Sales Performance

    {(value, index) => }

    Recent Activity

      {(activity) => }

    Dashboard Items

    {(row) => }
    ID Label Status Value Actions
    ) } function App() { let [rows, setRows] = createSignal([]) let [selected, setSelected] = createSignal(null) let [view, setView] = createSignal<'table' | 'dashboard'>('table') let run = () => { setRows(get1000Rows()) setSelected(null) } let runLots = () => { setRows(get10000Rows()) setSelected(null) } let add = () => { setRows((current) => [...current, ...get1000Rows()]) } let update = () => { setRows((current) => updatedEvery10thRow(current)) } let clear = () => { setRows([]) setSelected(null) } let swap = () => { setRows((current) => swapRows(current)) } let removeRow = (id: number) => { setRows((current) => remove(current, id)) } let sortAsc = () => { setRows((current) => sortRows(current, true)) } let sortDesc = () => { setRows((current) => sortRows(current, false)) } let switchToDashboard = () => { setView('dashboard') } let switchToTable = () => { setView('table') } let isSelected = createSelector(selected) return ( <> {view() === 'dashboard' ? ( ) : (

    SolidJS

    {(row) => { let rowId = row.id return ( ) }}
    {rowId} { event.preventDefault() setSelected(rowId) }} > {row.label} { event.preventDefault() removeRow(rowId) }} >
    )} ) } let el = document.getElementById('app')! render(() => , el) ================================================ FILE: packages/component/bench/frameworks/solid/package.json ================================================ { "name": "component-benchmark-solid", "private": true, "type": "module", "scripts": { "build:prod": "NODE_ENV=production node build.js", "build": "node build.js", "dev": "node build.js --watch" }, "dependencies": { "esbuild": "^0.27.1", "solid-js": "^1.9.3" }, "devDependencies": { "@types/node": "catalog:", "esbuild-plugin-solid": "^0.6.0" } } ================================================ FILE: packages/component/bench/frameworks/solid/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "jsx": "preserve", "jsxImportSource": "solid-js" }, "exclude": ["dist"] } ================================================ FILE: packages/component/bench/frameworks/styles.css ================================================ body { margin: 0; padding: 10px 0 0 0; overflow-y: scroll; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #ffffff; } * { box-sizing: border-box; } .container { max-width: 960px; margin: 0 auto; padding: 0 15px 40px; } .row { display: flex; flex-wrap: wrap; margin-left: -5px; margin-right: -5px; } .col-md-6, .col-sm-6 { padding-left: 5px; padding-right: 5px; } @media (min-width: 768px) { .col-md-6 { width: 50%; } } @media (min-width: 576px) { .col-sm-6 { width: 50%; } } .jumbotron { padding: 10px 20px; margin-bottom: 20px; background-color: #eeeeee; border-radius: 6px; } .jumbotron .row h1 { margin: 0; font-size: 40px; font-weight: 500; } .smallpad { padding: 5px; } .btn { display: inline-block; margin-bottom: 0; font-weight: 400; text-align: center; white-space: nowrap; vertical-align: middle; background-image: none; border: 1px solid transparent; padding: 6px 12px; font-size: 14px; line-height: 1.42857143; border-radius: 4px; user-select: none; } .btn:focus-visible { outline: 2px solid #222; outline-offset: 2px; } .btn-primary { color: #fff; background-color: #337ab7; border-color: #2e6da4; } .btn-primary:hover { background-color: #286090; border-color: #204d74; } .btn-block { display: block; width: 100%; } table { width: 100%; border-collapse: collapse; margin-bottom: 20px; /* contain: strict; */ } tr { /* contain-intrinsic-size: auto 36px; content-visibility: auto; */ } .table > tbody > tr > td { padding: 8px; border-top: 1px solid #dddddd; white-space: nowrap; } .table-hover > tbody > tr:hover { background-color: #f5f5f5; } .table-striped > tbody > tr:nth-child(odd) { background-color: #f9f9f9; } .table > tbody > tr.danger > td { background-color: #f2dede; } .test-data a { display: block; color: #337ab7; text-decoration: none; cursor: pointer; } .test-data a:hover { text-decoration: underline; } .glyphicon { position: relative; display: inline-block; } .glyphicon-remove::before { content: '×'; font-size: 16px; line-height: 1; } .preloadicon { position: absolute; top: -20px; left: -20px; } ================================================ FILE: packages/component/bench/package.json ================================================ { "private": true, "type": "module", "dependencies": { "@remix-run/fetch-router": "workspace:*", "@remix-run/node-fetch-server": "workspace:*", "@remix-run/static-middleware": "workspace:*" }, "devDependencies": { "@types/node": "catalog:", "playwright": "^1.49.1", "tsx": "catalog:" }, "scripts": { "start": "tsx server.ts", "dev": "tsx watch server.ts", "build-frameworks": "pnpm --filter \"component-benchmark-*\" run build", "bench": "tsx runner.ts" } } ================================================ FILE: packages/component/bench/runner.ts ================================================ import { spawn, type ChildProcess } from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' import { parseArgs } from 'node:util' import { chromium, type Browser, type Page } from 'playwright' const PORT = 44100 const BASE_URL = `http://localhost:${PORT}` const REMIX_RESULTS_FILE = path.join(import.meta.dirname, '.remix-prev-results.json') const LAST_ARGS_FILE = path.join(import.meta.dirname, '.last-args.json') interface SavedArgs { cpu: string runs: string warmups: string headless: boolean table: boolean profile: boolean allocProfile: boolean framework: string[] benchmark: string[] } function saveArgs(args: SavedArgs): void { fs.writeFileSync(LAST_ARGS_FILE, JSON.stringify(args, null, 2)) } function loadLastArgs(): SavedArgs | null { try { if (fs.existsSync(LAST_ARGS_FILE)) { return JSON.parse(fs.readFileSync(LAST_ARGS_FILE, 'utf-8')) } } catch { // Ignore errors } return null } // Check for 'repeat' command let isRepeat = process.argv[2] === 'repeat' // Parse command line arguments let { values: args } = parseArgs({ options: { cpu: { type: 'string', default: '4' }, runs: { type: 'string', default: '5' }, warmups: { type: 'string', default: '2' }, headless: { type: 'boolean', default: false }, table: { type: 'boolean', default: false }, profile: { type: 'boolean', default: false }, 'alloc-profile': { type: 'boolean', default: false }, framework: { type: 'string', multiple: true, short: 'f', // default: ['remix', 'preact'], }, benchmark: { type: 'string', multiple: true, short: 'b' }, }, allowPositionals: true, }) // If repeating, load saved args if (isRepeat) { let savedArgs = loadLastArgs() if (savedArgs) { args = { ...args, ...savedArgs } if ('allocProfile' in savedArgs) { args['alloc-profile'] = savedArgs.allocProfile } console.log('Repeating with saved options:', savedArgs) } else { console.error('No previous run found. Run a benchmark first.') process.exit(1) } } else { // Save current args for repeat saveArgs({ cpu: args.cpu!, runs: args.runs!, warmups: args.warmups!, headless: args.headless!, table: args.table!, profile: args.profile!, allocProfile: args['alloc-profile']!, framework: args.framework || [], benchmark: args.benchmark || [], }) } let cpuThrottling = parseInt(args.cpu!, 10) let benchmarkRuns = parseInt(args.runs!, 10) let warmupRuns = parseInt(args.warmups!, 10) let headless = args.headless! let useTable = args.table! let showProfile = args.profile! let showAllocProfile = args['alloc-profile']! let frameworkFilter = args.framework || [] let benchmarkFilter = args.benchmark || [] interface FunctionProfile { name: string time: number percentage: number } interface AllocationProfile { name: string bytes: number percentage: number } interface TimingResult { scripting: number total: number profile?: FunctionProfile[] allocProfile?: AllocationProfile[] } interface BenchmarkResult { framework: string operation: string scripting: { times: number[]; mean: number; median: number; min: number; max: number } total: { times: number[]; mean: number; median: number; min: number; max: number } } interface Operation { name: string setup?: (page: Page) => Promise action: (page: Page) => Promise teardown?: (page: Page) => Promise } const EVENT_TIMING_TIMEOUT_MS = 5000 // Click an element and measure time until next paint using Event Timing API // Also captures Chrome DevTools Profiler data for detailed function-level analysis async function clickAndMeasure( page: Page, selector: string, operationName?: string, ): Promise { // Set up the observer before clicking (using string to avoid tsx transformation issues) await page.evaluate(` window.__benchResult = null; window.__benchObserver = new PerformanceObserver(function(list) { var entries = list.getEntries(); for (var i = 0; i < entries.length; i++) { var entry = entries[i]; if (entry.entryType === 'event' && entry.name === 'click') { window.__benchResult = { scripting: entry.processingEnd - entry.processingStart, total: entry.duration }; window.__benchObserver.disconnect(); return; } } }); window.__benchObserver.observe({ type: 'event', buffered: false, durationThreshold: 0 }); `) // Start Chrome DevTools Profiler let cdp = await page.context().newCDPSession(page) if (showProfile) { await cdp.send('Profiler.enable') await cdp.send('Profiler.start') } if (showAllocProfile) { await cdp.send('HeapProfiler.enable') await cdp.send('HeapProfiler.startSampling', { samplingInterval: 32768, includeObjectsCollectedByMajorGC: true, includeObjectsCollectedByMinorGC: true, }) } // Use Playwright's click which fires real pointer events await page.click(selector) let timing: TimingResult let cpuProfileResult: any let allocProfileResult: any try { let operationLabel = operationName ?? 'unknown' timing = (await page.evaluate(` new Promise(function(resolve, reject) { var timeoutMs = ${EVENT_TIMING_TIMEOUT_MS} var selector = ${JSON.stringify(selector)} var operationName = ${JSON.stringify(operationLabel)} var timeoutId = setTimeout(function() { if (window.__benchObserver) { window.__benchObserver.disconnect() } reject(new Error( 'Timed out waiting for Event Timing click entry after ' + timeoutMs + 'ms (selector="' + selector + '", operation="' + operationName + '")' )) }, timeoutMs) function check() { if (window.__benchResult !== null) { clearTimeout(timeoutId) resolve(window.__benchResult) return } requestAnimationFrame(check) } requestAnimationFrame(check) }) `)) as TimingResult } finally { if (showProfile) { cpuProfileResult = await cdp.send('Profiler.stop').catch(() => null) await cdp.send('Profiler.disable').catch(() => undefined) } if (showAllocProfile) { allocProfileResult = await cdp.send('HeapProfiler.stopSampling').catch(() => null) await cdp.send('HeapProfiler.disable').catch(() => undefined) } } // Process profiling data let profileData: FunctionProfile[] | undefined if (showProfile && cpuProfileResult && cpuProfileResult.profile) { let profile = cpuProfileResult.profile as any let nodes = profile.nodes || [] let samples = profile.samples || [] let timeDeltas = profile.timeDeltas || [] // Calculate self time for each function let functionTimes = new Map() let functionNames = new Map() // Build function name map for (let node of nodes) { let name = node.callFrame?.functionName || node.callFrame?.url || 'unknown' if (name.includes('node_modules')) continue // Skip node_modules functionNames.set(node.id, name) } // Calculate time spent in each function (self time = time when this function is on top of stack) let totalTime = 0 for (let i = 0; i < samples.length; i++) { let sampleId = samples[i] let delta = timeDeltas[i] || 0 totalTime += delta // Self time is when this function is the top of the stack let current = functionTimes.get(sampleId) || 0 functionTimes.set(sampleId, current + delta) } // Sort by self time and get top 30 profileData = Array.from(functionTimes.entries()) .map(([id, time]) => ({ name: functionNames.get(id) || 'unknown', time: time / 1000, // Convert to ms percentage: totalTime > 0 ? (time / totalTime) * 100 : 0, })) .filter((item) => !item.name.includes('node_modules')) .sort((a, b) => b.time - a.time) .slice(0, 30) } let allocProfileData: AllocationProfile[] | undefined if (showAllocProfile && allocProfileResult && allocProfileResult.profile) { allocProfileData = buildAllocationProfile(allocProfileResult.profile as any) } return { ...timing, profile: profileData, allocProfile: allocProfileData } } // Wait for the main thread to be idle (no pending tasks) async function waitForIdle(page: Page): Promise { await page.evaluate(` new Promise(function(resolve) { // First wait for paint to complete requestAnimationFrame(function() { requestAnimationFrame(function() { // Then wait for the main thread to be idle requestIdleCallback(function() { // Double-check with another idle callback to ensure cleanup is done requestIdleCallback(resolve, { timeout: 100 }); }, { timeout: 100 }); }); }); }) `) } // Click without measuring (for setup/teardown) async function click(page: Page, selector: string): Promise { await page.click(selector) // Wait for paint and idle to complete before continuing await waitForIdle(page) } // Clear all rows async function clear(page: Page): Promise { await click(page, '#clear') } // Create 1000 rows async function create1k(page: Page): Promise { await click(page, '#run') } // Define all benchmark operations const operations: Operation[] = [ { name: 'create1k', setup: clear, action: (page) => clickAndMeasure(page, '#run', 'create1k'), }, // { // name: 'create10k', // setup: clear, // action: (page) => clickAndMeasure(page, '#runlots', 'create10k'), // }, { name: 'append1k', setup: create1k, action: (page) => clickAndMeasure(page, '#add', 'append1k'), teardown: clear, }, { name: 'update', setup: create1k, action: (page) => clickAndMeasure(page, '#update', 'update'), teardown: clear, }, { name: 'clear', setup: create1k, action: (page) => clickAndMeasure(page, '#clear', 'clear'), }, { name: 'swapRows', setup: create1k, action: (page) => clickAndMeasure(page, '#swaprows', 'swapRows'), teardown: clear, }, { name: 'selectRow', setup: create1k, action: (page) => clickAndMeasure(page, 'tbody tr:first-child td.col-md-4 a', 'selectRow'), teardown: clear, }, { name: 'removeRow', setup: create1k, action: (page) => clickAndMeasure(page, 'tbody tr:first-child td.col-md-1 a', 'removeRow'), teardown: clear, }, { name: 'replace1k', setup: create1k, action: (page) => clickAndMeasure(page, '#run', 'replace1k'), teardown: clear, }, { name: 'sortAsc', setup: create1k, action: (page) => clickAndMeasure(page, '#sortasc', 'sortAsc'), teardown: clear, }, { name: 'sortDesc', setup: create1k, action: (page) => clickAndMeasure(page, '#sortdesc', 'sortDesc'), teardown: clear, }, { name: 'switchToDashboard', setup: create1k, action: (page) => clickAndMeasure(page, '#switchToDashboard', 'switchToDashboard'), teardown: async (page) => { await click(page, '#switchToTable') await clear(page) }, }, { name: 'renderDashboard', setup: clear, action: (page) => clickAndMeasure(page, '#switchToDashboard', 'renderDashboard'), teardown: async (page) => { await click(page, '#switchToTable') await clear(page) }, }, { name: 'teardownDashboard', setup: async (page) => { await clear(page) await click(page, '#switchToDashboard') }, action: (page) => clickAndMeasure(page, '#switchToTable', 'teardownDashboard'), teardown: clear, }, { name: 'sortDashboardAsc', setup: async (page) => { await clear(page) await click(page, '#switchToDashboard') }, action: (page) => clickAndMeasure(page, '#sortDashboardAsc', 'sortDashboardAsc'), teardown: async (page) => { await click(page, '#switchToTable') await clear(page) }, }, { name: 'sortDashboardDesc', setup: async (page) => { await clear(page) await click(page, '#switchToDashboard') }, action: (page) => clickAndMeasure(page, '#sortDashboardDesc', 'sortDashboardDesc'), teardown: async (page) => { await click(page, '#switchToTable') await clear(page) }, }, ] // Start the benchmark server function startServer(): Promise { return new Promise((resolve, reject) => { let server = spawn('tsx', ['server.ts'], { cwd: import.meta.dirname, stdio: ['ignore', 'pipe', 'pipe'], }) let started = false server.stdout?.on('data', (data: Buffer) => { let output = data.toString() if (output.includes('Benchmark server running') && !started) { started = true resolve(server) } }) server.stderr?.on('data', (data: Buffer) => { if (!started) { reject(new Error(`Server error: ${data.toString()}`)) } }) server.on('error', reject) // Timeout if server doesn't start setTimeout(() => { if (!started) { server.kill() reject(new Error('Server failed to start within timeout')) } }, 10000) }) } // Stop the server function stopServer(server: ChildProcess): Promise { return new Promise((resolve) => { server.on('close', () => resolve()) server.kill('SIGTERM') }) } // Get list of frameworks function getFrameworks(): string[] { let frameworksDir = path.join(import.meta.dirname, 'frameworks') let entries = fs.readdirSync(frameworksDir, { withFileTypes: true }) return entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() } // Save remix results to file for comparison with next run function saveRemixResults(results: BenchmarkResult[]): void { let remixResults = results.filter((r) => r.framework === 'remix') if (remixResults.length > 0) { fs.writeFileSync(REMIX_RESULTS_FILE, JSON.stringify(remixResults, null, 2)) } } // Load previous remix results if they exist function loadPreviousRemixResults(): BenchmarkResult[] { try { if (fs.existsSync(REMIX_RESULTS_FILE)) { let data = fs.readFileSync(REMIX_RESULTS_FILE, 'utf-8') let results: BenchmarkResult[] = JSON.parse(data) // Rename framework to "remix (prev)" return results.map((r) => ({ ...r, framework: 'remix (prev)' })) } } catch { // Ignore errors loading previous results } return [] } // Run a single operation and measure time async function measureOperation(page: Page, operation: Operation): Promise { // Run setup if defined if (operation.setup) { await operation.setup(page) } // Wait for idle before measuring to ensure no pending work from setup await waitForIdle(page) // Measure the action (returns timing from Event Timing API) let timing = await operation.action(page) // Run teardown if defined if (operation.teardown) { await operation.teardown(page) } // Wait for idle after teardown to ensure cleanup is complete before next operation await waitForIdle(page) return timing } // Calculate statistics for an array of numbers function calcStats(times: number[]) { let sorted = [...times].sort((a, b) => a - b) return { times, mean: times.reduce((a, b) => a + b, 0) / times.length, median: sorted[Math.floor(sorted.length / 2)], min: sorted[0], max: sorted[sorted.length - 1], } } // Aggregate profiling data and calculate medians function aggregateProfiles( profiles: FunctionProfile[][], operationName: string, ): FunctionProfile[] | null { if (profiles.length === 0) return null // Collect all function names across all runs let functionMap = new Map() for (let profile of profiles) { for (let func of profile) { if (!functionMap.has(func.name)) { functionMap.set(func.name, []) } functionMap.get(func.name)!.push(func.time) } } // Calculate median time for each function // Also calculate average percentage across runs let aggregated: FunctionProfile[] = [] for (let [name, times] of functionMap.entries()) { let sorted = [...times].sort((a, b) => a - b) let median = sorted[Math.floor(sorted.length / 2)] // Calculate average percentage across all runs let percentages: number[] = [] for (let profile of profiles) { let func = profile.find((f) => f.name === name) if (func) { percentages.push(func.percentage) } } let avgPercentage = percentages.length > 0 ? percentages.reduce((a, b) => a + b, 0) / percentages.length : 0 aggregated.push({ name, time: median, percentage: avgPercentage, }) } // Sort by median time and return top 30 return aggregated.sort((a, b) => b.time - a.time).slice(0, 30) } function aggregateAllocationProfiles(profiles: AllocationProfile[][]): AllocationProfile[] | null { if (profiles.length === 0) return null let functionMap = new Map() for (let profile of profiles) { for (let func of profile) { if (!functionMap.has(func.name)) { functionMap.set(func.name, []) } functionMap.get(func.name)!.push(func.bytes) } } let aggregated: AllocationProfile[] = [] for (let [name, bytes] of functionMap.entries()) { let sorted = [...bytes].sort((a, b) => a - b) let median = sorted[Math.floor(sorted.length / 2)] let percentages: number[] = [] for (let profile of profiles) { let func = profile.find((f) => f.name === name) if (func) { percentages.push(func.percentage) } } let avgPercentage = percentages.length > 0 ? percentages.reduce((a, b) => a + b, 0) / percentages.length : 0 aggregated.push({ name, bytes: median, percentage: avgPercentage, }) } return aggregated.sort((a, b) => b.bytes - a.bytes).slice(0, 30) } // Print profiling table function printProfileTable(profile: FunctionProfile[], operationName: string): void { if (profile.length === 0) return console.log(`\n${operationName}`) console.log('📊 Top functions by self time (median):') console.log('═'.repeat(90)) console.log(`${'Function'.padEnd(70)} ${'Time (ms)'.padStart(10)} ${'%'.padStart(8)}`) console.log('─'.repeat(90)) for (let item of profile) { let name = item.name.length > 68 ? '...' + item.name.slice(-65) : item.name console.log( `${name.padEnd(70)} ${item.time.toFixed(2).padStart(10)} ${item.percentage.toFixed(1).padStart(7)}%`, ) } console.log('═'.repeat(90)) } function printAllocationProfileTable(profile: AllocationProfile[], operationName: string): void { if (profile.length === 0) return console.log(`\n${operationName}`) console.log('🧠 Top functions by allocated heap (median):') console.log('═'.repeat(100)) console.log( `${'Function'.padEnd(68)} ${'Bytes'.padStart(14)} ${'KB'.padStart(10)} ${'%'.padStart(8)}`, ) console.log('─'.repeat(100)) for (let item of profile) { let name = item.name.length > 66 ? '...' + item.name.slice(-63) : item.name let kb = item.bytes / 1024 console.log( `${name.padEnd(68)} ${item.bytes.toFixed(0).padStart(14)} ${kb.toFixed(2).padStart(10)} ${item.percentage.toFixed(1).padStart(7)}%`, ) } console.log('═'.repeat(100)) } // Run benchmark for a single framework async function benchmarkFramework( page: Page, framework: string, ): Promise<{ results: BenchmarkResult[] profiles: Map allocProfiles: Map }> { let results: BenchmarkResult[] = [] let profiles = new Map() let allocProfiles = new Map() let url = `${BASE_URL}/${framework}/index.html` // Filter operations if benchmark filter is specified let filteredOperations = benchmarkFilter.length > 0 ? operations.filter((op) => benchmarkFilter.some((filter) => op.name.includes(filter))) : operations for (let operation of filteredOperations) { let scriptingTimes: number[] = [] let totalTimes: number[] = [] let runProfiles: FunctionProfile[][] = [] let runAllocProfiles: AllocationProfile[][] = [] // Reload page before each operation to reset all JS state (idCounter, etc.) await page.goto(url) await page.waitForSelector('#run') // Warmup runs (not recorded) for (let i = 0; i < warmupRuns; i++) { await measureOperation(page, operation) } // Benchmark runs for (let i = 0; i < benchmarkRuns; i++) { let timing = await measureOperation(page, operation) scriptingTimes.push(timing.scripting) totalTimes.push(timing.total) if (showProfile && timing.profile) { runProfiles.push(timing.profile) } if (showAllocProfile && timing.allocProfile) { runAllocProfiles.push(timing.allocProfile) } } results.push({ framework, operation: operation.name, scripting: calcStats(scriptingTimes), total: calcStats(totalTimes), }) if (showProfile && runProfiles.length > 0) { profiles.set(operation.name, runProfiles) } if (showAllocProfile && runAllocProfiles.length > 0) { allocProfiles.set(operation.name, runAllocProfiles) } process.stdout.write('.') } return { results, profiles, allocProfiles } } // ANSI color codes const RESET = '\x1b[0m' const RED = '\x1b[31m' const GREEN = '\x1b[32m' const YELLOW = '\x1b[33m' const WHITE = '\x1b[97m' const BG_GRAY = '\x1b[48;5;240m' const BOLD = '\x1b[1m' const DIM = '\x1b[2m' // Print combined bar graph with scripting (yellow) and total bars function printBarGraph(allResults: BenchmarkResult[]): void { let operationNames = [...new Set(allResults.map((r) => r.operation))] let frameworks = [...new Set(allResults.map((r) => r.framework))] let hasRemix = frameworks.includes('remix') // Put remix first if it exists if (hasRemix) { frameworks = ['remix', ...frameworks.filter((f) => f !== 'remix')] } // Get terminal width (default to 100, max 120) let termWidth = Math.min(process.stdout.columns || 100, 120) // Find max framework name length for label padding let maxNameLen = Math.max(...frameworks.map((f) => f.length)) let labelWidth = maxNameLen + 4 // " name: " // Reserve space for label + bar + " scripting_value " + " total_value (ratio)" let suffixWidth = 25 let barMaxWidth = termWidth - labelWidth - suffixWidth // Calculate global max value across all operations (use total since it's always >= scripting) let globalMax = Math.max(...allResults.map((r) => r.total.median)) for (let opName of operationNames) { console.log(`${DIM}${opName}${RESET}`) let remixResult = hasRemix ? allResults.find((r) => r.framework === 'remix' && r.operation === opName) : null let remixTotal = remixResult ? remixResult.total.median : null for (let fw of frameworks) { let result = allResults.find((r) => r.framework === fw && r.operation === opName) let scriptingValue = result ? result.scripting.median : 0 let totalValue = result ? result.total.median : 0 let scriptingRounded = Math.round(scriptingValue * 10) / 10 let totalRounded = Math.round(totalValue * 10) / 10 // Calculate bar lengths (scaled to global max) let scriptingBarLen = Math.round((scriptingValue / globalMax) * barMaxWidth) let totalBarLen = Math.round((totalValue / globalMax) * barMaxWidth) let remainingBarLen = totalBarLen - scriptingBarLen // Build the scripting bar (yellow) let scriptingBar = '█'.repeat(scriptingBarLen) // Build the remaining bar (default color, total - scripting) let remainingBar = '█'.repeat(Math.max(0, remainingBarLen)) // Scripting value with gray background and white text (fixed width for alignment) let scriptingText = ` ${String(scriptingRounded).padStart(5)} ` // Build total suffix with ratio let totalSuffix = String(totalRounded) if (fw !== 'remix' && remixTotal !== null && remixTotal > 0) { let ratio = Math.round((totalValue / remixTotal) * 10) / 10 let color = ratio < 1 ? RED : ratio > 1 ? GREEN : '' totalSuffix += ` ${color}(${ratio}x)${color ? RESET : ''}` } // Print single combined line: label + yellow bars + scripting value (gray bg) + remaining bars + total let label = (' ' + fw + ':').padEnd(labelWidth) console.log( `${label}${YELLOW}${scriptingBar}${RESET}${BG_GRAY}${WHITE}${scriptingText}${RESET}${remainingBar} ${totalSuffix}`, ) } console.log('') } } // Print results as two tables (scripting and total) function printTable(allResults: BenchmarkResult[]): void { let operationNames = [...new Set(allResults.map((r) => r.operation))] let frameworks = [...new Set(allResults.map((r) => r.framework))] // Put remix first if it exists if (frameworks.includes('remix')) { frameworks = ['remix', ...frameworks.filter((f) => f !== 'remix')] } // Build scripting table with flags for slow operations let scriptingData: Record> = {} for (let opName of operationNames) { let remixResult = allResults.find((r) => r.framework === 'remix' && r.operation === opName) let remixValue = remixResult ? remixResult.scripting.median : 0 let otherValues = frameworks .filter((fw) => fw !== 'remix') .map((fw) => { let result = allResults.find((r) => r.framework === fw && r.operation === opName) return result ? result.scripting.median : 0 }) .filter((v) => v > 0) // Check if remix is significantly slower (2x longer than fastest other) let isSlow = false if (remixValue && otherValues.length > 0) { let fastestOther = Math.min(...otherValues) isSlow = remixValue > fastestOther * 2.0 } let displayName = isSlow ? `${opName} 🚩` : opName scriptingData[displayName] = {} for (let fw of frameworks) { let result = allResults.find((r) => r.framework === fw && r.operation === opName) scriptingData[displayName][fw] = result ? Math.round(result.scripting.median * 10) / 10 : 0 } } // Build total table with flags for slow operations let totalData: Record> = {} for (let opName of operationNames) { let remixResult = allResults.find((r) => r.framework === 'remix' && r.operation === opName) let remixValue = remixResult ? remixResult.total.median : 0 let otherValues = frameworks .filter((fw) => fw !== 'remix') .map((fw) => { let result = allResults.find((r) => r.framework === fw && r.operation === opName) return result ? result.total.median : 0 }) .filter((v) => v > 0) // Check if remix is significantly slower (>20% slower than fastest other) let isSlow = false if (remixValue && otherValues.length > 0) { let fastestOther = Math.min(...otherValues) isSlow = remixValue > fastestOther * 1.2 } let displayName = isSlow ? `${opName} 🚩` : opName totalData[displayName] = {} for (let fw of frameworks) { let result = allResults.find((r) => r.framework === fw && r.operation === opName) totalData[displayName][fw] = result ? Math.round(result.total.median * 10) / 10 : 0 } } console.log('Scripting Time (ms):') console.table(scriptingData) console.log('') console.log('Total Time (ms):') console.table(totalData) } // Print results as bar graphs or tables function printResults(allResults: BenchmarkResult[]): void { if (useTable) { printTable(allResults) } else { printBarGraph(allResults) } } // Main benchmark runner async function main(): Promise { let server: ChildProcess | null = null let browser: Browser | null = null try { console.log('Starting benchmark server...') server = await startServer() console.log('Launching browser...') browser = await chromium.launch({ headless }) let page = await browser.newPage() // Enable CPU throttling via CDP let client = await page.context().newCDPSession(page) await client.send('Emulation.setCPUThrottlingRate', { rate: cpuThrottling }) let allFrameworks = getFrameworks() let frameworks = allFrameworks // Filter frameworks if specified if (frameworkFilter.length > 0) { let invalidFrameworks = frameworkFilter.filter((f) => !allFrameworks.includes(f)) if (invalidFrameworks.length > 0) { console.error(`Error: Invalid framework(s): ${invalidFrameworks.join(', ')}`) console.error(`Available frameworks: ${allFrameworks.join(', ')}`) process.exit(1) } frameworks = frameworkFilter } let allResults: BenchmarkResult[] = [] let allProfiles = new Map() let allAllocProfiles = new Map() // Load previous remix results if remix is being benchmarked let hasRemix = frameworks.includes('remix') let previousRemixResults: BenchmarkResult[] = [] if (hasRemix) { previousRemixResults = loadPreviousRemixResults() if (previousRemixResults.length > 0) { console.log('Loaded previous remix results for comparison') } } console.log(`Benchmarking ${frameworks.length} frameworks: ${frameworks.join(', ')}`) console.log(`${warmupRuns} warmup runs, ${benchmarkRuns} benchmark runs per operation`) console.log(`CPU throttling: ${cpuThrottling}x`) console.log('') for (let framework of frameworks) { process.stdout.write(` ${framework}: `) let { results, profiles, allocProfiles } = await benchmarkFramework(page, framework) allResults.push(...results) // Store profiles keyed by framework-operation name for (let [operationName, runProfiles] of profiles.entries()) { let key = `${framework}-${operationName}` allProfiles.set(key, runProfiles) } for (let [operationName, runAllocProfiles] of allocProfiles.entries()) { let key = `${framework}-${operationName}` allAllocProfiles.set(key, runAllocProfiles) } console.log(' done') } // Save current remix results for next run if (hasRemix) { saveRemixResults(allResults) } // Add previous remix results to display only when remix is the only framework // (When comparing against other frameworks, we don't need to show previous remix) if (previousRemixResults.length > 0 && frameworks.length === 1) { allResults.push(...previousRemixResults) } // Print aggregated profiling tables first if (showProfile && allProfiles.size > 0) { for (let [key, runProfiles] of allProfiles.entries()) { let aggregated = aggregateProfiles(runProfiles, key) if (aggregated) { printProfileTable(aggregated, key) } } } if (showAllocProfile && allAllocProfiles.size > 0) { for (let [key, runAllocProfiles] of allAllocProfiles.entries()) { let aggregated = aggregateAllocationProfiles(runAllocProfiles) if (aggregated) { printAllocationProfileTable(aggregated, key) } } } // Print benchmark results after profiles printResults(allResults) console.log('Benchmark complete!') } finally { console.log('Cleaning up...') if (browser) { await browser.close() } if (server) { await stopServer(server) } } } main().catch((error) => { console.error('Benchmark failed:', error) process.exit(1) }) function buildAllocationProfile(rawProfile: any): AllocationProfile[] { let bytesByName = new Map() let totalBytes = 0 function walk(node: any) { if (!node || typeof node !== 'object') return let selfSize = Number(node.selfSize || 0) let name = node.callFrame?.functionName || node.callFrame?.url || node.callFrame?.scriptId || 'unknown' if (!name.includes('node_modules')) { let current = bytesByName.get(name) || 0 bytesByName.set(name, current + selfSize) totalBytes += selfSize } let children = Array.isArray(node.children) ? node.children : [] for (let child of children) { walk(child) } } walk(rawProfile.head) return Array.from(bytesByName.entries()) .map(([name, bytes]) => ({ name, bytes, percentage: totalBytes > 0 ? (bytes / totalBytes) * 100 : 0, })) .sort((a, b) => b.bytes - a.bytes) .slice(0, 30) } ================================================ FILE: packages/component/bench/server.ts ================================================ import * as fs from 'node:fs' import * as http from 'node:http' import * as path from 'node:path' import { createRouter } from '@remix-run/fetch-router' import { route } from '@remix-run/fetch-router/routes' import { createRequestListener } from '@remix-run/node-fetch-server' import { staticFiles } from '@remix-run/static-middleware' let frameworksDir = path.resolve(import.meta.dirname, 'frameworks') let routes = route({ index: '/', }) let router = createRouter({ middleware: [staticFiles('./frameworks')], }) let html = String.raw router.get(routes.index, () => { let entries = fs.readdirSync(frameworksDir, { withFileTypes: true }) let frameworks = entries .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() let links = frameworks .map((name) => `
  • ${name}
  • `) .join('') return new Response( html` Benchmarks

    Benchmarks

      ${links}
    `, { headers: { 'Content-Type': 'text/html' } }, ) }) let server = http.createServer( createRequestListener(async (request) => { return await router.fetch(request) }), ) server.listen(44100, () => { console.log('Benchmark server running at http://localhost:44100') }) function shutdown() { server.close(() => { process.exit(0) }) } process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) ================================================ FILE: packages/component/bench/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "types": ["node"] }, "exclude": ["frameworks", "dist"] } ================================================ FILE: packages/component/demos/.gitignore ================================================ *.bundled.* ================================================ FILE: packages/component/demos/animation/aspect-ratio.tsx ================================================ import { type Handle } from 'remix/component' import { css, on, spring } from 'remix/component' export function AspectRatio(handle: Handle) { let aspectRatio = 1 let width = 100 return () => (
    ) } let rangeInputCss = { flex: 1, accentColor: '#8df0cc', cursor: 'pointer', } as const ================================================ FILE: packages/component/demos/animation/bouncy-switch.tsx ================================================ import { type Handle } from 'remix/component' import { css, on, spring } from 'remix/component' let bounceEasing = `linear(0, 0.258 12%, 0.424 18.3%, 0.633 24.4%, 0.999 33.3%, 0.783 39.8%, 0.733 42.5%, 0.716 45.1%, 0.731 47.6%, 0.777 50.2%, 0.999 57.7%, 0.906 61.7%, 0.883 63.5%, 0.876 65.2%, 0.901 68.7%, 0.999 74.5%, 0.964 77.4%, 0.953 80.1%, 0.961 82.6%, 1 88.2%, 0.99 91.9%, 1)` export function BouncySwitch(handle: Handle) { let isOn = true return () => (
    { isOn = !isOn handle.update() }), ]} >
    ) } ================================================ FILE: packages/component/demos/animation/color-interpolation.tsx ================================================ import { css } from 'remix/component' /* * A comparison of color interpolation methods. * * The "dimming effect" in browser animations happens because sRGB is * gamma-encoded, so linear interpolation passes through desaturated colors. * * The OKLCH box uses the @property hack: register a custom property as , * animate it 0→1, then use color-mix(in oklch, ...) with that number. */ export function ColorInterpolation() { return () => (
    sRGB
    OKLCH
    ) } ================================================ FILE: packages/component/demos/animation/cube.tsx ================================================ import type { Handle } from 'remix/component' import { css, ref } from 'remix/component' export function Cube(handle: Handle) { let cube: HTMLDivElement function animate(t: number) { if (handle.signal.aborted) return let rotate = Math.sin(t / 10000) * 200 let y = (1 + Math.sin(t / 1000)) * -25 cube.style.transform = `translateY(${y}px) rotateX(${rotate}deg) rotateY(${rotate}deg)` requestAnimationFrame(animate) } return () => (
    { cube = node requestAnimationFrame(animate) }), css({ width: '100px', height: '100px', position: 'relative', transformStyle: 'preserve-3d', }), ]} >
    ) } ================================================ FILE: packages/component/demos/animation/default-animate.tsx ================================================ import { animateEntrance, animateLayout, css, on, type Handle } from 'remix/component' let nextId = 1 function createItem() { return { id: nextId++, label: `Item ${nextId - 1}` } } export function DefaultAnimate(handle: Handle) { let items = [createItem(), createItem()] return () => (
    {items.map((item) => (
    {item.label}
    ))}
    ) } ================================================ FILE: packages/component/demos/animation/enter.tsx ================================================ import { animateEntrance, css, spring } from 'remix/component' export function EnterAnimation() { return () => (
    ) } ================================================ FILE: packages/component/demos/animation/entry.tsx ================================================ import { createRoot, css, on, type Handle, type RemixNode } from 'remix/component' import { DefaultAnimate } from './default-animate.tsx' import { EnterAnimation } from './enter.tsx' import { ExitAnimation } from './exit.tsx' import { Press } from './press.tsx' import { HTMLContent } from './html-content.tsx' import { Keyframes } from './keyframes.tsx' import { InterruptibleKeyframes } from './interruptible-keyframes.tsx' import { RollingSquare } from './rolling-square.tsx' import { Rotate } from './rotate.tsx' import { TransitionOptions } from './transition-options.tsx' import { Cube } from './cube.tsx' import { SharedLayout } from './shared-layout.tsx' import { AspectRatio } from './aspect-ratio.tsx' import { BouncySwitch } from './bouncy-switch.tsx' import { ColorInterpolation } from './color-interpolation.tsx' import { FlipToggle } from './flip-toggle.tsx' import { Reordering } from './reordering.tsx' import { MultiStateBadge } from './multi-state-badge.tsx' import { HoldToConfirm } from './hold-to-confirm.tsx' import { MaterialRipple } from './material-ripple.tsx' function Tile(handle: Handle) { let remountKey = 0 return ({ title, children, notes }: { title: string; children: RemixNode; notes?: string }) => (

    {title}

    {children}
    {notes && (

    {notes}

    )}
    ) } createRoot(document.body).render( <>

    Animations

    Most animations are adapted from Motion. Thank you for your work Matt Perry!

    , ) ================================================ FILE: packages/component/demos/animation/exit.tsx ================================================ import type { Handle } from 'remix/component' import { animateEntrance, animateExit, css, on, spring } from 'remix/component' export function ExitAnimation(handle: Handle) { let isVisible = true let shouldAnimate = false handle.queueTask(() => { shouldAnimate = true }) return () => (
    {isVisible && (
    )}
    ) } ================================================ FILE: packages/component/demos/animation/flip-toggle.tsx ================================================ import { animateLayout, css, on, type Handle } from 'remix/component' export function FlipToggle(handle: Handle) { let isOn = false return () => ( ) } ================================================ FILE: packages/component/demos/animation/hold-to-confirm.tsx ================================================ import { animateEntrance, animateExit, createMixin, css, on, pressEvents, spring, type Handle, } from 'remix/component' // Demo let buttonExitAnimation = { opacity: 0, transform: 'scale(1.15)', duration: 100, easing: 'ease-in', } let confirmationEnterAnimation = { opacity: 0, transform: 'scale(0.9)', duration: 200, easing: 'ease-out', } export function HoldToConfirm(handle: Handle) { let confirmed = false return () => (
    *': { gridArea: '1 / 1' }, }), ]} > {!confirmed && ( { confirmed = true handle.update() }} /> )} {confirmed && ( { confirmed = false handle.update() }} /> )}
    ) } function HoldButton(handle: Handle) { let confirming = false return (props: { onConfirm: () => void }) => ( ) } function Confirmation() { return (props: { onReset: () => void }) => (
    Deleted
    ) } function TrashIcon() { return () => ( ) } function CheckIcon() { return () => ( ) } const PRESS_CONFIRM_TIME = 2000 let pressConfirmStartEventType = 'demo:press-confirm-start' as const let pressConfirmCancelEventType = 'demo:press-confirm-cancel' as const let pressConfirmEndEventType = 'demo:press-confirm-end' as const declare global { interface HTMLElementEventMap { [pressConfirmStartEventType]: Event [pressConfirmCancelEventType]: Event [pressConfirmEndEventType]: Event } } let baseConfirmPress = createMixin((handle) => { let timer = 0 let clearTimer = () => { if (timer) { clearTimeout(timer) timer = 0 } } handle.addEventListener('remove', clearTimer) return (props) => ( { let target = event.currentTarget clearTimer() target.dispatchEvent(new Event(pressConfirmStartEventType, { bubbles: true })) timer = window.setTimeout(() => { target.dispatchEvent(new Event(pressConfirmEndEventType, { bubbles: true })) }, PRESS_CONFIRM_TIME) }), on(pressEvents.up, (event) => { clearTimer() event.currentTarget.dispatchEvent( new Event(pressConfirmCancelEventType, { bubbles: true }), ) }), on(pressEvents.cancel, (event) => { clearTimer() event.currentTarget.dispatchEvent( new Event(pressConfirmCancelEventType, { bubbles: true }), ) }), ]} /> ) }) type ConfirmPressMixin = typeof baseConfirmPress & { readonly start: typeof pressConfirmStartEventType readonly cancel: typeof pressConfirmCancelEventType readonly end: typeof pressConfirmEndEventType } let confirmPress: ConfirmPressMixin = Object.assign(baseConfirmPress, { start: pressConfirmStartEventType, cancel: pressConfirmCancelEventType, end: pressConfirmEndEventType, }) ================================================ FILE: packages/component/demos/animation/html-content.tsx ================================================ import type { Handle } from 'remix/component' import { css, spring } from 'remix/component' export function HTMLContent(handle: Handle) { let count = 0 let from = 0 let to = 100 let counter = spring({ duration: 3000, bounce: 0 }) function animate() { if (handle.signal.aborted) return let { value: t, done } = counter.next() if (done) return count = Math.round(from + (to - from) * t) handle.update() requestAnimationFrame(animate) } handle.queueTask(() => { requestAnimationFrame(animate) }) return () => (
          {count}
        
    ) } ================================================ FILE: packages/component/demos/animation/index.html ================================================ Animation ================================================ FILE: packages/component/demos/animation/interruptible-keyframes.tsx ================================================ import type { Handle } from 'remix/component' import { css, on, ref } from 'remix/component' export function InterruptibleKeyframes(handle: Handle) { let box: HTMLDivElement let currentAnimation: Animation | null = null function getCurrentScale(): number { let matrix = new DOMMatrix(getComputedStyle(box).transform) return matrix.a } function interruptAnimation() { if (currentAnimation) { currentAnimation.commitStyles() currentAnimation.cancel() currentAnimation = null } } return () => (
    (box = node)), css({ width: 100, height: 100, backgroundColor: '#0cdcf7', borderRadius: 5, }), on('pointerenter', () => { interruptAnimation() let startScale = getCurrentScale() currentAnimation = box.animate( [ { transform: `scale(${startScale})`, offset: 0, easing: 'ease-in-out' }, { transform: 'scale(1.1)', offset: 0.6, easing: 'ease-out' }, { transform: 'scale(1.6)', offset: 1 }, ], { duration: 500, fill: 'forwards', }, ) }), on('pointerleave', () => { interruptAnimation() let startScale = getCurrentScale() currentAnimation = box.animate( [{ transform: `scale(${startScale})` }, { transform: 'scale(1)' }], { duration: 300, easing: 'ease-out', fill: 'forwards', }, ) }), ]} /> ) } ================================================ FILE: packages/component/demos/animation/keyframes.tsx ================================================ import { css } from 'remix/component' export function Keyframes() { return () => (
    ) } ================================================ FILE: packages/component/demos/animation/material-ripple.tsx ================================================ import type { Handle } from 'remix/component' import { animateEntrance, animateExit, css, on, pressEvents, ref } from 'remix/component' type Ripple = { id: number x: number y: number size: number } export function MaterialRipple(handle: Handle) { let ripples: Ripple[] = [] let idCounter = 0 let buttonEl: HTMLButtonElement | null = null function createRipple(originX: number, originY: number) { if (!buttonEl) return let rect = buttonEl.getBoundingClientRect() let localX = originX - rect.left let localY = originY - rect.top let dx = Math.max(localX, rect.width - localX) let dy = Math.max(localY, rect.height - localY) let radius = Math.sqrt(dx * dx + dy * dy) let size = radius * 2 let id = ++idCounter ripples = [...ripples, { id, x: localX, y: localY, size }] handle.update() } function removeAllRipples() { if (ripples.length > 0) { ripples = [] handle.update() } } return () => ( ) } ================================================ FILE: packages/component/demos/animation/mixin-presence-list.tsx ================================================ import type { Handle } from 'remix/component' import { animateEntrance, animateExit, css, on } from 'remix/component' let nextId = 1 function createItem() { return { id: nextId++, label: `Row ${nextId - 1}` } } export function MixinPresenceList(handle: Handle) { let items = [createItem(), createItem(), createItem()] return () => (
    {items.map((item) => (
    {item.label}
    ))}
    ) } ================================================ FILE: packages/component/demos/animation/mixin-reclaim.tsx ================================================ import type { Handle } from 'remix/component' import { animateEntrance, animateExit, css, on } from 'remix/component' export function MixinReclaim(handle: Handle) { let visible = true let interruptTimer: number | undefined function clearInterruptTimer() { if (interruptTimer === undefined) return window.clearTimeout(interruptTimer) interruptTimer = undefined } function scheduleInterrupt() { clearInterruptTimer() visible = false handle.update() interruptTimer = window.setTimeout(() => { visible = true handle.update() interruptTimer = undefined }, 140) } return () => (
    {visible && (
    Reclaim Me Mid-Exit
    )}
    ) } ================================================ FILE: packages/component/demos/animation/multi-state-badge.tsx ================================================ import type { Handle } from 'remix/component' import { animateEntrance, animateExit, css, on, ref } from 'remix/component' import { spring } from '../../src/lib/spring.ts' const STATES = { idle: 'Start', processing: 'Processing', success: 'Done', error: 'Something went wrong', } as const type State = keyof typeof STATES function getNextState(state: State): State { let states = Object.keys(STATES) as State[] let nextIndex = (states.indexOf(state) + 1) % states.length return states[nextIndex] } const ICON_SIZE = 20 const STROKE_WIDTH = 1.5 const VIEW_BOX_SIZE = 24 let iconEnterAnimation = { transform: 'translateY(-40px) scale(0.5)', filter: 'blur(6px)', duration: 150, easing: 'ease-out', } let iconExitAnimation = { transform: 'translateY(40px) scale(0.5)', filter: 'blur(6px)', duration: 150, easing: 'ease-in', } export function MultiStateBadge(handle: Handle) { let state: State = 'idle' return () => (
    ) } function Badge(handle: Handle) { let badgeEl: HTMLDivElement let prevState: State | null = null return (props: { state: State }) => { // Trigger shake/scale animations on state change if (prevState !== null && prevState !== props.state) { handle.queueTask(() => { if (props.state === 'error') { badgeEl.animate( { transform: [ 'translateX(0)', 'translateX(-6px)', 'translateX(6px)', 'translateX(-6px)', 'translateX(0)', ], }, { duration: 300, easing: 'ease-in-out', delay: 100 }, ) } else if (props.state === 'success') { badgeEl.animate( { transform: ['scale(1)', 'scale(1.2)', 'scale(1)'] }, { duration: 300, easing: 'ease-in-out' }, ) } }) } prevState = props.state return (
    (badgeEl = node)), css({ backgroundColor: '#e2e8f0', color: '#0f1115', display: 'flex', overflow: 'hidden', alignItems: 'center', justifyContent: 'center', padding: '12px 20px', fontSize: 16, borderRadius: 999, willChange: 'transform, filter', transition: `gap ${spring('snappy')}`, }), ]} style={{ gap: props.state === 'idle' ? '0px' : '8px' }} >
    ) } } function Icon() { return (props: { state: State }) => ( {props.state === 'processing' && ( )} {props.state === 'success' && ( )} {props.state === 'error' && ( )} ) } function Loader() { return () => (
    { node.animate( { transform: ['rotate(0deg)', 'rotate(360deg)'] }, { duration: 1000, iterations: Infinity }, ) }), css({ display: 'flex', alignItems: 'center', justifyContent: 'center', width: ICON_SIZE, height: ICON_SIZE, }), ]} >
    ) } function Check() { return () => ( { let length = node.getTotalLength() node.style.strokeDasharray = `${length}` node.style.strokeDashoffset = `${length}` node.animate( { strokeDashoffset: [length, 0] }, { ...spring({ duration: 300, bounce: 0.1 }), fill: 'forwards' }, ) }), ]} /> ) } function X() { return () => ( { let length = node.getTotalLength() node.style.strokeDasharray = `${length}` node.style.strokeDashoffset = `${length}` node.animate( { strokeDashoffset: [length, 0] }, { ...spring({ duration: 300, bounce: 0.1 }), fill: 'forwards' }, ) }), ]} /> { let length = node.getTotalLength() node.style.strokeDasharray = `${length}` node.style.strokeDashoffset = `${length}` node.animate( { strokeDashoffset: [length, 0] }, { ...spring({ duration: 300, bounce: 0.1 }), delay: 100, fill: 'forwards' }, ) }), ]} /> ) } function Label(handle: Handle) { let measureEl: HTMLSpanElement let labelWidth = 0 let labelHeight = 0 // Don't animate the label on initial render let isFirstRender = true handle.queueTask(() => { isFirstRender = false }) return (props: { state: State }) => { // Measure label dimensions after render handle.queueTask(() => { if (measureEl) { let rect = measureEl.getBoundingClientRect() if (rect.width !== labelWidth || rect.height !== labelHeight) { labelWidth = rect.width labelHeight = rect.height handle.update() } } }) let labelMix = [ animateExit({ transform: 'translateY(20px)', opacity: 0, filter: 'blur(10px)', duration: 200, easing: 'ease-in-out', }), ] if (!isFirstRender) { labelMix.unshift( animateEntrance({ transform: 'translateY(-20px)', opacity: 0, filter: 'blur(10px)', duration: 200, easing: 'ease-in-out', }), ) } return ( {/* Hidden measurement element */} (measureEl = node)), css({ position: 'absolute', visibility: 'hidden', whiteSpace: 'nowrap' }), ]} > {STATES[props.state]} {props.state === 'idle' && ( {STATES.idle} )} {props.state === 'processing' && ( {STATES.processing} )} {props.state === 'success' && ( {STATES.success} )} {props.state === 'error' && ( {STATES.error} )} ) } } ================================================ FILE: packages/component/demos/animation/press.tsx ================================================ import { css, on, pressEvents, spring, type Handle } from 'remix/component' export function Press(handle: Handle) { let pressed = false return () => (
    { pressed = true handle.update() }), on(pressEvents.up, () => { pressed = false handle.update() }), ]} /> ) } ================================================ FILE: packages/component/demos/animation/reordering.tsx ================================================ import { animateLayout, css, type Handle, spring } from 'remix/component' let initialOrder = ['#ff0088', '#dd00ee', '#9911ff', '#0d63f8'] function shuffle(array: T[]): T[] { let result = [...array] for (let i = result.length - 1; i > 0; i--) { let j = Math.floor(Math.random() * (i + 1)) ;[result[i], result[j]] = [result[j], result[i]] } return result } export function Reordering(handle: Handle) { let order = initialOrder function scheduleNextShuffle() { setTimeout(() => { if (handle.signal.aborted) return order = shuffle(order) handle.update() scheduleNextShuffle() }, 1000) } scheduleNextShuffle() return () => (
      {order.map((backgroundColor) => (
    • ))}
    ) } ================================================ FILE: packages/component/demos/animation/rolling-square.tsx ================================================ import { type Handle } from 'remix/component' import { css, on, spring } from 'remix/component' export function RollingSquare(handle: Handle) { let toggled = false return () => (
    ) } ================================================ FILE: packages/component/demos/animation/rotate.tsx ================================================ import { css, spring } from 'remix/component' export function Rotate() { return () => (
    ) } ================================================ FILE: packages/component/demos/animation/shared-layout.tsx ================================================ import { animateEntrance, animateExit, css, on, type Handle, type Props, type RemixNode, } from 'remix/component' let ease = 'cubic-bezier(0.26, 0.02, 0.23, 0.94)' function OverlapExample(handle: Handle) { let shouldAnimate = false handle.queueTask(() => { shouldAnimate = true }) return ({ state }: { state: boolean }) => { let animationMix = [ animateExit({ opacity: 0, transform: 'scale(0.8)', duration: 300, easing: ease, }), ] if (shouldAnimate) { animationMix.unshift( animateEntrance({ opacity: 0, transform: 'scale(0.6)', duration: 300, easing: ease, }), ) } return (
    *': { gridArea: '1 / 1' } })]} > {state ? (
    ) : (
    )}
    ) } } function WaitExample(handle: Handle) { let shouldAnimate = false handle.queueTask(() => { shouldAnimate = true }) return ({ state }: { state: boolean }) => { let animationMix = [ animateExit({ opacity: 0, transform: 'scale(0.8)', duration: 300, easing: ease, }), ] if (shouldAnimate) { animationMix.unshift( animateEntrance({ opacity: 0, transform: 'scale(0.6)', duration: 300, easing: ease, delay: 300, }), ) } return (
    *': { gridArea: '1 / 1' } })]} > {state ? (
    ) : (
    )}
    ) } } export function SharedLayout(handle: Handle) { let state = true let shouldAnimate = false handle.queueTask(() => { shouldAnimate = true }) return () => (
    ) } function Circle() { return (props: { filled?: boolean; children: RemixNode }) => (
    {props.children}
    ) } function FilledIcon() { return () => ( ) } function OutlineIcon() { return () => ( ) } ================================================ FILE: packages/component/demos/animation/transition-options.tsx ================================================ import { animateEntrance, css } from 'remix/component' export function TransitionOptions() { return () => (
    ) } ================================================ FILE: packages/component/demos/basic/entry.tsx ================================================ import { createRoot, on, type Handle } from 'remix/component' function App(handle: Handle) { let count = 0 return () => ( ) } createRoot(document.body).render() ================================================ FILE: packages/component/demos/basic/index.html ================================================ Basic ================================================ FILE: packages/component/demos/controlled-uncontrolled-values/entry.tsx ================================================ import { createRoot, on, type Handle } from 'remix/component' function App(handle: Handle) { let controlledText = 'hello' let controlledChecked = true let uncontrolledTextSnapshot = 'type to update this' let uncontrolledCheckedSnapshot = true let renderCount = 0 let uncontrolledVersion = 0 let rerender = () => { renderCount++ handle.update() } let resetControlled = () => { controlledText = 'hello' controlledChecked = true rerender() } let remountUncontrolled = () => { uncontrolledVersion++ uncontrolledTextSnapshot = 'type to update this' uncontrolledCheckedSnapshot = true rerender() } return () => (

    Controlled vs Uncontrolled Values

    Render count: {renderCount}

    Controlled

    These values come from component state. The text input allows everything except digits, and invalid input does not call update.

    State snapshot: text={JSON.stringify(controlledText)}, checked= {String(controlledChecked)}

    Uncontrolled

    These initialize from defaultValue/defaultChecked once and then keep their own DOM state.

    Last DOM snapshot: text={JSON.stringify(uncontrolledTextSnapshot)}, checked= {String(uncontrolledCheckedSnapshot)}
    ) } let root = createRoot(document.body) root.render() ================================================ FILE: packages/component/demos/controlled-uncontrolled-values/index.html ================================================ Controlled vs Uncontrolled Values ================================================ FILE: packages/component/demos/draggable/draggable.tsx ================================================ import { createMixin, on } from 'remix/component' export type DragDetail = { left: number top: number } export let dragStartEvent = 'rmx:dragstart' as const export let dragEndEvent = 'rmx:dragend' as const type DraggableProps = { on?: Record void> } let baseDraggable = createMixin((handle) => { let node: HTMLElement | null = null let enabled = true let pointerId: number | null = null let startLeft = 0 let startTop = 0 let startClientX = 0 let startClientY = 0 handle.addEventListener('insert', (event) => { node = event.node }) handle.addEventListener('remove', stopDrag) return (nextEnabled: boolean = true, props) => { enabled = nextEnabled if (!enabled) { stopDrag() } return onPointerDown(event))]} /> } function onPointerDown(event: PointerEvent) { if (event.button !== 0) return if (!enabled) return if (!node) return let style = getComputedStyle(node) if (style.position === 'static') { node.style.position = 'relative' } node.style.cursor = 'grabbing' startLeft = readPx(node.style.left) startTop = readPx(node.style.top) startClientX = event.clientX startClientY = event.clientY pointerId = event.pointerId try { node.setPointerCapture(event.pointerId) } catch {} window.addEventListener('pointermove', onPointerMove) window.addEventListener('pointerup', onPointerDone) window.addEventListener('pointercancel', onPointerDone) dispatchDragEvent(node, dragStartEvent) } function onPointerMove(event: PointerEvent) { if (!node) return if (pointerId == null) return if (event.pointerId !== pointerId) return let dx = event.clientX - startClientX let dy = event.clientY - startClientY node.style.left = `${startLeft + dx}px` node.style.top = `${startTop + dy}px` void handle.update() } function onPointerDone(event: PointerEvent) { if (!node) return if (pointerId == null) return if (event.pointerId !== pointerId) return stopDrag() dispatchDragEvent(node, dragEndEvent) } function stopDrag() { if (!node) return if (pointerId == null) return pointerId = null node.style.cursor = 'grab' window.removeEventListener('pointermove', onPointerMove) window.removeEventListener('pointerup', onPointerDone) window.removeEventListener('pointercancel', onPointerDone) } }) function dispatchDragEvent(node: HTMLElement, type: string) { node.dispatchEvent( new CustomEvent(type, { detail: { left: readPx(node.style.left), top: readPx(node.style.top), }, bubbles: true, }), ) } function readPx(value: string) { if (!value) return 0 let parsed = Number.parseFloat(value) if (!Number.isFinite(parsed)) return 0 return parsed } type DraggableMixin = typeof baseDraggable & { readonly start: typeof dragStartEvent readonly end: typeof dragEndEvent } export let draggable: DraggableMixin = Object.assign(baseDraggable, { start: dragStartEvent, end: dragEndEvent, }) declare global { interface HTMLElementEventMap { [dragStartEvent]: CustomEvent [dragEndEvent]: CustomEvent } } ================================================ FILE: packages/component/demos/draggable/entry.tsx ================================================ import { createRoot } from 'remix/component' import type { Handle } from 'remix/component' import { draggable } from './draggable.tsx' function App(_handle: Handle) { return () => (

    Draggable mixin demo

    Drag the box with your mouse or trackpad.

    drag me
    ) } createRoot(document.body).render() ================================================ FILE: packages/component/demos/draggable/index.html ================================================ Component Draggable Demo ================================================ FILE: packages/component/demos/drummer/app.tsx ================================================ import { css, addEventListeners, on, pressEvents, ref, type Handle } from 'remix/component' import { Drummer } from './drummer.ts' import { tempoEvents } from './tempo-interaction.tsx' import { BPMDisplay, Button, ControlGroup, EqualizerBar, EqualizerLayout, Layout, TempoButton, TempoButtons, TempoLayout, } from './components.tsx' import { createVoiceLooper } from './voice-looper.ts' export function App(handle: Handle) { let drummer = new Drummer(80) handle.context.set(drummer) handle.queueTask(() => { document.addEventListener('keydown', (event) => { if (event.key === ' ') { drummer.toggle() } if (event.key === 'ArrowUp') { drummer.setTempo(drummer.bpm + 1) } if (event.key === 'ArrowDown') { drummer.setTempo(drummer.bpm - 1) } }) }) return () => ( ) } export function Equalizer(handle: Handle) { let drummer = handle.context.get(App) let kickVolumes = [0.4, 0.8, 0.3, 0.1] let snareVolumes = [0.4, 1, 0.7] let hatVolumes = [0.1, 0.8] let createVoice = createVoiceLooper(handle.update) let kick = createVoice() let snare = createVoice() let hat = createVoice() addEventListeners(drummer, handle.signal, { kick: () => kick.trigger(1), snare: () => snare.trigger(1), hat: () => hat.trigger(1), }) return () => { // get values from all the generators let kicks = kickVolumes.map((volume) => kick.value * volume) let snares = snareVolumes.map((volume) => snare.value * volume) let hats = hatVolumes.map((volume) => hat.value * volume) return ( {/* kick */} {/* snare */} {/* hat */} ) } } function DrumControls(handle: Handle) { let drummer = handle.context.get(App) let stop: HTMLButtonElement let play: HTMLButtonElement addEventListeners(drummer, handle.signal, { change: () => { handle.update() }, }) return () => ( ) } function TempoDisplay(handle: Handle) { let drummer = handle.context.get(App) return () => ( { drummer.setTempo(drummer.bpm + 1) }), ]} /> { drummer.setTempo(drummer.bpm - 1) }), ]} orientation="down" /> ) } ================================================ FILE: packages/component/demos/drummer/components.tsx ================================================ import type { Handle, RemixNode, Props } from 'remix/component' import { css } from 'remix/component' export function Layout() { return ({ children }: { children: RemixNode }) => (
    REMIX 3
    DRUM MACHINE
    {children}
    ) } export function EqualizerLayout() { return ({ children }: { children: RemixNode }) => (
    {children}
    ) } export function TempoLayout() { return ({ children }: { children: RemixNode }) => (
    {children}
    ) } export function TempoButtons() { return ({ children }: { children: RemixNode }) => (
    {children}
    ) } export function BPMDisplay() { return ({ bpm }: { bpm: number }) => (
    BPM
    {bpm}
    ) } export function EqualizerBar(handle: Handle) { let colors = [ '#FF3000', '#FF3000', '#E561C3', '#E561C3', '#FFD400', '#FFD400', '#64C146', '#64C146', '#1A72FF', '#1A72FF', ] return ({ volume }: { volume: number /* 0-1 */ }) => { let startIndexToShow = 10 - Math.round(volume * 10) return (
    {Array.from({ length: 10 }).map((_, index) => (
    = startIndexToShow ? 1 : 0.25, }} /> ))}
    ) } } export function ControlGroup() { return ({ children, mix: mixOverride, ...rest }: Props<'div'>) => (
    {children}
    ) } export function Button() { return ({ children, mix, ...rest }: Props<'button'>) => ( ) } export function Triangle() { return ({ label, orientation }: { label: string; orientation: 'up' | 'down' }) => { let up = '5,1.34 9.33,8.66 0.67,8.66' let down = '5,8.66 9.33,1.34 0.67,1.34' return ( ) } } interface TempoButtonProps extends Props<'button'> { orientation: 'up' | 'down' } export function TempoButton() { return ({ orientation, mix: mixOverride, ...rest }: TempoButtonProps) => ( ) } export function Logo() { return () => ( ) } ================================================ FILE: packages/component/demos/drummer/drummer.ts ================================================ import { TypedEventTarget } from 'remix/component' interface DrummerEventMap { kick: DrumEvent snare: DrumEvent hat: DrumEvent play: DrumEvent stop: DrumEvent tempoChange: DrumEvent change: DrumEvent } class DrumEvent extends Event { tempo: number constructor(type: keyof DrummerEventMap, tempo: number) { super(type) this.tempo = tempo } } export class Drummer extends TypedEventTarget { #audioCtx: AudioContext | null = null #masterGain: GainNode | null = null #noiseBuffer: AudioBuffer | null = null #_isPlaying = false #tempoBpm = 90 #current16th = 0 #nextNoteTime = 0 #intervalId: number | null = null // Scheduler settings readonly #lookaheadMs = 25 // how frequently to check (ms) readonly #scheduleAheadS = 0.1 // how far ahead to schedule (s) constructor(tempoBpm: number = 90) { super() this.#tempoBpm = tempoBpm } get isPlaying() { return this.#_isPlaying } get bpm() { return this.#tempoBpm } async toggle() { if (this.isPlaying) { await this.stop() } else { await this.play() } } setTempo(bpm: number) { this.#tempoBpm = Math.max(30, Math.min(300, Math.floor(bpm || this.#tempoBpm))) this.dispatchEvent(new DrumEvent('tempoChange', this.#tempoBpm)) this.dispatchEvent(new DrumEvent('change', this.#tempoBpm)) } async play(bpm?: number) { this.#ensureContext() if (!this.#audioCtx) return if (bpm) { this.setTempo(bpm) } await this.#audioCtx.resume() if (this.#_isPlaying) return this.#_isPlaying = true this.#nextNoteTime = this.#audioCtx.currentTime // don't reset current16th so setTempo can adjust mid-groove if restarted if (this.#intervalId != null) window.clearInterval(this.#intervalId) this.#intervalId = window.setInterval(this.#scheduler, this.#lookaheadMs) this.dispatchEvent(new DrumEvent('play', this.#tempoBpm)) this.dispatchEvent(new DrumEvent('change', this.#tempoBpm)) } async stop() { if (!this.#audioCtx) return if (this.#intervalId != null) { window.clearInterval(this.#intervalId) this.#intervalId = null } this.#_isPlaying = false this.#current16th = 0 this.#nextNoteTime = this.#audioCtx.currentTime this.dispatchEvent(new DrumEvent('stop', this.#tempoBpm)) this.dispatchEvent(new DrumEvent('change', this.#tempoBpm)) } #ensureContext() { if (!this.#audioCtx) { let Ctx = (window as any).AudioContext || (window as any).webkitAudioContext let ctx: AudioContext = new Ctx() this.#audioCtx = ctx this.#masterGain = ctx.createGain() this.#masterGain.gain.value = 0.8 this.#masterGain.connect(ctx.destination) this.#noiseBuffer = this.#createNoiseBuffer(ctx) } } #secondsPer16th(): number { return 60 / Math.max(1, this.#tempoBpm) / 4 } #createNoiseBuffer(ctx: AudioContext): AudioBuffer { let length = ctx.sampleRate // 1 second let buffer = ctx.createBuffer(1, length, ctx.sampleRate) let data = buffer.getChannelData(0) for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1 return buffer } #playKick(time: number) { if (!this.#audioCtx || !this.#masterGain) return let osc = this.#audioCtx.createOscillator() let gain = this.#audioCtx.createGain() osc.type = 'sine' osc.frequency.setValueAtTime(150, time) osc.frequency.exponentialRampToValueAtTime(50, time + 0.1) gain.gain.setValueAtTime(1, time) gain.gain.exponentialRampToValueAtTime(0.001, time + 0.15) osc.connect(gain).connect(this.#masterGain) osc.start(time) osc.stop(time + 0.2) this.dispatchEvent(new DrumEvent('kick', this.#tempoBpm)) this.dispatchEvent(new DrumEvent('change', this.#tempoBpm)) } #playSnare(time: number) { if (!this.#audioCtx || !this.#masterGain || !this.#noiseBuffer) return // Noise component let noise = this.#audioCtx.createBufferSource() noise.buffer = this.#noiseBuffer let band = this.#audioCtx.createBiquadFilter() band.type = 'bandpass' band.frequency.value = 1800 let noiseGain = this.#audioCtx.createGain() noiseGain.gain.setValueAtTime(1, time) noiseGain.gain.exponentialRampToValueAtTime(0.01, time + 0.2) noise.connect(band).connect(noiseGain).connect(this.#masterGain) noise.start(time) noise.stop(time + 0.2) // Body/tonal component let osc = this.#audioCtx.createOscillator() let oscGain = this.#audioCtx.createGain() osc.type = 'triangle' osc.frequency.setValueAtTime(200, time) oscGain.gain.setValueAtTime(0.6, time) oscGain.gain.exponentialRampToValueAtTime(0.01, time + 0.12) osc.connect(oscGain).connect(this.#masterGain) osc.start(time) osc.stop(time + 0.15) this.dispatchEvent(new DrumEvent('snare', this.#tempoBpm)) this.dispatchEvent(new DrumEvent('change', this.#tempoBpm)) } #playHiHat(time: number) { if (!this.#audioCtx || !this.#masterGain || !this.#noiseBuffer) return let noise = this.#audioCtx.createBufferSource() noise.buffer = this.#noiseBuffer let hp = this.#audioCtx.createBiquadFilter() hp.type = 'highpass' hp.frequency.value = 7000 let gain = this.#audioCtx.createGain() gain.gain.setValueAtTime(0.5, time) gain.gain.exponentialRampToValueAtTime(0.001, time + 0.04) noise.connect(hp).connect(gain).connect(this.#masterGain) noise.start(time) noise.stop(time + 0.05) this.dispatchEvent(new DrumEvent('hat', this.#tempoBpm)) this.dispatchEvent(new DrumEvent('change', this.#tempoBpm)) } // Simple "boom bap" pattern over 16 steps // Kick: 1 and 3 -> steps 0, 8 // Snare: 2 and 4 -> steps 4, 12 // Hi-hat: eighth notes -> steps 0,2,4,6,8,10,12,14 #scheduleStep(step: number, time: number) { if (step === 0 || step === 10) this.#playKick(time) if (step === 4 || step === 12) this.#playSnare(time) if (step % 2 === 0) this.#playHiHat(time) if (step === 7 || step === 9) this.#playHiHat(time) } #advanceNote() { this.#nextNoteTime += this.#secondsPer16th() this.#current16th = (this.#current16th + 1) % 16 } #scheduler = () => { if (!this.#audioCtx) return while (this.#nextNoteTime < this.#audioCtx.currentTime + this.#scheduleAheadS) { this.#scheduleStep(this.#current16th, this.#nextNoteTime) this.#advanceNote() } } } ================================================ FILE: packages/component/demos/drummer/entry.tsx ================================================ import { createRoot } from 'remix/component' import { App } from './app.tsx' createRoot(document.body).render() ================================================ FILE: packages/component/demos/drummer/index.html ================================================ Remix Jam ================================================ FILE: packages/component/demos/drummer/tempo-interaction.tsx ================================================ import { createMixin, on } from 'remix/component' declare global { interface HTMLElementEventMap { [tempoEventType]: TempoEvent } } export class TempoEvent extends Event { bpm: number constructor(type: typeof tempoEventType, bpm: number) { super(type) this.bpm = bpm } } export let tempoEventType = 'my:tempo' as const let baseTempoEvents = createMixin((handle) => { let taps: number[] = [] let resetTimer = 0 let handleTap = (node: HTMLElement) => { clearTimeout(resetTimer) taps.push(Date.now()) taps = taps.filter((tap) => Date.now() - tap < 4000) if (taps.length >= 4) { let intervals = [] for (let i = 1; i < taps.length; i++) { intervals.push(taps[i] - taps[i - 1]) } let bpm = intervals.map((interval) => 60000 / interval) let avgBpm = Math.round(bpm.reduce((sum, value) => sum + value, 0) / bpm.length) node.dispatchEvent(new TempoEvent(tempoEventType, avgBpm)) } resetTimer = window.setTimeout(() => { taps = [] }, 4000) } return (props) => ( { console.log('pointerdown', event) handleTap(event.currentTarget) }), on('keydown', (event) => { if (event.repeat) return if (event.key === 'Enter' || event.key === ' ') { handleTap(event.currentTarget) } }), ]} /> ) }) type TempoEventsMixin = typeof baseTempoEvents & { readonly type: typeof tempoEventType } export let tempoEvents: TempoEventsMixin = Object.assign(baseTempoEvents, { type: tempoEventType, }) ================================================ FILE: packages/component/demos/drummer/voice-looper.ts ================================================ export type DecayGenerator = Generator export function createExponentialDecayGenerator( halfLifeMs: number, startValue: number, startMs: number, ): DecayGenerator { let localEpsilon = 0.001 function* decay(): Generator { let value = startValue let lastMs = startMs while (value > localEpsilon) { let input = yield value let nowMs = typeof input === 'number' ? input : performance.now() let deltaMs = Math.max(0, nowMs - lastMs) lastMs = nowMs let decayFactor = Math.pow(0.5, deltaMs / halfLifeMs) value = value * decayFactor } return 0 } return decay() } export function createVoiceLooper(render: () => void, epsilon: number = 0.001) { let frameId: number | null = null type EnvelopeState = { value: number halfLifeMs: number gen: DecayGenerator | null } let envelopes: EnvelopeState[] = [] function ensureLoop() { if (frameId == null) { frameId = requestAnimationFrame(tick) render() } } function tick(now: number) { let anyActive = false for (let i = 0; i < envelopes.length; i++) { let state = envelopes[i] if (state.gen) { let result = state.gen.next(now) state.value = result.value ?? 0 if (result.done) { state.gen = null state.value = 0 } else if (state.value > epsilon) { anyActive = true } } } if (anyActive) { render() frameId = requestAnimationFrame(tick) } else { frameId = null } } function createVoice(halfLifeMs: number = 220) { let state: EnvelopeState = { value: 0, halfLifeMs, gen: null, } envelopes.push(state) return { get value() { return state.value }, trigger(amplitude: number = 1) { let now = performance.now() state.value = amplitude state.gen = createExponentialDecayGenerator(state.halfLifeMs, amplitude, now) void state.gen.next() ensureLoop() }, } } return createVoice } ================================================ FILE: packages/component/demos/keyed-list/entry.tsx ================================================ import { createRoot, on, type Handle } from 'remix/component' type ListItem = { id: string label: string } function App(handle: Handle) { let items: ListItem[] = [ { id: 'a', label: 'Item A' }, { id: 'b', label: 'Item B' }, { id: 'c', label: 'Item C' }, { id: 'd', label: 'Item D' }, ] let shuffleInterval: ReturnType | null = null let moveUp = (index: number) => { if (index === 0) return let newItems = [...items] ;[newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]] items = newItems handle.update() } let moveDown = (index: number) => { if (index === items.length - 1) return let newItems = [...items] ;[newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]] items = newItems handle.update() } let reverse = () => { items = [...items].reverse() handle.update() } let shuffle = () => { let newItems = [...items] for (let i = newItems.length - 1; i > 0; i--) { let j = Math.floor(Math.random() * (i + 1)) ;[newItems[i], newItems[j]] = [newItems[j], newItems[i]] } items = newItems handle.update() } let toggleAutoShuffle = () => { if (shuffleInterval !== null) { clearInterval(shuffleInterval) shuffleInterval = null } else { shuffleInterval = setInterval(() => { shuffle() }, 1000) } handle.update() } return () => (
    {items.map((item, index) => (
    ))}
    ) } let container = document.getElementById('app') if (container) { createRoot(container).render() } ================================================ FILE: packages/component/demos/keyed-list/index.html ================================================ Keyed List Demo

    Keyed List Demo

    Try this: Type in the inputs, focus one, then click a reorder button. Notice that:

    • Input values are preserved
    • Focus stays on the same input element
    • DOM nodes are reused (not recreated)

    This works because each item has a key prop that uniquely identifies it.

    ================================================ FILE: packages/component/demos/package.json ================================================ { "name": "component-demos", "private": true, "type": "module", "dependencies": { "motion": "^12.28.1", "remix": "workspace:^" }, "devDependencies": { "@types/dom-navigation": "^1.0.7", "@types/node": "catalog:", "concurrently": "^9.2.1", "esbuild": "^0.25.11" }, "scripts": { "dev": "concurrently \"pnpm dev:browser\" \"pnpm dev:server\"", "dev:browser": "esbuild ./*/entry.tsx --bundle --outdir=. --out-extension:.js=.bundled.js --format=esm --platform=browser --target=es2020 --sourcemap=inline --watch", "dev:server": "node --experimental-strip-types --watch server.ts", "build": "esbuild ./*/entry.tsx --bundle --outdir=. --out-extension:.js=.bundled.js --format=esm --platform=browser --target=es2020", "typecheck": "tsc --noEmit", "start": "node --experimental-strip-types server.ts" } } ================================================ FILE: packages/component/demos/readme/entry.tsx ================================================ import { addEventListeners, createRoot, css, on, ref, TypedEventTarget, type Handle, type RemixNode, } from 'remix/component' // ============================================================================ // Getting Started - Basic App Example // ============================================================================ function App(handle: Handle) { let count = 0 return () => ( ) } // ============================================================================ // Component State and Updates - Counter // ============================================================================ function Counter(handle: Handle) { let count = 0 return () => (
    Count: {count}
    ) } // ============================================================================ // Components - Greeting // ============================================================================ function Greeting(handle: Handle) { return (props: { name: string }) =>

    Hello, {props.name}!

    } // ============================================================================ // Stateful Components - CounterWithSetup // ============================================================================ function CounterWithSetup(handle: Handle, setup: number) { // Setup phase: runs once let count = setup // Return render function: runs on every update return (props: { label?: string }) => (
    {props.label || 'Count'}: {count}
    ) } // ============================================================================ // Setup Prop vs Props - CounterWithLabel // ============================================================================ function CounterWithLabel(handle: Handle, setup: number) { let count = setup // use setup for initialization return (props: { label?: string }) => ( // props only contains render-time values
    {props.label}: {count}
    ) } // ============================================================================ // Events - SearchInput // ============================================================================ function SearchInput(handle: Handle) { let query = '' let results: string[] = [] let loading = false return () => (
    { query = event.currentTarget.value loading = true handle.update() // Simulated search with timeout setTimeout(() => { if (signal.aborted) return results = query ? [`Result for "${query}" 1`, `Result for "${query}" 2`] : [] loading = false handle.update() }, 300) }), ]} /> {loading &&
    Loading...
    } {!loading && results.length > 0 && (
      {results.map((r) => (
    • {r}
    • ))}
    )}
    ) } // ============================================================================ // Controlled Input - Slug Form // ============================================================================ function SlugForm(handle: Handle) { let slug = '' let generatedSlug = '' return () => (
    ) } // ============================================================================ // Global Events - KeyboardTracker // ============================================================================ function KeyboardTracker(handle: Handle) { let keys: string[] = [] addEventListeners(document, handle.signal, { keydown: (event) => { keys.push(event.key) if (keys.length > 10) keys.shift() handle.update() }, }) return () =>
    Keys: {keys.join(', ') || '(press some keys)'}
    } // ============================================================================ // CSS Prop - Button (Basic) // ============================================================================ function ButtonBasic(handle: Handle) { return () => ( ) } // ============================================================================ // CSS Prop - Button (Advanced with nested rules) // ============================================================================ function ButtonAdvanced(handle: Handle) { return () => ( ) } // ============================================================================ // Ref Mixin - Form (Basic) // ============================================================================ function FormBasic(handle: Handle) { let inputRef: HTMLInputElement return () => (
    (inputRef = node)), css({ marginRight: '8px', padding: '4px 8px' })]} />
    ) } // ============================================================================ // Ref Mixin with AbortSignal - ResizeObserver Component // ============================================================================ function ResizeComponent(handle: Handle) { let dimensions = { width: 0, height: 0 } return () => (
    { // Set up something that needs cleanup let observer = new ResizeObserver((entries) => { let entry = entries[0] if (entry) { dimensions.width = Math.round(entry.contentRect.width) dimensions.height = Math.round(entry.contentRect.height) handle.update() } }) observer.observe(node) // Clean up when element is removed signal.addEventListener('abort', () => { observer.disconnect() }) }), css({ padding: '20px', backgroundColor: 'rgba(255, 255, 255, 0.1)', borderRadius: '8px', resize: 'both', overflow: 'auto', minWidth: '100px', minHeight: '60px', border: '1px solid rgb(209, 213, 219)', }), ]} > Resize me! ({dimensions.width} × {dimensions.height})
    ) } // ============================================================================ // handle.update() - Player // ============================================================================ function Player(handle: Handle) { let isPlaying = false let playButton: HTMLButtonElement let stopButton: HTMLButtonElement return () => (
    ) } // ============================================================================ // handle.queueTask - Form with scroll // ============================================================================ function FormWithScroll(handle: Handle) { let showDetails = false let detailsSection: HTMLElement return () => (
    {showDetails && (
    (detailsSection = node)), css({ marginTop: '1rem', padding: '1rem', border: '1px solid rgba(255, 255, 255, 0.2)', borderRadius: '8px', backgroundColor: 'rgba(255, 255, 255, 0.05)', }), ]} >

    Additional Details

    This section appears when the checkbox is checked.

    )}
    ) } // ============================================================================ // handle.signal - Clock // ============================================================================ function Clock(handle: Handle) { let interval = setInterval(() => { // clear the interval when the component is disconnected if (handle.signal.aborted) { clearInterval(interval) return } handle.update() }, 1000) return () => {new Date().toLocaleTimeString()} } // ============================================================================ // handle.id - LabeledInput // ============================================================================ function LabeledInput(handle: Handle) { return () => (
    ) } // ============================================================================ // Context API - Theme Provider and Consumer // ============================================================================ function ThemeProvider(handle: Handle<{ theme: string }>) { handle.context.set({ theme: 'dark' }) return () => (
    ) } function ThemedHeader(handle: Handle) { // Consume context from ThemeProvider let { theme } = handle.context.get(ThemeProvider) return () => (
    Header
    ) } // ============================================================================ // Context API with EventTarget - Advanced Theme // ============================================================================ class Theme extends TypedEventTarget<{ change: Event }> { #value: 'light' | 'dark' = 'light' get value() { return this.#value } setValue(value: 'light' | 'dark') { this.#value = value this.dispatchEvent(new Event('change')) } } function ThemeProviderAdvanced(handle: Handle) { let theme = new Theme() handle.context.set(theme) return () => (
    ) } function ThemedContent(handle: Handle) { let theme = handle.context.get(ThemeProviderAdvanced) // Subscribe to theme changes and update when it changes addEventListeners(theme, handle.signal, { change() { handle.update() }, }) return () => (
    Current theme: {theme.value}
    ) } // ============================================================================ // Fragments - List // ============================================================================ function ListWithFragment(handle: Handle) { return () => (
      <>
    • Item 1
    • Item 2
    • Item 3
    ) } // ============================================================================ // Example Container Component // ============================================================================ function Example(handle: Handle) { return (props: { title: string; children: RemixNode }) => (

    {props.title}

    {props.children}
    ) } // ============================================================================ // Main Demo App // ============================================================================ function DemoApp(handle: Handle) { return () => (
    ) } createRoot(document.getElementById('app')!).render() ================================================ FILE: packages/component/demos/readme/index.html ================================================ README Examples

    README Examples

    ================================================ FILE: packages/component/demos/server.ts ================================================ import * as fs from 'node:fs' import * as http from 'node:http' import * as path from 'node:path' import { createRouter } from 'remix/fetch-router' import { route } from 'remix/fetch-router/routes' import { createRequestListener } from 'remix/node-fetch-server' import { staticFiles } from 'remix/static-middleware' let demosDir = path.resolve(import.meta.dirname) let routes = route({ index: '/', }) let router = createRouter({ middleware: [staticFiles('.')], }) let html = String.raw router.get(routes.index, () => { let entries = fs.readdirSync(demosDir, { withFileTypes: true }) let demos = entries .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules') .map((entry) => entry.name) .sort() let links = demos.map((name) => `
  • ${name}
  • `).join('') return new Response( html` Component Demos

    Component Demos

      ${links}
    `, { headers: { 'Content-Type': 'text/html' } }, ) }) let server = http.createServer( createRequestListener(async (request) => await router.fetch(request)), ) server.listen(44100, () => { console.log('Demos server running at http://localhost:44100') }) function shutdown() { server.close(() => { process.exit(0) }) } process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) ================================================ FILE: packages/component/demos/spring/drag-release.ts ================================================ import { addEventListeners, createMixin } from 'remix/component' export let dragVelocityReleaseEventType = 'rmx:drag-velocity-release' as const declare global { interface HTMLElementEventMap { [dragVelocityReleaseEventType]: DragVelocityEvent } } export class DragVelocityEvent extends Event { clientX: number clientY: number velocityX: number // px/s velocityY: number // px/s constructor( type: typeof dragVelocityReleaseEventType, init: { clientX: number; clientY: number; velocityX: number; velocityY: number }, ) { super(type, { bubbles: true, cancelable: true }) this.clientX = init.clientX this.clientY = init.clientY this.velocityX = init.velocityX this.velocityY = init.velocityY } } let baseDragVelocityEvents = createMixin((handle) => { let target: HTMLElement let isTracking = false let pointerId: number | null = null let lastX = 0 let lastY = 0 let lastTime = 0 let velocityX = 0 let velocityY = 0 let onPointerDown = (event: PointerEvent) => { if (!event.isPrimary) return isTracking = true pointerId = event.pointerId lastX = event.clientX lastY = event.clientY lastTime = performance.now() velocityX = 0 velocityY = 0 target.setPointerCapture(event.pointerId) } let onPointerMove = (event: PointerEvent) => { if (!isTracking) return if (!event.isPrimary) return if (pointerId != null && event.pointerId !== pointerId) return let now = performance.now() let dt = (now - lastTime) / 1000 // seconds if (dt > 0) { // Smooth velocity with some decay of previous velocity let newVelocityX = (event.clientX - lastX) / dt let newVelocityY = (event.clientY - lastY) / dt velocityX = velocityX * 0.5 + newVelocityX * 0.5 velocityY = velocityY * 0.5 + newVelocityY * 0.5 } lastX = event.clientX lastY = event.clientY lastTime = now } let onPointerUp = (event: PointerEvent) => { if (!isTracking) return if (!event.isPrimary) return if (pointerId != null && event.pointerId !== pointerId) return isTracking = false pointerId = null // If too much time passed since last move, velocity is zero let timeSinceLastMove = (performance.now() - lastTime) / 1000 if (timeSinceLastMove > 0.1) { velocityX = 0 velocityY = 0 } target.dispatchEvent( new DragVelocityEvent(dragVelocityReleaseEventType, { clientX: event.clientX, clientY: event.clientY, velocityX, velocityY, }), ) } let onPointerCancel = () => { isTracking = false pointerId = null } handle.addEventListener('insert', (event) => { target = event.node addEventListeners(target, handle.signal, { pointerdown: onPointerDown, pointermove: onPointerMove, pointerup: onPointerUp, pointercancel: onPointerCancel, }) }) }) type DragVelocityEventsMixin = typeof baseDragVelocityEvents & { readonly release: typeof dragVelocityReleaseEventType } export let dragVelocityEvents: DragVelocityEventsMixin = Object.assign(baseDragVelocityEvents, { release: dragVelocityReleaseEventType, }) ================================================ FILE: packages/component/demos/spring/entry.tsx ================================================ import { addEventListeners, createRoot, css, on, ref, type Handle } from 'remix/component' import { dragVelocityEvents } from './drag-release.ts' import { spring, type SpringPreset } from 'remix/component' interface TrailPoint { x: number y: number time: number } function PointerTrail(handle: Handle) { let canvas: HTMLCanvasElement let points: TrailPoint[] = [] let isDown = false let releaseTime = 0 let animationId: number | null = null let maxAge = 150 // ms - how long points stay in the trail while dragging let fadeDuration = 800 // ms - how long the trail fades after release function draw() { if (!canvas) return let ctx = canvas.getContext('2d') if (!ctx) return let now = performance.now() ctx.clearRect(0, 0, canvas.width, canvas.height) // Calculate overall opacity based on release time let overallOpacity = 1 if (!isDown && releaseTime > 0) { let elapsed = now - releaseTime overallOpacity = Math.max(0, 1 - elapsed / fadeDuration) if (overallOpacity <= 0) { points = [] animationId = null return } } // Filter out old points while dragging if (isDown) { points = points.filter((p) => now - p.time < maxAge) } if (points.length < 2) { if (isDown || overallOpacity > 0) { animationId = requestAnimationFrame(draw) } else { animationId = null } return } // Draw the trail as a tapered path ctx.beginPath() ctx.lineCap = 'round' ctx.lineJoin = 'round' for (let i = 1; i < points.length; i++) { let prev = points[i - 1] let curr = points[i] // Age-based opacity (older = more transparent) let age = isDown ? (now - prev.time) / maxAge : 0 let segmentOpacity = (1 - age) * overallOpacity // Thickness tapers from thin (old) to thick (new) let progress = i / (points.length - 1) let thickness = 2 + progress * 8 ctx.beginPath() ctx.moveTo(prev.x, prev.y) ctx.lineTo(curr.x, curr.y) ctx.strokeStyle = `rgba(14, 165, 233, ${segmentOpacity * 0.6})` ctx.lineWidth = thickness ctx.stroke() } // Draw a red glow at the end to show direction if (points.length > 0) { let last = points[points.length - 1] let gradient = ctx.createRadialGradient(last.x, last.y, 0, last.x, last.y, 20) gradient.addColorStop(0, `rgba(239, 68, 68, ${overallOpacity * 0.6})`) gradient.addColorStop(1, 'rgba(239, 68, 68, 0)') ctx.fillStyle = gradient ctx.beginPath() ctx.arc(last.x, last.y, 20, 0, Math.PI * 2) ctx.fill() } animationId = requestAnimationFrame(draw) } function startDrawing() { if (!animationId) { animationId = requestAnimationFrame(draw) } } addEventListeners(document, handle.signal, { pointerdown(event) { if (!(event.target as HTMLElement).closest('.draggable')) return isDown = true releaseTime = 0 points = [{ x: event.clientX, y: event.clientY, time: performance.now() }] startDrawing() }, pointermove(event) { if (!isDown) return points.push({ x: event.clientX, y: event.clientY, time: performance.now() }) }, pointerup() { if (!isDown) return isDown = false releaseTime = performance.now() }, pointercancel() { isDown = false releaseTime = performance.now() }, }) return () => ( { canvas = node canvas.width = window.innerWidth canvas.height = window.innerHeight }), css({ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 5, }), ]} /> ) } function SpringDemo(handle: Handle) { // Target circle position (click to move) let targetX = window.innerWidth / 2 let targetY = window.innerHeight / 2 // Draggable circle state let dragX = window.innerWidth / 2 - 150 let dragY = window.innerHeight / 2 let isDragging = false let isAnimating = false // Current spring transitions (separate for X and Y to capture 2D velocity) let transitionX = '' let transitionY = '' let selectedPreset: SpringPreset = 'bouncy' // Get default spring transition for target circle let springValue = spring(selectedPreset) addEventListeners(document, handle.signal, { click(event) { // Ignore clicks on controls or when dragging if ((event.target as HTMLElement).closest('.controls')) return if ((event.target as HTMLElement).closest('.draggable')) return targetX = event.clientX targetY = event.clientY handle.update() }, }) return () => (
    {/* Pointer trail */} {/* Target circle */}
    {/* Draggable circle */}
    { isAnimating = false handle.update() }), on('pointerdown', (event) => { event.preventDefault() isDragging = true isAnimating = false handle.update() }), on('pointermove', (event) => { if (!isDragging) return dragX = event.clientX dragY = event.clientY handle.update() }), dragVelocityEvents(), on(dragVelocityEvents.release, (event) => { isDragging = false // Calculate distance to target on each axis let distX = targetX - dragX let distY = targetY - dragY if (Math.abs(distX) < 1 && Math.abs(distY) < 1) { handle.update() return } // Create separate springs for X and Y with their own normalized velocities // normalizedVelocity = velocity / distance (with sign!) for each axis // The sign matters: positive = moving toward target, negative = moving away if (Math.abs(distX) >= 1) { let normalizedVelocityX = event.velocityX / distX normalizedVelocityX = Math.max(-20, Math.min(20, normalizedVelocityX)) transitionX = String(spring(selectedPreset, { velocity: normalizedVelocityX })) } else { transitionX = String(spring(selectedPreset)) } if (Math.abs(distY) >= 1) { let normalizedVelocityY = event.velocityY / distY normalizedVelocityY = Math.max(-20, Math.min(20, normalizedVelocityY)) transitionY = String(spring(selectedPreset, { velocity: normalizedVelocityY })) } else { transitionY = String(spring(selectedPreset)) } // Animate to target isAnimating = true dragX = targetX dragY = targetY handle.update() }), ]} style={{ left: `${dragX}px`, top: `${dragY}px`, cursor: isDragging ? 'grabbing' : 'grab', transition: isAnimating ? `left ${transitionX}, top ${transitionY}` : 'none', }} /> {/* Controls */}
    {(Object.keys(spring.presets) as SpringPreset[]).map((preset) => ( ))}
    {/* Instructions */}
    Click to move target • Drag the blue circle and release to see velocity-based spring
    ) } createRoot(document.body).render() ================================================ FILE: packages/component/demos/spring/index.html ================================================ Spring Animation ================================================ FILE: packages/component/demos/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "types": ["node", "dom-navigation"], "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "jsx": "react-jsx", "jsxImportSource": "remix/component", "paths": { "*": ["./*"], "remix/component/jsx-runtime": ["../../remix/src/component/jsx-runtime.ts"], "remix/component/jsx-dev-runtime": ["../../remix/src/component/jsx-dev-runtime.ts"] } }, "exclude": ["dist"] } ================================================ FILE: packages/component/docs/components.md ================================================ # Components All components follow a consistent two-phase structure. ## Component Structure 1. **Setup Phase** - Runs once when the component is first created 2. **Render Phase** - Runs on initial render and every update afterward ```tsx function MyComponent(handle: Handle, setup: SetupType) { // Setup phase: runs once let state = initializeState(setup) // Return render function: runs on every update return (props: Props) => { return
    {/* render content */}
    } } ``` ## Runtime Behavior When a component is rendered: 1. **First Render**: - The component function is called with `handle` and the `setup` prop - The returned render function is stored - The render function is called with regular props - Any tasks queued via `handle.queueTask()` are executed after rendering 2. **Subsequent Updates**: - Only the render function is called - Setup phase is skipped, setup closure persists for the lifetime of the component instance - Props are passed to the render function - The `setup` prop is stripped from props - Tasks queued during the update are executed after rendering 3. **Component Removal**: - `handle.signal` is aborted - All event listeners registered via `addEventListeners()` are automatically cleaned up - Any queued tasks are executed with an aborted signal ## Setup vs Props The `setup` prop is special—it's only available in the setup phase and is automatically excluded from props. This prevents accidental stale captures: ```tsx function Counter(handle: Handle, setup: number) { // setup prop (e.g., initialCount) only available here let count = setup return (props: { label: string }) => { // props only receives { label } - setup is excluded return (
    {props.label}: {count}
    ) } } // Usage let element = ``` ## Basic Rendering The simplest component just returns JSX: ```tsx function Greeting() { return (props: { name: string }) =>
    Hello, {props.name}!
    } let el = ``` ## Prop Passing Props flow from parent to child through JSX attributes: ```tsx function Parent() { return () => } function Child() { return (props: { message: string; count: number }) => (

    {props.message}

    Count: {props.count}

    ) } ``` ## Stateful Updates State is managed with plain JavaScript variables. Call `handle.update()` to trigger a re-render: ```tsx function Counter(handle: Handle) { let count = 0 return () => (
    Count: {count}
    ) } ``` ## See Also - [Handle API](./handle.md) - Complete handle API reference - [Patterns](./patterns.md) - State management best practices ================================================ FILE: packages/component/docs/composition.md ================================================ # Composition Building component trees with props, children, refs, and keys. ## Props Props flow from parent to child through JSX attributes: ```tsx function Parent() { return () => } function Child() { return (props: { message: string; count: number }) => (

    {props.message}

    Count: {props.count}

    ) } ``` ## Children Components can compose other components via `children`: ```tsx function Layout() { return (props: { children: RemixNode }) => (
    My App
    {props.children}
    © 2024
    ) } function App() { return () => (

    Welcome

    Content goes here

    ) } ``` ## Ref Mixin Use the `ref(...)` mixin to get a reference to the DOM node after it's rendered. This is useful for DOM operations like focusing elements, scrolling, measuring dimensions, or setting up observers. ```tsx function Form(handle: Handle) { let inputRef: HTMLInputElement return () => (
    (inputRef = node))]} />
    ) } ``` The `ref` callback receives an `AbortSignal` as its second parameter, which is aborted when the element is removed from the DOM. Use this for cleanup operations: ```tsx function ResizeTracker(handle: Handle) { let dimensions = { width: 0, height: 0 } return () => (
    { // Set up ResizeObserver let observer = new ResizeObserver((entries) => { let entry = entries[0] if (entry) { dimensions.width = Math.round(entry.contentRect.width) dimensions.height = Math.round(entry.contentRect.height) handle.update() } }) observer.observe(node) // Clean up when element is removed signal.addEventListener('abort', () => { observer.disconnect() }) }), ]} > Size: {dimensions.width} x {dimensions.height}
    ) } ``` The `ref` callback is called only once when the element is first rendered, not on every update. ## Key Prop Use the `key` prop to uniquely identify elements in lists. Keys enable efficient diffing and preserve DOM nodes and component state when lists are reordered, filtered, or updated. ```tsx function TodoList(handle: Handle) { let todos = [ { id: '1', text: 'Buy milk' }, { id: '2', text: 'Walk dog' }, { id: '3', text: 'Write code' }, ] return () => (
      {todos.map((todo) => (
    • {todo.text}
    • ))}
    ) } ``` When you reorder, add, or remove items, keys ensure: - **DOM nodes are reused** - Elements with matching keys are moved, not recreated - **Component state is preserved** - Component instances persist across reorders - **Focus and selection are maintained** - Input focus stays with the same element - **Input values are preserved** - Form values remain with their elements ```tsx function ReorderableList(handle: Handle) { let items = [ { id: 'a', label: 'Item A' }, { id: 'b', label: 'Item B' }, { id: 'c', label: 'Item C' }, ] function reverse() { items = [...items].reverse() handle.update() } return () => (
    {items.map((item) => (
    ))}
    ) } ``` Even when the list order changes, each input maintains its value and focus state because the `key` prop identifies which DOM node corresponds to which item. Keys can be any type (string, number, bigint, object, symbol), but should be stable and unique within the list: ```tsx // Good: stable, unique IDs { items.map((item) => ) } // Good: index can work if list never reorders { items.map((item, index) => ) } // Bad: don't use random values or values that change { items.map((item) => ) } ``` ## See Also - [Context](./context.md) - Indirect composition without prop drilling ================================================ FILE: packages/component/docs/context.md ================================================ # Context Context enables components to communicate without direct prop passing. ## Basic Context Use `handle.context.set()` to provide values and `handle.context.get()` to consume them: ```tsx function ThemeProvider(handle: Handle<{ theme: 'light' | 'dark' }>) { let theme: 'light' | 'dark' = 'light' handle.context.set({ theme }) return (props: { children: RemixNode }) => (
    {props.children}
    ) } function ThemedContent(handle: Handle) { let { theme } = handle.context.get(ThemeProvider) return () => (
    Current theme: {theme}
    ) } ``` **Important:** `handle.context.set()` does not cause any updates—it simply stores a value. If you want the component tree to update when context changes, you must call `handle.update()` after setting the context (as shown above). ## TypedEventTarget for Granular Updates For better performance, use `TypedEventTarget` to avoid updating the entire subtree. This allows descendants to subscribe to specific changes rather than re-rendering on every parent update: ```tsx import { TypedEventTarget } from 'remix/component' class Theme extends TypedEventTarget<{ change: Event }> { #value: 'light' | 'dark' = 'light' get value() { return this.#value } setValue(value: 'light' | 'dark') { this.#value = value this.dispatchEvent(new Event('change')) } } function ThemeProvider(handle: Handle) { let theme = new Theme() handle.context.set(theme) return (props: { children: RemixNode }) => (
    {props.children}
    ) } function ThemedContent(handle: Handle) { let theme = handle.context.get(ThemeProvider) // Subscribe to granular updates addEventListeners(theme, handle.signal, { change() { handle.update() }, }) return () => (
    Current theme: {theme.value}
    ) } ``` Benefits of this pattern: - **No unnecessary re-renders**: Only components that subscribe to changes are updated - **Decoupled updates**: The provider doesn't need to call `handle.update()` when context changes - **Type-safe events**: `TypedEventTarget` ensures event handlers receive the correct event types ## Context with Multiple Values Provide multiple related values through context: ```tsx class AppContext extends TypedEventTarget<{ userChange: Event; settingsChange: Event }> { #user: User | null = null #settings: Settings = defaultSettings get user() { return this.#user } get settings() { return this.#settings } setUser(user: User | null) { this.#user = user this.dispatchEvent(new Event('userChange')) } setSettings(settings: Settings) { this.#settings = settings this.dispatchEvent(new Event('settingsChange')) } } function AppProvider(handle: Handle) { let context = new AppContext() handle.context.set(context) return (props: { children: RemixNode }) => props.children } // Components can subscribe to only the events they care about function UserDisplay(handle: Handle) { let context = handle.context.get(AppProvider) addEventListeners(context, handle.signal, { userChange() { handle.update() }, }) return () =>
    {context.user?.name ?? 'Not logged in'}
    } ``` ## See Also - [Handle API](./handle.md) - `handle.context` reference - [Events](./events.md) - `addEventListeners()` for subscribing to EventTargets ================================================ FILE: packages/component/docs/events.md ================================================ # Events Event handling with the `on()` mixin and signal-based interruption management. ## Basic Event Handling Use the `on()` mixin to attach event listeners to elements: ```tsx function Button(handle: Handle) { let count = 0 return () => ( ) } ``` ## Event Handler Signature Event handlers receive the event object and an optional `AbortSignal`: ```tsx mix={[on('click', (event) => { // event is the DOM event event.preventDefault() }), on('input', async (event, signal) => { // signal is aborted when handler is re-entered or component removed let response = await fetch('/api', { signal }) })]} ``` ## Signals in Event Handlers Event handlers receive an `AbortSignal` that's automatically aborted when: - The handler is re-entered (user triggers another event before the previous one completes) - The component is removed from the tree This prevents race conditions when users create events faster than async work completes: ```tsx function SearchInput(handle: Handle) { let results: string[] = [] let loading = false return () => (
    { let query = event.currentTarget.value loading = true handle.update() // Passing signal automatically aborts previous requests let response = await fetch(`/search?q=${query}`, { signal }) let data = await response.json() // Manual check for APIs that don't accept a signal if (signal.aborted) return results = data.results loading = false handle.update() }), ]} /> {loading &&
    Loading...
    } {!loading && results.length > 0 && (
      {results.map((result, i) => (
    • {result}
    • ))}
    )}
    ) } ``` The signal ensures only the latest search request completes, preventing stale results from overwriting newer ones. ## Multiple Event Types Handle multiple events on the same element: ```tsx function InteractiveBox(handle: Handle) { let state = 'idle' return () => (
    { state = 'hovered' handle.update() }), on('mouseleave', () => { state = 'idle' handle.update() }), on('click', () => { state = 'clicked' handle.update() }), ]} > State: {state}
    ) } ``` ## Form Events Common form event patterns: ```tsx function Form(handle: Handle) { return () => (
    { event.preventDefault() let formData = new FormData(event.currentTarget) // Process form data }), ]} > { // Validate on blur let value = event.currentTarget.value if (!value.includes('@')) { event.currentTarget.setCustomValidity('Invalid email') } }), on('input', (event) => { // Clear validation on input event.currentTarget.setCustomValidity('') }), ]} />
    ) } ``` ## Keyboard Events Handle keyboard interactions: ```tsx function KeyboardNav(handle: Handle) { let selectedIndex = 0 let items = ['Apple', 'Banana', 'Cherry'] return () => (
      { switch (event.key) { case 'ArrowDown': event.preventDefault() selectedIndex = Math.min(selectedIndex + 1, items.length - 1) handle.update() break case 'ArrowUp': event.preventDefault() selectedIndex = Math.max(selectedIndex - 1, 0) handle.update() break } }), ]} > {items.map((item, i) => (
    • {item}
    • ))}
    ) } ``` ## Global Event Listeners Use `addEventListeners()` for global event targets with automatic cleanup: ```tsx function WindowResizeTracker(handle: Handle) { let width = window.innerWidth let height = window.innerHeight // Set up global listeners once in setup addEventListeners(window, handle.signal, { resize() { width = window.innerWidth height = window.innerHeight handle.update() }, }) return () => (
    Window size: {width} x {height}
    ) } ``` ```tsx function KeyboardTracker(handle: Handle) { let keys: string[] = [] addEventListeners(document, handle.signal, { keydown(event) { keys.push(event.key) handle.update() }, }) return () =>
    Keys: {keys.join(', ')}
    } ``` ## Best Practices ### Prefer Press Events Over Click For interactive elements, prefer `press` events over `click`. Press events provide better cross-device behavior: - Fire on both mouse and touch interactions - Handle keyboard activation (Enter/Space) automatically - Prevent ghost clicks on touch devices - Support press-and-hold patterns ```tsx // ❌ Avoid: click doesn't handle all interaction modes well // ✅ Prefer: press handles mouse, touch, and keyboard uniformly ``` Use `click` only when you specifically need mouse-click behavior (e.g., detecting right-clicks or modifier keys). ### Do Work in Event Handlers Do as much work as possible in event handlers. Use the event handler scope for transient state: ```tsx // ✅ Good: Do work in handler, only store what renders need function SearchResults(handle: Handle) { let results: string[] = [] // Needed for rendering let loading = false // Needed for rendering loading state return () => (
    { let query = event.currentTarget.value // Do work in handler scope loading = true handle.update() let response = await fetch(`/search?q=${query}`, { signal }) let data = await response.json() if (signal.aborted) return // Only store what's needed for rendering results = data.results loading = false handle.update() }), ]} /> {loading &&
    Loading...
    } {results.map((result, i) => (
    {result}
    ))}
    ) } ``` ### Always Check signal.aborted For async work, always check the signal or pass it to APIs that support it: ```tsx mix={[on('click', async (event, signal) => { // Option 1: Pass signal to fetch let response = await fetch('/api', { signal }) // Option 2: Manual check after await let data = await someAsyncOperation() if (signal.aborted) return // Safe to update state handle.update() })]} ``` ## See Also - [Handle API](./handle.md) - `addEventListeners()` for global listeners - [Patterns](./patterns.md) - Data loading and async patterns ================================================ FILE: packages/component/docs/frames.md ================================================ # Frames A `` renders server content into the page. Frames can stream in after the initial HTML, nest inside other frames, contain client entries, and be reloaded from the client without a full page navigation. ## Basic usage ```tsx import { Frame } from 'remix/component' function App() { return () => (

    Dashboard

    Loading sidebar...
    } />
    ) } ``` ### Props - **`src`** (required) - The URL to fetch the frame content from. - **`fallback`** (optional) - Content to show while the frame is loading. When provided, the frame streams non-blocking (the initial page renders immediately with the fallback, and the real content arrives later). Without a fallback, the frame blocks rendering until its content resolves. - **`name`** (optional) - Registers the frame for lookup via `handle.frames.get(name)` from client entries. ## Blocking vs non-blocking The presence of a `fallback` prop determines streaming behavior: **Blocking** (no fallback): The server waits for the frame content before sending the initial HTML chunk. Use this for content that must be visible immediately. ```tsx ``` **Non-blocking** (with fallback): The fallback renders in the initial chunk. The real content streams in later and replaces the fallback. Use this for content that can load progressively. ```tsx Loading...
    } /> ``` ## Resolving frame content On the server, `renderToStream` calls your `resolveFrame` function to get the HTML for each frame: ```tsx import { renderToStream } from 'remix/component/server' let stream = renderToStream(, { frameSrc: request.url, async resolveFrame(src, _target, context) { let res = await fetch(new URL(src, context?.currentFrameSrc ?? request.url)) return res.body // or res.text() for a string }, }) ``` `resolveFrame` can return: - A string of HTML - A `ReadableStream` - A promise of either Frame content is itself rendered with `renderToStream`, so frames can contain other frames and client entries. The hydration data from nested frames is merged into the parent response automatically. When a server frame response is itself rendered with `renderToStream()`, pass `frameSrc` for that frame's URL and forward `topFrameSrc` from `resolveFrame()` if you want nested SSR components to keep seeing the outer document URL through `handle.frames.top.src`. ## Reloading frames Client entries inside a frame can trigger a reload via `handle.frame.reload()`: ```tsx import { clientEntry, on, type Handle } from 'remix/component' export let RefreshButton = clientEntry( '/assets/refresh.js#RefreshButton', function RefreshButton(handle: Handle) { return () => ( ) }, ) ``` You can also reload adjacent named frames: ```tsx ``` ```tsx function CartRow(handle: Handle) { return () => ( ) } ``` `handle.frames.get(name)` returns `undefined` when no named frame is mounted. When a frame reloads: 1. The frame's `src` is re-fetched via `resolveFrame` on the client. 2. The new HTML is parsed and diffed against the current frame content. 3. Matching DOM nodes are updated in place. New nodes are inserted, removed nodes are cleaned up. 4. Client entries inside the frame receive updated props from the server while preserving their local component state. This means a counter inside a reloading frame keeps its count, but sees any new props the server sends. ## Nested frames Frames can nest. Each frame owns its own region of the DOM and hydrates its client entries independently: ```tsx function App() { return () => (
    Loading outer...
    } />
    ) } // /outer response: function OuterFrame() { return () => (

    Outer

    Loading inner...
    } />
    ) } ``` Nested frames stream independently. The outer frame can resolve and render while the inner frame is still loading. During SSR, `handle.frame.src` should point at the frame currently being rendered, while `handle.frames.top.src` should stay fixed at the outer document URL. Use `renderToStream({ frameSrc, topFrameSrc })` inside nested `resolveFrame()` handlers to preserve that distinction. ## Client-resolved frames On the client, `run` accepts an optional `resolveFrame` implementation: ```tsx let app = run({ loadModule: ..., async resolveFrame(src, signal, target) { let headers = new Headers({ accept: 'text/html' }) if (target) headers.set('x-remix-target', target) let response = await fetch(src, { headers, signal }) return response.body ?? (await response.text()) }, }) ``` This is used both for initial hydration of pending frames and for `handle.frame.reload()` calls. If omitted, frames resolve to `

    resolve frame unimplemented

    `. Because this function defines the trust boundary for frame HTML, only return content from sources you trust. ## Frame lifecycle 1. **Server render** - Frame content is resolved via `resolveFrame` and serialized into the HTML stream. Frame metadata is stored in the `rmx-data` script. 2. **Client boot** - `run` discovers frame boundaries, hydrates client entries inside them, and sets up observers for any pending (non-blocking) frames still streaming. 3. **Reload** - `handle.frame.reload()` re-fetches the frame's `src`, diffs the new content into the DOM, and re-hydrates any client entries with updated props. 4. **Dispose** - When a frame is removed (e.g., parent re-render), its client entries are cleaned up and sub-frames are disposed recursively. ## See Also - [Server Rendering](./server-rendering.md) - Streaming HTML with `renderToStream` - [Hydration](./hydration.md) - Client entries and the `run` function ================================================ FILE: packages/component/docs/getting-started.md ================================================ # Getting Started Create interactive UIs with Remix Component using a two-phase component model: setup runs once, render runs on every update. ## Client-Only Root To start using Remix Component on the client, create a root and render your top-level component: ```tsx import { createRoot } from 'remix/component' import type { Handle } from 'remix/component' function App(handle: Handle) { return () => (

    Hello, World!

    ) } // Create a root attached to a DOM element let container = document.getElementById('app')! let root = createRoot(container) // Render your app root.render() ``` The `createRoot` function takes a DOM element (or `document.body`) and returns a root object with a `render` method. You can call `render` multiple times to update the app: ```tsx function App(handle: Handle) { let count = 0 return () => (

    Count: {count}

    ) } let root = createRoot(document.body) root.render() ``` ## Root Methods The root object provides several methods: - **`render(node)`** - Renders a component tree into the root container - **`flush()`** - Synchronously flushes all pending updates and tasks - **`dispose()`** - Removes the component tree and cleans up ```tsx let root = createRoot(document.body) // Render initial app root.render() // Flush any pending updates synchronously root.flush() // Later, remove the app root.dispose() ``` ## Server-Rendered App For a server-rendered app, define your page as a component, render it with `renderToStream`, and hydrate client entries on the client: ### Server ```tsx import { renderToStream } from 'remix/component/server' import { Frame } from 'remix/component' import { Counter } from './assets/counter.tsx' function App() { return () => ( My App ` } function escapeScriptJson(json: string): string { // Avoid prematurely closing the script tag when serialized data contains "". return json.replace(/): string { let parts: string[] = [] for (let [key, value] of Object.entries(style)) { if (value == null) continue if (typeof value === 'boolean') continue if (typeof value === 'number' && !Number.isFinite(value)) continue // Convert camelCase to kebab-case let cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) // Add px to numeric values where appropriate let shouldAppendPx = typeof value === 'number' && value !== 0 && !NUMERIC_CSS_PROPS.has(cssKey) && !cssKey.startsWith('--') let cssValue = shouldAppendPx ? `${value}px` : Array.isArray(value) ? value.join(', ') : String(value) parts.push(`${cssKey}: ${cssValue};`) } return parts.join(' ') } // Frame styles work end-to-end when frame handlers use their own `renderToStream`: // the handler's `finalizeHtml` emits `') }) it('places styles in head when no html root', async () => { let stream = renderToStream(
    No HTML root
    ) let html = await drain(stream) // Style should be in a head element expect(html).toMatch(/^

    Index of ${pathname}

    ${tableRows}
    Name Size Type
    `) } async function calculateDirectorySize(dirPath: string): Promise { let totalSize = 0 try { let dirents = await fsp.readdir(dirPath, { withFileTypes: true }) for (let dirent of dirents) { let fullPath = path.join(dirPath, dirent.name) try { if (dirent.isDirectory()) { totalSize += await calculateDirectorySize(fullPath) } else if (dirent.isFile()) { let stats = await fsp.stat(fullPath) totalSize += stats.size } } catch { // Skip files/folders we can't access } } } catch { // If we can't read the directory, return 0 } return totalSize } function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' let units = ['B', 'kB', 'MB', 'GB', 'TB'] let i = Math.floor(Math.log(bytes) / Math.log(1024)) let size = bytes / Math.pow(1024, i) return size.toFixed(i === 0 ? 0 : 1) + ' ' + units[i] } ================================================ FILE: packages/static-middleware/src/lib/static.test.ts ================================================ import * as assert from 'node:assert/strict' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { describe, it, beforeEach, afterEach } from 'node:test' import { createRouter } from '@remix-run/fetch-router' import { formData } from '@remix-run/form-data-middleware' import { methodOverride } from '@remix-run/method-override-middleware' import { staticFiles } from './static.ts' describe('staticFiles middleware', () => { let tmpDir: string beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'static-middleware-test-')) }) afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }) }) function createTestFile(filename: string, content: string, date?: Date) { let filePath = path.join(tmpDir, filename) let dir = path.dirname(filePath) if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }) } fs.writeFileSync(filePath, content) if (date) { fs.utimesSync(filePath, date, date) } return filePath } describe('basic functionality', () => { it('serves a file', async () => { createTestFile('test.txt', 'Hello, World!') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') }) it('serves a file with HEAD request', async () => { createTestFile('test.txt', 'Hello, World!') let router = createRouter() router.head('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt', { method: 'HEAD' }) assert.equal(response.status, 200) assert.equal(await response.text(), '') assert.equal(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') }) it('serves files from nested directories', async () => { createTestFile('dir/subdir/file.txt', 'Nested file') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/dir/subdir/file.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Nested file') }) it('falls through to handler for non-existent file', async () => { let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Custom Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/nonexistent.txt') assert.equal(response.status, 404) assert.equal(await response.text(), 'Custom Fallback Handler') }) it('falls through to handler when requesting a directory', async () => { let dirPath = path.join(tmpDir, 'subdir') fs.mkdirSync(dirPath) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir') assert.equal(response.status, 404) assert.equal(await response.text(), 'Fallback Handler') }) }) it('supports etag by default', async () => { let lastModified = new Date('2025-01-01') createTestFile('test.txt', 'Hello, World!', lastModified) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') let etag = response.headers.get('ETag') assert.ok(etag) assert.equal(etag, 'W/"13-1735689600000"') let response2 = await router.fetch('https://remix.run/test.txt', { headers: { 'If-None-Match': etag }, }) assert.equal(response2.status, 304) assert.equal(await response2.text(), '') }) it('does not send etag if etag is disabled', async () => { let lastModified = new Date('2025-01-01') createTestFile('test.txt', 'Hello, World!', lastModified) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { etag: false })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('ETag'), null) }) it('supports last-modified by default', async () => { let lastModified = new Date('2025-01-01') createTestFile('test.txt', 'Hello, World!', lastModified) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Last-Modified'), lastModified.toUTCString()) }) it('does not send last-modified if lastModified is disabled', async () => { let lastModified = new Date('2025-01-01') createTestFile('test.txt', 'Hello, World!', lastModified) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { lastModified: false })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Last-Modified'), null) }) it('does not support accept-ranges by default for compressible files', async () => { let lastModified = new Date('2025-01-01') createTestFile('test.txt', 'Hello, World!', lastModified) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Accept-Ranges'), null) let response2 = await router.fetch('https://remix.run/test.txt', { headers: { Range: 'bytes=0-4' }, }) assert.equal(response2.status, 200) assert.equal(await response2.text(), 'Hello, World!') }) it('supports range requests when acceptRanges is explicitly enabled', async () => { let lastModified = new Date('2025-01-01') createTestFile('test.txt', 'Hello, World!', lastModified) let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { acceptRanges: true })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Accept-Ranges'), 'bytes') let response2 = await router.fetch('https://remix.run/test.txt', { headers: { Range: 'bytes=0-4' }, }) assert.equal(response2.status, 206) assert.equal(await response2.text(), 'Hello') assert.equal(response2.headers.get('Content-Range'), 'bytes 0-4/13') assert.equal(response2.headers.get('Content-Length'), '5') assert.equal(response2.headers.get('Accept-Ranges'), 'bytes') }) it('supports range requests with acceptRanges function', async () => { createTestFile('test.txt', 'Hello, World!') createTestFile('video.dat', 'fake video data') let router = createRouter() router.get('/*', { middleware: [ staticFiles(tmpDir, { acceptRanges: (file) => file.type.startsWith('video/'), }), ], action() { return new Response('Fallback Handler', { status: 404 }) }, }) // test.txt should not have ranges (text/plain doesn't match video/* filter) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Accept-Ranges'), null) let response2 = await router.fetch('https://remix.run/test.txt', { headers: { Range: 'bytes=0-4' }, }) assert.equal(response2.status, 200) assert.equal(await response2.text(), 'Hello, World!') }) it('supports cache-control', async () => { createTestFile('test.txt', 'Hello, World!') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { cacheControl: 'public, max-age=3600' })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt') assert.equal(response.headers.get('Cache-Control'), 'public, max-age=3600') }) it('works with multiple static middleware instances', async () => { createTestFile('assets/style.css', 'body {}') createTestFile('images/logo.png', 'PNG data') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response1 = await router.fetch('https://remix.run/assets/style.css') assert.equal(response1.status, 200) assert.equal(await response1.text(), 'body {}') let response2 = await router.fetch('https://remix.run/images/logo.png') assert.equal(response2.status, 200) assert.equal(await response2.text(), 'PNG data') }) it('works as fallback middleware', async () => { createTestFile('index.html', '

    Fallback Handler

    ') let router = createRouter() router.get('/api/users', () => new Response('Users API')) router.get('*path', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response1 = await router.fetch('https://remix.run/api/users') assert.equal(await response1.text(), 'Users API') let response2 = await router.fetch('https://remix.run/index.html') assert.equal(await response2.text(), '

    Fallback Handler

    ') }) for (let method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'] as const) { it(`ignores ${method} requests`, async () => { createTestFile('test.txt', 'Hello, World!') let router = createRouter() router.route(method, '/*path', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/test.txt', { method }) assert.equal(response.status, 404) assert.equal(await response.text(), 'Fallback Handler') }) } it('prevents path traversal with .. in pathname', async () => { createTestFile('secret.txt', 'Secret content') let publicDirName = 'public' createTestFile(`${publicDirName}/allowed.txt`, 'Allowed content') let router = createRouter() router.get('/*', { middleware: [staticFiles(path.join(tmpDir, publicDirName))], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let allowedResponse = await router.fetch('https://remix.run/allowed.txt') assert.equal(allowedResponse.status, 200) assert.equal(await allowedResponse.text(), 'Allowed content') let traversalResponse = await router.fetch('https://remix.run/../secret.txt') assert.equal(traversalResponse.status, 404) }) it('does not support absolute paths in the URL', async () => { let parentDir = path.dirname(tmpDir) let secretFileName = 'secret-outside-root.txt' let secretPath = path.join(parentDir, secretFileName) fs.writeFileSync(secretPath, 'Secret content') let router = createRouter() router.get('*path', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) try { let response = await router.fetch(`https://remix.run/${secretPath}`) assert.equal(response.status, 404) } finally { fs.unlinkSync(secretPath) } }) describe('filter option', () => { it('filters files based on custom filter function', async () => { createTestFile('index.html', '

    Home

    ') createTestFile('secret.txt', 'Secret') createTestFile('public.txt', 'Public') let router = createRouter() router.get('/*', { middleware: [ staticFiles(tmpDir, { filter: (path) => !path.includes('secret'), }), ], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let secretResponse = await router.fetch('https://remix.run/secret.txt') assert.equal(secretResponse.status, 404) assert.equal(await secretResponse.text(), 'Fallback Handler') let publicResponse = await router.fetch('https://remix.run/public.txt') assert.equal(publicResponse.status, 200) assert.equal(await publicResponse.text(), 'Public') }) }) describe('index option', () => { it('serves default index.html when requesting a directory', async () => { createTestFile('subdir/index.html', '

    Index Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    Index Page

    ') assert.equal(response.headers.get('Content-Type'), 'text/html; charset=utf-8') }) it('serves default index.html when requesting a directory without trailing slash', async () => { createTestFile('subdir/index.html', '

    Index Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir') assert.equal(response.status, 200) assert.equal(await response.text(), '

    Index Page

    ') assert.equal(response.headers.get('Content-Type'), 'text/html; charset=utf-8') }) it('serves default index.htm when index.html does not exist', async () => { createTestFile('subdir/index.htm', '

    HTM Index Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    HTM Index Page

    ') assert.equal(response.headers.get('Content-Type'), 'text/html; charset=utf-8') }) it('prefers index.html over index.htm when both exist', async () => { createTestFile('subdir/index.html', '

    HTML Index

    ') createTestFile('subdir/index.htm', '

    HTM Index

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    HTML Index

    ') }) it('falls through when directory has no index file', async () => { let dirPath = path.join(tmpDir, 'subdir') fs.mkdirSync(dirPath) createTestFile('subdir/other.txt', 'Not an index file') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 404) assert.equal(await response.text(), 'Fallback Handler') }) it('serves custom index file when specified', async () => { createTestFile('subdir/default.html', '

    Custom Default Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { index: ['default.html'] })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    Custom Default Page

    ') }) it('tries custom index files in order', async () => { createTestFile('subdir/home.html', '

    Home Page

    ') createTestFile('subdir/default.html', '

    Default Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { index: ['index.html', 'home.html', 'default.html'] })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    Home Page

    ') }) it('serves root directory index file', async () => { createTestFile('index.html', '

    Root Index

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir)], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    Root Index

    ') }) it('supports empty index array to disable index file serving', async () => { createTestFile('subdir/index.html', '

    Index Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { index: [] })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 404) assert.equal(await response.text(), 'Fallback Handler') }) it('supports index: false to disable index file serving', async () => { createTestFile('subdir/index.html', '

    Index Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { index: false })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 404) assert.equal(await response.text(), 'Fallback Handler') }) it('supports index: true to use default index files', async () => { createTestFile('subdir/index.html', '

    Index Page

    ') let router = createRouter() router.get('/*', { middleware: [staticFiles(tmpDir, { index: true })], action() { return new Response('Fallback Handler', { status: 404 }) }, }) let response = await router.fetch('https://remix.run/subdir/') assert.equal(response.status, 200) assert.equal(await response.text(), '

    Index Page

    ') }) }) describe('works with method-override middleware', () => { it('ignores overridden POST requests', async () => { createTestFile('test.txt', 'Hello, World!') let router = createRouter({ middleware: [formData(), methodOverride()], }) router.post('/*path', { middleware: [staticFiles(tmpDir)], action() { return new Response('POST handler called', { status: 200 }) }, }) let formDataPayload = new FormData() formDataPayload.append('_method', 'POST') formDataPayload.append('name', 'test') let response = await router.fetch('https://remix.run/test.txt', { method: 'POST', body: formDataPayload, }) assert.equal(response.status, 200) assert.equal(await response.text(), 'POST handler called') }) it('serves files with overridden GET requests', async () => { createTestFile('test.txt', 'Hello, World!') let router = createRouter({ middleware: [formData(), methodOverride()], }) router.get('/*path', { middleware: [staticFiles(tmpDir)], action() { return new Response('GET handler fallback', { status: 404 }) }, }) let formDataPayload = new FormData() formDataPayload.append('_method', 'GET') let response = await router.fetch('https://remix.run/test.txt', { method: 'POST', body: formDataPayload, }) assert.equal(response.status, 200) assert.equal(await response.text(), 'Hello, World!') assert.equal(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') }) }) }) ================================================ FILE: packages/static-middleware/src/lib/static.ts ================================================ import * as path from 'node:path' import * as fsp from 'node:fs/promises' import { openLazyFile } from '@remix-run/fs' import type { Middleware } from '@remix-run/fetch-router' import { createFileResponse as sendFile, type FileResponseOptions } from '@remix-run/response/file' import { generateDirectoryListing } from './directory-listing.ts' /** * Function that determines if HTTP Range requests should be supported for a given file. * * @param file The File object being served * @returns true if range requests should be supported */ export type AcceptRangesFunction = (file: File) => boolean /** * Options for the {@link staticFiles} middleware in addition to {@link FileResponseOptions}. */ export interface StaticFilesOptions extends Omit { /** * Filter function to determine which files should be served. * * @param path The relative path being requested * @returns Whether to serve the file */ filter?: (path: string) => boolean /** * Whether to support HTTP Range requests for partial content. * * Can be a boolean or a function that receives the file. * When enabled, includes Accept-Ranges header and handles Range requests * with 206 Partial Content responses. * * Defaults to enabling ranges only for non-compressible MIME types, * as defined by `isCompressibleMimeType()` from `@remix-run/mime`. * * Note: Range requests and compression are mutually exclusive. When * `Accept-Ranges: bytes` is present in the response headers, the compression * middleware will not compress the response. This is why the default behavior * enables ranges only for non-compressible types. * * @example * // Force range request support for all files * acceptRanges: true * * @example * // Enable ranges for videos only * acceptRanges: (file) => file.type.startsWith('video/') */ acceptRanges?: boolean | AcceptRangesFunction /** * Files to try and serve as the index file when the request path targets a directory. * * - `true`: Use default index files `['index.html', 'index.htm']` * - `false`: Disable index file serving * - `string[]`: Custom list of index files to try in order * * @default true */ index?: boolean | string[] /** * Whether to return an HTML page listing the files in a directory when the request path * targets a directory. If both this and `index` are set, `index` takes precedence. * * @default false */ listFiles?: boolean } /** * Creates a middleware that serves static files from the filesystem. * * Uses the URL pathname to resolve files, removing the leading slash to make it a relative path. * The middleware always falls through to the handler if the file is not found or an error occurs. * * @param root The root directory to serve files from (absolute or relative to cwd) * @param options Configuration for file responses * @returns The static files middleware */ export function staticFiles(root: string, options: StaticFilesOptions = {}): Middleware { // Ensure root is an absolute path root = path.resolve(root) let { acceptRanges, filter, index: indexOption, listFiles, ...fileOptions } = options // Normalize index option let index: string[] if (indexOption === false) { index = [] } else if (indexOption === true || indexOption === undefined) { index = ['index.html', 'index.htm'] } else { index = indexOption } return async (context, next) => { if (context.method !== 'GET' && context.method !== 'HEAD') { return next() } let relativePath = context.url.pathname.replace(/^\/+/, '') if (filter && !filter(relativePath)) { return next() } let targetPath = path.join(root, relativePath) let filePath: string | undefined try { let stats = await fsp.stat(targetPath) if (stats.isFile()) { filePath = targetPath } else if (stats.isDirectory()) { // Try each index file in turn for (let indexFile of index) { let indexPath = path.join(targetPath, indexFile) try { let indexStats = await fsp.stat(indexPath) if (indexStats.isFile()) { filePath = indexPath break } } catch { // Index file doesn't exist, continue to next } } // If no index file found and listFiles is enabled, show directory listing if (!filePath && listFiles) { return generateDirectoryListing(targetPath, context.url.pathname) } } } catch { // Path doesn't exist or isn't accessible, fall through } if (filePath) { let fileName = path.relative(root, filePath) let lazyFile = openLazyFile(filePath, { name: fileName }) let finalFileOptions: FileResponseOptions = { ...fileOptions } // If acceptRanges is a function, evaluate it with the lazyFile // Otherwise, pass it directly to sendFile if (typeof acceptRanges === 'function') { finalFileOptions.acceptRanges = acceptRanges(lazyFile) } else if (acceptRanges !== undefined) { finalFileOptions.acceptRanges = acceptRanges } return sendFile(lazyFile, context.request, finalFileOptions) } return next() } } ================================================ FILE: packages/static-middleware/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, "declarationMap": true, "outDir": "./dist" }, "include": ["global.d.ts", "src"], "exclude": ["src/**/*.test.ts", "dist"] } ================================================ FILE: packages/static-middleware/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true }, "exclude": ["dist"] } ================================================ FILE: packages/tar-parser/.changes/README.md ================================================ # Changes Directory See the [Contributing Guide](../../../CONTRIBUTING.md#adding-a-change-file) for documentation on how to add change files. ================================================ FILE: packages/tar-parser/CHANGELOG.md ================================================ # `tar-parser` CHANGELOG This is the changelog for [`tar-parser`](https://github.com/remix-run/remix/tree/main/packages/tar-parser). It follows [semantic versioning](https://semver.org/). ## v0.7.0 (2025-11-20) - Update dev dependencies to use `@remix-run/fs` instead of `@remix-run/lazy-file/fs`. ## v0.6.0 (2025-11-04) - Build using `tsc` instead of `esbuild`. This means modules in the `dist` directory now mirror the layout of modules in the `src` directory. ## v0.5.0 (2025-10-22) - BREAKING CHANGE: Removed CommonJS build. This package is now ESM-only. If you need to use this package in a CommonJS project, you will need to use dynamic `import()`. ## v0.4.0 (2025-07-24) - Renamed package from `@mjackson/tar-parser` to `@remix-run/tar-parser` ## v0.3.0 (2025-06-06) - Add `/src` to npm package, so "go to definition" goes to the actual source - Use one set of types for all built files, instead of separate types for ESM and CJS - Build using esbuild directly instead of tsup ## v0.2.2 (2025-02-04) - Add `Promise` to `TarEntryHandler` return type ## v0.2.1 (2025-01-24) - Add support for environments that do not support `ReadableStream.prototype[Symbol.asyncIterator]` (i.e. Safari), see #46 ## v0.2.0 (2025-01-07) - Fix a bug that hangs the process when trying to read zero-length entries. ## v0.1.0 (2024-12-06) - Initial release ================================================ FILE: packages/tar-parser/LICENSE ================================================ MIT License Copyright (c) 2025 Shopify Inc. 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: packages/tar-parser/README.md ================================================ # tar-parser Streaming [tar archive]() parsing for JavaScript. `tar-parser` handles POSIX/GNU/PAX archives incrementally so large tar files can be processed without buffering the full payload. ## Features - **Universal Runtime** - Runs anywhere JavaScript runs - **Web Streams** - Built on the standard [web Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API), so it's composable with `fetch()` streams - **Format Support** - Supports POSIX, GNU, and PAX tar formats - **Memory Efficient** - Does not buffer anything in normal usage - **Zero Dependencies** - No external dependencies ## Installation ```sh npm i remix ``` ## Usage The main parser interface is the `parseTar(archive, handler)` function: ```ts import { parseTar } from 'remix/tar-parser' let response = await fetch('https://github.com/remix-run/remix/archive/refs/heads/main.tar.gz') await parseTar(response.body.pipeThrough(new DecompressionStream('gzip')), (entry) => { console.log(entry.name, entry.size) }) ``` If you're parsing an archive with filename encodings other than UTF-8, use the `filenameEncoding` option: ```ts let response = await fetch(/* ... */) await parseTar(response.body, { filenameEncoding: 'latin1' }, (entry) => { console.log(entry.name, entry.size) }) ``` ## Benchmark `tar-parser` performs on par with other popular tar parsing libraries on Node.js. ``` > @remix-run/tar-parser@0.0.0 bench /Users/michael/Projects/remix-the-web/packages/tar-parser > node ./bench/runner.ts Platform: Darwin (24.0.0) CPU: Apple M1 Pro Date: 12/6/2024, 11:00:55 AM Node.js v22.8.0 ┌────────────┬────────────────────┐ │ (index) │ lodash npm package │ ├────────────┼────────────────────┤ │ tar-parser │ '6.23 ms ± 0.58' │ │ tar-stream │ '6.72 ms ± 2.24' │ │ node-tar │ '6.49 ms ± 0.44' │ └────────────┴────────────────────┘ ``` ## Related Packages - [`multipart-parser`](https://github.com/remix-run/remix/tree/main/packages/multipart-parser) - Fast, streaming multipart parser for JavaScript ## Credits `tar-parser` is based on the excellent [tar-stream package](https://www.npmjs.com/package/tar-stream) (MIT license) and adopts the same core parsing algorithm, utility functions, and many test cases. ## License See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE) ================================================ FILE: packages/tar-parser/bench/package.json ================================================ { "name": "tar-parser-bench", "private": true, "type": "module", "dependencies": { "@remix-run/fs": "workspace:^", "@remix-run/tar-parser": "workspace:^", "gunzip-maybe": "^1.4.2", "tar": "^7.4.3", "tar-stream": "^3.1.7" }, "devDependencies": { "@types/gunzip-maybe": "^1.4.2", "@types/tar": "^6.1.13", "@types/tar-stream": "^3.1.3" } } ================================================ FILE: packages/tar-parser/bench/parsers/node-tar.ts ================================================ import * as fs from 'node:fs' import * as tar from 'tar' export async function parse(filename: string): Promise { let stream = fs.createReadStream(filename) let start = performance.now() await new Promise((resolve, reject) => { stream .pipe(tar.t()) .on('entry', (entry) => { entry.resume() }) .on('finish', () => { resolve() }) }) return performance.now() - start } ================================================ FILE: packages/tar-parser/bench/parsers/tar-parser.ts ================================================ import { parseTar } from '@remix-run/tar-parser' import { openLazyFile } from '@remix-run/fs' export async function parse(filename: string): Promise { let stream = openLazyFile(filename).stream().pipeThrough(new DecompressionStream('gzip')) let start = performance.now() await parseTar(stream, (_entry) => { // Do nothing }) return performance.now() - start } ================================================ FILE: packages/tar-parser/bench/parsers/tar-stream.ts ================================================ import * as fs from 'node:fs' import gunzip from 'gunzip-maybe' import tar from 'tar-stream' export async function parse(filename: string): Promise { let stream = fs.createReadStream(filename).pipe(gunzip()) let start = performance.now() await new Promise((resolve, reject) => { let extract = tar.extract() extract.on('error', reject) extract.on('entry', function (_header, stream, next) { stream.on('end', function () { next() // ready for next entry }) stream.resume() // just auto drain the stream }) extract.on('finish', function () { resolve() }) stream.pipe(extract) }) return performance.now() - start } ================================================ FILE: packages/tar-parser/bench/runner.ts ================================================ import * as os from 'node:os' import * as process from 'node:process' import { fixtures } from '../test/utils.ts' import * as nodeTar from './parsers/node-tar.ts' import * as tarParser from './parsers/tar-parser.ts' import * as tarStream from './parsers/tar-stream.ts' const benchmarks = [{ name: 'lodash npm package', filename: fixtures.lodashNpmPackage }] interface Parser { parse(filename: string): Promise } async function runParserBenchmarks( parser: Parser, times = 1000, ): Promise { let results: BenchmarkResults[string] = {} for (let benchmark of benchmarks) { let measurements: number[] = [] for (let i = 0; i < times; ++i) { measurements.push(await parser.parse(benchmark.filename)) } results[benchmark.name] = getMeanAndStdDev(measurements) } return results } function getMeanAndStdDev(measurements: number[]): string { let mean = measurements.reduce((a, b) => a + b, 0) / measurements.length let variance = measurements.reduce((a, b) => a + (b - mean) ** 2, 0) / measurements.length let stdDev = Math.sqrt(variance) return mean.toFixed(2) + ' ms ± ' + stdDev.toFixed(2) } interface BenchmarkResults { [parserName: string]: { [benchmarkName: string]: string } } async function runBenchmarks(parserName?: string): Promise { let results: BenchmarkResults = {} if (parserName === 'tar-parser' || parserName === undefined) { results['tar-parser'] = await runParserBenchmarks(tarParser) } if (parserName === 'tar-stream' || parserName === undefined) { results['tar-stream'] = await runParserBenchmarks(tarStream) } if (parserName === 'node-tar' || parserName === undefined) { results['node-tar'] = await runParserBenchmarks(nodeTar) } return results } function printResults(results: BenchmarkResults) { console.log(`Platform: ${os.type()} (${os.release()})`) console.log(`CPU: ${os.cpus()[0].model}`) console.log(`Date: ${new Date().toLocaleString()}`) console.log(`Node.js ${process.version}`) console.table(results) } runBenchmarks(process.argv[2]).then(printResults, (error) => { console.error(error) process.exit(1) }) ================================================ FILE: packages/tar-parser/package.json ================================================ { "name": "@remix-run/tar-parser", "version": "0.7.0", "description": "A fast, efficient parser for tar streams in any JavaScript environment", "author": "Michael Jackson ", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", "directory": "packages/tar-parser" }, "homepage": "https://github.com/remix-run/remix/tree/main/packages/tar-parser#readme", "files": [ "LICENSE", "README.md", "dist", "src", "!src/**/*.test.ts" ], "type": "module", "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, "publishConfig": { "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "./package.json": "./package.json" } }, "devDependencies": { "@remix-run/fs": "workspace:*", "@types/node": "catalog:", "@typescript/native-preview": "catalog:" }, "scripts": { "bench": "node ./bench/runner.ts", "build": "tsgo -p tsconfig.build.json", "clean": "git clean -fdX", "prepublishOnly": "pnpm run build", "test": "node --test", "typecheck": "tsgo --noEmit" }, "keywords": [ "tar", "archive", "parser", "stream" ] } ================================================ FILE: packages/tar-parser/src/globals.ts ================================================ // This file provides global type augmentation for ReadableStream async iteration. // See https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/62651 declare global { interface ReadableStream { values(options?: { preventCancel?: boolean }): AsyncIterableIterator [Symbol.asyncIterator](): AsyncIterableIterator } } export {} ================================================ FILE: packages/tar-parser/src/index.ts ================================================ import './globals.ts' export { TarParseError, type TarHeader, type ParseTarHeaderOptions, parseTarHeader, type ParseTarOptions, parseTar, type TarParserOptions, TarParser, TarEntry, } from './lib/tar.ts' ================================================ FILE: packages/tar-parser/src/lib/read-stream.ts ================================================ // We need this little helper for environments that do not support // ReadableStream.prototype[Symbol.asyncIterator] yet. See #46 export async function* readStream(stream: ReadableStream): AsyncIterable { let reader = stream.getReader() while (true) { let result = await reader.read() if (result.done) break yield result.value } } ================================================ FILE: packages/tar-parser/src/lib/tar.test.ts ================================================ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { fixtures, readFixture } from '../../test/utils.ts' import { type TarHeader, parseTar } from './tar.ts' async function bufferBytes(stream: ReadableStream): Promise> { let chunks: Uint8Array[] = [] let length = 0 for await (let chunk of stream) { chunks.push(chunk) length += chunk.byteLength } let result = new Uint8Array(length) let offset = 0 for (let chunk of chunks) { result.set(chunk, offset) offset += chunk.byteLength } return result } async function bufferString( stream: ReadableStream, encoding = 'utf-8', ): Promise { let decoder = new TextDecoder(encoding) let string = '' for await (let chunk of stream) { string += decoder.decode(chunk, { stream: true }) } string += decoder.decode() return string } async function computeHash( buffer: Uint8Array, algorithm = 'SHA-256', ): Promise { let digest = await crypto.subtle.digest(algorithm, buffer) return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, '0')) .join('') .slice(0, 8) } describe('TarParser', () => { it('parses express-4.21.1.tgz', async () => { let entries: Record = {} await parseTar(readFixture(fixtures.expressNpmPackage), async (entry) => { let hash = await computeHash(await bufferBytes(entry.body)) entries[entry.name] = hash }) assert.deepEqual(entries, { 'package/LICENSE': '95a57628', 'package/lib/application.js': '5901b32f', 'package/lib/express.js': '2f25585c', 'package/index.js': '4d2f5afc', 'package/lib/router/index.js': '19c5ca9b', 'package/lib/middleware/init.js': '48c1d12f', 'package/lib/router/layer.js': 'c90709dc', 'package/lib/middleware/query.js': '6edce396', 'package/lib/request.js': '64ac1075', 'package/lib/response.js': '4b5c338c', 'package/lib/router/route.js': '86db1235', 'package/lib/utils.js': '9035c6d9', 'package/lib/view.js': 'ec627880', 'package/package.json': '774eaac2', 'package/History.md': 'ca257313', 'package/Readme.md': '016f344e', }) }) it('parses fetch-proxy-0.1.0.tar.gz', async () => { let count = 0 await parseTar(readFixture(fixtures.fetchProxyGithubArchive), async (entry) => { // Drain the body stream to avoid memory accumulation for await (let _ of entry.body) { } count++ }) assert.equal(count, 192) }) it('parses lodash-4.17.21.tgz', async () => { let count = 0 await parseTar(readFixture(fixtures.lodashNpmPackage), async (entry) => { // Drain the body stream to avoid memory accumulation for await (let _ of entry.body) { } count++ }) assert.equal(count, 1054) }) it('parses npm-11.0.0.tgz', async () => { let count = 0 await parseTar(readFixture(fixtures.npmNpmPackage), async (entry) => { // Drain the body stream to avoid memory accumulation for await (let _ of entry.body) { } count++ }) assert.equal(count, 2368) }) }) describe('tar-stream test cases', () => { it('parses one-file.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.oneFile), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: 'test.txt', mode: 0o644, uid: 501, gid: 20, size: 12, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, 'hello world\n', ], ]) }) it('parses multi-file.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.multiFile), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: 'file-1.txt', mode: 0o644, uid: 501, gid: 20, size: 12, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, 'i am file-1\n', ], [ { name: 'file-2.txt', mode: 0o644, uid: 501, gid: 20, size: 12, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, 'i am file-2\n', ], ]) }) it('parses pax.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.pax), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: 'pax.txt', mode: 0o644, uid: 501, gid: 20, size: 12, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: { path: 'pax.txt', special: 'sauce', }, }, 'hello world\n', ], ]) }) it('parses types.tar', async () => { let headers: TarHeader[] = [] await parseTar(readFixture(fixtures.types), async (entry) => { headers.push(entry.header) }) assert.deepEqual(headers, [ { name: 'directory', mode: 0o755, uid: 501, gid: 20, size: 0, mtime: 1387580181, type: 'directory', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, { name: 'directory-link', mode: 0o755, uid: 501, gid: 20, size: 0, mtime: 1387580181, type: 'symlink', linkname: 'directory', uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, ]) }) it('parses long-name.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.longName), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: 'my/file/is/longer/than/100/characters/and/should/use/the/prefix/header/foobarbaz/foobarbaz/foobarbaz/foobarbaz/foobarbaz/foobarbaz/filename.txt', mode: 0o644, uid: 501, gid: 20, size: 16, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, 'hello long name\n', ], ]) }) it('parses unicode-bsd.tar', async () => { let headers: TarHeader[] = [] await parseTar(readFixture(fixtures.unicodeBsd), async (entry) => { headers.push(entry.header) }) assert.deepEqual(headers, [ { name: 'høllø.txt', mode: 0o644, uid: 501, gid: 20, size: 4, mtime: 1387588646, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: { 'SCHILY.dev': '16777217', 'SCHILY.ino': '3599143', 'SCHILY.nlink': '1', atime: '1387589077', ctime: '1387588646', path: 'høllø.txt', }, }, ]) }) it('parses unicode.tar', async () => { let headers: TarHeader[] = [] await parseTar(readFixture(fixtures.unicode), async (entry) => { headers.push(entry.header) }) assert.deepEqual(headers, [ { name: 'høstål.txt', mode: 0o644, uid: 501, gid: 20, size: 8, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: { path: 'høstål.txt' }, }, ]) }) it('parses name-is-100.tar', async () => { let entries: [number, string][] = [] await parseTar(readFixture(fixtures.nameIs100), async (entry) => { entries.push([entry.header.name.length, await bufferString(entry.body)]) }) assert.deepEqual(entries, [[100, 'hello\n']]) }) it('parses space.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.space), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.equal(entries.length, 4) }) it('parses gnu-long-path.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.gnuLongPath), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.equal(entries.length, 1) }) it('parses base-256-uid-gid.tar', async () => { let headers: TarHeader[] = [] await parseTar(readFixture(fixtures.base256UidGid), async (entry) => { headers.push(entry.header) }) assert.equal(headers.length, 1) assert.equal(headers[0].uid, 116435139) assert.equal(headers[0].gid, 1876110778) }) it('parses base-256-size.tar', async () => { let headers: TarHeader[] = [] await parseTar(readFixture(fixtures.base256Size), async (entry) => { headers.push(entry.header) }) assert.deepEqual(headers, [ { name: 'test.txt', mode: 0o644, uid: 501, gid: 20, size: 12, mtime: 1387580181, type: 'file', linkname: null, uname: 'maf', gname: 'staff', devmajor: 0, devminor: 0, pax: null, }, ]) }) it('parses latin1.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.latin1), { filenameEncoding: 'latin1' }, async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: "En français, s'il vous plaît?.txt", mode: 0o644, uid: 0, gid: 0, size: 14, mtime: 1495941034, type: 'file', linkname: null, uname: 'root', gname: 'root', devmajor: 0, devminor: 0, pax: null, }, 'Hello, world!\n', ], ]) }) it('throws when parsing incomplete.tar', async () => { await assert.rejects( async () => { await parseTar(readFixture(fixtures.incomplete), () => {}) }, { name: 'TarParseError', message: 'Unexpected end of archive', }, ) }) it('parses gnu.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.gnu), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: 'test.txt', mode: 0o644, uid: 12345, gid: 67890, size: 14, mtime: 1559239869, type: 'file', linkname: null, uname: 'myuser', gname: 'mygroup', devmajor: 0, devminor: 0, pax: null, }, 'Hello, world!\n', ], ]) }) it('parses gnu-incremental.tar', async () => { let entries: [TarHeader, string][] = [] await parseTar(readFixture(fixtures.gnuIncremental), async (entry) => { entries.push([entry.header, await bufferString(entry.body)]) }) assert.deepEqual(entries, [ [ { name: 'test.txt', mode: 0o644, uid: 12345, gid: 67890, size: 14, mtime: 1559239869, type: 'file', linkname: null, uname: 'myuser', gname: 'mygroup', devmajor: 0, devminor: 0, pax: null, }, 'Hello, world!\n', ], ]) }) }) ================================================ FILE: packages/tar-parser/src/lib/tar.ts ================================================ import { readStream } from './read-stream.ts' import { buffersEqual, concatChunks, computeChecksum, decodeLongPath, decodePax, getOctal, getString, overflow, } from './utils.ts' const TarBlockSize = 512 /** * An error thrown when parsing a tar archive fails. */ export class TarParseError extends Error { /** * @param message The error message */ constructor(message: string) { super(message) this.name = 'TarParseError' } } /** * The parsed header of a tar entry. */ export interface TarHeader { /** * Entry path stored in the archive. */ name: string /** * File mode parsed from the header, or `null` when unavailable. */ mode: number | null /** * Numeric user ID parsed from the header, or `null` when unavailable. */ uid: number | null /** * Numeric group ID parsed from the header, or `null` when unavailable. */ gid: number | null /** * Entry size in bytes. */ size: number /** * Last modification time parsed from the header, or `null` when unavailable. */ mtime: number | null /** * Normalized entry type such as `file` or `directory`. */ type: string /** * Linked path target for link entries, or `null` when not present. */ linkname: string | null /** * User name parsed from the header. */ uname: string /** * Group name parsed from the header. */ gname: string /** * Major device number for device entries, or `null` when unavailable. */ devmajor: number | null /** * Minor device number for device entries, or `null` when unavailable. */ devminor: number | null /** * Decoded PAX metadata attached to the entry, or `null` when none is present. */ pax: Record | null } const TarFileTypes: Record = { '0': 'file', '1': 'link', '2': 'symlink', '3': 'character-device', '4': 'block-device', '5': 'directory', '6': 'fifo', '7': 'contiguous-file', '27': 'gnu-long-link-path', '28': 'gnu-long-path', '30': 'gnu-long-path', '55': 'pax-global-header', '72': 'pax-header', } const ZeroOffset = '0'.charCodeAt(0) const UstarMagic = new Uint8Array([0x75, 0x73, 0x74, 0x61, 0x72, 0x00]) // "ustar\0" const UstarVersion = new Uint8Array([ZeroOffset, ZeroOffset]) // "00" const GnuMagic = new Uint8Array([0x75, 0x73, 0x74, 0x61, 0x72, 0x20]) // "ustar " const GnuVersion = new Uint8Array([0x20, 0x00]) // " \0" /** * Options for parsing tar headers. */ export interface ParseTarHeaderOptions { /** * Set `false` to disallow unknown header formats. * * @default true */ allowUnknownFormat?: boolean /** * The label (encoding) for filenames. * * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings) * * @default 'utf-8' */ filenameEncoding?: string } /** * Parses a tar header block. * * @param block The tar header block * @param options Options that control how the header is parsed * @returns The parsed tar header */ export function parseTarHeader(block: Uint8Array, options?: ParseTarHeaderOptions): TarHeader { if (block.length !== TarBlockSize) { throw new TarParseError('Invalid tar header size') } let allowUnknownFormat = options?.allowUnknownFormat ?? true let filenameEncoding = options?.filenameEncoding ?? 'utf-8' // Tar header format // Offset Size Field // 0 100 Filename // 100 8 File mode (octal) // 108 8 Owner's numeric user ID (octal) // 116 8 Group's numeric user ID (octal) // 124 12 File size in bytes (octal) // 136 12 Last modification time (octal) // 148 8 Checksum for header block (octal) // 156 1 Type flag // 157 100 Name of linked file // 257 6 Magic string "ustar\0" or "ustar " // 263 2 Version "00" or " \0" // 265 32 Owner username // 297 32 Owner groupname // 329 8 Device major number (octal) // 337 8 Device minor number (octal) // 345 155 Filename prefix (ustar only) let checksum = getOctal(block, 148, 8) if (checksum !== computeChecksum(block)) { throw new TarParseError( 'Invalid tar header. Maybe the tar is corrupted or needs to be gunzipped?', ) } let typeFlag = block[156] === 0 ? 0 : block[156] - ZeroOffset let header: TarHeader = { name: getString(block, 0, 100, filenameEncoding), mode: getOctal(block, 100, 8), uid: getOctal(block, 108, 8), gid: getOctal(block, 116, 8), size: getOctal(block, 124, 12) ?? 0, mtime: getOctal(block, 136, 12), type: TarFileTypes[typeFlag] ?? 'unknown', linkname: block[157] === 0 ? null : getString(block, 157, 100, filenameEncoding), uname: getString(block, 265, 32), gname: getString(block, 297, 32), devmajor: getOctal(block, 329, 8), devminor: getOctal(block, 337, 8), pax: null, } let magic = block.subarray(257, 263) let version = block.subarray(263, 265) if (buffersEqual(magic, UstarMagic) && buffersEqual(version, UstarVersion)) { // UStar (posix) format if (block[345] !== 0) { let prefix = getString(block, 345, 155) header.name = prefix + '/' + header.name } } else if (buffersEqual(magic, GnuMagic) && buffersEqual(version, GnuVersion)) { // GNU format } else if (!allowUnknownFormat) { throw new TarParseError('Invalid tar header, unknown format') } return header } type TarArchiveSource = | ReadableStream | Uint8Array | Iterable | AsyncIterable type TarEntryHandler = (entry: TarEntry) => void | Promise /** * Options for parsing a tar archive. */ export type ParseTarOptions = ParseTarHeaderOptions /** * Parse a tar archive and call the given handler for each entry it contains. * * ```ts * import { parseTar } from 'remix/tar-parser'; * * await parseTar(archive, (entry) => { * console.log(entry.name); * }); * ``` * * @param archive The tar archive source data * @param handler A function to call for each entry in the archive * @returns A promise that resolves when the parse is finished */ export async function parseTar(archive: TarArchiveSource, handler: TarEntryHandler): Promise export async function parseTar( archive: TarArchiveSource, options: ParseTarOptions, handler: TarEntryHandler, ): Promise export async function parseTar( archive: TarArchiveSource, options: ParseTarOptions | TarEntryHandler, handler?: TarEntryHandler, ): Promise { let opts: ParseTarOptions | undefined if (typeof options === 'function') { handler = options } else { opts = options } let parser = new TarParser(opts) await parser.parse(archive, handler!) } /** * Options for configuring a {@link TarParser}. */ export type TarParserOptions = ParseTarHeaderOptions /** * A parser for tar archives. */ export class TarParser { #buffer: Uint8Array | null = null #missing = 0 #header: TarHeader | null = null #bodyController: ReadableStreamDefaultController | null = null #longHeader = false #gnuLongPath: string | null = null #gnuLongLinkPath: string | null = null #paxGlobal: Record | null = null #pax: Record | null = null #options?: TarParserOptions /** * @param options Options that control how the tar archive is parsed */ constructor(options?: TarParserOptions) { this.#options = options } /** * Parse a stream/buffer tar archive and call the given handler for each entry it contains. * Resolves when the parse is finished and all handlers resolve. * * @param archive The tar archive source data * @param handler A function to call for each entry in the archive * @returns A promise that resolves when the parse is finished */ async parse(archive: TarArchiveSource, handler: TarEntryHandler): Promise { this.#reset() let results: unknown[] = [] function handleEntry(entry: TarEntry): void { results.push(handler(entry)) } if (archive instanceof ReadableStream) { for await (let chunk of readStream(archive)) { this.#write(chunk, handleEntry) } } else if (isAsyncIterable(archive)) { for await (let chunk of archive) { this.#write(chunk, handleEntry) } } else if (archive instanceof Uint8Array) { this.#write(archive, handleEntry) } else if (isIterable(archive)) { for (let chunk of archive) { this.#write(chunk, handleEntry) } } else { throw new TypeError('Cannot parse tar archive; expected a stream or buffer') } if (this.#missing !== 0) { throw new TarParseError('Unexpected end of archive') } await Promise.all(results) } #reset(): void { this.#buffer = null this.#missing = 0 this.#header = null this.#bodyController = null this.#longHeader = false this.#gnuLongPath = null this.#gnuLongLinkPath = null this.#paxGlobal = null this.#pax = null } #write(chunk: Uint8Array, handler: TarEntryHandler): void { if (this.#buffer !== null) { this.#buffer = concatChunks(this.#buffer, chunk) } else { this.#buffer = chunk } while (this.#buffer !== null && this.#buffer.length > 0) { if (this.#missing > 0) { if (this.#bodyController !== null) { this.#parseBody() continue } if (this.#longHeader) { if (this.#missing > this.#buffer.length) break this.#parseLongHeader() continue } if (this.#missing >= this.#buffer.length) { this.#missing -= this.#buffer.length this.#buffer = null break } this.#buffer = this.#buffer.subarray(this.#missing) this.#missing = 0 } if (this.#buffer.length < TarBlockSize) break this.#parseHeader(handler) } } #parseHeader(handler: TarEntryHandler): void { let block = this.#read(TarBlockSize) if (isZeroBlock(block)) { this.#header = null return } this.#header = parseTarHeader(block, this.#options) switch (this.#header.type) { case 'gnu-long-path': case 'gnu-long-link-path': case 'pax-global-header': case 'pax-header': this.#longHeader = true this.#missing = this.#header.size return } if (this.#gnuLongPath) { this.#header.name = this.#gnuLongPath this.#gnuLongPath = null } if (this.#gnuLongLinkPath) { this.#header.linkname = this.#gnuLongLinkPath this.#gnuLongLinkPath = null } if (this.#pax) { if (this.#pax.path) this.#header.name = this.#pax.path if (this.#pax.linkpath) this.#header.linkname = this.#pax.linkpath if (this.#pax.size) this.#header.size = parseInt(this.#pax.size, 10) this.#header.pax = this.#pax this.#pax = null } if (this.#header.size === 0 || this.#header.type === 'directory') { let emptyBody = new ReadableStream({ start(controller) { controller.close() }, }) handler(new TarEntry(this.#header, emptyBody)) this.#bodyController = null this.#missing = 0 return } let body = new ReadableStream({ start: (controller) => { this.#bodyController = controller }, }) handler(new TarEntry(this.#header, body)) this.#missing = this.#header.size } #parseLongHeader(): void { this.#longHeader = false let buffer = this.#read(this.#header!.size) switch (this.#header!.type) { case 'gnu-long-path': this.#gnuLongPath = decodeLongPath(buffer) break case 'gnu-long-link-path': this.#gnuLongLinkPath = decodeLongPath(buffer) break case 'pax-global-header': this.#paxGlobal = decodePax(buffer) break case 'pax-header': this.#pax = this.#paxGlobal !== null ? Object.assign({}, this.#paxGlobal, decodePax(buffer)) : decodePax(buffer) break } this.#missing = overflow(this.#header!.size) } #parseBody(): void { if (this.#missing >= this.#buffer!.length) { this.#bodyController!.enqueue(this.#buffer!) this.#missing -= this.#buffer!.length this.#buffer = null } else { this.#bodyController!.enqueue(this.#read(this.#missing)) this.#bodyController!.close() this.#bodyController = null this.#missing = overflow(this.#header!.size) } } #read(size: number): Uint8Array { let result = this.#buffer!.subarray(0, size) this.#buffer = this.#buffer!.subarray(size) return result } } function isIterable(value: unknown): value is Iterable { return typeof value === 'object' && value != null && Symbol.iterator in value } function isAsyncIterable(value: unknown): value is AsyncIterable { return typeof value === 'object' && value != null && Symbol.asyncIterator in value } function isZeroBlock(buffer: Uint8Array): boolean { return buffer.every((byte) => byte === 0) } /** * An entry in a tar archive. */ export class TarEntry { #header: TarHeader #body: ReadableStream #bodyUsed = false /** * @param header The header info for this entry * @param body The entry's content as a stream */ constructor(header: TarHeader, body: ReadableStream) { this.#header = header this.#body = body } /** * The content of this entry as an [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer). * * @returns A promise that resolves to an `ArrayBuffer` */ async arrayBuffer(): Promise { return (await this.bytes()).buffer as ArrayBuffer } /** * The content of this entry as a `ReadableStream`. */ get body(): ReadableStream { return this.#body } /** * Whether the body of this entry has been consumed. */ get bodyUsed(): boolean { return this.#bodyUsed } /** * The content of this entry buffered into a single typed array. * * @returns A promise that resolves to a `Uint8Array` */ async bytes(): Promise { if (this.#bodyUsed) { throw new Error('Body is already consumed or is being consumed') } this.#bodyUsed = true let result = new Uint8Array(this.size) let offset = 0 for await (let chunk of readStream(this.#body)) { result.set(chunk, offset) offset += chunk.length } return result } /** * The raw header info associated with this entry. */ get header(): TarHeader { return this.#header } /** * The name of this entry. */ get name(): string { return this.header.name } /** * The size of this entry in bytes. */ get size(): number { return this.header.size } /** * The content of this entry as a string. * * Note: Do not use this for binary data, use `await entry.bytes()` or stream `entry.body` directly instead. * * @returns A promise that resolves to the entry's content as a string */ async text(): Promise { return new TextDecoder().decode(await this.bytes()) } } ================================================ FILE: packages/tar-parser/src/lib/utils.ts ================================================ export function buffersEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false } return true } export function concatChunks(a: Uint8Array, b: Uint8Array): Uint8Array { let result = new Uint8Array(a.length + b.length) result.set(a) result.set(b, a.length) return result } export function computeChecksum(block: Uint8Array): number { let sum = 8 * 32 for (let i = 0; i < 148; i++) sum += block[i] for (let i = 156; i < 512; i++) sum += block[i] return sum } export function decodeLongPath(buffer: Uint8Array): string { return Utf8Decoder.decode(buffer) } export function decodePax(buffer: Uint8Array): Record { let pax: Record = {} while (buffer.length) { let i = 0 while (i < buffer.length && buffer[i] !== 32) i++ let len = parseInt(Utf8Decoder.decode(buffer.subarray(0, i)), 10) if (!len) break let val = Utf8Decoder.decode(buffer.subarray(i + 1, len - 1)) let eq = val.indexOf('=') if (eq === -1) break pax[val.slice(0, eq)] = val.slice(eq + 1) buffer = buffer.subarray(len) } return pax } export function indexOf(buffer: Uint8Array, value: number, offset: number, end: number): number { for (; offset < end; offset++) { if (buffer[offset] === value) return offset } return end } export function getString(buffer: Uint8Array, offset: number, size: number, label = 'utf-8') { return new TextDecoder(label).decode( buffer.subarray(offset, indexOf(buffer, 0, offset, offset + size)), ) } const Utf8Decoder = new TextDecoder() export function getOctal(buffer: Uint8Array, offset: number, size: number) { let value = buffer.subarray(offset, offset + size) offset = 0 if (value[offset] & 0x80) return parse256(value) // Older versions of tar can prefix with spaces while (offset < value.length && value[offset] === 32) offset++ let end = clamp(indexOf(value, 32, offset, value.length), value.length, value.length) while (offset < end && value[offset] === 0) offset++ if (end === offset) return 0 return parseInt(Utf8Decoder.decode(value.subarray(offset, end)), 8) } function clamp(index: number, len: number, defaultValue: number): number { if (typeof index !== 'number') return defaultValue index = ~~index // Coerce to integer. if (index >= len) return len if (index >= 0) return index index += len if (index >= 0) return index return 0 } /* Copied from the tar-stream repo who copied it from the node-tar repo. */ function parse256(buf: Uint8Array): number | null { // first byte MUST be either 80 or FF // 80 for positive, FF for 2's comp let positive if (buf[0] === 0x80) positive = true else if (buf[0] === 0xff) positive = false else return null // build up a base-256 tuple from the least sig to the highest let tuple = [] let i for (i = buf.length - 1; i > 0; i--) { let byte = buf[i] if (positive) tuple.push(byte) else tuple.push(0xff - byte) } let sum = 0 let len = tuple.length for (i = 0; i < len; i++) { sum += tuple[i] * Math.pow(256, i) } return positive ? sum : -1 * sum } export function overflow(size: number): number { size &= 511 return size && 512 - size } ================================================ FILE: packages/tar-parser/test/utils.ts ================================================ import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { openLazyFile } from '@remix-run/fs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const fixturesDir = path.resolve(__dirname, 'fixtures') export const fixtures = { base256Size: path.resolve(fixturesDir, 'base-256-size.tar'), base256UidGid: path.resolve(fixturesDir, 'base-256-uid-gid.tar'), expressNpmPackage: path.resolve(fixturesDir, 'express-4.21.1.tgz'), fetchProxyGithubArchive: path.resolve(fixturesDir, 'fetch-proxy-0.1.0.tar.gz'), gnuIncremental: path.resolve(fixturesDir, 'gnu-incremental.tar'), gnuLongPath: path.resolve(fixturesDir, 'gnu-long-path.tar'), gnu: path.resolve(fixturesDir, 'gnu.tar'), incomplete: path.resolve(fixturesDir, 'incomplete.tar'), latin1: path.resolve(fixturesDir, 'latin1.tar'), lodashNpmPackage: path.resolve(fixturesDir, 'lodash-4.17.21.tgz'), longName: path.resolve(fixturesDir, 'long-name.tar'), multiFile: path.resolve(fixturesDir, 'multi-file.tar'), npmNpmPackage: path.resolve(fixturesDir, 'npm-11.0.0.tgz'), nameIs100: path.resolve(fixturesDir, 'name-is-100.tar'), oneFile: path.resolve(fixturesDir, 'one-file.tar'), pax: path.resolve(fixturesDir, 'pax.tar'), space: path.resolve(fixturesDir, 'space.tar'), types: path.resolve(fixturesDir, 'types.tar'), unicodeBsd: path.resolve(fixturesDir, 'unicode-bsd.tar'), unicode: path.resolve(fixturesDir, 'unicode.tar'), } export function readFixture(filename: string): ReadableStream { let stream = openLazyFile(filename).stream() return filename.endsWith('.tar.gz') || filename.endsWith('.tgz') ? stream.pipeThrough(new DecompressionStream('gzip')) : stream } ================================================ FILE: packages/tar-parser/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "declaration": true, "declarationMap": true, "outDir": "./dist" }, "include": ["src"], "exclude": ["src/**/*.test.ts", "dist"] } ================================================ FILE: packages/tar-parser/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "Bundler", "target": "ESNext", "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": true }, "exclude": ["bench", "dist"] } ================================================ FILE: pnpm-workspace.yaml ================================================ catalog: '@types/node': ^24.6.0 '@typescript/native-preview': 7.0.0-dev.20251125.1 playwright: ^1.53.1 tsx: ^4.20.6 typescript: ^5.9.3 packages: - demos/* - docs - packages/* - packages/fetch-router/demos/* - packages/form-data-parser/demos/* - packages/component/demos - packages/component/bench - packages/component/bench/frameworks/* - packages/lazy-file/scripts - packages/multipart-parser/bench - packages/multipart-parser/demos/* - packages/node-fetch-server/bench - packages/node-fetch-server/demos/* - packages/route-pattern/bench - packages/static-middleware/demos/* - packages/tar-parser/bench - scripts shellEmulator: true ================================================ FILE: scripts/changes-preview.ts ================================================ import { parseAllChangeFiles, formatValidationErrors, generateChangelogContent, generateCommitMessage, } from './utils/changes.ts' import { colors, colorize } from './utils/color.ts' /** * Main preview function */ function main() { let result = parseAllChangeFiles() if (!result.valid) { console.error(colorize('Validation failed', colors.red) + '\n') console.error(formatValidationErrors(result.errors)) console.error() process.exit(1) } let { releases } = result if (releases.length === 0) { console.log('No packages have changes to release.\n') process.exit(0) } console.log(colorize('CHANGES', colors.lightBlue)) console.log() console.log(`${releases.length} package${releases.length === 1 ? '' : 's'} with changes:\n`) for (let release of releases) { console.log( ` • ${release.packageName}: ${release.currentVersion} → ${release.nextVersion} (${release.bump} bump)`, ) for (let change of release.changes) { console.log(` - ${change.file}`) } console.log() } console.log(colorize('CHANGELOG PREVIEW', colors.lightBlue)) console.log() for (let release of releases) { console.log(colorize(`${release.packageDirName}/CHANGELOG.md:`, colors.gray)) console.log() console.log(generateChangelogContent(release)) } console.log(colorize('COMMIT MESSAGE', colors.lightBlue)) console.log() console.log(generateCommitMessage(releases)) console.log() console.log(colorize('VERSION COMMAND', colors.lightBlue)) console.log() console.log('pnpm changes:version') console.log() } main() ================================================ FILE: scripts/changes-validate.ts ================================================ import * as fs from 'node:fs' import { parseAllChangeFiles, formatValidationErrors } from './utils/changes.ts' import { colors, colorize } from './utils/color.ts' import { getAllPackageDirNames, getPackageFile } from './utils/packages.ts' function getMissingChangelogPackageDirNames(): string[] { let packageDirNames = getAllPackageDirNames() let missing: string[] = [] for (let packageDirName of packageDirNames) { let changelogPath = getPackageFile(packageDirName, 'CHANGELOG.md') if (!fs.existsSync(changelogPath)) { missing.push(packageDirName) } } return missing } /** * Validates all change files in the repository * Exits with code 1 if any validation errors are found */ function main() { let hasErrors = false // Validate all packages have changelogs console.log('Validating package changelogs...\n') let missingChangelogPackageDirNames = getMissingChangelogPackageDirNames() if (missingChangelogPackageDirNames.length > 0) { hasErrors = true console.error(colorize('Missing changelogs', colors.red) + '\n') for (let packageDirName of missingChangelogPackageDirNames) { console.error(`📦 ${packageDirName}: Missing CHANGELOG.md file`) } console.error() } // Validate change files console.log('Validating change files...\n') let result = parseAllChangeFiles() if (!result.valid) { hasErrors = true console.error(colorize('Invalid change files', colors.red) + '\n') console.error(formatValidationErrors(result.errors)) console.error() } else { console.log(colorize('All change files are valid!', colors.lightGreen) + '\n') } if (hasErrors) { process.exit(1) } } main() ================================================ FILE: scripts/changes-version.ts ================================================ /** * Updates package.json versions, CHANGELOG.md files, and creates a release commit. * * Usage: * pnpm changes:version [--no-commit] * * Options: * --no-commit Only update files, don't commit (for manual review) */ import * as fs from 'node:fs' import * as path from 'node:path' import { parseAllChangeFiles, formatValidationErrors, generateChangelogContent, generateCommitMessage, } from './utils/changes.ts' import { colors, colorize } from './utils/color.ts' import { getPackageFile, getPackagePath } from './utils/packages.ts' import { readJson, writeJson, readFile, writeFile } from './utils/fs.ts' import { logAndExec } from './utils/process.ts' /** * Updates package.json version */ function updatePackageJson(packageDirName: string, newVersion: string) { let packageJsonPath = getPackageFile(packageDirName, 'package.json') let packageJson = readJson(packageJsonPath) packageJson.version = newVersion writeJson(packageJsonPath, packageJson) console.log(` ✓ Updated package.json to ${newVersion}`) } /** * Updates CHANGELOG.md with new content */ function updateChangelog(packageDirName: string, newContent: string) { let changelogPath = getPackageFile(packageDirName, 'CHANGELOG.md') let existingChangelog = readFile(changelogPath) let lines = existingChangelog.split('\n') // Find the first ## version entry let firstVersionIndex = lines.findIndex((line) => line.startsWith('## ')) let updatedChangelog: string if (firstVersionIndex !== -1) { // Insert before the first version entry lines.splice(firstVersionIndex, 0, newContent) updatedChangelog = lines.join('\n') } else { // No version entries yet - append to the end updatedChangelog = existingChangelog.trimEnd() + '\n\n' + newContent + '\n' } writeFile(changelogPath, updatedChangelog) console.log(` ✓ Updated CHANGELOG.md`) } /** * Deletes all change files (except README.md) */ function deleteChangeFiles(packageDirName: string) { let changesDir = path.join(getPackagePath(packageDirName), '.changes') let files = fs.readdirSync(changesDir) let changeFiles = files.filter((file) => file !== 'README.md' && file.endsWith('.md')) for (let file of changeFiles) { let filePath = path.join(changesDir, file) fs.unlinkSync(filePath) } console.log(` ✓ Deleted ${changeFiles.length} change file${changeFiles.length === 1 ? '' : 's'}`) } /** * Main version function */ function main() { let skipCommit = process.argv.includes('--no-commit') console.log('Validating change files...\n') let result = parseAllChangeFiles() if (!result.valid) { console.error(colorize('Validation failed', colors.red) + '\n') console.error(formatValidationErrors(result.errors)) console.error() process.exit(1) } let { releases } = result if (releases.length === 0) { console.log('No packages have changes to release.\n') process.exit(0) } console.log(colorize('Validation passed!', colors.lightGreen) + '\n') console.log('═'.repeat(80)) console.log(colorize(skipCommit ? 'UPDATING VERSION' : 'PREPARING RELEASE', colors.lightBlue)) console.log('═'.repeat(80)) console.log() // Process each package for (let release of releases) { console.log( colorize(`${release.packageName}:`, colors.gray) + ` ${release.currentVersion} → ${release.nextVersion}`, ) // Update package.json updatePackageJson(release.packageDirName, release.nextVersion) // Update CHANGELOG.md let changelogContent = generateChangelogContent(release) updateChangelog(release.packageDirName, changelogContent) // Delete change files deleteChangeFiles(release.packageDirName) console.log() } if (skipCommit) { // Success message for --no-commit console.log('═'.repeat(80)) console.log(colorize('VERSION UPDATED', colors.lightGreen)) console.log('═'.repeat(80)) console.log() console.log('Files have been updated. Review the changes, then manually commit:') console.log() console.log('```sh') let commitMessage = generateCommitMessage(releases) console.log(`git add .`) console.log() console.log(`git commit -m "${commitMessage}"`) console.log('```') console.log() } else { // Stage all changes console.log('Staging changes...') logAndExec('git add .') console.log() // Create commit let commitMessage = generateCommitMessage(releases) console.log('Creating commit...') logAndExec(`git commit -m "${commitMessage}"`) console.log() // Success message (skip in CI since the workflow handles the rest) if (!process.env.CI) { console.log('═'.repeat(80)) console.log('✅ RELEASE PREPARED') console.log('═'.repeat(80)) console.log() console.log('Release commit has been created locally.') console.log() console.log('To publish, push and the publish workflow will handle the rest:') console.log() console.log(' git push') console.log() } } } main() ================================================ FILE: scripts/detect-changed-packages.ts ================================================ import * as cp from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' type PackageInfo = { dirName: string name: string dependencies: string[] } type CliOptions = { baseRef: string headRef: string listOnly: boolean } function main() { let options = parseArgs(process.argv.slice(2)) let packageInfos = getPackageInfos() let changedPackages = getChangedPackageNames(options.baseRef, options.headRef, packageInfos) let selectedPackages = getSelectedPackageNames(changedPackages, packageInfos) if (options.listOnly) { console.log(JSON.stringify(selectedPackages, null, 2)) return } if (selectedPackages.length === 0) { console.log( `No package changes detected between ${options.baseRef} and ${options.headRef}. Skipping package tests.`, ) return } console.log( `Running tests for packages changed between ${options.baseRef} and ${options.headRef}:`, ) for (let packageName of selectedPackages) { console.log(`- ${packageName}`) } console.log() let args = selectedPackages.flatMap((packageName) => ['--filter', packageName]) args.push('run', 'test') let result = cp.spawnSync('pnpm', args, { stdio: 'inherit', shell: process.platform === 'win32', }) if (result.status !== 0) { process.exit(result.status ?? 1) } } function parseArgs(args: string[]): CliOptions { let baseRef = '' let headRef = 'HEAD' let listOnly = false for (let arg of args) { if (arg === '--list') { listOnly = true continue } if (baseRef === '') { baseRef = arg continue } if (headRef === 'HEAD') { headRef = arg continue } throw new Error(`Unexpected argument: ${arg}`) } if (baseRef === '') { throw new Error( 'Usage: node ./scripts/detect-changed-packages.ts [head-ref] [--list]', ) } return { baseRef, headRef, listOnly } } function getPackageInfos(): PackageInfo[] { let packagesDir = path.join(process.cwd(), 'packages') let infos: PackageInfo[] = [] for (let dirName of fs.readdirSync(packagesDir)) { let packageJsonPath = path.join(packagesDir, dirName, 'package.json') if (!fs.existsSync(packageJsonPath)) { continue } let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { name?: string dependencies?: Record devDependencies?: Record optionalDependencies?: Record peerDependencies?: Record } if (typeof packageJson.name !== 'string') { continue } let dependencyNames = new Set() for (let field of [ packageJson.dependencies, packageJson.devDependencies, packageJson.optionalDependencies, packageJson.peerDependencies, ]) { for (let dependencyName of Object.keys(field ?? {})) { dependencyNames.add(dependencyName) } } infos.push({ dirName, name: packageJson.name, dependencies: [...dependencyNames], }) } return infos } function getChangedPackageNames( baseRef: string, headRef: string, packageInfos: PackageInfo[], ): Set { let diffOutput = cp.execFileSync( 'git', ['diff', '--name-only', `${baseRef}...${headRef}`, '--', 'packages/*'], { encoding: 'utf8' }, ) let dirNameToPackageName = new Map(packageInfos.map((info) => [info.dirName, info.name])) let changedPackages = new Set() for (let file of diffOutput.split('\n')) { if (!file.startsWith('packages/')) { continue } let [, dirName] = file.split('/', 3) let packageName = dirNameToPackageName.get(dirName) if (packageName != null) { changedPackages.add(packageName) } } return changedPackages } function getSelectedPackageNames( changedPackages: Set, packageInfos: PackageInfo[], ): string[] { if (changedPackages.size === 0) { return [] } let knownPackageNames = new Set(packageInfos.map((info) => info.name)) let reverseDependencies = new Map>() for (let info of packageInfos) { reverseDependencies.set(info.name, new Set()) } for (let info of packageInfos) { for (let dependencyName of info.dependencies) { if (!knownPackageNames.has(dependencyName)) { continue } reverseDependencies.get(dependencyName)?.add(info.name) } } let selectedPackages = new Set(changedPackages) let queue = [...changedPackages] while (queue.length > 0) { let packageName = queue.shift() if (packageName == null) { continue } for (let dependentName of reverseDependencies.get(packageName) ?? []) { if (selectedPackages.has(dependentName)) { continue } selectedPackages.add(dependentName) queue.push(dependentName) } } return packageInfos .map((info) => info.name) .filter((packageName) => selectedPackages.has(packageName)) } main() ================================================ FILE: scripts/generate-remix.ts ================================================ /** * Auto-generates the remix umbrella package by: * 1. Scanning all @remix-run/* packages in packages/ directory * 2. Creating source files that re-export from each package and sub-export * 3. Generating exports configuration in package.json * 4. Setting up dependencies for all referenced packages * * Run: node docs/generate-remix.ts */ import fs from 'node:fs/promises' import path from 'node:path' import url from 'node:url' import * as semver from 'semver' import { logAndExec } from './utils/process.ts' let __dirname = path.dirname(url.fileURLToPath(import.meta.url)) let packagesDir = path.resolve(__dirname, '../packages') let remixDir = path.join(packagesDir, 'remix') let remixChangesDir = path.join(remixDir, '.changes') let remixPackageJsonPath = path.join(remixDir, 'package.json') const SOURCE_FOLDER = 'src' type RemixRunPackage = { name: string version: string exports: ExportEntry[] } type ExportEntry = { // The source file path relative to src: `headers.ts`, `headers/cookie-storage.ts` sourceFile: string // The export path in package.json exports: `./headers`, `./headers/cookie-storage`1 exportPath: string // The package/sub-export to re-export from: `@remix-run/headers`, `@remix-run/headers/cookie-storage` reExportFrom: string } let { remixRunPackages, allExports } = await getRemixRunPackages() let remixPackageJson = JSON.parse(await fs.readFile(remixPackageJsonPath, 'utf-8')) // Track existing exports for comparison let existingExports = new Set( Object.keys(remixPackageJson.exports || {}).filter( (key) => key !== '.' && key !== './package.json', ), ) // Update remixPackageJson in place and output to disk await updateRemixPackage() // Generate change files await outputExportsChangeFiles(remixPackageJson.exports) // Implementations async function getRemixRunPackages() { console.log('Scanning packages...') // Get all packages except remix itself let packageDirNames = (await fs.readdir(packagesDir, { withFileTypes: true })) .filter((dirent) => dirent.isDirectory() && dirent.name !== 'remix') .map((dirent) => dirent.name) let remixRunPackages: RemixRunPackage[] = [] // Scan each package for its exports for (let packageDirName of packageDirNames) { let packageJsonPath = path.join(packagesDir, packageDirName, 'package.json') let packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) let packageName = packageJson.name as string // Skip if not a @remix-run package if (!packageName.startsWith('@remix-run/')) { continue } let remixRunPackage: RemixRunPackage = { name: packageName, version: packageJson.version, exports: [], } remixRunPackages.push(remixRunPackage) let shortName = packageName.replace('@remix-run/', '') // Get all exports except package.json let packageExports = packageJson.exports if (packageExports && typeof packageExports === 'object') { for (let [exportPath, _] of Object.entries(packageExports)) { if (exportPath === './package.json') continue if (exportPath === '.') { // Main export remixRunPackage.exports.push({ sourceFile: `${shortName}.ts`, exportPath: `./${shortName}`, reExportFrom: packageName, }) } else { // Sub-export (e.g., "./cookie-storage") let subExport = exportPath.replace('./', '') remixRunPackage.exports.push({ sourceFile: `${shortName}/${subExport}.ts`, exportPath: `./${shortName}/${subExport}`, reExportFrom: `${packageName}/${subExport}`, }) } } } } // Sort exports by export path for consistent ordering let allExports = remixRunPackages.flatMap((pkg) => pkg.exports) allExports.sort((a, b) => a.exportPath.localeCompare(b.exportPath)) console.log( `Found ${remixRunPackages.length} @remix-run packages with a total of ${allExports.length} exports.`, ) return { remixRunPackages, allExports } } async function updateRemixPackage() { // Ensure we have a passing linter before generating code logAndExec(`npx eslint packages/remix/ --max-warnings=0`) // Clear existing source files let sourceFolderPath = path.join(remixDir, SOURCE_FOLDER) await fs.rm(sourceFolderPath, { recursive: true, force: true }) await fs.mkdir(sourceFolderPath, { recursive: true }) // Generate fresh source files console.log('Generating Remix source files...') for (let entry of allExports) { let sourceFilePath = path.join(remixDir, SOURCE_FOLDER, entry.sourceFile) // Create subdirectory if needed let sourceFileDir = path.dirname(sourceFilePath) await fs.mkdir(sourceFileDir, { recursive: true }) let content = [ `// IMPORTANT: This file is auto-generated, please do not edit manually.`, `export * from '${entry.reExportFrom}'\n`, ].join('\n') await fs.writeFile(sourceFilePath, content, 'utf-8') } // Run linter against generated code with --fix logAndExec(`npx eslint packages/remix/ --max-warnings=0 --fix`) // Update package.json console.log('Updating Remix package.json...') remixPackageJson.exports = {} remixPackageJson.publishConfig.exports = {} for (let entry of allExports) { let exportPath = path.join(SOURCE_FOLDER, entry.sourceFile) remixPackageJson.exports[entry.exportPath] = `./${exportPath}` let distFile = path.join(entry.sourceFile.replace(/\.ts$/, '')) remixPackageJson.publishConfig.exports[entry.exportPath] = { types: `./dist/${distFile}.d.ts`, default: `./dist/${distFile}.js`, } } remixPackageJson.exports['./package.json'] = './package.json' remixPackageJson.publishConfig.exports['./package.json'] = './package.json' for (let packageInfo of remixRunPackages) { remixPackageJson.dependencies[packageInfo.name] = 'workspace:^' } await fs.writeFile( remixPackageJsonPath, JSON.stringify(remixPackageJson, null, 2) + '\n', 'utf-8', ) } // Build exports change summary async function outputExportsChangeFiles(exportsConfig: Record) { let newExportsSet = new Set( Object.keys(exportsConfig).filter((key) => key !== '.' && key !== './package.json'), ) let addedExports = Array.from(newExportsSet).filter((key) => !existingExports.has(key)) let removedExports = Array.from(existingExports).filter((key) => !newExportsSet.has(key)) if (addedExports.length === 0 && removedExports.length === 0) { return } let semverType = removedExports.length > 0 ? 'major' : 'minor' let changeFileBaseName = 'remix.update-exports.md' let changeFile = path.join(remixChangesDir, `${semverType}.${changeFileBaseName}`) let alternateSemverType = semverType === 'major' ? 'minor' : 'major' let alternateChangeFile = path.join( remixChangesDir, `${alternateSemverType}.${changeFileBaseName}`, ) let legacyChangeFilePattern = /^(major|minor)\.remix\.update-exports-\d+\.md$/ let changes = '' // Remove any old timestamped exports change files from prior runs. for (let fileName of await fs.readdir(remixChangesDir)) { if (!legacyChangeFilePattern.test(fileName)) { continue } await fs.unlink(path.join(remixChangesDir, fileName)) } // Remove the alternate semver deterministic file if present so we only keep one. try { await fs.unlink(alternateChangeFile) } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { throw e } } if (removedExports.length > 0) { console.log() console.log('Removed package.json exports:') changes += 'Removed `package.json` `exports`:\n' for (let exportPath of removedExports) { exportPath = exportPath.replace('./', '') let exportName = `remix/${exportPath}` console.log(` - ${exportName}`) changes += ` - \`${exportName}\`\n` // Remove re-export file let srcFile = path.join(remixDir, SOURCE_FOLDER, exportPath + '.ts') try { await fs.unlink(srcFile) } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { throw e } } } } if (addedExports.length > 0) { console.log() console.log('Added package.json exports:') changes += 'Added `package.json` `exports`:\n' for (let exportPath of addedExports) { let entry = allExports.find((e) => e.exportPath === exportPath) exportPath = `remix/${exportPath.replace('./', '')}` if (entry) { console.log(` - ${exportPath} → ${entry.reExportFrom}`) changes += ` - \`${exportPath}\` to re-export APIs from \`${entry.reExportFrom}\`\n` } } } await fs.writeFile(changeFile, changes, 'utf-8') console.log() console.log('Created exports change file:') console.log(` - ${path.relative(process.cwd(), changeFile)}`) } ================================================ FILE: scripts/package.json ================================================ { "name": "remix-the-scripts", "private": true, "type": "module", "dependencies": { "@octokit/request": "^9.1.3", "@types/node": "catalog:", "@types/semver": "^7.5.8", "semver": "^7.6.3" }, "scripts": { "test": "node --test", "typecheck": "tsgo --noEmit" } } ================================================ FILE: scripts/pr-preview.ts ================================================ /** * PR Preview Script * * This script manages preview builds for pull requests by: * - Creating comments on PRs with installation instructions for preview builds * - Cleaning up preview branches when PRs are merged or closed * * Commands: * - `comment `: Adds a comment to the specified PR with instructions * to install the preview build. Updates any existing preview comments. * - `cleanup `: Deletes the preview branch from the remote repository * and adds a cleanup notification comment to the PR. * * Usage: `node pr-preview.ts ` */ import { parseArgs } from 'node:util' import { createPrComment, deletePrComment, getPrComments, updatePrComment } from './utils/github.ts' import { logAndExec } from './utils/process.ts' const STICKY_MARKER = '' const CLEANUP_MARKER = '' let { positionals } = parseArgs({ allowPositionals: true, strict: true, }) if (positionals.length !== 3) { printUsage() process.exit(1) } let [command, prNumberString, branch] = positionals let prNumber = parseInt(prNumberString, 10) if (isNaN(prNumber) || prNumber <= 0) { printUsage() throw new Error(`Invalid PR number: ${prNumberString}`) } let commands: Record Promise> = { comment, cleanup, } if (commands[command]) { await commands[command]() } else { printUsage() throw new Error(`Unknown command: ${command}`) } function printUsage() { console.error('Usage: node pr-preview.ts ') console.error(' comment - Add preview comment to PR') console.error(' cleanup - Delete branch from origin') } async function comment() { let commentBody = `\ ${STICKY_MARKER} ### Preview Build Available A preview build has been created for this PR. You can install it using: \`\`\`sh pnpm install "remix-run/remix#${branch}&path:packages/remix" \`\`\` This preview build will be updated automatically as you push new commits.` // Get existing comments let comments = await getPrComments(prNumber) // Only add a comment if one doesn't already exist let stickyComment = comments.find((comment) => comment.body?.includes(STICKY_MARKER)) if (stickyComment) { console.log('Updating preview comment on PR') await updatePrComment(stickyComment.id, commentBody) } else { console.log('Adding preview comment to PR') await createPrComment(prNumber, commentBody) } // Delete cleanup comment if it exists let cleanupComment = comments.find((comment) => comment.body?.includes(CLEANUP_MARKER)) if (cleanupComment) { console.log('Deleting existing cleanup comment') await deletePrComment(cleanupComment.id) } } async function cleanup() { console.log(`Deleted branch: ${branch}`) await logAndExec(`git push --delete origin ${branch}`) let commentBody = `\ ${CLEANUP_MARKER} The preview branch \`${branch}\` has been deleted now that this PR is merged/closed.` console.log('Adding cleanup comment to PR') await createPrComment(prNumber, commentBody) } ================================================ FILE: scripts/publish.ts ================================================ /** * Publishes packages to npm and creates tags/releases for what was published. * * This script uses pnpm publish with --report-summary, reads the summary file, * and creates Git tags + GitHub releases. When the remix package is in prerelease * mode (has .changes/config.json with prereleaseChannel), it publishes in two phases: * all other packages as "latest", then remix with the "next" tag. * * This script is designed for CI use. For previewing releases, use `pnpm changes:preview`. * * Usage: * node scripts/publish.ts [--skip-ci-check] [--dry-run] * * Options: * --skip-ci-check Bypass the CI environment check * --dry-run Show what would be published without actually publishing. * Queries npm to determine unpublished packages and previews * what the GitHub releases would look like. */ import * as cp from 'node:child_process' import * as fs from 'node:fs' import * as path from 'node:path' import { findVersionIntroductionCommit, getLocalTagTarget, getRemoteTagTarget, tagExists, } from './utils/git.ts' import { createRelease, releaseExists } from './utils/github.ts' import { getRootDir, logAndExec } from './utils/process.ts' import { readChangesConfig, getChangelogEntry } from './utils/changes.ts' import { getAllPackageDirNames, getPackageFile, getGitTag, packageNameToDirectoryName, getPackageShortName, } from './utils/packages.ts' import { readJson, fileExists } from './utils/fs.ts' let rootDir = getRootDir() let args = process.argv.slice(2) let skipCiCheck = args.includes('--skip-ci-check') let dryRun = args.includes('--dry-run') interface PublishedPackage { packageName: string version: string tag: string } interface PublishSummary { publishedPackages: Array<{ name: string version: string }> } interface LocalPackage { dirName: string npmName: string localVersion: string } interface TagPlan { pkg: PublishedPackage targetCommit: string } /** * Read published packages from pnpm's publish summary file. * See https://pnpm.io/cli/publish#--report-summary */ function readPublishSummary(): PublishedPackage[] { let summaryPath = path.join(rootDir, 'pnpm-publish-summary.json') if (!fs.existsSync(summaryPath)) { throw new Error( `pnpm-publish-summary.json not found. This is unexpected after a successful publish.`, ) } let summary: PublishSummary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8')) return summary.publishedPackages.map((pkg) => ({ packageName: pkg.name, version: pkg.version, tag: getGitTag(pkg.name, pkg.version), })) } /** * Get local package metadata from the workspace. */ function getLocalPackages(): LocalPackage[] { let packageDirNames = getAllPackageDirNames() let localPackages: LocalPackage[] = [] for (let packageDirName of packageDirNames) { let packageJsonPath = getPackageFile(packageDirName, 'package.json') // Skip directories without a package.json if (!fileExists(packageJsonPath)) { continue } let packageJson = readJson(packageJsonPath) localPackages.push({ dirName: packageDirName, npmName: packageJson.name as string, localVersion: packageJson.version as string, }) } return localPackages } /** * Check if a specific version of a package is published on npm. */ async function isVersionPublished(packageName: string, version: string): Promise { return new Promise((resolve) => { cp.exec( `npm view ${packageName}@${version} version`, { encoding: 'utf-8' }, (_error, stdout) => { // If we get output that matches the version, it exists resolve(stdout.trim() === version) }, ) }) } /** * Get all packages that have versions not yet published to npm. */ async function getUnpublishedPackages(): Promise { let localPackages = getLocalPackages() // Query npm for all packages in parallel let npmResults = await Promise.all( localPackages.map(async (pkg) => ({ pkg, isPublished: await isVersionPublished(pkg.npmName, pkg.localVersion), })), ) // Filter to unpublished packages let unpublished: PublishedPackage[] = [] for (let { pkg, isPublished } of npmResults) { if (!isPublished) { unpublished.push({ packageName: pkg.npmName, version: pkg.localVersion, tag: getGitTag(pkg.npmName, pkg.localVersion), }) } } return unpublished } /** * Find package versions that are already published to npm but missing git tags. * This enables release recovery after partial publish failures. */ async function getPublishedPackagesMissingTags(): Promise { let localPackages = getLocalPackages() let npmResults = await Promise.all( localPackages.map(async (pkg) => ({ pkg, isPublished: await isVersionPublished(pkg.npmName, pkg.localVersion), })), ) let missingTags: PublishedPackage[] = [] for (let { pkg, isPublished } of npmResults) { if (!isPublished) { continue } let tag = getGitTag(pkg.npmName, pkg.localVersion) if (!tagExists(tag)) { missingTags.push({ packageName: pkg.npmName, version: pkg.localVersion, tag, }) } } return missingTags } /** * Find package versions that are already published and tagged but missing GitHub releases. * This enables release recovery after tags were pushed successfully. */ async function getPublishedPackagesMissingReleases(): Promise { let localPackages = getLocalPackages() let npmResults = await Promise.all( localPackages.map(async (pkg) => ({ pkg, isPublished: await isVersionPublished(pkg.npmName, pkg.localVersion), })), ) let missingReleases: PublishedPackage[] = [] for (let { pkg, isPublished } of npmResults) { if (!isPublished) { continue } let tag = getGitTag(pkg.npmName, pkg.localVersion) if (!tagExists(tag)) { continue } if (!(await releaseExists(tag))) { missingReleases.push({ packageName: pkg.npmName, version: pkg.localVersion, tag, }) } } return missingReleases } function dedupePublishedPackages(packages: PublishedPackage[]): PublishedPackage[] { let seenTags = new Set() let deduped: PublishedPackage[] = [] for (let pkg of packages) { if (seenTags.has(pkg.tag)) { continue } seenTags.add(pkg.tag) deduped.push(pkg) } return deduped } function isShallowRepository(): boolean { try { let output = cp.execFileSync('git', ['rev-parse', '--is-shallow-repository'], { stdio: 'pipe', encoding: 'utf-8', }) return output.trim() === 'true' } catch { return false } } function ensureGitHistoryForVersionLookup() { if (isShallowRepository()) { console.log('\nRepository is shallow, fetching full history for release tag anchoring...') logAndExec('git fetch --unshallow --tags origin') return } console.log('\nFetching tags from origin...') logAndExec('git fetch --tags origin') } function resolveTagPlans(packages: PublishedPackage[]): TagPlan[] { let plans: TagPlan[] = [] for (let pkg of packages) { let packageDirName = packageNameToDirectoryName(pkg.packageName) if (packageDirName === null) { throw new Error( `Could not map package "${pkg.packageName}" to a workspace directory for tag anchoring.`, ) } let packageJsonPath = path .relative(rootDir, getPackageFile(packageDirName, 'package.json')) .replaceAll('\\', '/') let targetCommit = findVersionIntroductionCommit(packageJsonPath, pkg.version) if (targetCommit === null) { throw new Error( `Could not find commit that introduced ${pkg.packageName}@${pkg.version} from ${packageJsonPath}. Ensure full git history is available.`, ) } plans.push({ pkg, targetCommit }) } return plans } function verifyTagTargets(tagPlans: TagPlan[]) { let mismatches: Array<{ tag: string scope: 'local' | 'remote' actual: string expected: string }> = [] for (let plan of tagPlans) { let localTarget = getLocalTagTarget(plan.pkg.tag) if (localTarget !== null && localTarget !== plan.targetCommit) { mismatches.push({ tag: plan.pkg.tag, scope: 'local', actual: localTarget, expected: plan.targetCommit, }) } let remoteTarget = getRemoteTagTarget(plan.pkg.tag) if (remoteTarget !== null && remoteTarget !== plan.targetCommit) { mismatches.push({ tag: plan.pkg.tag, scope: 'remote', actual: remoteTarget, expected: plan.targetCommit, }) } } if (mismatches.length > 0) { let lines = ['Detected existing tags pointing at unexpected commits:'] for (let mismatch of mismatches) { lines.push( ` • ${mismatch.tag} (${mismatch.scope}) expected ${mismatch.expected.slice(0, 12)} but found ${mismatch.actual.slice(0, 12)}`, ) } lines.push('Refusing to continue to avoid creating inconsistent release metadata.') throw new Error(lines.join('\n')) } } interface ChangelogWarning { packageName: string version: string } /** * Preview GitHub releases for packages that would be published. * Returns warnings for packages with missing changelog entries. */ function previewGitHubReleases(packages: PublishedPackage[]): { warnings: ChangelogWarning[] } { let warnings: ChangelogWarning[] = [] console.log('GitHub Release Preview') console.log('═'.repeat(60)) console.log() for (let pkg of packages) { let tagName = getGitTag(pkg.packageName, pkg.version) let releaseName = `${getPackageShortName(pkg.packageName)} v${pkg.version}` let changes = getChangelogEntry({ packageName: pkg.packageName, version: pkg.version }) let body = changes?.body ?? 'No changelog entry found for this version.' if (changes === null) { warnings.push({ packageName: pkg.packageName, version: pkg.version }) } console.log(`📦 ${releaseName}`) console.log(` Tag: ${tagName}`) console.log() console.log(' Release notes:') console.log() for (let line of body.split('\n')) { console.log(` ${line}`) } console.log() console.log('─'.repeat(60)) console.log() } return { warnings } } async function main() { // Safety check: this script should only run in CI when not in dry run mode if (!process.env.CI && !skipCiCheck && !dryRun) { console.error('The publish script is designed for CI use only.') console.error('Use --skip-ci-check to bypass this check for local use.') console.error('Use --dry-run to preview the publish process.') console.error('\nFor previewing releases, use: pnpm changes:preview') process.exit(1) } if (dryRun) { console.log('🔍 DRY RUN MODE - No packages will be published\n') } // Check if remix is in prerelease mode let remixChangesConfig = readChangesConfig('remix') let remixPrereleaseChannel: string | null = null if (remixChangesConfig.exists) { if (!remixChangesConfig.valid) { console.error('Error reading remix changes config:', remixChangesConfig.error) process.exit(1) } remixPrereleaseChannel = remixChangesConfig.config.prereleaseChannel || null if (remixPrereleaseChannel) { console.log(`Remix is in prerelease mode (channel: ${remixPrereleaseChannel})`) console.log('Publishing in two phases: other packages as "latest", then remix as "next"\n') } } // Publish packages to npm console.log('Publishing packages to npm...\n') let published: PublishedPackage[] = [] if (remixPrereleaseChannel) { let publishCommands = [ // Phase 1: Publish everything in `packages` except remix (with --report-summary so we know what was published) 'pnpm publish --recursive --filter "./packages/*" --filter "!remix" --access public --no-git-checks --report-summary', // Phase 2: Publish remix with "next" tag (with --report-summary so we know if remix was published) 'pnpm publish --filter remix --tag next --access public --no-git-checks --report-summary', ] if (dryRun) { console.log('Would run:') for (let publishCommand of publishCommands) { console.log(` $ ${publishCommand}`) } console.log() } else { for (let publishCommand of publishCommands) { logAndExec(publishCommand) published.push(...readPublishSummary()) } } } else { // Single-phase publish: everything as latest let publishCommand = 'pnpm publish --recursive --filter "./packages/*" --access public --no-git-checks --report-summary' if (dryRun) { console.log('Would run:') console.log(` $ ${publishCommand}`) console.log() } else { logAndExec(publishCommand) published.push(...readPublishSummary()) } } // In dry run mode, query npm to determine what would be published // and preview the GitHub releases. This is designed to be run against // the contents of the "Release" PR / `pnpm changes:version` output. if (dryRun) { console.log('Checking npm for unpublished versions...\n') let unpublished = await getUnpublishedPackages() if (unpublished.length === 0) { console.log('All package versions are already published to npm.') console.log('\n🔍 Dry run complete.') return } console.log( `${unpublished.length} package${unpublished.length === 1 ? '' : 's'} would be published:\n`, ) for (let pkg of unpublished) { console.log(` • ${pkg.packageName}@${pkg.version}`) } console.log() let { warnings } = previewGitHubReleases(unpublished) if (warnings.length > 0) { console.log('⚠️ WARNINGS') console.log('═'.repeat(60)) console.log() console.log('The following packages have no changelog entry for their version:') console.log() for (let warning of warnings) { console.log(` • ${warning.packageName} v${warning.version}`) } console.log() console.log('Their GitHub releases will show "No changelog entry found for this version."') console.log('This may indicate a missing or malformed CHANGELOG.md entry.') console.log() } console.log( '🔍 Dry run complete. No packages published, no git tags or GitHub releases created.', ) return } if (published.length > 0) { console.log(`\n${published.length} package${published.length === 1 ? '' : 's'} published:`) for (let pkg of published) { console.log(` • ${pkg.packageName}@${pkg.version}`) } } else { console.log('\nNo new packages were published.') } let packagesNeedingTagsOrReleases = dedupePublishedPackages([ ...published, ...(await getPublishedPackagesMissingTags()), ...(await getPublishedPackagesMissingReleases()), ]) if (packagesNeedingTagsOrReleases.length === 0) { console.log('\nNo packages need git tags or GitHub releases.') return } ensureGitHistoryForVersionLookup() console.log('\nResolving release tag targets...') let tagPlans = resolveTagPlans(packagesNeedingTagsOrReleases) for (let plan of tagPlans) { console.log(` • ${plan.pkg.tag} -> ${plan.targetCommit.slice(0, 12)}`) } verifyTagTargets(tagPlans) // Configure git console.log('\nConfiguring git...') logAndExec('git config user.name "Remix Run Bot"') logAndExec('git config user.email "hello@remix.run"') // Create tags (skip if already exist) console.log(`\nCreating tag${tagPlans.length === 1 ? '' : 's'} for published packages...`) let createdTags: TagPlan[] = [] for (let plan of tagPlans) { let localTarget = getLocalTagTarget(plan.pkg.tag) let remoteTarget = getRemoteTagTarget(plan.pkg.tag) if (localTarget !== null || remoteTarget !== null) { let existingTarget = localTarget ?? remoteTarget console.log(` ⊘ ${plan.pkg.tag} (already exists at ${existingTarget?.slice(0, 12)})`) continue } cp.execFileSync('git', ['tag', plan.pkg.tag, plan.targetCommit], { stdio: 'pipe' }) console.log(` ✓ ${plan.pkg.tag} -> ${plan.targetCommit.slice(0, 12)}`) createdTags.push(plan) } // Push tags if any were created if (createdTags.length > 0) { console.log(`\nPushing tag${createdTags.length === 1 ? '' : 's'}...`) for (let plan of createdTags) { let ref = `refs/tags/${plan.pkg.tag}` process.stdout.write(` $ git push origin ${ref}\n`) try { cp.execFileSync('git', ['push', 'origin', ref], { stdio: 'inherit' }) } catch { let remoteTarget = getRemoteTagTarget(plan.pkg.tag) if (remoteTarget === plan.targetCommit) { console.log( ` ⊘ ${plan.pkg.tag} (already exists remotely at ${plan.targetCommit.slice(0, 12)})`, ) continue } throw new Error( `Failed to push ${plan.pkg.tag}, and remote target does not match expected commit ${plan.targetCommit.slice(0, 12)}.`, ) } } } else { console.log('\nNo new tags to push.') } // Create GitHub releases (skip if already exists) console.log('\nCreating GitHub releases...') let failedReleases: Array<{ pkg: PublishedPackage; error: string }> = [] for (let pkg of packagesNeedingTagsOrReleases) { let result = await createRelease(pkg.packageName, pkg.version) if (result.status === 'created') { console.log(` ✓ ${pkg.packageName} v${pkg.version}`) } else if (result.status === 'skipped') { console.log(` ⊘ ${pkg.packageName} v${pkg.version} (${result.reason.toLowerCase()})`) } else { console.log(` ✗ ${pkg.packageName} v${pkg.version} (failed)`) failedReleases.push({ pkg, error: result.error }) } } // Report any failures if (failedReleases.length > 0) { console.error('\n⚠️ Some GitHub releases failed to create:') for (let { pkg, error } of failedReleases) { console.error(` • ${pkg.packageName} v${pkg.version}: ${error}`) } console.error('\nYou may need to create these releases manually.') process.exit(1) } console.log('\n✅ Done.') } main() ================================================ FILE: scripts/release-pr.ts ================================================ /** * Opens or updates the release PR. * * Usage: * node scripts/release-pr.ts [--preview] * * Environment: * GITHUB_TOKEN - Required (unless --preview) */ import { parseAllChangeFiles, generateCommitMessage } from './utils/changes.ts' import { generatePrBody } from './utils/release-pr.ts' import { logAndExec } from './utils/process.ts' import { findOpenPr, createPr, updatePr, closePr } from './utils/github.ts' let args = process.argv.slice(2) let preview = args.includes('--preview') let baseBranch = 'main' let prBranch = 'release-pr/main' let prTitle = 'Release' async function main() { console.log(preview ? '🔍 PREVIEW MODE\n' : '') // Parse and validate changes console.log('Validating change files...') let result = parseAllChangeFiles() if (!result.valid) { console.error('Validation errors found:') for (let error of result.errors) { console.error(` ${error.packageDirName}/${error.file}: ${error.error}`) } process.exit(1) } let { releases } = result if (releases.length === 0) { console.log('No pending changes to release.') // Check if there's a stale PR that should be closed if (!preview && process.env.GITHUB_TOKEN) { let existingPr = await findOpenPr(prBranch, baseBranch) if (existingPr) { console.log(`\nClosing stale PR #${existingPr.number}...`) await closePr( existingPr.number, 'Closing automatically — all change files have been removed or released.', ) console.log(`✅ Closed PR: ${existingPr.html_url}`) } } process.exit(0) } console.log(`\nFound ${releases.length} package${releases.length === 1 ? '' : 's'} with changes:`) for (let release of releases) { console.log(` • ${release.packageName}: ${release.currentVersion} → ${release.nextVersion}`) } console.log() // Generate content let commitMessage = generateCommitMessage(releases) let prBody = generatePrBody(releases) if (preview) { console.log('Would create/update PR with:') console.log(` Branch: ${prBranch}`) console.log(` Title: ${prTitle}`) console.log(` Commit: ${commitMessage.split('\n')[0]}`) console.log('\nPR Body:') console.log('─'.repeat(60)) console.log(prBody) console.log('─'.repeat(60)) console.log('\nPreview complete. No changes made.') process.exit(0) } // Require token for non-preview if (!process.env.GITHUB_TOKEN) { console.error('GITHUB_TOKEN environment variable is required') process.exit(1) } // Configure git console.log('Configuring git...') logAndExec('git config user.name "Remix Run Bot"') logAndExec('git config user.email "hello@remix.run"') // Create or switch to PR branch console.log(`\nSwitching to branch: ${prBranch}`) logAndExec(`git checkout -B ${prBranch}`) // Reset to base branch logAndExec(`git reset --hard origin/${baseBranch}`) // Run version command console.log('\nRunning pnpm changes:version...') logAndExec('pnpm changes:version') console.log('\nPushing branch...') logAndExec(`git push origin ${prBranch} --force`) // Create or update PR console.log('\nChecking for existing PR...') let existingPr = await findOpenPr(prBranch, baseBranch) if (existingPr) { console.log(`Updating existing PR #${existingPr.number}...`) await updatePr(existingPr.number, { title: prTitle, body: prBody }) console.log(`\n✅ Updated PR: ${existingPr.html_url}`) } else { console.log('Creating new PR...') let newPr = await createPr({ title: prTitle, body: prBody, head: prBranch, base: baseBranch }) console.log(`\n✅ Created PR #${newPr.number}: ${newPr.html_url}`) } } main().catch((error) => { console.error('Error:', error.message) process.exit(1) }) ================================================ FILE: scripts/setup-installable-branch.ts ================================================ import * as fsp from 'node:fs/promises' import * as path from 'node:path' import * as util from 'node:util' import { logAndExec } from './utils/process.ts' /** * This script prepares a base branch (usually `main`) to be PNPM-installable * directly from GitHub via a new branch (usually `preview/main`): * * pnpm install "remix-run/remix#preview/main&path:packages/remix" * * To do this, we can run a build, make some minor changes to the repo, and * commit the build + changes to the new branch. These changes would never be * down-merged back to the source branch. * * This script does the following: * - Checks out the new branch and resets it to the base (current) branch * - Runs a build * - Removes `dist/` from `.gitignore` * - Updates all internal `@remix-run/*` deps to use the github format for the * given installable branch * - Copies all `publishConfig`'s down so we get `exports` from `dist/` instead of `src/` * - Commits the changes * * Then, after pushing, `pnpm install "remix-run/remix#preview/main&path:packages/remix"` * sees the `remix` nested deps and they all point to github with similar URLs so * they install as nested deps the same way. */ let { positionals } = util.parseArgs({ allowPositionals: true, }) // Use first positional argument or fall back to --branch flag or default let installableBranch = positionals[0] if (!installableBranch) { throw new Error('Error: You must provide an installable branch name') } // Error if git status is not clean let gitStatus = logAndExec('git status --porcelain', true) if (gitStatus) { throw new Error('Error: Git working directory is not clean. Commit or stash changes first.') } // Capture the current branch name let sha = logAndExec('git rev-parse --short HEAD ', true).trim() console.log(`Preparing installable branch \`${installableBranch}\` from sha ${sha}`) // Switch to new branch and reset to current commit on base branch logAndExec(`git checkout -B ${installableBranch}`) // Build dist/ folders logAndExec('pnpm build') await updateGitignore() await updatePackageDependencies() logAndExec('git add .') logAndExec(`git commit -a -m "installable build from ${sha}"`) console.log( [ '', `✅ Done!`, '', `You can now push the \`${installableBranch}\` branch to GitHub and install via:`, '', ` pnpm install "remix-run/remix#${installableBranch}&path:packages/remix"`, ].join('\n'), ) // Remove `dist` from gitignore so we include built code in the repo async function updateGitignore() { let gitignorePath = path.join(process.cwd(), '.gitignore') let content = await fsp.readFile(gitignorePath, 'utf-8') let filtered = content .split('\n') .filter((line) => !line.trim().startsWith('dist')) .join('\n') await fsp.writeFile(gitignorePath, filtered) console.log('Updated .gitignore') } // Update `package.json` files to point to this branch on github async function updatePackageDependencies() { let packagesDir = path.join(process.cwd(), 'packages') let packageDirNames = await fsp.readdir(packagesDir, { withFileTypes: true }) for (let dir of packageDirNames) { if (!dir.isDirectory()) continue let packageJsonPath = path.join(packagesDir, dir.name, 'package.json') let content = await fsp.readFile(packageJsonPath, 'utf-8') let pkg = JSON.parse(content) // Point all `@remix-run/` dependencies to this branch on github if (pkg.dependencies) { for (let name of Object.keys(pkg.dependencies)) { if (name.startsWith('@remix-run/')) { let packageDirName = name.replace('@remix-run/', '') pkg.dependencies[name] = `remix-run/remix#${installableBranch}&path:packages/${packageDirName}` } } } // Apply `publishConfig` overrides if (pkg.publishConfig) { Object.assign(pkg, pkg.publishConfig) delete pkg.publishConfig } await fsp.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n') console.log(`Updated ${dir.name}`) } console.log('Done') } function commitChanges() {} ================================================ FILE: scripts/tsconfig.json ================================================ { "compilerOptions": { "lib": ["ES2024"], "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "allowJs": true, "checkJs": true, "noEmit": true, "allowImportingTsExtensions": true }, "exclude": ["dist"] } ================================================ FILE: scripts/utils/changes.test.ts ================================================ import assert from 'node:assert/strict' import { test } from 'node:test' import type { PackageRelease } from './changes.ts' import { generateChangelogContent } from './changes.ts' function makeRelease(overrides: Partial = {}): PackageRelease { return { packageDirName: 'remix', packageName: 'remix', currentVersion: '3.0.0-alpha.3', nextVersion: '3.0.0-alpha.4', bump: 'patch', changes: [], dependencyBumps: [], ...overrides, } } test('generateChangelogContent groups prerelease changes into a single section', () => { let content = generateChangelogContent( makeRelease({ changes: [ { file: 'minor.alpha.md', bump: 'minor', content: 'Alpha change', }, { file: 'major.breaking.md', bump: 'major', content: 'BREAKING CHANGE: Breaking change', }, { file: 'patch.fix.md', bump: 'patch', content: 'Patch change', }, ], dependencyBumps: [ { packageName: '@remix-run/router', version: '1.2.3', releaseUrl: 'https://example.com/router', }, ], }), ) assert.match(content, /^## v3\.0\.0-alpha\.4/m) assert.match(content, /^### Pre-release Changes$/m) assert.doesNotMatch(content, /^### Major Changes$/m) assert.doesNotMatch(content, /^### Minor Changes$/m) assert.doesNotMatch(content, /^### Patch Changes$/m) assert.match(content, /- BREAKING CHANGE: Breaking change/) assert.match(content, /- Alpha change/) assert.match(content, /- Patch change/) assert.match(content, /- Bumped `@remix-run\/\*` dependencies:/) assert.match(content, /\[`router@1\.2\.3`\]\(https:\/\/example\.com\/router\)/) }) test('generateChangelogContent keeps stable releases grouped by bump type', () => { let content = generateChangelogContent( makeRelease({ currentVersion: '3.0.0', nextVersion: '3.0.1', changes: [ { file: 'minor.feature.md', bump: 'minor', content: 'Feature change', }, ], dependencyBumps: [ { packageName: '@remix-run/router', version: '1.2.3', releaseUrl: 'https://example.com/router', }, ], }), ) assert.match(content, /^## v3\.0\.1/m) assert.match(content, /^### Minor Changes$/m) assert.match(content, /^### Patch Changes$/m) assert.doesNotMatch(content, /^### Pre-release Changes$/m) }) ================================================ FILE: scripts/utils/changes.ts ================================================ import * as fs from 'node:fs' import * as path from 'node:path' import { getAllPackageDirNames, getPackageFile, getPackagePath, packageNameToDirectoryName, getTransitiveDependents, getGitHubReleaseUrl, getPackageDependencies, getGitTag, } from './packages.ts' import { fileExists, readFile, readJson } from './fs.ts' import { inc, major, prerelease, type ReleaseType } from './semver.ts' const bumpTypes = ['major', 'minor', 'patch'] as const type BumpType = (typeof bumpTypes)[number] // Changes configuration (from packages/remix/.changes/config.json) // Only the remix package supports changes config. export interface ChangesConfig { prereleaseChannel: string } export type ParsedChangesConfig = | { exists: false } | { exists: true; valid: true; config: ChangesConfig } | { exists: true; valid: false; error: string } /** * Reads and validates a package's .changes/config.json. */ export function readChangesConfig(packageDirName: string): ParsedChangesConfig { let packagePath = getPackagePath(packageDirName) let configJsonPath = path.join(packagePath, '.changes', 'config.json') if (!fs.existsSync(configJsonPath)) { return { exists: false } } let content: unknown try { content = JSON.parse(fs.readFileSync(configJsonPath, 'utf-8')) } catch { return { exists: true, valid: false, error: 'Invalid JSON in .changes/config.json' } } if (typeof content !== 'object' || content === null) { return { exists: true, valid: false, error: '.changes/config.json must be an object', } } let obj = content as Record if ('prereleaseChannel' in obj) { if (typeof obj.prereleaseChannel !== 'string' || obj.prereleaseChannel.trim().length === 0) { return { exists: true, valid: false, error: '.changes/config.json "prereleaseChannel" must be a non-empty string', } } return { exists: true, valid: true, config: { prereleaseChannel: obj.prereleaseChannel.trim() }, } } return { exists: true, valid: true, config: { prereleaseChannel: '' } } } /** * Extracts the prerelease identifier from a version string (e.g., "alpha" from "3.0.0-alpha.5") */ function getPrereleaseIdentifier(version: string): string | null { let parts = prerelease(version) if (parts === null || parts.length === 0) { return null } return typeof parts[0] === 'string' ? parts[0] : null } /** * Calculates the next version based on current version, bump type, and changes config. */ function getNextVersion( currentVersion: string, bumpType: BumpType, changesConfig: ChangesConfig | null, ): string { let currentPrereleaseId = getPrereleaseIdentifier(currentVersion) let isCurrentPrerelease = currentPrereleaseId !== null if (changesConfig !== null && changesConfig.prereleaseChannel) { // In prerelease mode let targetChannel = changesConfig.prereleaseChannel if (currentPrereleaseId === targetChannel) { // Same channel - just bump the counter let nextVersion = inc(currentVersion, 'prerelease', targetChannel) if (nextVersion == null) { throw new Error(`Invalid prerelease increment: ${currentVersion}`) } return nextVersion } else { // Entering prerelease or transitioning to a new channel (e.g., stable → alpha, or alpha → beta) // Apply the bump type to get the base version, then add prerelease suffix let baseVersion = isCurrentPrerelease ? currentVersion.replace(/-.*$/, '') // Strip existing prerelease suffix : inc(currentVersion, bumpType as ReleaseType) if (baseVersion == null) { throw new Error(`Invalid version increment: ${currentVersion} + ${bumpType}`) } return `${baseVersion}-${targetChannel}.0` } } else { // Not in prerelease mode if (isCurrentPrerelease) { // Graduating from prerelease to stable - strip the prerelease suffix let baseVersion = currentVersion.replace(/-.*$/, '') return baseVersion } else { // Normal stable release let nextVersion = inc(currentVersion, bumpType as ReleaseType) if (nextVersion == null) { throw new Error(`Invalid version increment: ${currentVersion} + ${bumpType}`) } return nextVersion } } } interface ChangeFile { file: string bump: BumpType content: string } interface ValidationError { packageDirName: string file: string error: string } type ParsedPackageChanges = | { valid: true; changes: ChangeFile[]; changesConfig: ChangesConfig | null } | { valid: false; errors: ValidationError[] } /** * Parses and validates all change files for a package. * Returns changes if valid, or errors if invalid. */ function parsePackageChanges(packageDirName: string): ParsedPackageChanges { let packagePath = getPackagePath(packageDirName) let changesDir = path.join(packagePath, '.changes') let changes: ChangeFile[] = [] let errors: ValidationError[] = [] // Changes directory should exist (with at least README.md) if (!fs.existsSync(changesDir)) { return { valid: false, errors: [ { packageDirName, file: '.changes/', error: 'Changes directory does not exist', }, ], } } // README.md should exist in .changes directory so it persists between releases let readmePath = path.join(changesDir, 'README.md') if (!fs.existsSync(readmePath)) { errors.push({ packageDirName, file: '.changes/README.md', error: 'README.md is missing from .changes directory', }) } // Get package version to determine validation rules let packageJsonPath = getPackageFile(packageDirName, 'package.json') let packageJson = readJson(packageJsonPath) let currentVersion = packageJson.version as string let majorVersion = major(currentVersion) let isV1Plus = majorVersion >= 1 let currentVersionPrereleaseId = getPrereleaseIdentifier(currentVersion) let isCurrentVersionPrerelease = currentVersionPrereleaseId !== null // Handle .changes/config.json - only supported for remix package let changesConfig: ChangesConfig | null = null let configJsonPath = path.join(changesDir, 'config.json') if (packageDirName === 'remix') { // For remix, read and validate the changes config let parsedChangesConfig = readChangesConfig(packageDirName) if (parsedChangesConfig.exists) { if (!parsedChangesConfig.valid) { errors.push({ packageDirName, file: '.changes/config.json', error: parsedChangesConfig.error, }) return { valid: false, errors } } changesConfig = parsedChangesConfig.config } } else { // For non-remix packages, error if config.json exists if (fs.existsSync(configJsonPath)) { errors.push({ packageDirName, file: '.changes/config.json', error: '.changes/config.json is only supported for the "remix" package. Remove this file.', }) return { valid: false, errors } } } // Read all files in .changes directory let files = fs.readdirSync(changesDir) let changeFileNames = files.filter((file) => file !== 'README.md' && file !== 'config.json') let hasChangeFiles = changeFileNames.filter((f) => f.endsWith('.md')).length > 0 // Validate changes config / version consistency let configPrereleaseChannel = changesConfig?.prereleaseChannel ?? null let isActivePrereleaseMode = Boolean(configPrereleaseChannel) && isCurrentVersionPrerelease if (configPrereleaseChannel) { // Config has prerelease channel if ( currentVersionPrereleaseId !== null && currentVersionPrereleaseId !== configPrereleaseChannel ) { // Channel mismatch (e.g., version is alpha but config says beta) - need change files to transition if (!hasChangeFiles) { errors.push({ packageDirName, file: '.changes/config.json', error: `prereleaseChannel '${configPrereleaseChannel}' doesn't match version's prerelease identifier '${currentVersionPrereleaseId}'. Add a change file to transition to ${configPrereleaseChannel}.`, }) } } else if (!isCurrentVersionPrerelease && !hasChangeFiles) { // Config says prerelease but version is stable AND no change files - need change files to enter prerelease errors.push({ packageDirName, file: '.changes/config.json', error: `prereleaseChannel exists but version ${currentVersion} is stable. Add a change file to enter prerelease mode, or remove prereleaseChannel if this package should not be in prerelease.`, }) } } else { // No prerelease channel - validate version is stable (unless graduating with change files) if (isCurrentVersionPrerelease && !hasChangeFiles) { errors.push({ packageDirName, file: '.changes/', error: `Version ${currentVersion} is a prerelease but no prereleaseChannel exists. Either add .changes/config.json with { "prereleaseChannel": "${currentVersionPrereleaseId}" }, or add a change file to graduate to stable.`, }) } } for (let file of changeFileNames) { // Skip non-.md files if (!file.endsWith('.md')) { continue } // Parse filename format when it follows bump naming (e.g. "minor.add-feature.md") let bump: BumpType | null = null let withoutExt = file.slice(0, -3) let dotIndex = withoutExt.indexOf('.') if (dotIndex !== -1) { let bumpStr = withoutExt.slice(0, dotIndex) let name = withoutExt.slice(dotIndex + 1) if (bumpTypes.includes(bumpStr as BumpType) && name.length > 0) { bump = bumpStr as BumpType } } if (bump == null && isActivePrereleaseMode) { // In prerelease mode, bump type does not affect versioning, so any filename is allowed. bump = 'patch' } if (bump == null) { errors.push({ packageDirName, file, error: 'Change file must be a ".md" file starting with "major.", "minor.", or "patch." (e.g. "minor.add-feature.md")', }) continue } // Read file content let filePath = path.join(changesDir, file) let content = fs.readFileSync(filePath, 'utf-8').trim() // Check if file is not empty if (content.length === 0) { errors.push({ packageDirName, file, error: 'Change file cannot be empty', }) continue } // Check if first line starts with a bullet point let firstLine = content.split('\n')[0].trim() if (firstLine.startsWith('- ') || firstLine.startsWith('* ')) { errors.push({ packageDirName, file, error: 'Change file should not start with a bullet point (- or *). The bullet will be added automatically in the CHANGELOG. Just write the text directly.', }) continue } // Check for headings that aren't level 4, 5, or 6 let invalidHeadingMatch = content.match(/^(#{1,3}|#{7,})\s+/m) if (invalidHeadingMatch) { let headingLevel = invalidHeadingMatch[1].length errors.push({ packageDirName, file, error: `Headings in change files must be level 4 (####), 5 (#####), or 6 (######), but found level ${headingLevel}. This is because change files are nested within the changelog which already uses heading levels 1-3.`, }) continue } // Validate breaking change prefix matches the correct bump type (only for stable releases) // In prerelease mode, breaking changes don't need special handling since we're just bumping counter if (!configPrereleaseChannel) { let isBreakingChange = hasBreakingChangePrefix(content) if (isBreakingChange) { if (isV1Plus && bump !== 'major') { errors.push({ packageDirName, file, error: `Breaking changes in v1+ packages must use "major." prefix (current version: ${currentVersion}). Rename to "major.${file.slice(file.indexOf('.') + 1)}"`, }) continue } else if (!isV1Plus && !isCurrentVersionPrerelease && bump !== 'minor') { errors.push({ packageDirName, file, error: `Breaking changes in v0.x packages must use "minor." prefix (current version: ${currentVersion}). Rename to "minor.${file.slice(file.indexOf('.') + 1)}"`, }) continue } } } // File is valid, add to changes changes.push({ file, bump, content }) } // Validate entering prerelease requires a major bump if (configPrereleaseChannel && !isCurrentVersionPrerelease && changes.length > 0) { let hasMajorBump = changes.some((c) => c.bump === 'major') if (!hasMajorBump) { errors.push({ packageDirName, file: '.changes/config.json', error: 'Entering prerelease mode requires a major version bump. Add a change file with "major." prefix (e.g. "major.release-v2-alpha.md").', }) } } if (errors.length > 0) { return { valid: false, errors } } return { valid: true, changes, changesConfig } } /** * Represents a dependency that was bumped, triggering this release. */ export interface DependencyBump { packageName: string version: string releaseUrl: string } export interface PackageRelease { packageDirName: string packageName: string currentVersion: string nextVersion: string bump: BumpType changes: ChangeFile[] /** Dependencies that were bumped, triggering this release (if any) */ dependencyBumps: DependencyBump[] } type ParsedChanges = | { valid: true; releases: PackageRelease[] } | { valid: false; errors: ValidationError[] } /** * Parses and validates all change files across all packages. * Also includes packages that need to be released due to dependency changes. * Returns releases if valid, or errors if invalid. */ export function parseAllChangeFiles(): ParsedChanges { let packageDirNames = getAllPackageDirNames() let errors: ValidationError[] = [] // Build maps for lookup let dirNameToPackageName = new Map() let packageNameToDirName = new Map() // First pass: collect package info and validate change files interface ParsedPackageInfo { packageDirName: string packageName: string currentVersion: string changes: ChangeFile[] changesConfig: ChangesConfig | null } let parsedPackages: ParsedPackageInfo[] = [] // Read the remix changes config once (only remix supports changes config) let remixChangesConfig = readChangesConfig('remix') let validRemixChangesConfig: ChangesConfig | null = null if (remixChangesConfig.exists && remixChangesConfig.valid) { validRemixChangesConfig = remixChangesConfig.config } for (let packageDirName of packageDirNames) { let parsed = parsePackageChanges(packageDirName) if (!parsed.valid) { errors.push(...parsed.errors) continue } let packageJsonPath = getPackageFile(packageDirName, 'package.json') let packageJson = readJson(packageJsonPath) let packageName = packageJson.name as string let currentVersion = packageJson.version as string dirNameToPackageName.set(packageDirName, packageName) packageNameToDirName.set(packageName, packageDirName) // For remix package, use the changes config even if there are no change files // (to correctly bump prerelease counter for dependency-triggered releases) let changesConfig = parsed.changesConfig if (packageDirName === 'remix' && changesConfig === null && validRemixChangesConfig) { changesConfig = validRemixChangesConfig } parsedPackages.push({ packageDirName, packageName, currentVersion, changes: parsed.changes, changesConfig, }) } if (errors.length > 0) { return { valid: false, errors } } // Find packages with direct changes let directlyChangedPackages = new Set() for (let pkg of parsedPackages) { if (pkg.changes.length > 0) { directlyChangedPackages.add(pkg.packageName) } } // Find all packages that transitively depend on changed packages let transitiveDependents = getTransitiveDependents(directlyChangedPackages) // Determine all packages that will be released let allReleasingPackages = new Set([ ...directlyChangedPackages, ...transitiveDependents.keys(), ]) // Compute next versions for all releasing packages // We need to do this in dependency order to correctly compute dependency bumps let packageVersions = new Map() // packageName -> nextVersion // First, compute versions for directly changed packages for (let pkg of parsedPackages) { if (pkg.changes.length > 0) { let bump = getHighestBump(pkg.changes.map((c) => c.bump)) if (bump == null) continue let nextVersion = getNextVersion(pkg.currentVersion, bump, pkg.changesConfig) packageVersions.set(pkg.packageName, nextVersion) } } // Then, compute versions for dependency-triggered releases // We need to do this iteratively because a package's version depends on knowing // which of its dependencies are being released for (let pkg of parsedPackages) { if ( !directlyChangedPackages.has(pkg.packageName) && allReleasingPackages.has(pkg.packageName) ) { // This package is being released due to dependency changes // Use the package's changes config if it has one (e.g., remix in prerelease mode) let nextVersion = getNextVersion(pkg.currentVersion, 'patch', pkg.changesConfig) packageVersions.set(pkg.packageName, nextVersion) } } // Now build the final releases with dependency bumps let releases: PackageRelease[] = [] for (let pkg of parsedPackages) { if (!allReleasingPackages.has(pkg.packageName)) { continue } let nextVersion = packageVersions.get(pkg.packageName) if (nextVersion == null) continue // Compute dependency bumps: which of this package's direct dependencies are being released? let dependencyBumps: DependencyBump[] = [] let deps = getPackageDependencies(pkg.packageName) for (let depName of deps) { if (allReleasingPackages.has(depName)) { let depVersion = packageVersions.get(depName) if (depVersion) { dependencyBumps.push({ packageName: depName, version: depVersion, releaseUrl: getGitHubReleaseUrl(depName, depVersion), }) } } } // Sort dependency bumps alphabetically by package name dependencyBumps.sort((a, b) => a.packageName.localeCompare(b.packageName)) let bump: BumpType = 'patch' if (pkg.changes.length > 0) { bump = getHighestBump(pkg.changes.map((c) => c.bump)) ?? 'patch' } releases.push({ packageDirName: pkg.packageDirName, packageName: pkg.packageName, currentVersion: pkg.currentVersion, nextVersion, bump, changes: pkg.changes, dependencyBumps, }) } // Sort by package name for consistency releases.sort((a, b) => a.packageName.localeCompare(b.packageName)) return { valid: true, releases } } /** * Formats validation errors for display */ export function formatValidationErrors(errors: ValidationError[]): string { let errorsByPackageDirName: Record = {} for (let error of errors) { if (!errorsByPackageDirName[error.packageDirName]) { errorsByPackageDirName[error.packageDirName] = [] } errorsByPackageDirName[error.packageDirName].push(error) } let lines: string[] = [] for (let [packageDirName, packageErrors] of Object.entries(errorsByPackageDirName)) { lines.push(`📦 ${packageDirName}:`) for (let error of packageErrors) { lines.push(` ${error.file}: ${error.error}`) } lines.push('') } let packageCount = Object.keys(errorsByPackageDirName).length lines.push( `Found ${errors.length} error${errors.length === 1 ? '' : 's'} in ${packageCount} package${packageCount === 1 ? '' : 's'}`, ) return lines.join('\n') } /** * Determines the highest severity bump type from an array of bump types. */ function getHighestBump(bumps: BumpType[]): BumpType | null { if (bumps.includes('major')) return 'major' if (bumps.includes('minor')) return 'minor' if (bumps.includes('patch')) return 'patch' return null } /** * Checks if content starts with "BREAKING CHANGE: " (case-insensitive, * ignoring markdown formatting and leading whitespace) */ function hasBreakingChangePrefix(content: string): boolean { return content .trimStart() .replace(/^[*_]+/, '') .toLowerCase() .startsWith('breaking change: ') } /** * Formats a changelog entry from change file content */ function formatChangelogEntry(content: string): string { let lines = content.trim().split('\n') if (lines.length === 1) { return `- ${lines[0]}` } // Multi-line: first line is bullet, rest are indented let [firstLine, ...restLines] = lines let formatted = [`- ${firstLine}`] for (let line of restLines) { // Add proper indentation for continuation lines formatted.push(line ? ` ${line}` : '') } return formatted.join('\n') } function sortChangelogChanges(changes: PackageRelease['changes']): PackageRelease['changes'] { // Sort with breaking changes hoisted to top, then alphabetically by filename return [...changes].sort((a, b) => { let aBreaking = hasBreakingChangePrefix(a.content) let bBreaking = hasBreakingChangePrefix(b.content) if (aBreaking !== bBreaking) return aBreaking ? -1 : 1 return a.file.localeCompare(b.file) }) } /** * Generates a section for a specific bump type (e.g., "### Major Changes") */ const sectionTitles: Record = { major: 'Major Changes', minor: 'Minor Changes', patch: 'Patch Changes', } function generateBumpTypeSection( changes: PackageRelease['changes'], bumpType: BumpType, subheadingLevel: number, ): string | null { let filtered = changes.filter((c) => c.bump === bumpType) if (filtered.length === 0) { return null } let sorted = sortChangelogChanges(filtered) let lines: string[] = [] let subheadingPrefix = '#'.repeat(subheadingLevel) lines.push(`${subheadingPrefix} ${sectionTitles[bumpType]}`) lines.push('') for (let change of sorted) { lines.push(formatChangelogEntry(change.content)) lines.push('') } return lines.join('\n') } function generatePrereleaseChangesSection( changes: PackageRelease['changes'], dependencyBumps: DependencyBump[], subheadingLevel: number, ): string | null { if (changes.length === 0 && dependencyBumps.length === 0) { return null } let lines: string[] = [] let subheadingPrefix = '#'.repeat(subheadingLevel) lines.push(`${subheadingPrefix} Pre-release Changes`) lines.push('') let sortedChanges = sortChangelogChanges(changes) for (let change of sortedChanges) { lines.push(formatChangelogEntry(change.content)) lines.push('') } if (dependencyBumps.length > 0) { lines.push('- Bumped `@remix-run/*` dependencies:') for (let dep of dependencyBumps) { let tag = getGitTag(dep.packageName, dep.version) lines.push(` - [\`${tag}\`](${dep.releaseUrl})`) } lines.push('') } return lines.join('\n') } /** * Generates the dependency bumps section for a changelog entry */ function generateDependencyBumpsSection( dependencyBumps: DependencyBump[], subheadingLevel: number, ): string | null { if (dependencyBumps.length === 0) { return null } let lines: string[] = [] let subheadingPrefix = '#'.repeat(subheadingLevel) lines.push(`${subheadingPrefix} Patch Changes`) lines.push('') lines.push('- Bumped `@remix-run/*` dependencies:') for (let dep of dependencyBumps) { let tag = getGitTag(dep.packageName, dep.version) lines.push(` - [\`${tag}\`](${dep.releaseUrl})`) } lines.push('') return lines.join('\n') } /** * Generates changelog content for a package release */ export function generateChangelogContent( release: PackageRelease, options: { /** Whether to include package name in heading. Default: false */ includePackageName?: boolean /** Markdown heading level (2 = ##, 3 = ###). Default: 2 */ headingLevel?: 2 | 3 } = {}, ): string { let { includePackageName = false, headingLevel = 2 } = options let lines: string[] = [] let headingPrefix = '#'.repeat(headingLevel) let packagePart = includePackageName ? `${release.packageName} ` : '' lines.push(`${headingPrefix} ${packagePart}v${release.nextVersion}`) lines.push('') let subheadingLevel = headingLevel + 1 // In prerelease mode, all change-file entries are grouped into a single section. let isPrereleaseRelease = getPrereleaseIdentifier(release.nextVersion) !== null if (isPrereleaseRelease) { let prereleaseSection = generatePrereleaseChangesSection( release.changes, release.dependencyBumps, subheadingLevel, ) if (prereleaseSection) { lines.push(prereleaseSection) } return lines.join('\n') } // Generate sections in order: major, minor, patch (skipping empty sections) for (let bumpType of bumpTypes) { let section = generateBumpTypeSection(release.changes, bumpType, subheadingLevel) if (section) { lines.push(section) } } // Add dependency bumps section if there are any // Only add if there are no other patch changes (to avoid duplicate "Patch Changes" heading) if (release.dependencyBumps.length > 0) { let hasPatchChanges = release.changes.some((c) => c.bump === 'patch') if (hasPatchChanges) { // Append to existing patch section (without heading) lines.push('- Bumped `@remix-run/*` dependencies:') for (let dep of release.dependencyBumps) { let tag = getGitTag(dep.packageName, dep.version) lines.push(` - [\`${tag}\`](${dep.releaseUrl})`) } lines.push('') } else { // Create new patch section with heading let section = generateDependencyBumpsSection(release.dependencyBumps, subheadingLevel) if (section) { lines.push(section) } } } return lines.join('\n') } /** * Generates the commit message for all releases */ export function generateCommitMessage(releases: PackageRelease[]): string { let subject = 'Release' let body = releases .map((r) => `- ${r.packageName}: ${r.currentVersion} -> ${r.nextVersion}`) .join('\n') return `${subject}\n\n${body}` } // ============================================================================= // CHANGELOG.md parsing utilities (for reading already-released changes) // ============================================================================= interface ChangelogEntry { version: string date?: Date body: string } type AllChangelogEntries = Record /** * Parses a package's CHANGELOG.md and returns all version entries */ function parseChangelog(packageDirName: string): AllChangelogEntries | null { let changelogPath = getPackageFile(packageDirName, 'CHANGELOG.md') if (!fileExists(changelogPath)) { return null } let changelog = readFile(changelogPath) let parser = /^## ([a-z\d\.\-]+)(?: \(([^)]+)\))?$/gim let result: AllChangelogEntries = {} let match while ((match = parser.exec(changelog))) { let [_, versionString, dateString] = match let lastIndex = parser.lastIndex let version = versionString.startsWith('v') ? versionString.slice(1) : versionString let date = dateString ? new Date(dateString) : undefined let nextMatch = parser.exec(changelog) let body = changelog.slice(lastIndex, nextMatch ? nextMatch.index : undefined).trim() result[version] = { version, date, body } parser.lastIndex = lastIndex } return result } /** * Gets a specific version's entry from a package's CHANGELOG.md. * Accepts an npm package name (e.g., "@remix-run/static-middleware" or "remix"). */ export function getChangelogEntry({ packageName, version, }: { packageName: string version: string }): ChangelogEntry | null { let dirName = packageNameToDirectoryName(packageName) if (dirName === null) { return null } let allEntries = parseChangelog(dirName) if (allEntries !== null) { return allEntries[version] ?? null } return null } ================================================ FILE: scripts/utils/color.ts ================================================ export let colors = { // Regular colors black: '\x1b[30m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', // Bright colors gray: '\x1b[90m', lightRed: '\x1b[91m', lightGreen: '\x1b[92m', lightYellow: '\x1b[93m', lightBlue: '\x1b[94m', lightMagenta: '\x1b[95m', lightCyan: '\x1b[96m', lightWhite: '\x1b[97m', // Styles bold: '\x1b[1m', dim: '\x1b[2m', underline: '\x1b[4m', reset: '\x1b[0m', } export function colorize(text: string, color: string): string { if (process.env.NO_COLOR) { return text } return `${color}${text}${colors.reset}` } ================================================ FILE: scripts/utils/fs.ts ================================================ import * as fs from 'node:fs' export function fileExists(filename: string): boolean { return fs.existsSync(filename) } export function readFile(filename: string, encoding: BufferEncoding = 'utf-8'): string { try { return fs.readFileSync(filename, encoding) } catch (error) { if (isFsError(error) && error.code === 'ENOENT') { console.error(`Not found: "${filename}"`) process.exit(1) } else { throw error } } } function isFsError(error: unknown): error is { code: string } { return ( typeof error === 'object' && error != null && 'code' in error && typeof error.code === 'string' ) } export function writeFile(filename: string, data: string): void { fs.writeFileSync(filename, data) } export function readJson(filename: string): any { return JSON.parse(readFile(filename)) } export function writeJson(filename: string, data: any): void { writeFile(filename, JSON.stringify(data, null, 2) + '\n') } ================================================ FILE: scripts/utils/git.test.ts ================================================ import assert from 'node:assert/strict' import * as cp from 'node:child_process' import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' import { test } from 'node:test' import { findVersionIntroductionCommit, getLocalTagTarget } from './git.ts' function execGit(args: string[], cwd: string): string { return cp.execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim() } test('findVersionIntroductionCommit returns the commit where the target version was introduced', () => { let tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remix-git-test-')) let originalCwd = process.cwd() let packageJsonPath = 'packages/example/package.json' try { process.chdir(tempDir) execGit(['init'], tempDir) execGit(['config', 'user.name', 'Test Bot'], tempDir) execGit(['config', 'user.email', 'test@example.com'], tempDir) fs.mkdirSync(path.dirname(packageJsonPath), { recursive: true }) fs.writeFileSync( packageJsonPath, `${JSON.stringify({ name: '@remix-run/example', version: '1.0.0' }, null, 2)}\n`, ) execGit(['add', '.'], tempDir) execGit(['commit', '-m', 'initial release'], tempDir) let initialCommit = execGit(['rev-parse', 'HEAD'], tempDir) fs.writeFileSync('README.md', 'hello\n') execGit(['add', 'README.md'], tempDir) execGit(['commit', '-m', 'docs change'], tempDir) fs.writeFileSync( packageJsonPath, `${JSON.stringify({ name: '@remix-run/example', version: '1.1.0' }, null, 2)}\n`, ) execGit(['add', packageJsonPath], tempDir) execGit(['commit', '-m', 'bump version'], tempDir) let versionBumpCommit = execGit(['rev-parse', 'HEAD'], tempDir) fs.writeFileSync( packageJsonPath, `${JSON.stringify( { name: '@remix-run/example', version: '1.1.0', description: 'metadata only', }, null, 2, )}\n`, ) execGit(['add', packageJsonPath], tempDir) execGit(['commit', '-m', 'metadata tweak'], tempDir) assert.equal(findVersionIntroductionCommit(packageJsonPath, '1.1.0'), versionBumpCommit) assert.equal(findVersionIntroductionCommit(packageJsonPath, '1.0.0'), initialCommit) assert.equal(findVersionIntroductionCommit(packageJsonPath, '9.9.9'), null) execGit(['tag', 'example@1.1.0', versionBumpCommit], tempDir) assert.equal(getLocalTagTarget('example@1.1.0'), versionBumpCommit) } finally { process.chdir(originalCwd) fs.rmSync(tempDir, { recursive: true, force: true }) } }) ================================================ FILE: scripts/utils/git.ts ================================================ import * as cp from 'node:child_process' function execGit(args: string[]): string { return cp.execFileSync('git', args, { stdio: 'pipe', encoding: 'utf-8' }).trim() } function parseVersionFromPackageJson(content: string): string | null { try { let parsed = JSON.parse(content) as Record return typeof parsed.version === 'string' ? parsed.version : null } catch { return null } } function getPackageVersionAtRef(ref: string, packageJsonPath: string): string | null { try { let content = execGit(['show', `${ref}:${packageJsonPath}`]) return parseVersionFromPackageJson(content) } catch { return null } } /** * Finds the commit that introduced a specific version in a package.json file. * Returns null when the version can't be found in the available git history. */ export function findVersionIntroductionCommit( packageJsonPath: string, version: string, ): string | null { let normalizedPath = packageJsonPath.replaceAll('\\', '/') let output: string try { output = execGit(['log', '--format=%H', '--', normalizedPath]) } catch { return null } if (output.length === 0) { return null } let commits = output.split('\n').filter((line) => line.length > 0) for (let commit of commits) { let commitVersion = getPackageVersionAtRef(commit, normalizedPath) if (commitVersion !== version) { continue } let parentLine = execGit(['rev-list', '--parents', '-n', '1', commit]) let [_commit, ...parents] = parentLine.split(' ').filter((line) => line.length > 0) if (parents.length === 0) { return commit } let introducedInCommit = false for (let parent of parents) { let parentVersion = getPackageVersionAtRef(parent, normalizedPath) if (parentVersion !== version) { introducedInCommit = true break } } if (introducedInCommit) { return commit } } return null } /** * Gets the local commit target for a tag. * Returns null when the tag does not exist locally. */ export function getLocalTagTarget(tag: string): string | null { try { return execGit(['rev-parse', '--verify', `refs/tags/${tag}^{commit}`]) } catch { return null } } /** * Gets the remote commit target for a tag from origin. * Returns null when the tag does not exist remotely. */ export function getRemoteTagTarget(tag: string): string | null { try { let output = execGit([ 'ls-remote', '--tags', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`, ]) let lines = output.split('\n').filter((line) => line.length > 0) if (lines.length === 0) { return null } let peeledLine = lines.find((line) => line.endsWith(`refs/tags/${tag}^{}`)) if (peeledLine) { return peeledLine.split('\t')[0] } return lines[0].split('\t')[0] } catch { return null } } /** * Check if a git tag exists */ export function tagExists(tag: string): boolean { return getLocalTagTarget(tag) !== null || getRemoteTagTarget(tag) !== null } ================================================ FILE: scripts/utils/github.ts ================================================ import { request } from '@octokit/request' import { getChangelogEntry } from './changes.ts' import { getGitTag, getPackageShortName } from './packages.ts' let owner = 'remix-run' let repo = 'remix' function getToken(): string { let token = process.env.GITHUB_TOKEN if (!token) { throw new Error('GITHUB_TOKEN environment variable is required') } return token } function auth() { return { headers: { authorization: `token ${getToken()}` } } } export type CreateReleaseResult = | { status: 'created'; url: string } | { status: 'skipped'; reason: string } | { status: 'error'; error: string } /** * Check if a GitHub release exists for a tag. */ export async function releaseExists(tag: string): Promise { try { await request('GET /repos/{owner}/{repo}/releases/tags/{tag}', { ...auth(), owner, repo, tag, }) return true } catch (error) { if (error instanceof Error && 'status' in error && error.status === 404) { return false } throw error } } /** * Creates a GitHub release for a package version. * Returns a result object indicating success, already exists, or error. */ export async function createRelease( packageName: string, version: string, options: { preview?: boolean } = {}, ): Promise { let { preview = false } = options let tagName = getGitTag(packageName, version) let releaseName = `${getPackageShortName(packageName)} v${version}` let changes = getChangelogEntry({ packageName, version }) let body = changes?.body ?? 'No changelog entry found for this version.' if (preview) { console.log(` Tag: ${tagName}`) console.log(` Name: ${releaseName}`) console.log() console.log(' Body:') console.log() for (let line of body.split('\n')) { console.log(` ${line}`) } console.log() return { status: 'skipped', reason: 'Preview mode' } } try { if (await releaseExists(tagName)) { return { status: 'skipped', reason: 'Already exists' } } } catch (error) { let message = error instanceof Error ? error.message : String(error) return { status: 'error', error: message } } try { let response = await request('POST /repos/{owner}/{repo}/releases', { ...auth(), owner, repo, tag_name: tagName, name: releaseName, body, }) return { status: 'created', url: response.data.html_url } } catch (error) { let message = error instanceof Error ? error.message : String(error) return { status: 'error', error: message } } } /** * Find an open PR from a specific branch to a base branch */ export async function findOpenPr(head: string, base: string) { let response = await request('GET /repos/{owner}/{repo}/pulls', { ...auth(), owner, repo, state: 'open', head: `${owner}:${head}`, base, }) return response.data.length > 0 ? response.data[0] : null } /** * Create a new PR */ export async function createPr(options: { title: string body: string head: string base: string }) { let response = await request('POST /repos/{owner}/{repo}/pulls', { ...auth(), owner, repo, title: options.title, body: options.body, head: options.head, base: options.base, }) return response.data } /** * Update an existing PR */ export async function updatePr(prNumber: number, options: { title?: string; body?: string }) { await request('PATCH /repos/{owner}/{repo}/pulls/{pull_number}', { ...auth(), owner, repo, pull_number: prNumber, ...options, }) } /** * Close a PR with an optional comment */ export async function closePr(prNumber: number, comment?: string) { if (comment) { await request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...auth(), owner, repo, issue_number: prNumber, body: comment, }) } await request('PATCH /repos/{owner}/{repo}/pulls/{pull_number}', { ...auth(), owner, repo, pull_number: prNumber, state: 'closed', }) } /** * Get all comments on a PR */ export async function getPrComments(prNumber: number) { let response = await request('GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...auth(), owner, repo, issue_number: prNumber, }) return response.data } /** * Create a comment on a PR */ export async function createPrComment(prNumber: number, body: string) { let response = await request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...auth(), owner, repo, issue_number: prNumber, body, }) return response.data } /** * Update a comment on a PR */ export async function updatePrComment(commentId: number, body: string) { await request('PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}', { ...auth(), owner, repo, comment_id: commentId, body, }) } /** * Delete a comment on a PR */ export async function deletePrComment(commentId: number) { await request('DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}', { ...auth(), owner, repo, comment_id: commentId, }) } ================================================ FILE: scripts/utils/packages.ts ================================================ import * as fs from 'node:fs' import * as path from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = path.dirname(fileURLToPath(import.meta.url)) export const packagesDir = path.relative( process.cwd(), path.resolve(__dirname, '..', '..', 'packages'), ) export const GITHUB_REPO_URL = 'https://github.com/remix-run/remix' export function getAllPackageDirNames(): string[] { return fs.readdirSync(packagesDir).filter((name) => { let packagePath = getPackagePath(name) return fs.existsSync(packagePath) && fs.statSync(packagePath).isDirectory() }) } export function getPackagePath(packageDirName: string): string { return path.resolve(packagesDir, packageDirName) } export function getPackageFile(packageDirName: string, filename: string): string { return path.join(getPackagePath(packageDirName), filename) } /** * Builds a mapping from npm package names to directory names by reading * all package.json files in the packages directory. */ let getNpmPackageNameToDirectoryMap = (() => { let map: Map | null = null return function getNpmPackageNameToDirectoryMap(): Map { if (map !== null) { return map } map = new Map() let dirNames = getAllPackageDirNames() for (let dirName of dirNames) { let packageJsonPath = getPackageFile(dirName, 'package.json') if (fs.existsSync(packageJsonPath)) { try { let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) if (typeof packageJson.name === 'string') { map.set(packageJson.name, dirName) } } catch { // Skip invalid package.json files } } } return map } })() /** * Converts an npm package name to the directory name in the packages folder. * Returns null if no mapping is found. * * Examples: * "@remix-run/static-middleware" -> "static-middleware" * "remix" -> "remix" */ export function packageNameToDirectoryName(packageName: string): string | null { return getNpmPackageNameToDirectoryMap().get(packageName) ?? null } /** * Returns the short name used in git tags for a package. * For @remix-run/* packages, strips the scope. For "remix", returns "remix". * * Examples: * "@remix-run/headers" -> "headers" * "remix" -> "remix" */ export function getPackageShortName(packageName: string): string { if (packageName.startsWith('@remix-run/')) { return packageName.slice('@remix-run/'.length) } return packageName } /** * Generates the git tag for a package release. * * Examples: * ("@remix-run/headers", "0.11.0") -> "headers@0.11.0" * ("remix", "3.0.0") -> "remix@3.0.0" */ export function getGitTag(packageName: string, version: string): string { return `${getPackageShortName(packageName)}@${version}` } /** * Generates the GitHub release URL for a package release. */ export function getGitHubReleaseUrl(packageName: string, version: string): string { let tag = getGitTag(packageName, version) return `${GITHUB_REPO_URL}/releases/tag/${tag}` } interface PackageInfo { name: string version: string dirName: string dependencies: string[] // Only @remix-run/* dependencies } /** * Gets information about all packages in the monorepo, including their * @remix-run/* dependencies. */ let getPackageInfoMap = (() => { let map: Map | null = null return function getPackageInfoMap(): Map { if (map !== null) { return map } map = new Map() let dirNames = getAllPackageDirNames() for (let dirName of dirNames) { let packageJsonPath = getPackageFile(dirName, 'package.json') if (fs.existsSync(packageJsonPath)) { try { let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) let name = packageJson.name as string let version = packageJson.version as string // Collect @remix-run/* dependencies from the dependencies field let dependencies: string[] = [] let deps = packageJson.dependencies as Record | undefined if (deps) { for (let depName of Object.keys(deps)) { if (depName.startsWith('@remix-run/') || depName === 'remix') { dependencies.push(depName) } } } map.set(name, { name, version, dirName, dependencies }) } catch { // Skip invalid package.json files } } } return map } })() /** * Gets the @remix-run/* dependencies for a package. */ export function getPackageDependencies(packageName: string): string[] { let info = getPackageInfoMap().get(packageName) return info?.dependencies ?? [] } /** * Builds a reverse dependency graph: maps each package to the set of packages * that depend on it. */ export function buildReverseDependencyGraph(): Map> { let graph = new Map>() let packageInfoMap = getPackageInfoMap() // Initialize empty sets for all packages for (let packageName of packageInfoMap.keys()) { graph.set(packageName, new Set()) } // Build reverse edges for (let [packageName, info] of packageInfoMap) { for (let dep of info.dependencies) { let dependents = graph.get(dep) if (dependents) { dependents.add(packageName) } } } return graph } /** * Gets all packages that transitively depend on any of the given packages. * Returns a map from dependent package name to the set of changed packages it depends on. */ export function getTransitiveDependents(changedPackages: Set): Map> { let reverseGraph = buildReverseDependencyGraph() let result = new Map>() // For each changed package, find all its transitive dependents function addDependents(changedPackage: string, originalChangedPackage: string) { let directDependents = reverseGraph.get(changedPackage) if (!directDependents) return for (let dependent of directDependents) { // Skip if this is one of the originally changed packages if (changedPackages.has(dependent)) continue // Track which changed packages this dependent needs let changedDeps = result.get(dependent) if (!changedDeps) { changedDeps = new Set() result.set(dependent, changedDeps) } changedDeps.add(originalChangedPackage) // Recursively process dependents addDependents(dependent, originalChangedPackage) } } for (let changedPackage of changedPackages) { addDependents(changedPackage, changedPackage) } return result } ================================================ FILE: scripts/utils/process.ts ================================================ import * as cp from 'node:child_process' import * as path from 'node:path' /** * Get the root directory of the monorepo (parent of scripts/). * Works whether called directly or via node --eval. */ export function getRootDir(): string { // import.meta.dirname is the directory containing this file (scripts/utils/) // Go up two levels to get the repo root if (import.meta.dirname) { return path.join(import.meta.dirname, '..', '..') } // Fallback for environments where import.meta.dirname isn't available return process.cwd() } export function logAndExec(command: string, captureOutput = false): string { console.log(`$ ${command}`) if (captureOutput) { return cp.execSync(command, { stdio: 'pipe', encoding: 'utf-8' }).trim() } else { cp.execSync(command, { stdio: 'inherit' }) return '' } } ================================================ FILE: scripts/utils/release-pr.test.ts ================================================ import assert from 'node:assert/strict' import { test } from 'node:test' import type { PackageRelease } from './changes.ts' import { generatePrBody } from './release-pr.ts' function makeRelease({ packageDirName, packageName, nextVersion, }: { packageDirName: string packageName: string nextVersion: string }): PackageRelease { return { packageDirName, packageName, currentVersion: '1.0.0', nextVersion, bump: 'patch', changes: [{ file: 'patch.test-change.md', bump: 'patch', content: 'Test change' }], dependencyBumps: [], } } test('generatePrBody puts remix first and sorts remaining packages alphabetically', () => { let body = generatePrBody([ makeRelease({ packageDirName: 'zeta', packageName: '@remix-run/zeta', nextVersion: '1.0.1', }), makeRelease({ packageDirName: 'remix', packageName: 'remix', nextVersion: '3.0.0', }), makeRelease({ packageDirName: 'beta', packageName: '@remix-run/beta', nextVersion: '1.0.1', }), makeRelease({ packageDirName: 'alpha', packageName: '@remix-run/alpha', nextVersion: '1.0.1', }), ]) let remixTableIndex = body.indexOf('| remix |') let alphaTableIndex = body.indexOf('| @remix-run/alpha |') let betaTableIndex = body.indexOf('| @remix-run/beta |') let zetaTableIndex = body.indexOf('| @remix-run/zeta |') assert.notEqual(remixTableIndex, -1) assert.notEqual(alphaTableIndex, -1) assert.notEqual(betaTableIndex, -1) assert.notEqual(zetaTableIndex, -1) assert.ok(remixTableIndex < alphaTableIndex) assert.ok(alphaTableIndex < betaTableIndex) assert.ok(betaTableIndex < zetaTableIndex) let remixChangelogIndex = body.indexOf('## remix v3.0.0') let alphaChangelogIndex = body.indexOf('## @remix-run/alpha v1.0.1') let betaChangelogIndex = body.indexOf('## @remix-run/beta v1.0.1') let zetaChangelogIndex = body.indexOf('## @remix-run/zeta v1.0.1') assert.notEqual(remixChangelogIndex, -1) assert.notEqual(alphaChangelogIndex, -1) assert.notEqual(betaChangelogIndex, -1) assert.notEqual(zetaChangelogIndex, -1) assert.ok(remixChangelogIndex < alphaChangelogIndex) assert.ok(alphaChangelogIndex < betaChangelogIndex) assert.ok(betaChangelogIndex < zetaChangelogIndex) }) ================================================ FILE: scripts/utils/release-pr.ts ================================================ import type { PackageRelease } from './changes.ts' import { generateChangelogContent } from './changes.ts' // GitHub has a 65,536 character limit for PR body. We use 60,000 to be safe. let maxBodyLength = 60_000 /** * Generates the PR body for a release PR */ export function generatePrBody(releases: PackageRelease[]): string { let orderedReleases = sortReleasesForDisplay(releases) let header = generateHeader() let releasesTable = generateReleasesTable(orderedReleases) let changelogs = generateChangelogs(orderedReleases) let fullBody = [header, releasesTable, changelogs].join('\n\n') // If under limit, return full body if (fullBody.length <= maxBodyLength) { return fullBody } // Truncate changelogs section to fit let baseLength = header.length + releasesTable.length + 100 // buffer for truncation notice let availableForChangelogs = maxBodyLength - baseLength let truncatedChangelogs = truncateChangelogs(orderedReleases, availableForChangelogs) return [header, releasesTable, truncatedChangelogs].join('\n\n') } function sortReleasesForDisplay(releases: PackageRelease[]): PackageRelease[] { return [...releases].sort((a, b) => { let aIsRemix = a.packageDirName === 'remix' let bIsRemix = b.packageDirName === 'remix' if (aIsRemix && !bIsRemix) { return -1 } if (!aIsRemix && bIsRemix) { return 1 } return a.packageName.localeCompare(b.packageName) }) } function generateHeader(): string { return [ 'This PR is managed by the [`release-pr`](https://github.com/remix-run/remix/blob/main/.github/workflows/release-pr.yaml) workflow. ' + 'Do not edit it manually. ' + 'See [CONTRIBUTING.md](https://github.com/remix-run/remix/blob/main/CONTRIBUTING.md#releases) for more.', ].join('\n') } function generateReleasesTable(releases: PackageRelease[]): string { let lines = ['# Releases', '', '| Package | Version |', '|---------|---------|'] for (let release of releases) { lines.push( `| ${release.packageName} | \`${release.currentVersion}\` → \`${release.nextVersion}\` |`, ) } return lines.join('\n') } function generateChangelogs(releases: PackageRelease[]): string { let lines = ['# Changelogs'] for (let release of releases) { lines.push('') lines.push(generatePackageChangelog(release)) } return lines.join('\n') } function generatePackageChangelog(release: PackageRelease): string { return generateChangelogContent(release, { includePackageName: true, headingLevel: 2, }) } function truncateChangelogs(releases: PackageRelease[], maxLength: number): string { let lines = ['# Changelogs'] let currentLength = lines.join('\n').length let includedCount = 0 for (let release of releases) { let changelog = '\n\n' + generatePackageChangelog(release) if (currentLength + changelog.length <= maxLength) { lines.push('') lines.push(generatePackageChangelog(release)) currentLength += changelog.length includedCount++ } else { break } } let omittedCount = releases.length - includedCount if (omittedCount > 0) { lines.push('') lines.push( `> ⚠️ ${omittedCount} changelog${omittedCount === 1 ? '' : 's'} omitted due to size limits. See the PR diff for full details.`, ) } return lines.join('\n') } ================================================ FILE: scripts/utils/semver.ts ================================================ export type ReleaseType = 'major' | 'minor' | 'patch' | 'prerelease' type ParsedVersion = { major: number minor: number patch: number prerelease: Array } const semverPattern = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/ function parse(version: string): ParsedVersion | null { let match = semverPattern.exec(version) if (match == null) { return null } let prerelease = match[4] ? match[4] .split('.') .filter(Boolean) .map((part) => (/^\d+$/.test(part) ? Number(part) : part)) : [] return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease, } } function format(version: ParsedVersion): string { let base = `${version.major}.${version.minor}.${version.patch}` if (version.prerelease.length === 0) { return base } return `${base}-${version.prerelease.join('.')}` } export function major(version: string): number { let parsed = parse(version) if (parsed == null) { throw new Error(`Invalid semver version: ${version}`) } return parsed.major } export function prerelease(version: string): Array | null { let parsed = parse(version) if (parsed == null) { throw new Error(`Invalid semver version: ${version}`) } return parsed.prerelease.length > 0 ? parsed.prerelease : null } export function inc(version: string, releaseType: ReleaseType, identifier?: string): string | null { let parsed = parse(version) if (parsed == null) { return null } if (releaseType === 'major') { return format({ major: parsed.major + 1, minor: 0, patch: 0, prerelease: [], }) } if (releaseType === 'minor') { return format({ major: parsed.major, minor: parsed.minor + 1, patch: 0, prerelease: [], }) } if (releaseType === 'patch') { return format({ major: parsed.major, minor: parsed.minor, patch: parsed.patch + 1, prerelease: [], }) } if (identifier == null || identifier.trim() === '') { return null } let nextPrerelease = [...parsed.prerelease] if (nextPrerelease.length === 0 || nextPrerelease[0] !== identifier) { nextPrerelease = [identifier, 0] } else { let lastIndex = nextPrerelease.length - 1 let lastPart = nextPrerelease[lastIndex] if (typeof lastPart === 'number') { nextPrerelease[lastIndex] = lastPart + 1 } else { nextPrerelease.push(0) } } return format({ major: parsed.major, minor: parsed.minor, patch: parsed.patch, prerelease: nextPrerelease, }) }