Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4243cb2451 | |||
| 7e8a0d96d2 | |||
| 4d19dec71a | |||
| 76a5ac293e | |||
| 668b34d25e | |||
| 6a2d033f32 | |||
| e71df640a9 | |||
| 330f6c548e | |||
| 4d2412a0fe | |||
| 21a4fabc28 | |||
| 3418aa614b | |||
| 88a0754ae0 | |||
| f3e4597ef4 | |||
| 2bf04ece54 | |||
| 295b2eb3b6 | |||
| f0182abc83 | |||
| 1105b9b71a | |||
| 7077dbf65e | |||
| 6b355ab9ad | |||
| 68d7139ebe | |||
| c58f6f7a68 | |||
| 6516e15bd8 | |||
| 7f2365f4e0 | |||
| f560594610 | |||
| ebd9767926 | |||
| 1c631a1b6f | |||
| 110a8ea48c | |||
| 10f0ef3301 | |||
| 2e4a6307b4 | |||
| 30ea5eb13a | |||
| 5846f30228 | |||
| 3afa42c209 | |||
| 86b1628953 | |||
| ea23dbd297 | |||
| 9065e64be8 | |||
| 9970b9b68e | |||
| b4113134ad | |||
| 2a07ea42f1 | |||
| 91afa5ebbf | |||
| 65262dc3d7 | |||
| 9105701ae0 | |||
| 9b35cc484b | |||
| 30a04a5a06 | |||
| 493315af48 | |||
| 8db1da69e9 | |||
| cd7a45101e | |||
| 29d107dc0a | |||
| cf7dc8d719 | |||
| 9ced599b19 | |||
| 67592ec2b4 |
+25
-3
@@ -31,10 +31,32 @@ DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
|
||||
# AUTH_GITHUB_CLIENT_ID=
|
||||
# AUTH_GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Resend is an email service used for signing in to Trigger.dev via a Magic Link.
|
||||
# Emails will print to the console if you leave these commented out
|
||||
# Configure an email transport to allow users to sign in to Trigger.dev via a Magic Link.
|
||||
# If none are configured, emails will print to the console instead.
|
||||
# Uncomment one of the following blocks to allow delivery of
|
||||
|
||||
# Resend
|
||||
### Visit https://resend.com, create an account and get your API key. Then insert it below along with your From and Reply To email addresses. Visit https://resend.com/docs for more information.
|
||||
# RESEND_API_KEY=<api_key>
|
||||
# EMAIL_TRANSPORT=resend
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
# RESEND_API_KEY=
|
||||
|
||||
# Generic SMTP
|
||||
### Enter the configuration provided by your mail provider. Visit https://nodemailer.com/smtp/ for more information
|
||||
### SMTP_SECURE = false will use STARTTLS when connecting to a server that supports it (usually port 587)
|
||||
# EMAIL_TRANSPORT=smtp
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
# SMTP_HOST=
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=
|
||||
# SMTP_PASSWORD=
|
||||
|
||||
# AWS Simple Email Service
|
||||
### Authentication is configured using the default Node.JS credentials provider chain (https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain)
|
||||
# EMAIL_TRANSPORT=aws-ses
|
||||
# FROM_EMAIL=
|
||||
# REPLY_TO_EMAIL=
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function LyftLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="48"
|
||||
height="34"
|
||||
viewBox="0 0 48 34"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 0.593341H7.28904V20.1709C7.28904 23.2692 8.70514 25.1147 9.82493 25.9058C8.63943 26.9605 5.01673 27.8834 2.3162 25.6421C0.724053 24.3209 0 22.1485 0 20.1049V0.593341ZM45.5536 16.856V14.7986H47.7767V7.58069H45.3354C44.3982 3.24724 40.5432 0 35.9328 0C30.6217 0 26.3164 4.30894 26.3164 9.62402V26.48C27.8295 26.6928 29.6322 26.4536 31.1659 25.1807C32.7578 23.8594 33.4818 21.6872 33.4818 19.6436V19.0226H37.1222V11.8047H33.4818V9.62402H33.4905C33.4905 8.27426 34.5839 7.18004 35.9328 7.18004C37.2815 7.18004 38.3792 8.27426 38.3792 9.62402V16.856C38.3792 22.1711 42.6891 26.48 48 26.48V19.3C46.6513 19.3 45.5536 18.2057 45.5536 16.856ZM17.9474 7.58069V18.1737C17.9474 18.7751 17.4488 19.2626 16.8337 19.2626C16.2185 19.2626 15.7199 18.7751 15.7199 18.1737V7.58069H8.50752V20.0392C8.50752 22.2803 9.26697 25.1147 12.7231 26.0376C16.1828 26.9615 18.1899 25.049 18.1899 25.049C18.007 26.3089 16.8213 27.2318 14.9113 27.4296C13.4661 27.5791 11.6178 27.1 10.6959 26.7045V33.305C13.0451 33.9983 15.5298 34.2223 17.9614 33.7501C22.3744 32.8932 25.1596 29.2019 25.1596 24.2908V7.58069H17.9474Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export function MiddayLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={102}
|
||||
height={30}
|
||||
viewBox="0 0 100 30"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
d="M14.347 0a14.931 14.931 0 0 0-6.282 1.68l6.282 10.88V0Zm0 17.443L8.067 28.32a14.933 14.933 0 0 0 6.28 1.68V17.443ZM15.652 30V17.432l6.285 10.887A14.932 14.932 0 0 1 15.652 30Zm0-17.43V0c2.26.097 4.392.693 6.287 1.682l-6.287 10.889ZM2.336 23.068l10.884-6.284-6.284 10.884a15.093 15.093 0 0 1-4.6-4.6Zm25.33-16.132-10.88 6.282 6.282-10.88a15.094 15.094 0 0 1 4.598 4.598ZM2.335 6.934a15.094 15.094 0 0 1 4.6-4.6l6.284 10.884L2.335 6.934Zm-.654 1.13A14.931 14.931 0 0 0 0 14.35h12.568L1.681 8.064Zm0 13.873a14.932 14.932 0 0 1-1.68-6.282h12.562L1.682 21.938Zm15.754-7.587H30a14.93 14.93 0 0 0-1.68-6.285L17.435 14.35Zm10.884 7.586-10.878-6.28H30a14.932 14.932 0 0 1-1.68 6.28Zm-11.533-5.151 6.281 10.88a15.092 15.092 0 0 0 4.598-4.599l-10.88-6.281Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M92.34 11.912h1.637l2.995 8.223 2.884-8.223h1.619l-4 11.107c-.372 1.06-1.08 1.544-2.196 1.544h-1.172v-1.358h1.024c.502 0 .8-.186.986-.707l.353-.912h-.52l-3.61-9.674ZM82.744 14.814c.39-1.916 1.916-3.126 4.018-3.126 2.549 0 3.963 1.489 3.963 4.13v3.964c0 .446.186.632.614.632h.39v1.358h-.65c-1.005 0-1.88-.335-1.861-1.544-.428.93-1.544 1.767-3.107 1.767-1.954 0-3.535-1.041-3.535-2.79 0-2.028 1.544-2.55 3.702-2.977l2.921-.558c-.018-1.712-.818-2.53-2.437-2.53-1.265 0-2.102.65-2.4 1.804l-1.618-.13Zm1.432 4.39c0 .8.689 1.452 2.14 1.433 1.637 0 2.92-1.153 2.92-3.442v-.167l-2.362.41c-1.47.26-2.698.371-2.698 1.767ZM80.129 8.563v13.21h-1.377l-.056-1.452c-.558 1.042-1.618 1.675-3.144 1.675-2.847 0-4.168-2.419-4.168-5.154s1.321-5.153 4.168-5.153c1.451 0 2.493.558 3.051 1.562V8.563h1.526Zm-7.145 8.28c0 1.915.819 3.701 2.884 3.701 2.028 0 2.865-1.823 2.865-3.702 0-1.953-.837-3.758-2.865-3.758-2.065 0-2.884 1.786-2.884 3.758ZM68.936 8.563v13.21H67.56l-.056-1.452c-.558 1.042-1.619 1.675-3.144 1.675-2.847 0-4.168-2.419-4.168-5.154s1.321-5.153 4.168-5.153c1.45 0 2.493.558 3.05 1.562V8.563h1.526Zm-7.144 8.28c0 1.915.819 3.701 2.884 3.701 2.028 0 2.865-1.823 2.865-3.702 0-1.953-.837-3.758-2.865-3.758-2.065 0-2.884 1.786-2.884 3.758ZM56.212 11.912h1.525v9.86h-1.525v-9.86Zm-.037-1.544V8.6h1.6v1.768h-1.6ZM40.224 11.912h1.395l.056 1.674c.446-1.21 1.47-1.898 2.846-1.898 1.414 0 2.438.763 2.865 2.084.428-1.34 1.47-2.084 3.014-2.084 1.973 0 3.126 1.377 3.126 3.74v6.344H52v-5.897c0-1.805-.707-2.828-1.916-2.828-1.544 0-2.437 1.041-2.437 2.846v5.88H46.12v-5.899c0-1.767-.725-2.827-1.916-2.827-1.526 0-2.456 1.079-2.456 2.827v5.898h-1.525v-9.86Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export function TldrawLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="96"
|
||||
height="25"
|
||||
viewBox="0 0 96 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.3031 0.283066C15.4501 0.283066 18.5971 0.281726 21.7441 0.283066C23.5701 0.283066 24.6473 1.36288 24.6486 3.19829C24.6513 9.4695 24.6513 15.7407 24.6486 22.0133C24.6486 23.8607 23.5808 24.9325 21.7521 24.9338C15.4581 24.9352 9.16417 24.9352 2.87018 24.9338C1.08836 24.9338 0.000510729 23.8446 -0.000828987 22.0521C-0.0021687 15.7581 -0.0021687 9.46414 0.000510729 3.17015C0.00185044 1.34546 1.08568 0.284406 2.93047 0.283066C6.05469 0.281726 9.18024 0.283066 12.3045 0.283066H12.3031ZM12.1223 16.9518C12.0499 17.7543 11.6386 18.3116 11.088 18.8006C10.8201 19.0391 10.4798 19.3713 10.8308 19.6902C11.0532 19.8938 11.5475 19.9997 11.8356 19.9019C13.7808 19.2374 15.2183 16.2498 14.582 14.3019C14.2725 13.3533 13.2825 12.6178 12.3674 12.6594C11.4095 12.7022 10.5682 13.4016 10.3525 14.3327C10.0819 15.4982 10.6553 16.3637 12.1236 16.9518H12.1223ZM12.4143 5.20384C11.2354 5.21858 10.315 6.1604 10.3257 7.34337C10.3364 8.52768 11.2662 9.47218 12.4331 9.48155C13.6134 9.49093 14.586 8.49016 14.5605 7.29112C14.5351 6.10815 13.5892 5.18776 12.4157 5.2025L12.4143 5.20384Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M85.3405 16.6586C85.4195 16.4443 85.5267 16.2353 85.5722 16.0142C85.9849 13.9846 86.3961 11.9549 86.786 9.92119C86.8784 9.44024 87.1035 9.22588 87.6006 9.25402C88.3093 9.29287 89.2229 9.03832 89.6731 9.39067C90.1206 9.73899 90.138 10.6768 90.284 11.3681C90.6176 12.9597 90.9096 14.5606 91.2339 16.1536C91.2674 16.317 91.408 16.459 91.4978 16.6117C91.5808 16.455 91.7269 16.3023 91.7389 16.1415C91.889 14.1185 92.0323 12.0942 92.1449 10.0699C92.1784 9.48043 92.4021 9.23124 93.0063 9.25134C93.9602 9.28215 94.9167 9.25937 95.9992 9.25937C95.8612 10.157 95.7433 10.9809 95.604 11.8022C95.1793 14.3061 94.7292 16.8073 94.3259 19.3153C94.2201 19.9731 93.995 20.308 93.2582 20.2598C92.3472 20.1995 91.4268 20.2008 90.5158 20.2598C89.8781 20.3013 89.6316 20.0227 89.5284 19.4586C89.2564 17.9689 88.9724 16.4818 88.683 14.9947C88.6415 14.783 88.5397 14.5847 88.4647 14.3798L88.2543 14.3945C88.182 14.7026 88.1029 15.0094 88.0373 15.3189C87.7412 16.735 87.4425 18.1524 87.1598 19.5712C87.0593 20.0776 86.7873 20.2692 86.2702 20.2531C85.2668 20.2236 84.262 20.2249 83.2585 20.2531C82.807 20.2651 82.6048 20.0736 82.5351 19.6596C81.9979 16.4724 81.4526 13.2879 80.9247 10.0994C80.8149 9.4322 80.985 9.26607 81.6763 9.26071C82.4346 9.25536 83.1942 9.28081 83.9525 9.25134C84.3799 9.23526 84.538 9.37995 84.5688 9.81C84.7148 11.9013 84.8863 13.9899 85.0511 16.0785C85.0645 16.25 85.0872 16.4202 85.106 16.5916L85.3405 16.6586Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M58.7188 5.17457C58.7188 5.4961 58.7188 5.73457 58.7188 5.97438C58.7188 10.3928 58.7188 14.8125 58.7188 19.2309C58.7188 20.1981 58.6692 20.2437 57.6832 20.245C56.7266 20.245 55.7701 20.245 54.7492 20.245C54.7305 19.8846 54.7332 19.5778 54.6943 19.2764C54.6648 19.05 54.5844 18.8316 54.5268 18.6092C54.3446 18.7432 54.1276 18.8477 53.9856 19.0165C53.3988 19.7132 52.7678 20.3107 51.7952 20.4178C50.0951 20.6041 48.6656 19.7091 48.5263 18.0157C48.3534 15.9151 48.399 13.7863 48.5075 11.6775C48.5731 10.3968 49.3663 9.47371 50.6618 9.16424C52.0082 8.84405 53.1804 9.15888 54.0472 10.3244C54.1651 10.4825 54.3621 10.583 54.5215 10.7103C54.5818 10.512 54.6903 10.3137 54.693 10.1141C54.7077 8.75295 54.7198 7.39046 54.693 6.03065C54.6809 5.38892 54.9435 5.15045 55.5691 5.16653C56.5913 5.19332 57.6135 5.17323 58.7188 5.17323V5.17457ZM54.6956 14.6879C54.6956 14.6879 54.6983 14.6879 54.6997 14.6879C54.6997 14.2431 54.7064 13.7983 54.6983 13.3535C54.6863 12.6984 54.4009 12.4827 53.522 12.4465C52.816 12.4171 52.4181 12.681 52.3953 13.2839C52.3605 14.1949 52.3578 15.1086 52.394 16.0182C52.4235 16.7778 53.1175 17.086 54.149 16.7658C54.3728 16.6961 54.6313 16.3866 54.6715 16.1522C54.7532 15.6766 54.6956 15.1782 54.6956 14.6892V14.6879Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M79.2702 14.5379C79.2702 14.9612 79.2809 15.3859 79.2675 15.8093C79.2528 16.3103 79.2072 16.7886 79.9052 16.9105C80.0419 16.9346 80.1999 17.2937 80.2107 17.504C80.2482 18.2609 80.2442 19.0219 80.2107 19.7788C80.204 19.9383 79.991 20.2276 79.8891 20.2209C78.942 20.1566 77.9747 20.1312 77.061 19.9034C76.5974 19.7882 76.1821 19.3247 75.8365 18.9375C75.5176 18.5811 75.3328 18.5651 75.0796 18.9563C74.1592 20.3804 72.7833 20.6202 71.2587 20.3549C69.998 20.1352 69.1861 19.1559 69.1192 17.8805C69.1004 17.5241 69.1031 17.1664 69.1084 16.81C69.1299 14.988 70.1775 13.8948 72.0036 13.8265C72.873 13.7943 73.7439 13.8359 74.6133 13.805C74.8692 13.7957 75.1197 13.6537 75.3743 13.5733C75.2376 13.241 75.18 12.8257 74.9416 12.6006C74.7326 12.4037 74.3159 12.3314 74.0038 12.3582C72.8087 12.456 71.6124 12.5725 70.4321 12.7735C69.8815 12.8672 69.6778 12.7574 69.6872 12.2108C69.6993 11.5423 69.6483 10.8684 69.7127 10.2052C69.7354 9.96676 69.9712 9.60906 70.1789 9.54877C72.2661 8.94992 74.3896 8.7436 76.5171 9.29556C78.3324 9.7658 79.2072 10.9876 79.2702 13.0012C79.2863 13.5143 79.2729 14.0274 79.2729 14.5405L79.2702 14.5379ZM74.0426 15.6244C73.6956 15.7972 73.0177 15.9258 72.944 16.2112C72.6828 17.22 72.8797 17.721 74.0212 17.512C74.3668 17.4491 74.7138 17.3379 75.0286 17.1838C75.4359 16.9842 75.5833 16.2473 75.235 15.9941C74.9576 15.7932 74.5504 15.7704 74.0426 15.623V15.6244Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M36.6847 6.1808C36.6847 6.91899 36.6834 7.60224 36.6847 8.28549C36.6874 9.2032 36.7437 9.25813 37.6855 9.25947C38.3527 9.26081 39.0212 9.25947 39.7862 9.25947C39.7862 10.4116 39.8062 11.5343 39.758 12.6543C39.7527 12.7816 39.3628 12.9745 39.1391 12.9933C38.563 13.0415 37.9802 13.0187 37.4001 13.0067C36.8937 12.9959 36.6646 13.2197 36.6807 13.7328C36.6981 14.2901 36.6727 14.8488 36.6887 15.4047C36.7102 16.1282 36.9848 16.4202 37.7016 16.4604C38.1906 16.4886 38.6822 16.4725 39.1739 16.4698C39.5731 16.4685 39.8196 16.6373 39.8223 17.0566C39.829 18.0802 39.825 19.105 39.825 20.2304C37.802 20.1822 35.7576 20.6095 34.0227 19.2176C33.1144 18.4888 32.7567 17.4572 32.691 16.3291C32.6468 15.5735 32.6709 14.8126 32.6669 14.0543C32.6615 13.0991 32.5879 13.0241 31.6501 13.0053C31.4973 13.0026 31.3433 12.9852 31.0927 12.9665C31.0927 11.8706 31.0633 10.7854 31.1249 9.70693C31.1343 9.54349 31.5683 9.28358 31.8081 9.27956C32.4512 9.26885 32.695 8.99688 32.6709 8.38061C32.6495 7.84607 32.6133 7.30214 32.695 6.77831C32.7299 6.55458 33.0407 6.21295 33.239 6.20358C34.3469 6.14731 35.4602 6.17678 36.6861 6.17678L36.6847 6.1808Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M46.9683 20.2397C45.9474 20.2397 45.0766 20.2732 44.2098 20.2317C42.7214 20.162 41.7796 19.2631 41.5947 17.7894C41.5639 17.5469 41.5545 17.3004 41.5545 17.0552C41.5518 13.4206 41.5518 9.78594 41.5545 6.15129C41.5545 5.30191 41.6778 5.17732 42.5124 5.17464C43.5547 5.17062 44.5983 5.17464 45.7063 5.17464C45.7063 5.531 45.7063 5.79493 45.7063 6.05885C45.7063 9.27015 45.7143 12.4814 45.6982 15.6914C45.6956 16.2206 45.7746 16.5515 46.4177 16.5167C46.5945 16.5073 46.9388 16.7551 46.9442 16.8945C46.9884 17.9957 46.9683 19.0996 46.9683 20.2384V20.2397Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M65.088 9.26208C65.088 9.88237 65.076 10.3915 65.0961 10.8992C65.1014 11.0265 65.2126 11.1497 65.2756 11.2757C65.3801 11.1819 65.5194 11.1069 65.5824 10.9903C65.7833 10.6206 65.9146 10.2093 66.1477 9.86361C66.6073 9.18304 67.3106 8.97404 68.0823 9.09729C68.2819 9.12945 68.5793 9.43222 68.5874 9.62112C68.6343 10.7559 68.6075 11.8933 68.6128 13.0307C68.6155 13.5184 68.3275 13.4487 67.9831 13.4353C67.2383 13.4058 66.4867 13.3991 65.7458 13.4607C65.2635 13.5009 65.0639 13.8305 65.0733 14.3543C65.1041 16.0263 65.0719 17.6983 65.0907 19.3702C65.0974 19.9865 64.8777 20.2745 64.2319 20.2531C63.3196 20.2236 62.4046 20.2317 61.4909 20.2531C61.1104 20.2611 60.9751 20.1111 60.9751 19.7467C60.9805 16.4925 60.9764 13.237 60.9805 9.98285C60.9805 9.58763 61.1117 9.26744 61.5833 9.26476C62.7154 9.2594 63.8474 9.26342 65.0867 9.26342L65.088 9.26208Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function UnkeyLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="94"
|
||||
height="24"
|
||||
viewBox="0 0 96 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M16.9012 20.0088C11.5775 20.0088 8.54297 17.1873 8.54297 12.3427V1.9082H11.3912V12.1564C11.3912 15.7499 12.9883 17.2405 16.9012 17.2405C20.8141 17.2405 22.4112 15.7499 22.4112 12.1564V1.9082H25.286V12.3427C25.286 17.1873 22.2515 20.0088 16.9012 20.0088ZM30.7083 19.7426H27.8335V6.51322H30.4688V10.6125H30.6551C31.0544 8.37651 32.8112 6.24703 36.1651 6.24703C39.8385 6.24703 41.6486 8.72256 41.6486 11.7837V19.7426H38.7738V12.5556C38.7738 10.0801 37.6558 8.82903 34.9141 8.82903C32.0127 8.82903 30.7083 10.3197 30.7083 13.1945V19.7426ZM47.085 19.7426H44.2102V1.9082H47.085V11.7571H50.8648L54.831 6.51322H58.1849L53.2072 12.8751L58.1583 19.7426H54.7777L50.8648 14.3391H47.085V19.7426ZM66.2537 20.0088C61.9149 20.0088 59.0667 17.5599 59.0667 13.1412C59.0667 9.01536 61.8883 6.24703 66.2005 6.24703C70.2997 6.24703 73.0947 8.50961 73.0947 12.529C73.0947 13.0081 73.0681 13.3808 72.9882 13.7801H61.7552C61.8616 16.3355 63.1127 17.693 66.1739 17.693C68.9422 17.693 70.0868 16.788 70.0868 15.2175V15.0045H72.9616V15.2441C72.9616 18.0657 70.1933 20.0088 66.2537 20.0088ZM66.1472 8.50961C63.2192 8.50961 61.9415 9.81392 61.7818 12.183H70.3796V12.1297C70.3796 9.68083 68.9688 8.50961 66.1472 8.50961ZM77.3773 24.2678H75.4874V21.6592H78.0694C79.2406 21.6592 79.7198 21.3398 80.1191 20.4347L80.4385 19.7426L73.9169 6.51322H77.1378L80.5183 13.5405L81.8226 16.7081H82.0356L83.2867 13.5139L86.401 6.51322H89.5686L82.6744 21.18C81.5831 23.5491 80.0924 24.2678 77.3773 24.2678Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { TldrawLogo } from "~/assets/logos/TldrawLogo";
|
||||
import { UnkeyLogo } from "~/assets/logos/UnkeyLogo";
|
||||
import { LyftLogo } from "~/assets/logos/LyftLogo";
|
||||
import { MiddayLogo } from "~/assets/logos/MiddayLogo";
|
||||
|
||||
interface QuoteType {
|
||||
quote: string;
|
||||
@@ -72,11 +76,12 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex flex-col items-center gap-4 px-8">
|
||||
<Paragraph>Trusted by developers at</Paragraph>
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-charcoal-500 xl:justify-between xl:gap-0">
|
||||
<VerizonLogo />
|
||||
<ShopifyLogo />
|
||||
<ATAndTLogo />
|
||||
<LyftLogo className="w-11" />
|
||||
<UnkeyLogo />
|
||||
<MiddayLogo />
|
||||
<AppsmithLogo />
|
||||
<CalComLogo />
|
||||
<TldrawLogo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { InlineCode } from "./code/InlineCode";
|
||||
import {
|
||||
@@ -8,7 +10,31 @@ import {
|
||||
} from "./primitives/ClientTabs";
|
||||
import { ClipboardField } from "./primitives/ClipboardField";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
|
||||
type PackageManagerContextType = {
|
||||
activePackageManager: string;
|
||||
setActivePackageManager: (value: string) => void;
|
||||
};
|
||||
|
||||
const PackageManagerContext = createContext<PackageManagerContextType | undefined>(undefined);
|
||||
|
||||
export function PackageManagerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activePackageManager, setActivePackageManager] = useState("npm");
|
||||
|
||||
return (
|
||||
<PackageManagerContext.Provider value={{ activePackageManager, setActivePackageManager }}>
|
||||
{children}
|
||||
</PackageManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function usePackageManager() {
|
||||
const context = useContext(PackageManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePackageManager must be used within a PackageManagerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function InitCommand({ appOrigin, apiKey }: { appOrigin: string; apiKey: string }) {
|
||||
return (
|
||||
@@ -131,7 +157,6 @@ export function TriggerDevStep({ extra }: { extra?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Trigger.dev version 3 setup commands
|
||||
const v3PackageTag = "latest";
|
||||
|
||||
function getApiUrlArg() {
|
||||
@@ -160,14 +185,19 @@ function getApiUrlArg() {
|
||||
export function InitCommandV3() {
|
||||
const project = useProject();
|
||||
const projectRef = project.ref;
|
||||
|
||||
const apiUrlArg = getApiUrlArg();
|
||||
|
||||
const initCommandParts = [`trigger.dev@${v3PackageTag}`, "init", `-p ${projectRef}`, apiUrlArg];
|
||||
const initCommand = initCommandParts.filter(Boolean).join(" ");
|
||||
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
@@ -202,8 +232,14 @@ export function InitCommandV3() {
|
||||
}
|
||||
|
||||
export function TriggerDevStepV3() {
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
@@ -238,8 +274,14 @@ export function TriggerDevStepV3() {
|
||||
}
|
||||
|
||||
export function TriggerLoginStepV3() {
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
|
||||
@@ -65,6 +65,7 @@ import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { FreePlanUsage } from "../billing/v2/FreePlanUsage";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
@@ -91,6 +92,34 @@ type SideMenuProps = {
|
||||
defaultValue?: FeedbackType;
|
||||
};
|
||||
|
||||
function V2Countdown() {
|
||||
const [days, setDays] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const targetDate = new Date("2025-01-31T00:00:00Z");
|
||||
|
||||
const calculateDays = () => {
|
||||
const now = new Date();
|
||||
const difference = targetDate.getTime() - now.getTime();
|
||||
return Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setDays(calculateDays());
|
||||
}, 1000 * 60 * 60); // Update every hour
|
||||
|
||||
setDays(calculateDays()); // Initial calculation
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Header2 className="flex-wrap gap-4 text-error">
|
||||
V2 goes offline in <span className="tabular-nums">{days}d</span>
|
||||
</Header2>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
@@ -215,7 +244,8 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
</div>
|
||||
<div className="m-2">
|
||||
{project.version === "V2" && (
|
||||
<div className="flex flex-col gap-3 rounded border border-success/50 bg-success/10 p-3">
|
||||
<div className="flex flex-col gap-3 rounded border border-error/50 bg-error/5 p-3">
|
||||
<V2Countdown />
|
||||
<Paragraph variant="small/bright">
|
||||
This is a v2 project. V2 will be deprecated on January 31, 2025.{" "}
|
||||
<TextLink
|
||||
|
||||
@@ -5,7 +5,11 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const ClientTabs = TabsPrimitive.Root;
|
||||
const ClientTabs = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
|
||||
>((props, ref) => <TabsPrimitive.Root ref={ref} {...props} />);
|
||||
ClientTabs.displayName = TabsPrimitive.Root.displayName;
|
||||
|
||||
const ClientTabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
|
||||
@@ -482,7 +482,11 @@ export function SelectItem({
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
<ShortcutKey
|
||||
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
|
||||
shortcut={shortcut}
|
||||
variant={"small"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectItem>
|
||||
|
||||
@@ -26,8 +26,8 @@ export function Spinner({
|
||||
foreground: "#3C4B62",
|
||||
},
|
||||
dark: {
|
||||
background: "#15171A",
|
||||
foreground: "#272A2E",
|
||||
background: "rgba(18, 19, 23, 0.35)",
|
||||
foreground: "#1A1B1F",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -70,16 +70,18 @@ type TableRowProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className, disabled, children }, ref) => {
|
||||
({ className, disabled, isSelected, children }, ref) => {
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
disabled && "opacity-50",
|
||||
"group/table-row relative w-full after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
|
||||
disabled && "opacity-50",
|
||||
isSelected && isSelectedStyle,
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -146,6 +148,7 @@ type TableCellProps = TableCellBasicProps & {
|
||||
isSticky?: boolean;
|
||||
actionClassName?: string;
|
||||
rowHoverStyle?: keyof typeof rowHoverStyles;
|
||||
isSelected?: boolean;
|
||||
};
|
||||
|
||||
const rowHoverStyles = {
|
||||
@@ -160,6 +163,8 @@ const rowHoverStyles = {
|
||||
const stickyStyles =
|
||||
"sticky right-0 bg-background-dimmed group-hover/table-row:bg-charcoal-750 w-[--sticky-width] [&:has(.group-hover\\/table-row\\:block)]:w-auto";
|
||||
|
||||
const isSelectedStyle = "bg-charcoal-750 group-hover:bg-charcoal-750";
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
(
|
||||
{
|
||||
@@ -173,6 +178,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
hasAction = false,
|
||||
isSticky = false,
|
||||
rowHoverStyle = "default",
|
||||
isSelected,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -203,7 +209,8 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
to || onClick || hasAction ? "cursor-pointer" : "px-3 py-3 align-middle",
|
||||
!to && !onClick && alignmentClassName,
|
||||
isSticky && stickyStyles,
|
||||
rowHoverStyles[rowHoverStyle],
|
||||
isSelected && isSelectedStyle,
|
||||
!isSelected && rowHoverStyles[rowHoverStyle],
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
@@ -259,10 +266,20 @@ export const TableCellMenu = forwardRef<
|
||||
hiddenButtons?: ReactNode;
|
||||
popoverContent?: ReactNode;
|
||||
children?: ReactNode;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, isSticky, onClick, visibleButtons, hiddenButtons, popoverContent, children },
|
||||
{
|
||||
className,
|
||||
isSticky,
|
||||
onClick,
|
||||
visibleButtons,
|
||||
hiddenButtons,
|
||||
popoverContent,
|
||||
children,
|
||||
isSelected,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -275,9 +292,17 @@ export const TableCellMenu = forwardRef<
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
hasAction={true}
|
||||
isSelected={isSelected}
|
||||
>
|
||||
<div className="relative p-1">
|
||||
<div className="absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 bg-background-dimmed p-0.5 group-hover/table-row:rounded-[0.25rem] group-hover/table-row:bg-background-bright group-hover/table-row:ring-1 group-hover/table-row:ring-grid-bright">
|
||||
<div className="relative h-full p-1">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 rounded-[0.25rem] bg-background-dimmed p-0.5 group-hover/table-row:bg-background-bright group-hover/table-row:ring-1 group-hover/table-row:ring-grid-bright",
|
||||
isSelected && isSelectedStyle,
|
||||
isSelected &&
|
||||
"group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/50"
|
||||
)}
|
||||
>
|
||||
{/* Hidden buttons that show on hover */}
|
||||
{hiddenButtons && (
|
||||
<div className="hidden pr-0.5 group-hover/table-row:block group-hover/table-row:border-r group-hover/table-row:border-grid-dimmed">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { loader } from "~/root";
|
||||
import { useEffect } from "react";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
@@ -39,23 +40,27 @@ export function ToastUI({
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`self-end rounded-md border border-grid-bright bg-background-dimmed`}
|
||||
className={cn(
|
||||
"self-end rounded-md border border-grid-bright bg-background-dimmed",
|
||||
variant === "success" && "border-success",
|
||||
variant === "error" && "border-error"
|
||||
)}
|
||||
style={{
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 h-6 min-h-[1.5rem] w-6 min-w-[1.5rem] text-green-600" />
|
||||
<CheckCircleIcon className="mt-1 size-6 min-w-6 text-success" />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 h-6 w-6 min-w-[1.5rem] text-rose-600" />
|
||||
<ExclamationCircleIcon className="mt-1 size-6 min-w-6 text-error" />
|
||||
)}
|
||||
<Paragraph className="py-1 text-text-dimmed">{message}</Paragraph>
|
||||
<Paragraph className="py-1 text-text-bright">{message}</Paragraph>
|
||||
<button
|
||||
className="hover:bg-midnight-800 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
type CheckBatchCompletionDialogProps = {
|
||||
batchId: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function CheckBatchCompletionDialog({
|
||||
batchId,
|
||||
redirectPath,
|
||||
}: CheckBatchCompletionDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/batches/${batchId}/check-completion`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="check-completion">
|
||||
<DialogHeader>Try and resume batch</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
In rare cases, parent runs don't continue after child runs have completed.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If this doesn't help, please get in touch. We are working on a permanent fix for this.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form action={`/resources/batches/${batchId}/check-completion`} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : undefined}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Attempting resume..." : "Attempt resume"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -118,3 +118,34 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PENDING and CANCELED are not used so are ommited from the UI
|
||||
export const deploymentStatuses: WorkerDeploymentStatus[] = [
|
||||
"BUILDING",
|
||||
"DEPLOYING",
|
||||
"DEPLOYED",
|
||||
"FAILED",
|
||||
"TIMED_OUT",
|
||||
];
|
||||
|
||||
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
|
||||
switch (status) {
|
||||
case "PENDING":
|
||||
return "The deployment is queued and waiting to be processed.";
|
||||
case "BUILDING":
|
||||
return "The code is being built and prepared for deployment.";
|
||||
case "DEPLOYING":
|
||||
return "The deployment is in progress and tasks are being indexed.";
|
||||
case "DEPLOYED":
|
||||
return "The deployment has completed successfully.";
|
||||
case "CANCELED":
|
||||
return "The deployment was manually canceled.";
|
||||
case "FAILED":
|
||||
return "The deployment encountered an error and could not complete.";
|
||||
case "TIMED_OUT":
|
||||
return "The deployment exceeded the maximum allowed time and was stopped.";
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ function StatusDropdown({
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<TooltipContent side="right" sideOffset={50}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForTaskRunStatus(item.value)}
|
||||
</Paragraph>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function RunInspector({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function SpanInspector({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
|
||||
@@ -2,15 +2,14 @@ import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
RectangleStackIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger.dev/database";
|
||||
import { TaskRunAttemptStatus } from "~/database-types";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { HourglassIcon, SnowflakeIcon } from "lucide-react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TaskRunAttemptStatus } from "~/database-types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allTaskRunAttemptStatuses = Object.values(
|
||||
@@ -65,7 +64,7 @@ export function TaskRunAttemptStatusIcon({
|
||||
case "EXECUTING":
|
||||
return <Spinner className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
case "PAUSED":
|
||||
return <SnowflakeIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
return <HourglassIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
case "FAILED":
|
||||
return <XCircleIcon className={cn(runAttemptStatusClassNameColor(status), className)} />;
|
||||
case "CANCELED":
|
||||
@@ -91,7 +90,7 @@ export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus
|
||||
case "EXECUTING":
|
||||
return "text-pending";
|
||||
case "PAUSED":
|
||||
return "text-sky-300";
|
||||
return "text-charcoal-500";
|
||||
case "FAILED":
|
||||
return "text-error";
|
||||
case "CANCELED":
|
||||
@@ -117,7 +116,7 @@ export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null):
|
||||
case "EXECUTING":
|
||||
return "Executing";
|
||||
case "PAUSED":
|
||||
return "Frozen";
|
||||
return "Waiting";
|
||||
case "FAILED":
|
||||
return "Failed";
|
||||
case "CANCELED":
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
NoSymbolIcon,
|
||||
PauseCircleIcon,
|
||||
RectangleStackIcon,
|
||||
StopIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { SnowflakeIcon } from "lucide-react";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -41,9 +40,9 @@ export const filterableTaskRunStatuses = [
|
||||
"WAITING_FOR_DEPLOY",
|
||||
"DELAYED",
|
||||
"PENDING",
|
||||
"WAITING_TO_RESUME",
|
||||
"EXECUTING",
|
||||
"RETRYING_AFTER_FAILURE",
|
||||
"WAITING_TO_RESUME",
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
@@ -55,21 +54,21 @@ export const filterableTaskRunStatuses = [
|
||||
] as const satisfies Readonly<Array<TaskRunStatus>>;
|
||||
|
||||
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
|
||||
DELAYED: "Task has been delayed and is waiting to be executed",
|
||||
PENDING: "Task is waiting to be executed",
|
||||
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
|
||||
EXECUTING: "Task is currently being executed",
|
||||
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure",
|
||||
WAITING_TO_RESUME: "Task has been frozen and is waiting to be resumed",
|
||||
COMPLETED_SUCCESSFULLY: "Task has been successfully completed",
|
||||
CANCELED: "Task has been canceled",
|
||||
COMPLETED_WITH_ERRORS: "Task has failed with errors",
|
||||
INTERRUPTED: "Task has failed because it was interrupted",
|
||||
SYSTEM_FAILURE: "Task has failed due to a system failure",
|
||||
PAUSED: "Task has been paused by the user",
|
||||
CRASHED: "Task has crashed and won't be retried",
|
||||
EXPIRED: "Task has surpassed its ttl and won't be executed",
|
||||
TIMED_OUT: "Task has failed because it exceeded its maxDuration",
|
||||
DELAYED: "Task has been delayed and is waiting to be executed.",
|
||||
PENDING: "Task is waiting to be executed.",
|
||||
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing.",
|
||||
EXECUTING: "Task is currently being executed.",
|
||||
RETRYING_AFTER_FAILURE: "Task is being reattempted after a failure.",
|
||||
WAITING_TO_RESUME: `You have used a "wait" function. When the wait is complete, the task will resume execution.`,
|
||||
COMPLETED_SUCCESSFULLY: "Task has been successfully completed.",
|
||||
CANCELED: "Task has been canceled.",
|
||||
COMPLETED_WITH_ERRORS: "Task has failed with errors.",
|
||||
INTERRUPTED: "Task has failed because it was interrupted.",
|
||||
SYSTEM_FAILURE: "Task has failed due to a system failure.",
|
||||
PAUSED: "Task has been paused by the user.",
|
||||
CRASHED: "Task has crashed and won't be retried.",
|
||||
EXPIRED: "Task has surpassed its ttl and won't be executed.",
|
||||
TIMED_OUT: "Task has failed because it exceeded its maxDuration.",
|
||||
};
|
||||
|
||||
export const QUEUED_STATUSES = [
|
||||
@@ -126,7 +125,7 @@ export function TaskRunStatusIcon({
|
||||
case "EXECUTING":
|
||||
return <Spinner className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "WAITING_TO_RESUME":
|
||||
return <SnowflakeIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
return <HourglassIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return <ArrowPathIcon className={cn(runStatusClassNameColor(status), className)} />;
|
||||
case "PAUSED":
|
||||
@@ -165,7 +164,7 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return "text-pending";
|
||||
case "WAITING_TO_RESUME":
|
||||
return "text-sky-300";
|
||||
return "text-charcoal-500";
|
||||
case "PAUSED":
|
||||
return "text-amber-300";
|
||||
case "CANCELED":
|
||||
@@ -200,7 +199,7 @@ export function runStatusTitle(status: TaskRunStatus): string {
|
||||
case "EXECUTING":
|
||||
return "Executing";
|
||||
case "WAITING_TO_RESUME":
|
||||
return "Frozen";
|
||||
return "Waiting";
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
return "Reattempting";
|
||||
case "PAUSED":
|
||||
|
||||
@@ -44,9 +44,16 @@ const EnvironmentSchema = z.object({
|
||||
HIGHLIGHT_PROJECT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
|
||||
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
FROM_EMAIL: z.string().optional(),
|
||||
REPLY_TO_EMAIL: z.string().optional(),
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.coerce.number().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASSWORD: z.string().optional(),
|
||||
|
||||
PLAIN_API_KEY: z.string().optional(),
|
||||
RUNTIME_PLATFORM: z.enum(["docker-compose", "ecs", "local"]).default("local"),
|
||||
WORKER_SCHEMA: z.string().default("graphile_worker"),
|
||||
@@ -195,8 +202,16 @@ const EnvironmentSchema = z.object({
|
||||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
|
||||
/** These enable the alerts feature in v3 */
|
||||
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
|
||||
ALERT_FROM_EMAIL: z.string().optional(),
|
||||
ALERT_REPLY_TO_EMAIL: z.string().optional(),
|
||||
ALERT_RESEND_API_KEY: z.string().optional(),
|
||||
ALERT_SMTP_HOST: z.string().optional(),
|
||||
ALERT_SMTP_PORT: z.coerce.number().optional(),
|
||||
ALERT_SMTP_SECURE: z.coerce.boolean().optional(),
|
||||
ALERT_SMTP_USER: z.string().optional(),
|
||||
ALERT_SMTP_PASSWORD: z.string().optional(),
|
||||
|
||||
|
||||
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
|
||||
|
||||
@@ -243,6 +258,8 @@ const EnvironmentSchema = z.object({
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ListRunResponse, ListRunResponseItem, RunStatus } from "@trigger.dev/core/v3";
|
||||
import { ListRunResponse, ListRunResponseItem, parsePacket, RunStatus } from "@trigger.dev/core/v3";
|
||||
import { Project, RuntimeEnvironment, TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { z } from "zod";
|
||||
@@ -220,36 +220,51 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
|
||||
const results = await presenter.call(options);
|
||||
|
||||
const data: ListRunResponseItem[] = results.runs.map((run) => {
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
version: run.version ?? undefined,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt),
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
|
||||
isTest: run.isTest,
|
||||
ttl: run.ttl ?? undefined,
|
||||
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
|
||||
env: {
|
||||
id: run.environment.id,
|
||||
name: run.environment.slug,
|
||||
user: run.environment.userName,
|
||||
},
|
||||
tags: run.tags,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
|
||||
),
|
||||
};
|
||||
});
|
||||
logger.debug("RunListPresenter results", { results });
|
||||
|
||||
const data: ListRunResponseItem[] = await Promise.all(
|
||||
results.runs.map(async (run) => {
|
||||
const metadata = await parsePacket(
|
||||
{
|
||||
data: run.metadata ?? undefined,
|
||||
dataType: run.metadataType,
|
||||
},
|
||||
{
|
||||
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
version: run.version ?? undefined,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt),
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
|
||||
isTest: run.isTest,
|
||||
ttl: run.ttl ?? undefined,
|
||||
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
|
||||
env: {
|
||||
id: run.environment.id,
|
||||
name: run.environment.slug,
|
||||
user: run.environment.userName,
|
||||
},
|
||||
tags: run.tags,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
metadata,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -108,6 +108,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
},
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
},
|
||||
triggeredBy: {
|
||||
@@ -145,6 +146,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
deployedBy: deployment.triggeredBy,
|
||||
sdkVersion: deployment.worker?.sdkVersion,
|
||||
cliVersion: deployment.worker?.cliVersion,
|
||||
imageReference: deployment.imageReference,
|
||||
externalBuildData:
|
||||
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
||||
|
||||
@@ -216,6 +216,8 @@ export class RunListPresenter extends BasePresenter {
|
||||
depth: number;
|
||||
rootTaskRunId: string | null;
|
||||
batchId: string | null;
|
||||
metadata: string | null;
|
||||
metadataType: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -241,7 +243,9 @@ export class RunListPresenter extends BasePresenter {
|
||||
tr."usageDurationMs" AS "usageDurationMs",
|
||||
tr."depth" AS "depth",
|
||||
tr."rootTaskRunId" AS "rootTaskRunId",
|
||||
tr."runTags" AS "tags"
|
||||
tr."runTags" AS "tags",
|
||||
tr."metadata" AS "metadata",
|
||||
tr."metadataType" AS "metadataType"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
@@ -374,6 +378,8 @@ WHERE
|
||||
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
|
||||
depth: run.depth,
|
||||
rootTaskRunId: run.rootTaskRunId,
|
||||
metadata: run.metadata,
|
||||
metadataType: run.metadataType,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
|
||||
@@ -215,7 +215,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
const span = await eventRepository.getSpan(spanId, run.traceId);
|
||||
|
||||
const metadata = run.metadata
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType, { filteredKeys: ["$$streams"] })
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType, {
|
||||
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const context = {
|
||||
|
||||
+59
-50
@@ -20,7 +20,12 @@ import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
|
||||
import {
|
||||
InitCommandV3,
|
||||
PackageManagerProvider,
|
||||
TriggerDevStepV3,
|
||||
TriggerLoginStepV3,
|
||||
} from "~/components/SetupCommands";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
@@ -431,38 +436,40 @@ export default function Page() {
|
||||
|
||||
function CreateTaskInstructions() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You'll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
|
||||
in here to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in your project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You’ll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We’ve added a very simple example task
|
||||
in here to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -484,26 +491,28 @@ function UserHasNoTasks() {
|
||||
}
|
||||
>
|
||||
{open ? (
|
||||
<div>
|
||||
<Header2 spacing>Get setup in 3 minutes</Header2>
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<Header2 spacing>Get setup in 3 minutes</Header2>
|
||||
|
||||
<StepNumber stepNumber="1" title="Open up your project" className="mt-6" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>You'll need to open a terminal at the root of your project.</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'login' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerLoginStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Open up your project" className="mt-6" />
|
||||
<StepContentContainer>
|
||||
<Paragraph>You'll need to open a terminal at the root of your project.</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'login' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerLoginStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="4" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
) : (
|
||||
"Your DEV environment isn't setup yet."
|
||||
)}
|
||||
|
||||
+70
-7
@@ -1,6 +1,10 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -8,16 +12,19 @@ import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
@@ -29,12 +36,17 @@ import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import {
|
||||
BatchList,
|
||||
BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
@@ -150,11 +162,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Finished</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to batch</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.length === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={7}>
|
||||
<TableBlankRow colSpan={8}>
|
||||
{!isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches</Paragraph>
|
||||
@@ -162,7 +177,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
)}
|
||||
</TableBlankRow>
|
||||
) : batches.length === 0 ? (
|
||||
<TableBlankRow colSpan={7}>
|
||||
<TableBlankRow colSpan={8}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches match these filters</Paragraph>
|
||||
</div>
|
||||
@@ -215,13 +230,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<TableCell to={path}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={7}
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
@@ -231,3 +247,50 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
@@ -151,6 +151,10 @@ export default function Page() {
|
||||
<Property.Label>SDK Version</Property.Label>
|
||||
<Property.Value>{deployment.sdkVersion ? deployment.sdkVersion : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>CLI Version</Property.Label>
|
||||
<Property.Value>{deployment.cliVersion ? deployment.cliVersion : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Started at</Property.Label>
|
||||
<Property.Value>
|
||||
|
||||
+52
-15
@@ -35,7 +35,11 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatuses,
|
||||
deploymentStatusDescription,
|
||||
} from "~/components/runs/v3/DeploymentStatus";
|
||||
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
|
||||
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -120,7 +124,30 @@ export default function Page() {
|
||||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
{deploymentStatuses.map((status) => (
|
||||
<div
|
||||
key={status}
|
||||
className="grid grid-cols-[8rem_1fr] gap-x-2 py-2 first:pt-1 last:pb-1"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1.5 whitespace-nowrap">
|
||||
<DeploymentStatus status={status} isBuilt={false} />
|
||||
</div>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="!text-wrap text-text-dimmed"
|
||||
>
|
||||
{deploymentStatusDescription(status)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Status
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Tasks</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed at</TableHeaderCell>
|
||||
<TableHeaderCell>Deployed by</TableHeaderCell>
|
||||
@@ -142,11 +169,8 @@ export default function Page() {
|
||||
);
|
||||
const isSelected = deploymentParam === deployment.shortCode;
|
||||
return (
|
||||
<TableRow
|
||||
key={deployment.id}
|
||||
className={cn("group", isSelected ? "bg-grid-dimmed" : undefined)}
|
||||
>
|
||||
<TableCell to={path}>
|
||||
<TableRow key={deployment.id} className="group" isSelected={isSelected}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small">{deployment.shortCode}</Paragraph>
|
||||
{deployment.label && (
|
||||
@@ -154,30 +178,32 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<EnvironmentLabel
|
||||
environment={deployment.environment}
|
||||
userName={usernameForEnv}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>{deployment.version}</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.version}
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<DeploymentStatus
|
||||
status={deployment.status}
|
||||
isBuilt={deployment.isBuilt}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.tasksCount !== null ? deployment.tasksCount : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.deployedAt ? (
|
||||
<DateTime date={deployment.deployedAt} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.deployedBy ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserAvatar
|
||||
@@ -197,7 +223,11 @@ export default function Page() {
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<DeploymentActionsCell deployment={deployment} path={path} />
|
||||
<DeploymentActionsCell
|
||||
deployment={deployment}
|
||||
path={path}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
@@ -282,9 +312,11 @@ function CreateDeploymentInstructions() {
|
||||
function DeploymentActionsCell({
|
||||
deployment,
|
||||
path,
|
||||
isSelected,
|
||||
}: {
|
||||
deployment: DeploymentListItem;
|
||||
path: string;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const project = useProject();
|
||||
@@ -293,12 +325,17 @@ function DeploymentActionsCell({
|
||||
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
|
||||
|
||||
if (!canRollback && !canRetryIndexing) {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
return (
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{""}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
isSelected={isSelected}
|
||||
popoverContent={
|
||||
<>
|
||||
{canRollback && (
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
);
|
||||
e.preventDefault();
|
||||
},
|
||||
[currentPayloadJson, currentMetadataJson]
|
||||
[currentPayloadJson, currentMetadataJson, task]
|
||||
);
|
||||
|
||||
const [form, { environmentId, payload }] = useForm({
|
||||
|
||||
@@ -209,6 +209,7 @@ export default function Page() {
|
||||
title="Unlock more team members"
|
||||
to={v3BillingPath(organization)}
|
||||
buttonLabel="Upgrade"
|
||||
panelClassName="mt-4 max-w-sm"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
You've used all {limits.limit} of your available team members. Upgrade your plan to
|
||||
|
||||
@@ -9,15 +9,21 @@ import { env } from "~/env.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
|
||||
import {
|
||||
BatchProcessingStrategy,
|
||||
BatchTriggerV2Service,
|
||||
} from "~/v3/services/batchTriggerV2.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
headers: HeadersSchema.extend({
|
||||
"batch-processing-strategy": BatchProcessingStrategy.nullish(),
|
||||
}),
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
|
||||
@@ -52,6 +58,7 @@ const { action, loader } = createActionApiRoute(
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
"batch-processing-strategy": batchProcessingStrategy,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
@@ -67,6 +74,7 @@ const { action, loader } = createActionApiRoute(
|
||||
triggerClient,
|
||||
traceparent,
|
||||
tracestate,
|
||||
batchProcessingStrategy,
|
||||
});
|
||||
|
||||
const traceContext =
|
||||
@@ -79,7 +87,7 @@ const { action, loader } = createActionApiRoute(
|
||||
resolveIdempotencyKeyTTL(idempotencyKeyTTL) ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30);
|
||||
|
||||
const service = new BatchTriggerV2Service();
|
||||
const service = new BatchTriggerV2Service(batchProcessingStrategy ?? undefined);
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const loader = createLoaderApiRoute(
|
||||
findResource: (params, auth) => {
|
||||
return ApiRetrieveRunPresenter.findRun(params.runId, auth.environment);
|
||||
},
|
||||
shouldRetryNotFound: true,
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { realtimeStreams } from "~/services/realtimeStreamsGlobal.server";
|
||||
import { relayRealtimeStreams } from "~/services/realtime/relayRealtimeStreams.server";
|
||||
import { v1RealtimeStreams } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -16,7 +17,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
return realtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
|
||||
return relayRealtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
|
||||
}
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
@@ -50,7 +51,13 @@ export const loader = createLoaderApiRoute(
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run }) => {
|
||||
return realtimeStreams.streamResponse(run.friendlyId, params.streamId, request.signal);
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
return relayRealtimeStreams.streamResponse(
|
||||
request,
|
||||
run.friendlyId,
|
||||
params.streamId,
|
||||
authentication.environment,
|
||||
request.signal
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { v2RealtimeStreams } from "~/services/realtime/v2StreamsGlobal.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
},
|
||||
async ({ request, params, authentication }) => {
|
||||
if (!request.body) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
return v2RealtimeStreams.ingestData(request.body, run.id, params.streamId);
|
||||
}
|
||||
);
|
||||
|
||||
export { action };
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async (params, auth) => {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
return v2RealtimeStreams.streamResponse(
|
||||
request,
|
||||
run.id,
|
||||
params.streamId,
|
||||
authentication.environment,
|
||||
request.signal
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
|
||||
|
||||
export const checkCompletionSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
});
|
||||
|
||||
const ParamSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { batchId } = ParamSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: checkCompletionSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const resumeBatchRunService = new ResumeBatchRunService();
|
||||
const resumeResult = await resumeBatchRunService.call(batchId);
|
||||
|
||||
let message: string | undefined;
|
||||
|
||||
switch (resumeResult) {
|
||||
case "ERROR": {
|
||||
throw "Unknown error during batch completion check";
|
||||
}
|
||||
case "ALREADY_COMPLETED": {
|
||||
message = "Batch already completed.";
|
||||
break;
|
||||
}
|
||||
case "COMPLETED": {
|
||||
message = "Batch completed and parent tasks resumed.";
|
||||
break;
|
||||
}
|
||||
case "PENDING": {
|
||||
message = "Child runs still in progress. Please try again later.";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertExhaustive(resumeResult);
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(submission.value.redirectUrl, request, message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to check batch completion", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return redirectWithErrorMessage(submission.value.redirectUrl, request, error.message);
|
||||
} else {
|
||||
logger.error("Failed to check batch completion", { error });
|
||||
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Unknown error");
|
||||
}
|
||||
}
|
||||
};
|
||||
+2
-2
@@ -201,7 +201,7 @@ function SpanBody({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
@@ -442,7 +442,7 @@ function RunBody({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ShieldCheckIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowDownCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowDownCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Form, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { uiComponent } from "@team-plain/typescript-sdk";
|
||||
@@ -633,6 +633,11 @@ export function TierPro({
|
||||
const navigation = useNavigation();
|
||||
const formAction = `/resources/orgs/${organizationSlug}/select-plan`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDialogOpen(false);
|
||||
}, [subscription]);
|
||||
|
||||
return (
|
||||
<TierContainer>
|
||||
@@ -645,27 +650,67 @@ export function TierPro({
|
||||
<input type="hidden" name="type" value="paid" />
|
||||
<input type="hidden" name="planCode" value={plan.code} />
|
||||
<input type="hidden" name="callerPath" value={location.pathname} />
|
||||
<Button
|
||||
variant="tertiary/large"
|
||||
fullWidth
|
||||
form="subscribe-pro"
|
||||
className="text-md font-medium"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
|
||||
}
|
||||
LeadingIcon={
|
||||
isLoading && navigation.formData?.get("planCode") === plan.code ? Spinner : undefined
|
||||
}
|
||||
>
|
||||
{subscription?.plan === undefined
|
||||
? "Select plan"
|
||||
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
|
||||
? `Upgrade to ${plan.title}`
|
||||
: subscription.plan.code === plan.code
|
||||
? "Current plan"
|
||||
: `Upgrade to ${plan.title}`}
|
||||
</Button>
|
||||
{subscription?.plan !== undefined &&
|
||||
subscription?.plan?.type === "paid" &&
|
||||
subscription?.plan?.code !== plan.code &&
|
||||
subscription.canceledAt === undefined ? (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen} key="upgrade">
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="tertiary/large" fullWidth className="text-md font-medium">
|
||||
{`Upgrade to ${plan.title}`}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Upgrade plan</DialogHeader>
|
||||
<div className="mb-2 mt-4 flex items-start gap-3">
|
||||
<span>
|
||||
<ArrowUpCircleIcon className="size-12 text-primary" />
|
||||
</span>
|
||||
<Paragraph variant="base/bright" className="text-text-bright">
|
||||
Upgrade to get instant access to all the Pro features. You will be charged the
|
||||
new plan price for the remainder of this month on a pro rata basis.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary/medium" onClick={() => setIsDialogOpen(false)}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? () => <Spinner color="dark" /> : undefined}
|
||||
form="subscribe-pro"
|
||||
>
|
||||
{`Upgrade to ${plan.title}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary/large"
|
||||
fullWidth
|
||||
form="subscribe-pro"
|
||||
className="text-md font-medium"
|
||||
disabled={
|
||||
isLoading ||
|
||||
(subscription?.plan?.code === plan.code && subscription.canceledAt === undefined)
|
||||
}
|
||||
LeadingIcon={
|
||||
isLoading && navigation.formData?.get("planCode") === plan.code
|
||||
? Spinner
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{subscription?.plan === undefined
|
||||
? "Select plan"
|
||||
: subscription.plan.type === "free" || subscription.canceledAt !== undefined
|
||||
? `Upgrade to ${plan.title}`
|
||||
: subscription.plan.code === plan.code
|
||||
? "Current plan"
|
||||
: `Upgrade to ${plan.title}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
<ul className="flex flex-col gap-2.5">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-3 p-4">
|
||||
<div className="flex items-center gap-x-4 rounded-md bg-charcoal-750 px-3 py-2 text-text-bright">
|
||||
Blue: <Spinner color="blue" />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 rounded-md bg-charcoal-750 px-3 py-2 text-text-bright">
|
||||
White: <Spinner color="white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 rounded-md bg-charcoal-600 px-3 py-2 text-text-bright">
|
||||
Muted: <Spinner color="muted" />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center gap-x-4 rounded-md bg-charcoal-600 px-3 py-2 text-text-bright">
|
||||
Dark: <Spinner color="dark" />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 rounded-md bg-primary px-2 py-2 text-text-bright">
|
||||
<Spinner color="dark" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 rounded-md bg-charcoal-600 px-3 py-2 text-text-bright">
|
||||
Custom: <Spinner color={{ background: "#EA189E", foreground: "#6532F5" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,20 @@ export default function Story() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-4 p-4">
|
||||
<ToastUI variant="success" message="Success UI" t="-" />
|
||||
<ToastUI variant="error" message="Error UI" t="-" />
|
||||
<ToastUI
|
||||
variant="success"
|
||||
message="This is a long success message that wraps over multiple lines so we can test the UI."
|
||||
t="-"
|
||||
/>
|
||||
<ToastUI variant="error" message="Error UI" t="-" />
|
||||
<ToastUI
|
||||
variant="error"
|
||||
message="This is a long error message that wraps over multiple lines so we can test the UI."
|
||||
t="-"
|
||||
/>
|
||||
<br />
|
||||
<Button
|
||||
variant="primary/large"
|
||||
variant="primary/medium"
|
||||
onClick={() =>
|
||||
toast.custom((t) => <ToastUI variant="success" message="Success" t={t as string} />, {
|
||||
duration: Infinity, // Prevents auto-dismissal for demo purposes
|
||||
@@ -24,7 +29,7 @@ export default function Story() {
|
||||
Trigger success toast
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger/large"
|
||||
variant="danger/medium"
|
||||
onClick={() =>
|
||||
toast.custom((t) => <ToastUI variant="error" message="Error" t={t as string} />, {
|
||||
duration: Infinity,
|
||||
|
||||
@@ -84,6 +84,10 @@ const stories: Story[] = [
|
||||
name: "Shortcuts",
|
||||
slug: "shortcuts",
|
||||
},
|
||||
{
|
||||
name: "Spinners",
|
||||
slug: "spinner",
|
||||
},
|
||||
{
|
||||
name: "Switch",
|
||||
slug: "switch",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DeliverEmail, SendPlainTextOptions } from "emails";
|
||||
import { EmailClient } from "emails";
|
||||
import { EmailClient, MailTransportOptions } from "emails";
|
||||
import type { SendEmailOptions } from "remix-auth-email-link";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
@@ -13,7 +13,7 @@ const client = singleton(
|
||||
"email-client",
|
||||
() =>
|
||||
new EmailClient({
|
||||
apikey: env.RESEND_API_KEY,
|
||||
transport: buildTransportOptions(),
|
||||
imagesBaseUrl: env.APP_ORIGIN,
|
||||
from: env.FROM_EMAIL ?? "team@email.trigger.dev",
|
||||
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
|
||||
@@ -24,13 +24,45 @@ const alertsClient = singleton(
|
||||
"alerts-email-client",
|
||||
() =>
|
||||
new EmailClient({
|
||||
apikey: env.ALERT_RESEND_API_KEY,
|
||||
transport: buildTransportOptions(true),
|
||||
imagesBaseUrl: env.APP_ORIGIN,
|
||||
from: env.ALERT_FROM_EMAIL ?? "noreply@alerts.trigger.dev",
|
||||
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
|
||||
})
|
||||
);
|
||||
|
||||
function buildTransportOptions(alerts?: boolean): MailTransportOptions {
|
||||
const transportType = alerts ? env.ALERT_EMAIL_TRANSPORT : env.EMAIL_TRANSPORT
|
||||
logger.debug(`Constructing email transport '${transportType}' for usage '${alerts?'alerts':'general'}'`)
|
||||
|
||||
switch (transportType) {
|
||||
case "aws-ses":
|
||||
return { type: "aws-ses" };
|
||||
case "resend":
|
||||
return {
|
||||
type: "resend",
|
||||
config: {
|
||||
apiKey: alerts ? env.ALERT_RESEND_API_KEY : env.RESEND_API_KEY,
|
||||
}
|
||||
}
|
||||
case "smtp":
|
||||
return {
|
||||
type: "smtp",
|
||||
config: {
|
||||
host: alerts ? env.ALERT_SMTP_HOST : env.SMTP_HOST,
|
||||
port: alerts ? env.ALERT_SMTP_PORT : env.SMTP_PORT,
|
||||
secure: alerts ? env.ALERT_SMTP_SECURE : env.SMTP_SECURE,
|
||||
auth: {
|
||||
user: alerts ? env.ALERT_SMTP_USER : env.SMTP_USER,
|
||||
pass: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD
|
||||
}
|
||||
}
|
||||
};
|
||||
default:
|
||||
return { type: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
|
||||
// Auto redirect when in development mode
|
||||
if (env.NODE_ENV === "development") {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { RealtimeClient } from "../realtimeClient.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
|
||||
export type DatabaseRealtimeStreamsOptions = {
|
||||
prisma: PrismaClient;
|
||||
realtimeClient: RealtimeClient;
|
||||
};
|
||||
|
||||
// Class implementing both interfaces
|
||||
export class DatabaseRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
constructor(private options: DatabaseRealtimeStreamsOptions) {}
|
||||
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
return this.options.realtimeClient.streamChunks(
|
||||
request.url,
|
||||
environment,
|
||||
runId,
|
||||
streamId,
|
||||
signal,
|
||||
request.headers.get("x-trigger-electric-version") ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
async ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const textStream = stream.pipeThrough(new TextDecoderStream());
|
||||
|
||||
const reader = textStream.getReader();
|
||||
let sequence = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done || !value) {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.debug("[DatabaseRealtimeStreams][ingestData] Reading data", {
|
||||
streamId,
|
||||
runId,
|
||||
value,
|
||||
});
|
||||
|
||||
await this.options.prisma.realtimeStreamChunk.create({
|
||||
data: {
|
||||
runId,
|
||||
key: streamId,
|
||||
sequence: sequence++,
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("[DatabaseRealtimeStreams][ingestData] Error in ingestData:", { error });
|
||||
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-44
@@ -1,5 +1,8 @@
|
||||
import Redis, { RedisKey, RedisOptions, RedisValue } from "ioredis";
|
||||
import { logger } from "./logger.server";
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
import { LineTransformStream } from "./utils.server";
|
||||
|
||||
export type RealtimeStreamsOptions = {
|
||||
redis: RedisOptions | undefined;
|
||||
@@ -7,10 +10,17 @@ export type RealtimeStreamsOptions = {
|
||||
|
||||
const END_SENTINEL = "<<CLOSE_STREAM>>";
|
||||
|
||||
export class RealtimeStreams {
|
||||
// Class implementing both interfaces
|
||||
export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
constructor(private options: RealtimeStreamsOptions) {}
|
||||
|
||||
async streamResponse(runId: string, streamId: string, signal: AbortSignal): Promise<Response> {
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
const redis = new Redis(this.options.redis ?? {});
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
let isCleanedUp = false;
|
||||
@@ -47,7 +57,7 @@ export class RealtimeStreams {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(`data: ${fields[1]}\n\n`);
|
||||
controller.enqueue(fields[1]);
|
||||
|
||||
if (signal.aborted) {
|
||||
controller.close();
|
||||
@@ -79,7 +89,18 @@ export class RealtimeStreams {
|
||||
cancel: async () => {
|
||||
await cleanup();
|
||||
},
|
||||
});
|
||||
})
|
||||
.pipeThrough(new LineTransformStream())
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
for (const line of chunk) {
|
||||
controller.enqueue(`data: ${line}\n\n`);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeThrough(new TextEncoderStream());
|
||||
|
||||
async function cleanup() {
|
||||
if (isCleanedUp) return;
|
||||
@@ -89,7 +110,7 @@ export class RealtimeStreams {
|
||||
|
||||
signal.addEventListener("abort", cleanup);
|
||||
|
||||
return new Response(stream.pipeThrough(new TextEncoderStream()), {
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
@@ -110,59 +131,30 @@ export class RealtimeStreams {
|
||||
try {
|
||||
await redis.quit();
|
||||
} catch (error) {
|
||||
logger.error("[RealtimeStreams][ingestData] Error in cleanup:", { error });
|
||||
logger.error("[RedisRealtimeStreams][ingestData] Error in cleanup:", { error });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use TextDecoderStream to simplify text decoding
|
||||
const textStream = stream.pipeThrough(new TextDecoderStream());
|
||||
const reader = textStream.getReader();
|
||||
|
||||
const batchSize = 10; // Adjust this value based on performance testing
|
||||
let batchCommands: Array<[key: RedisKey, ...args: RedisValue[]]> = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
if (done || !value) {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.debug("[RealtimeStreams][ingestData] Reading data", { streamKey, value });
|
||||
logger.debug("[RedisRealtimeStreams][ingestData] Reading data", {
|
||||
streamKey,
|
||||
runId,
|
||||
value,
|
||||
});
|
||||
|
||||
// 'value' is a string containing the decoded text
|
||||
const lines = value.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
// Avoid unnecessary parsing; assume 'line' is already a JSON string
|
||||
// Add XADD command with MAXLEN option to limit stream size
|
||||
batchCommands.push([streamKey, "MAXLEN", "~", "2500", "*", "data", line]);
|
||||
|
||||
if (batchCommands.length >= batchSize) {
|
||||
// Send batch using a pipeline
|
||||
const pipeline = redis.pipeline();
|
||||
for (const args of batchCommands) {
|
||||
pipeline.xadd(...args);
|
||||
}
|
||||
await pipeline.exec();
|
||||
batchCommands = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
await redis.xadd(streamKey, "MAXLEN", "~", "1000", "*", "data", value);
|
||||
}
|
||||
|
||||
// Send any remaining commands
|
||||
if (batchCommands.length > 0) {
|
||||
const pipeline = redis.pipeline();
|
||||
for (const args of batchCommands) {
|
||||
pipeline.xadd(...args);
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
// Send the __end message to indicate the end of the stream
|
||||
await redis.xadd(streamKey, "MAXLEN", "~", "1000", "*", "data", END_SENTINEL);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
@@ -0,0 +1,257 @@
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
import { LineTransformStream } from "./utils.server";
|
||||
import { v1RealtimeStreams } from "./v1StreamsGlobal.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
export type RelayRealtimeStreamsOptions = {
|
||||
ttl: number;
|
||||
cleanupInterval: number;
|
||||
fallbackIngestor: StreamIngestor;
|
||||
fallbackResponder: StreamResponder;
|
||||
waitForBufferTimeout?: number; // Time to wait for buffer in ms (default: 500ms)
|
||||
waitForBufferInterval?: number; // Polling interval in ms (default: 50ms)
|
||||
};
|
||||
|
||||
interface RelayedStreamRecord {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
createdAt: number;
|
||||
lastAccessed: number;
|
||||
locked: boolean;
|
||||
finalized: boolean;
|
||||
}
|
||||
|
||||
export class RelayRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
private _buffers: Map<string, RelayedStreamRecord> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
private waitForBufferTimeout: number;
|
||||
private waitForBufferInterval: number;
|
||||
|
||||
constructor(private options: RelayRealtimeStreamsOptions) {
|
||||
this.waitForBufferTimeout = options.waitForBufferTimeout ?? 1200;
|
||||
this.waitForBufferInterval = options.waitForBufferInterval ?? 50;
|
||||
|
||||
// Periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, this.options.cleanupInterval).unref();
|
||||
}
|
||||
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
let record = this._buffers.get(`${runId}:${streamId}`);
|
||||
|
||||
if (!record) {
|
||||
logger.debug(
|
||||
"[RelayRealtimeStreams][streamResponse] No ephemeral record found, waiting to see if one becomes available",
|
||||
{
|
||||
streamId,
|
||||
runId,
|
||||
}
|
||||
);
|
||||
|
||||
record = await this.waitForBuffer(`${runId}:${streamId}`);
|
||||
|
||||
if (!record) {
|
||||
logger.debug(
|
||||
"[RelayRealtimeStreams][streamResponse] No ephemeral record found, using fallback",
|
||||
{
|
||||
streamId,
|
||||
runId,
|
||||
}
|
||||
);
|
||||
|
||||
// No ephemeral record, use fallback
|
||||
return this.options.fallbackResponder.streamResponse(
|
||||
request,
|
||||
runId,
|
||||
streamId,
|
||||
environment,
|
||||
signal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only 1 reader of the stream can use the relayed stream, the rest should use the fallback
|
||||
if (record.locked) {
|
||||
logger.debug("[RelayRealtimeStreams][streamResponse] Stream already locked, using fallback", {
|
||||
streamId,
|
||||
runId,
|
||||
});
|
||||
|
||||
return this.options.fallbackResponder.streamResponse(
|
||||
request,
|
||||
runId,
|
||||
streamId,
|
||||
environment,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
record.locked = true;
|
||||
record.lastAccessed = Date.now();
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][streamResponse] Streaming from ephemeral record", {
|
||||
streamId,
|
||||
runId,
|
||||
});
|
||||
|
||||
// Create a streaming response from the buffered data
|
||||
const stream = record.stream
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new LineTransformStream())
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
for (const line of chunk) {
|
||||
controller.enqueue(`data: ${line}\n\n`);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeThrough(new TextEncoderStream());
|
||||
|
||||
// Once we start streaming, consider deleting the buffer when done.
|
||||
// For a simple approach, we can rely on finalized and no more reads.
|
||||
// Or we can let TTL cleanup handle it if multiple readers might come in.
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"x-trigger-relay-realtime-streams": "true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<Response> {
|
||||
const [localStream, fallbackStream] = stream.tee();
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][ingestData] Ingesting data", { runId, streamId });
|
||||
|
||||
// Handle local buffering asynchronously and catch errors
|
||||
this.handleLocalIngestion(localStream, runId, streamId).catch((err) => {
|
||||
logger.error("[RelayRealtimeStreams][ingestData] Error in local ingestion:", { err });
|
||||
});
|
||||
|
||||
// Forward to the fallback ingestor asynchronously and catch errors
|
||||
return this.options.fallbackIngestor.ingestData(fallbackStream, runId, streamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles local buffering of the stream data.
|
||||
* @param stream The readable stream to buffer.
|
||||
* @param streamId The unique identifier for the stream.
|
||||
*/
|
||||
private async handleLocalIngestion(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
) {
|
||||
this.createOrUpdateRelayedStream(`${runId}:${streamId}`, stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an existing buffer or creates a new one for the given streamId.
|
||||
* @param streamId The unique identifier for the stream.
|
||||
*/
|
||||
private createOrUpdateRelayedStream(
|
||||
bufferKey: string,
|
||||
stream: ReadableStream<Uint8Array>
|
||||
): RelayedStreamRecord {
|
||||
let record = this._buffers.get(bufferKey);
|
||||
if (!record) {
|
||||
record = {
|
||||
stream,
|
||||
createdAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
finalized: false,
|
||||
locked: false,
|
||||
};
|
||||
this._buffers.set(bufferKey, record);
|
||||
} else {
|
||||
record.lastAccessed = Date.now();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now();
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][cleanup] Cleaning up old buffers", {
|
||||
bufferCount: this._buffers.size,
|
||||
});
|
||||
|
||||
for (const [key, record] of this._buffers.entries()) {
|
||||
// If last accessed is older than ttl, clean up
|
||||
if (now - record.lastAccessed > this.options.ttl) {
|
||||
this.deleteBuffer(key);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][cleanup] Cleaned up old buffers", {
|
||||
bufferCount: this._buffers.size,
|
||||
});
|
||||
}
|
||||
|
||||
private deleteBuffer(bufferKey: string) {
|
||||
this._buffers.delete(bufferKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a buffer to be created within a specified timeout.
|
||||
* @param streamId The unique identifier for the stream.
|
||||
* @returns A promise that resolves to true if the buffer was created, false otherwise.
|
||||
*/
|
||||
private async waitForBuffer(bufferKey: string): Promise<RelayedStreamRecord | undefined> {
|
||||
const timeout = this.waitForBufferTimeout;
|
||||
const interval = this.waitForBufferInterval;
|
||||
const maxAttempts = Math.ceil(timeout / interval);
|
||||
let attempts = 0;
|
||||
|
||||
return new Promise<RelayedStreamRecord | undefined>((resolve) => {
|
||||
const checkBuffer = () => {
|
||||
attempts++;
|
||||
if (this._buffers.has(bufferKey)) {
|
||||
resolve(this._buffers.get(bufferKey));
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
setTimeout(checkBuffer, interval);
|
||||
};
|
||||
checkBuffer();
|
||||
});
|
||||
}
|
||||
|
||||
// Don't forget to clear interval on shutdown if needed
|
||||
close() {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeRelayRealtimeStreams() {
|
||||
return new RelayRealtimeStreams({
|
||||
ttl: 1000 * 60 * 5, // 5 minutes
|
||||
cleanupInterval: 1000 * 60, // 1 minute
|
||||
fallbackIngestor: v1RealtimeStreams,
|
||||
fallbackResponder: v1RealtimeStreams,
|
||||
});
|
||||
}
|
||||
|
||||
export const relayRealtimeStreams = singleton(
|
||||
"relayRealtimeStreams",
|
||||
initializeRelayRealtimeStreams
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
|
||||
// Interface for stream ingestion
|
||||
export interface StreamIngestor {
|
||||
ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<Response>;
|
||||
}
|
||||
|
||||
// Interface for stream response
|
||||
export interface StreamResponder {
|
||||
streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export class LineTransformStream extends TransformStream<string, string[]> {
|
||||
private buffer = "";
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
transform: (chunk, controller) => {
|
||||
// Append the chunk to the buffer
|
||||
this.buffer += chunk;
|
||||
|
||||
// Split on newlines
|
||||
const lines = this.buffer.split("\n");
|
||||
|
||||
// The last element might be incomplete, hold it back in buffer
|
||||
this.buffer = lines.pop() || "";
|
||||
|
||||
// Filter out empty or whitespace-only lines
|
||||
const fullLines = lines.filter((line) => line.trim().length > 0);
|
||||
|
||||
// If we got any complete lines, emit them as an array
|
||||
if (fullLines.length > 0) {
|
||||
controller.enqueue(fullLines);
|
||||
}
|
||||
},
|
||||
flush: (controller) => {
|
||||
// On stream end, if there's leftover text, emit it as a single-element array
|
||||
const trimmed = this.buffer.trim();
|
||||
if (trimmed.length > 0) {
|
||||
controller.enqueue([trimmed]);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { RealtimeStreams } from "./realtimeStreams.server";
|
||||
import { RedisRealtimeStreams } from "./redisRealtimeStreams.server";
|
||||
|
||||
function initializeRealtimeStreams() {
|
||||
return new RealtimeStreams({
|
||||
function initializeRedisRealtimeStreams() {
|
||||
return new RedisRealtimeStreams({
|
||||
redis: {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
@@ -16,4 +16,4 @@ function initializeRealtimeStreams() {
|
||||
});
|
||||
}
|
||||
|
||||
export const realtimeStreams = singleton("realtimeStreams", initializeRealtimeStreams);
|
||||
export const v1RealtimeStreams = singleton("realtimeStreams", initializeRedisRealtimeStreams);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { realtimeClient } from "../realtimeClientGlobal.server";
|
||||
import { DatabaseRealtimeStreams } from "./databaseRealtimeStreams.server";
|
||||
|
||||
function initializeDatabaseRealtimeStreams() {
|
||||
return new DatabaseRealtimeStreams({
|
||||
prisma,
|
||||
realtimeClient,
|
||||
});
|
||||
}
|
||||
|
||||
export const v2RealtimeStreams = singleton("dbRealtimeStreams", initializeDatabaseRealtimeStreams);
|
||||
@@ -37,6 +37,23 @@ export class RealtimeClient {
|
||||
this.#registerCommands();
|
||||
}
|
||||
|
||||
async streamChunks(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
signal?: AbortSignal,
|
||||
clientVersion?: string
|
||||
) {
|
||||
return this.#streamChunksWhere(
|
||||
url,
|
||||
environment,
|
||||
`"runId"='${runId}' AND "key"='${streamId}'`,
|
||||
signal,
|
||||
clientVersion
|
||||
);
|
||||
}
|
||||
|
||||
async streamRun(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
@@ -85,12 +102,12 @@ export class RealtimeClient {
|
||||
whereClause: string,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const electricUrl = this.#constructElectricUrl(url, whereClause, clientVersion);
|
||||
const electricUrl = this.#constructRunsElectricUrl(url, whereClause, clientVersion);
|
||||
|
||||
return this.#performElectricRequest(electricUrl, environment, clientVersion);
|
||||
return this.#performElectricRequest(electricUrl, environment, undefined, clientVersion);
|
||||
}
|
||||
|
||||
#constructElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
|
||||
#constructRunsElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
|
||||
const $url = new URL(url.toString());
|
||||
|
||||
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape`);
|
||||
@@ -112,9 +129,44 @@ export class RealtimeClient {
|
||||
return electricUrl;
|
||||
}
|
||||
|
||||
async #streamChunksWhere(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
whereClause: string,
|
||||
signal?: AbortSignal,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const electricUrl = this.#constructChunksElectricUrl(url, whereClause, clientVersion);
|
||||
|
||||
return this.#performElectricRequest(electricUrl, environment, signal, clientVersion);
|
||||
}
|
||||
|
||||
#constructChunksElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
|
||||
const $url = new URL(url.toString());
|
||||
|
||||
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape`);
|
||||
|
||||
// Copy over all the url search params to the electric url
|
||||
$url.searchParams.forEach((value, key) => {
|
||||
electricUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
electricUrl.searchParams.set("where", whereClause);
|
||||
electricUrl.searchParams.set("table", `public."RealtimeStreamChunk"`);
|
||||
|
||||
if (!clientVersion) {
|
||||
// If the client version is not provided, that means we're using an older client
|
||||
// This means the client will be sending shape_id instead of handle
|
||||
electricUrl.searchParams.set("handle", electricUrl.searchParams.get("shape_id") ?? "");
|
||||
}
|
||||
|
||||
return electricUrl;
|
||||
}
|
||||
|
||||
async #performElectricRequest(
|
||||
url: URL,
|
||||
environment: RealtimeEnvironment,
|
||||
signal?: AbortSignal,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const shapeId = extractShapeId(url);
|
||||
@@ -129,13 +181,13 @@ export class RealtimeClient {
|
||||
|
||||
if (!shapeId) {
|
||||
// If the shapeId is not present, we're just getting the initial value
|
||||
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
|
||||
return longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
|
||||
}
|
||||
|
||||
const isLive = isLiveRequestUrl(url);
|
||||
|
||||
if (!isLive) {
|
||||
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
|
||||
return longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
@@ -177,7 +229,7 @@ export class RealtimeClient {
|
||||
|
||||
try {
|
||||
// ... (rest of your existing code for the long polling request)
|
||||
const response = await longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
|
||||
const response = await longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
|
||||
|
||||
// Decrement the counter after the long polling request is complete
|
||||
await this.#decrementConcurrency(environment.id, requestId);
|
||||
|
||||
@@ -33,6 +33,7 @@ type ApiKeyRouteBuilderOptions<
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
|
||||
authentication: ApiAuthenticationResultSuccess
|
||||
) => Promise<TResource | undefined>;
|
||||
shouldRetryNotFound?: boolean;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
@@ -81,6 +82,7 @@ export function createLoaderApiRoute<
|
||||
corsStrategy = "none",
|
||||
authorization,
|
||||
findResource,
|
||||
shouldRetryNotFound,
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
@@ -162,7 +164,10 @@ export function createLoaderApiRoute<
|
||||
if (!resource) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Not found" }, { status: 404 }),
|
||||
json(
|
||||
{ error: "Not found" },
|
||||
{ status: 404, headers: { "x-should-retry": shouldRetryNotFound ? "true" : "false" } }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,7 +563,7 @@ function getWorkerQueue() {
|
||||
handler: async (payload, job) => {
|
||||
const service = new ResumeBatchRunService();
|
||||
|
||||
return await service.call(payload.batchRunId);
|
||||
await service.call(payload.batchRunId);
|
||||
},
|
||||
},
|
||||
"v3.resumeTaskDependency": {
|
||||
@@ -733,7 +733,7 @@ function getWorkerQueue() {
|
||||
priority: 0,
|
||||
maxAttempts: 5,
|
||||
handler: async (payload, job) => {
|
||||
const service = new BatchTriggerV2Service();
|
||||
const service = new BatchTriggerV2Service(payload.strategy);
|
||||
|
||||
await service.processBatchTaskRun(payload);
|
||||
},
|
||||
|
||||
@@ -662,12 +662,25 @@ export async function resolveVariablesForEnvironment(runtimeEnvironment: Runtime
|
||||
runtimeEnvironment.id
|
||||
);
|
||||
|
||||
const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment);
|
||||
|
||||
const builtInVariables =
|
||||
runtimeEnvironment.type === "DEVELOPMENT"
|
||||
? await resolveBuiltInDevVariables(runtimeEnvironment)
|
||||
: await resolveBuiltInProdVariables(runtimeEnvironment);
|
||||
|
||||
return [...projectSecrets, ...builtInVariables];
|
||||
return [...overridableTriggerVariables, ...projectSecrets, ...builtInVariables];
|
||||
}
|
||||
|
||||
async function resolveOverridableTriggerVariables(runtimeEnvironment: RuntimeEnvironment) {
|
||||
let result: Array<EnvironmentVariable> = [
|
||||
{
|
||||
key: "TRIGGER_REALTIME_STREAM_VERSION",
|
||||
value: env.REALTIME_STREAM_VERSION,
|
||||
},
|
||||
];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment) {
|
||||
|
||||
@@ -207,6 +207,7 @@ function createCoordinatorNamespace(io: Server) {
|
||||
const payload = await sharedQueueTasks.getExecutionPayloadFromAttempt({
|
||||
id: attempt.id,
|
||||
setToExecuting: true,
|
||||
skipStatusChecks: true,
|
||||
});
|
||||
|
||||
if (!payload) {
|
||||
|
||||
@@ -219,6 +219,7 @@ export class DeliverAlertService extends BaseService {
|
||||
environment: alert.environment.slug,
|
||||
error: createJsonErrorObject(taskRunError),
|
||||
attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
|
||||
organization: alert.project.organization.title,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
@@ -244,6 +245,7 @@ export class DeliverAlertService extends BaseService {
|
||||
environment: alert.environment.slug,
|
||||
error: createJsonErrorObject(taskRunError),
|
||||
runLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRun.friendlyId}`,
|
||||
organization: alert.project.organization.title,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run not found", {
|
||||
@@ -276,6 +278,7 @@ export class DeliverAlertService extends BaseService {
|
||||
failedAt: alert.workerDeployment.failedAt ?? new Date(),
|
||||
error: preparedError,
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
organization: alert.project.organization.title,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
@@ -296,6 +299,7 @@ export class DeliverAlertService extends BaseService {
|
||||
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
taskCount: alert.workerDeployment.worker?.tasks.length ?? 0,
|
||||
organization: alert.project.organization.title,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parsePacket,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchTaskRun, Prisma, TaskRunAttempt } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { $transaction, prisma, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
@@ -25,12 +25,10 @@ import { z } from "zod";
|
||||
|
||||
const PROCESSING_BATCH_SIZE = 50;
|
||||
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
|
||||
|
||||
type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
|
||||
|
||||
const CURRENT_STRATEGY: BatchProcessingStrategy = "parallel";
|
||||
export const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
|
||||
export type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
|
||||
|
||||
export const BatchProcessingOptions = z.object({
|
||||
batchId: z.string(),
|
||||
@@ -52,6 +50,17 @@ export type BatchTriggerTaskServiceOptions = {
|
||||
};
|
||||
|
||||
export class BatchTriggerV2Service extends BaseService {
|
||||
private _batchProcessingStrategy: BatchProcessingStrategy;
|
||||
|
||||
constructor(
|
||||
batchProcessingStrategy?: BatchProcessingStrategy,
|
||||
protected readonly _prisma: PrismaClientOrTransaction = prisma
|
||||
) {
|
||||
super(_prisma);
|
||||
|
||||
this._batchProcessingStrategy = batchProcessingStrategy ?? "parallel";
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
@@ -452,14 +461,14 @@ export class BatchTriggerV2Service extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
switch (CURRENT_STRATEGY) {
|
||||
switch (this._batchProcessingStrategy) {
|
||||
case "sequential": {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: batchId,
|
||||
range: { start: 0, count: PROCESSING_BATCH_SIZE },
|
||||
attemptCount: 0,
|
||||
strategy: CURRENT_STRATEGY,
|
||||
strategy: this._batchProcessingStrategy,
|
||||
});
|
||||
|
||||
break;
|
||||
@@ -480,7 +489,7 @@ export class BatchTriggerV2Service extends BaseService {
|
||||
processingId: `${index}`,
|
||||
range,
|
||||
attemptCount: 0,
|
||||
strategy: CURRENT_STRATEGY,
|
||||
strategy: this._batchProcessingStrategy,
|
||||
},
|
||||
tx
|
||||
)
|
||||
@@ -539,6 +548,16 @@ export class BatchTriggerV2Service extends BaseService {
|
||||
|
||||
const $attemptCount = options.attemptCount + 1;
|
||||
|
||||
// Add early return if max attempts reached
|
||||
if ($attemptCount > MAX_ATTEMPTS) {
|
||||
logger.error("[BatchTriggerV2][processBatchTaskRun] Max attempts reached", {
|
||||
options,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
// You might want to update the batch status to failed here
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = await this._prisma.batchTaskRun.findFirst({
|
||||
where: { id: options.batchId },
|
||||
include: {
|
||||
|
||||
@@ -202,12 +202,30 @@ export async function createBackgroundTasks(
|
||||
});
|
||||
|
||||
if (typeof taskQueue.concurrencyLimit === "number") {
|
||||
logger.debug("CreateBackgroundWorkerService: updating concurrency limit", {
|
||||
workerId: worker.id,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
concurrencyLimit,
|
||||
taskidentifier: task.id,
|
||||
});
|
||||
await marqs?.updateQueueConcurrencyLimits(
|
||||
environment,
|
||||
taskQueue.name,
|
||||
taskQueue.concurrencyLimit
|
||||
);
|
||||
} else {
|
||||
logger.debug("CreateBackgroundWorkerService: removing concurrency limit", {
|
||||
workerId: worker.id,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
concurrencyLimit,
|
||||
taskidentifier: task.id,
|
||||
});
|
||||
await marqs?.removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -49,6 +49,14 @@ export class ExpireEnqueuedRunService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.lockedAt) {
|
||||
logger.debug("Run cannot be expired because it's locked", {
|
||||
run,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Expiring enqueued run", {
|
||||
run,
|
||||
});
|
||||
|
||||
@@ -64,9 +64,13 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
completedAt,
|
||||
});
|
||||
|
||||
// I moved the error update here for two reasons:
|
||||
// - A single update is more efficient than two
|
||||
// - If the status updates to a final status, realtime will receive that status and then shut down the stream
|
||||
// before the error is updated, which would cause the error to be lost
|
||||
const run = await this._prisma.taskRun.update({
|
||||
where: { id },
|
||||
data: { status, expiredAt, completedAt },
|
||||
data: { status, expiredAt, completedAt, error: error ? sanitizeError(error) : undefined },
|
||||
...(include ? { include } : {}),
|
||||
});
|
||||
|
||||
@@ -78,10 +82,6 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
await this.finalizeAttempt({ attemptStatus, error, run });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
await this.finalizeRunError(run, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#finalizeBatch(run);
|
||||
} catch (finalizeBatchError) {
|
||||
@@ -211,15 +211,6 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeRunError(run: TaskRun, error: TaskRunError) {
|
||||
await this._prisma.taskRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
error: sanitizeError(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async finalizeAttempt({
|
||||
attemptStatus,
|
||||
error,
|
||||
|
||||
@@ -78,6 +78,14 @@ export class ReplayTaskRunService extends BaseService {
|
||||
environmentId: authenticatedEnvironment.id,
|
||||
});
|
||||
|
||||
//get the queue from the original run, so we can use the same settings on the replay
|
||||
const taskQueue = await this._prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: authenticatedEnvironment.id,
|
||||
name: existingTaskRun.queue,
|
||||
},
|
||||
});
|
||||
|
||||
const triggerTaskService = new TriggerTaskService();
|
||||
return await triggerTaskService.call(
|
||||
existingTaskRun.taskIdentifier,
|
||||
@@ -85,9 +93,12 @@ export class ReplayTaskRunService extends BaseService {
|
||||
{
|
||||
payload: parsedPayload,
|
||||
options: {
|
||||
queue: {
|
||||
name: existingTaskRun.queue,
|
||||
},
|
||||
queue: taskQueue
|
||||
? {
|
||||
name: taskQueue.name,
|
||||
concurrencyLimit: taskQueue.concurrencyLimit ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
concurrencyKey: existingTaskRun.concurrencyKey ?? undefined,
|
||||
test: existingTaskRun.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
|
||||
@@ -35,7 +35,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
batchRunId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
if (batchRun.status === "COMPLETED") {
|
||||
@@ -46,7 +47,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
status: batchRun.status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
if (batchRun.items.some((item) => !finishedBatchRunStatuses.includes(item.status))) {
|
||||
@@ -57,7 +59,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
status: batchRun.status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return "PENDING";
|
||||
}
|
||||
|
||||
// If we are in development, or there is no dependent attempt, we can just mark the batch as completed and return
|
||||
@@ -71,7 +74,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return "COMPLETED";
|
||||
}
|
||||
|
||||
const dependentTaskAttempt = await this._prisma.taskRunAttempt.findFirst({
|
||||
@@ -98,12 +102,11 @@ export class ResumeBatchRunService extends BaseService {
|
||||
dependentTaskAttemptId: batchRun.dependentTaskAttemptId,
|
||||
});
|
||||
|
||||
return;
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
// This batch has a dependent attempt and just finalized, we should resume that attempt
|
||||
const environment = batchRun.runtimeEnvironment;
|
||||
|
||||
const dependentRun = dependentTaskAttempt.taskRun;
|
||||
|
||||
if (dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) {
|
||||
@@ -115,11 +118,13 @@ export class ResumeBatchRunService extends BaseService {
|
||||
|
||||
// We need to update the batchRun status so we don't resume it again
|
||||
const wasUpdated = await this.#setBatchToCompletedOnce(batchRun.id);
|
||||
|
||||
if (wasUpdated) {
|
||||
logger.debug("ResumeBatchRunService: Resuming dependent run with checkpoint", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttemptId: dependentTaskAttempt.id,
|
||||
});
|
||||
|
||||
await marqs?.enqueueMessage(
|
||||
environment,
|
||||
dependentRun.queue,
|
||||
@@ -136,6 +141,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
},
|
||||
dependentRun.concurrencyKey ?? undefined
|
||||
);
|
||||
|
||||
return "COMPLETED";
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: with checkpoint was already completed", {
|
||||
batchRunId: batchRun.id,
|
||||
@@ -143,6 +150,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
return "ALREADY_COMPLETED";
|
||||
}
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: attempt is not paused or there's no checkpoint event", {
|
||||
@@ -161,11 +170,13 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
return;
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
// We need to update the batchRun status so we don't resume it again
|
||||
const wasUpdated = await this.#setBatchToCompletedOnce(batchRun.id);
|
||||
|
||||
if (wasUpdated) {
|
||||
logger.debug("ResumeBatchRunService: Resuming dependent run without checkpoint", {
|
||||
batchRunId: batchRun.id,
|
||||
@@ -173,6 +184,7 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
await marqs?.replaceMessage(dependentRun.id, {
|
||||
type: "RESUME",
|
||||
completedAttemptIds: batchRun.items.map((item) => item.taskRunAttemptId).filter(Boolean),
|
||||
@@ -183,6 +195,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return "COMPLETED";
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: without checkpoint was already completed", {
|
||||
batchRunId: batchRun.id,
|
||||
@@ -190,6 +204,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
return "ALREADY_COMPLETED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,9 +439,16 @@ export class TriggerTaskService extends BaseService {
|
||||
|
||||
if (body.options?.queue) {
|
||||
const concurrencyLimit =
|
||||
typeof body.options.queue.concurrencyLimit === "number"
|
||||
? Math.max(0, body.options.queue.concurrencyLimit)
|
||||
: undefined;
|
||||
typeof body.options.queue?.concurrencyLimit === "number"
|
||||
? Math.max(
|
||||
Math.min(
|
||||
body.options.queue.concurrencyLimit,
|
||||
environment.maximumConcurrencyLimit,
|
||||
environment.organization.maximumConcurrencyLimit
|
||||
),
|
||||
0
|
||||
)
|
||||
: null;
|
||||
|
||||
let taskQueue = await tx.taskQueue.findFirst({
|
||||
where: {
|
||||
@@ -468,12 +475,32 @@ export class TriggerTaskService extends BaseService {
|
||||
});
|
||||
|
||||
if (typeof taskQueue.concurrencyLimit === "number") {
|
||||
logger.debug("TriggerTaskService: updating concurrency limit", {
|
||||
runId: taskRun.id,
|
||||
friendlyId: taskRun.friendlyId,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
existingConcurrencyLimit,
|
||||
concurrencyLimit,
|
||||
queueOptions: body.options?.queue,
|
||||
});
|
||||
await marqs?.updateQueueConcurrencyLimits(
|
||||
environment,
|
||||
taskQueue.name,
|
||||
taskQueue.concurrencyLimit
|
||||
);
|
||||
} else {
|
||||
logger.debug("TriggerTaskService: removing concurrency limit", {
|
||||
runId: taskRun.id,
|
||||
friendlyId: taskRun.friendlyId,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
existingConcurrencyLimit,
|
||||
concurrencyLimit,
|
||||
queueOptions: body.options?.queue,
|
||||
});
|
||||
await marqs?.removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,19 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
});
|
||||
|
||||
if (process.env.DASHBOARD_AND_API_DISABLED !== "true") {
|
||||
if (process.env.ALLOW_ONLY_REALTIME_API === "true") {
|
||||
// Block all requests that do not start with /realtime
|
||||
app.use((req, res, next) => {
|
||||
// Make sure /healthcheck is still accessible
|
||||
if (!req.url.startsWith("/realtime") && req.url !== "/healthcheck") {
|
||||
res.status(404).send("Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use(apiRateLimiter);
|
||||
|
||||
app.all(
|
||||
|
||||
@@ -15,7 +15,7 @@ import express, { Express } from "express";
|
||||
import request from "supertest";
|
||||
import { authorizationRateLimitMiddleware } from "../app/services/authorizationRateLimitMiddleware.server.js";
|
||||
|
||||
describe("authorizationRateLimitMiddleware", () => {
|
||||
describe.skipIf(process.env.GITHUB_ACTIONS)("authorizationRateLimitMiddleware", () => {
|
||||
let app: Express;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { containerWithElectricTest } from "@internal/testcontainers";
|
||||
import { containerWithElectricAndRedisTest } from "@internal/testcontainers";
|
||||
import { expect, describe } from "vitest";
|
||||
import { RealtimeClient } from "../app/services/realtimeClient.server.js";
|
||||
|
||||
describe("RealtimeClient", () => {
|
||||
containerWithElectricTest(
|
||||
describe.skipIf(process.env.GITHUB_ACTIONS)("RealtimeClient", () => {
|
||||
containerWithElectricAndRedisTest(
|
||||
"Should only track concurrency for live requests",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redis, electricOrigin, prisma }) => {
|
||||
@@ -139,7 +139,7 @@ describe("RealtimeClient", () => {
|
||||
}
|
||||
);
|
||||
|
||||
containerWithElectricTest(
|
||||
containerWithElectricAndRedisTest(
|
||||
"Should support subscribing to a run tag",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redis, electricOrigin, prisma }) => {
|
||||
@@ -218,7 +218,7 @@ describe("RealtimeClient", () => {
|
||||
}
|
||||
);
|
||||
|
||||
containerWithElectricTest(
|
||||
containerWithElectricAndRedisTest(
|
||||
"Should adapt for older client versions",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redis, electricOrigin, prisma }) => {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { redisTest } from "@internal/testcontainers";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { RealtimeStreams } from "../app/services/realtimeStreams.server.js";
|
||||
import { convertArrayToReadableStream, convertResponseSSEStreamToArray } from "./utils/streams.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 }); // 5 seconds
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("./logger.server", () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("RealtimeStreams", () => {
|
||||
redisTest("should stream data from producer to consumer", async ({ redis }) => {
|
||||
const streams = new RealtimeStreams({ redis: redis.options });
|
||||
const runId = "test-run";
|
||||
const streamId = "test-stream";
|
||||
|
||||
// Create a stream of test data
|
||||
const stream = convertArrayToReadableStream(["chunk1", "chunk2", "chunk3"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
|
||||
// Start consuming the stream
|
||||
const abortController = new AbortController();
|
||||
const responsePromise = streams.streamResponse(runId, streamId, abortController.signal);
|
||||
|
||||
// Start ingesting data
|
||||
await streams.ingestData(stream, runId, streamId);
|
||||
|
||||
// Get the response and read the stream
|
||||
const response = await responsePromise;
|
||||
const received = await convertResponseSSEStreamToArray(response);
|
||||
|
||||
expect(received).toEqual(["chunk1", "chunk2", "chunk3"]);
|
||||
});
|
||||
|
||||
redisTest("should handle multiple concurrent streams", async ({ redis }) => {
|
||||
const streams = new RealtimeStreams({ redis: redis.options });
|
||||
const runId = "test-run";
|
||||
|
||||
// Set up two different streams
|
||||
const stream1 = convertArrayToReadableStream(["1a", "1b", "1c"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
const stream2 = convertArrayToReadableStream(["2a", "2b", "2c"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
|
||||
// Start consuming both streams
|
||||
const abortController = new AbortController();
|
||||
const response1Promise = streams.streamResponse(runId, "stream1", abortController.signal);
|
||||
const response2Promise = streams.streamResponse(runId, "stream2", abortController.signal);
|
||||
|
||||
// Ingest data to both streams
|
||||
await Promise.all([
|
||||
streams.ingestData(stream1, runId, "stream1"),
|
||||
streams.ingestData(stream2, runId, "stream2"),
|
||||
]);
|
||||
|
||||
// Get and verify both responses
|
||||
const [response1, response2] = await Promise.all([response1Promise, response2Promise]);
|
||||
const [received1, received2] = await Promise.all([
|
||||
convertResponseSSEStreamToArray(response1),
|
||||
convertResponseSSEStreamToArray(response2),
|
||||
]);
|
||||
|
||||
expect(received1).toEqual(["1a", "1b", "1c"]);
|
||||
expect(received2).toEqual(["2a", "2b", "2c"]);
|
||||
});
|
||||
|
||||
redisTest("should handle early consumer abort", async ({ redis }) => {
|
||||
const streams = new RealtimeStreams({ redis: redis.options });
|
||||
const runId = "test-run";
|
||||
const streamId = "test-stream";
|
||||
|
||||
const stream = convertArrayToReadableStream(["chunk1", "chunk2", "chunk3"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
|
||||
// Start consuming but abort early
|
||||
const abortController = new AbortController();
|
||||
const responsePromise = streams.streamResponse(runId, streamId, abortController.signal);
|
||||
|
||||
// Get the response before aborting to ensure stream is properly set up
|
||||
const response = await responsePromise;
|
||||
|
||||
// Start reading the stream
|
||||
const readPromise = convertResponseSSEStreamToArray(response);
|
||||
|
||||
// Abort after a small delay to ensure everything is set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
abortController.abort();
|
||||
|
||||
// Start ingesting data after abort
|
||||
await streams.ingestData(stream, runId, streamId);
|
||||
|
||||
// Verify the stream was terminated
|
||||
const received = await readPromise;
|
||||
|
||||
expect(received).toEqual(["chunk1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM postgres:14
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y postgresql-14-partman \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -13,7 +13,9 @@ networks:
|
||||
services:
|
||||
database:
|
||||
container_name: database
|
||||
image: postgres:14
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.postgres
|
||||
restart: always
|
||||
volumes:
|
||||
- ${DB_VOLUME:-database-data}:/var/lib/postgresql/data/
|
||||
@@ -30,6 +32,8 @@ services:
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- wal_level=logical
|
||||
- -c
|
||||
- shared_preload_libraries=pg_partman_bgw
|
||||
|
||||
pgadmin:
|
||||
container_name: pgadmin
|
||||
@@ -61,7 +65,7 @@ services:
|
||||
- 6379:6379
|
||||
|
||||
electric:
|
||||
image: electricsql/electric:0.8.1
|
||||
image: electricsql/electric:1.0.0-beta.1@sha256:2262f6f09caf5fa45f233731af97b84999128170a9529e5f9b9b53642308493f
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres?sslmode=disable
|
||||
|
||||
@@ -191,7 +191,7 @@ Securely pass a Google credential JSON file to your Trigger.dev task using envir
|
||||
In your terminal, run the following command and copy the resulting base64 string:
|
||||
|
||||
```
|
||||
base64 path/to/your/service-account-file.json
|
||||
base64 -i path/to/your/service-account-file.json
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -89,35 +89,6 @@ const publicToken = await auth.createPublicToken({
|
||||
});
|
||||
```
|
||||
|
||||
### Write scopes
|
||||
|
||||
You can also specify write scopes, which is required for triggering tasks from your frontend application:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
write: {
|
||||
tasks: ["my-task-1", "my-task-2"],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This will allow the token to trigger the specified tasks. `tasks` is the only write scope available at the moment.
|
||||
|
||||
We **strongly** recommend creating short-lived tokens for write scopes, as they can be used to trigger tasks from your frontend application:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
write: {
|
||||
tasks: ["my-task-1"], // ✅ this token can trigger this task
|
||||
},
|
||||
},
|
||||
expirationTime: "1m", // ✅ this token will expire after 1 minute
|
||||
});
|
||||
```
|
||||
|
||||
### Expiration
|
||||
|
||||
By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token:
|
||||
|
||||
@@ -1,797 +0,0 @@
|
||||
---
|
||||
title: React hooks
|
||||
sidebarTitle: React hooks
|
||||
description: Using the Trigger.dev v3 API from your React application.
|
||||
---
|
||||
|
||||
Our react hooks package provides a set of hooks that make it easy to interact with the Trigger.dev API from your React application, using our [frontend API](/frontend/overview). You can use these hooks to fetch runs, batches, and subscribe to real-time updates.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `@trigger.dev/react-hooks` package in your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn install @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Authentication
|
||||
|
||||
All hooks accept an optional last argument `options` that accepts an `accessToken` param, which should be a valid Public Access Token. Learn more about [generating tokens in the frontend guide](/frontend/overview).
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken, // This is required
|
||||
baseURL: "https://your-trigger-dev-instance.com", // optional, only needed if you are self-hosting Trigger.dev
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can use our `TriggerAuthContext` provider
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: publicAccessToken }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Now children components can use the hooks to interact with the Trigger.dev API. If you are self-hosting Trigger.dev, you can provide the `baseURL` to the `TriggerAuthContext` provider.
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken: publicAccessToken,
|
||||
baseURL: "https://your-trigger-dev-instance.com",
|
||||
}}
|
||||
>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Next.js and client components
|
||||
|
||||
If you are using Next.js with the App Router, you have to make sure the component that uses the `TriggerAuthContext` is a client component. So for example, the following code will not work:
|
||||
|
||||
```tsx app/page.tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: "your-access-token" }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
That's because `Page` is a server component and the `TriggerAuthContext.Provider` uses client-only react code. To fix this, wrap the `TriggerAuthContext.Provider` in a client component:
|
||||
|
||||
```ts components/TriggerProvider.tsx
|
||||
"use client";
|
||||
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function TriggerProvider({
|
||||
accessToken,
|
||||
children,
|
||||
}: {
|
||||
accessToken: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing the token to the frontend
|
||||
|
||||
Techniques for passing the token to the frontend vary depending on your setup. Here are a few ways to do it for different setups:
|
||||
|
||||
#### Next.js App Router
|
||||
|
||||
If you are using Next.js with the App Router and you are triggering a task from a server action, you can use cookies to store and pass the token to the frontend.
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
"use server";
|
||||
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
// Set the auto-generated publicAccessToken in a cookie
|
||||
cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run
|
||||
|
||||
redirect(`/runs/${handle.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Then in the `/runs/[id].tsx` page, you can read the token from the cookie and pass it to the `TriggerProvider`.
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({ params }: { params: { id: string } }) {
|
||||
const publicAccessToken = cookies().get("publicAccessToken");
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Instead of a cookie, you could also use a query parameter to pass the token to the frontend:
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`);
|
||||
}
|
||||
```
|
||||
|
||||
And then in the `/runs/[id].tsx` page:
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams: { publicAccessToken: string };
|
||||
}) {
|
||||
return (
|
||||
<TriggerProvider accessToken={searchParams.publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Another alternative would be to use a server-side rendered page to fetch the token and pass it to the frontend:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
import { generatePublicAccessToken } from "@/trigger/auth";
|
||||
|
||||
export default async function RunPage({ params }: { params: { id: string } }) {
|
||||
// This will be executed on the server only
|
||||
const publicAccessToken = await generatePublicAccessToken(params.id);
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx trigger/auth.ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export async function generatePublicAccessToken(runId: string) {
|
||||
return auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: [runId],
|
||||
},
|
||||
},
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## SWR vs Realtime hooks
|
||||
|
||||
We offer two "styles" of hooks: SWR and Realtime. The SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. The Realtime hooks use [Trigger.dev realtime](/realtime) to subscribe to updates in real-time.
|
||||
|
||||
<Note>
|
||||
It can be a little confusing which one to use because [swr](https://swr.vercel.app/) can also be
|
||||
configured to poll for updates. But because of rate-limits and the way the Trigger.dev API works,
|
||||
we recommend using the Realtime hooks for most use-cases.
|
||||
</Note>
|
||||
|
||||
All hooks named `useRealtime*` are Realtime hooks, and all hooks named `use*` are SWR hooks.
|
||||
|
||||
## Realtime hooks
|
||||
|
||||
### useRealtimeRun
|
||||
|
||||
The `useRealtimeRun` hook allows you to subscribe to a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the run's payload and output, you can provide the type of your task to the `useRealtimeRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun<typeof myTask>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information about the type of the run object and more.
|
||||
|
||||
### useRealtimeRunsWithTag
|
||||
|
||||
The `useRealtimeRunsWithTag` hook allows you to subscribe to multiple runs with a specific tag.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the runs payload and output, you can provide the type of your task to the `useRealtimeRunsWithTag` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now runs[i].payload and runs[i].output are correctly typed
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If `useRealtimeRunsWithTag` could return multiple different types of tasks, you can pass a union of all the task types to the hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask1, myTask2 } from "@/trigger/myTasks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask1 | typeof myTask2>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// You can narrow down the type of the run based on the taskIdentifier
|
||||
for (const run of runs) {
|
||||
if (run.taskIdentifier === "my-task-1") {
|
||||
// run is correctly typed as myTask1
|
||||
} else if (run.taskIdentifier === "my-task-2") {
|
||||
// run is correctly typed as myTask2
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information.
|
||||
|
||||
### useRealtimeBatch
|
||||
|
||||
The `useRealtimeBatch` hook allows you to subscribe to a batch of runs by its the batch ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeBatch } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ batchId }: { batchId: string }) {
|
||||
const { runs, error } = useRealtimeBatch(batchId);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information.
|
||||
|
||||
### useRealtimeRunWithStreams
|
||||
|
||||
The `useRealtimeRunWithStreams` hook allows you to subscribe to a run by its ID and also receive any streams that are emitted by the task. See our [Realtime documentation](/realtime#streams) for more information about emitting streams from a task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>
|
||||
{Object.keys(streams).map((stream) => (
|
||||
<div key={stream}>Stream: {stream}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can provide the type of the streams to the `useRealtimeRunWithStreams` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
As you can see above, each stream is an array of the type you provided, keyed by the stream name. If instead of a pure text stream you have a stream of objects, you can provide the type of the object:
|
||||
|
||||
```tsx
|
||||
import type { TextStreamPart } from "ai";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = { openai: TextStreamPart<{}> };
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai
|
||||
?.filter((stream) => stream.type === "text-delta")
|
||||
?.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
#### enabled
|
||||
|
||||
You can pass the `enabled` option to the Realtime hooks to enable or disable the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to conditionally disable using the hook based on some state.
|
||||
|
||||
#### id
|
||||
|
||||
You can pass the `id` option to the Realtime hooks to change the ID of the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
id,
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
id,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to change the ID of the subscription based on some state. Passing in a different ID will unsubscribe from the current subscription and subscribe to the new one (and remove any cached data).
|
||||
|
||||
#### experimental_throttleInMs
|
||||
|
||||
The `*withStreams` variants of the Realtime hooks accept an `experimental_throttleInMs` option to throttle the updates from the server. This can be useful if you are getting too many updates and want to reduce the number of updates.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { runs, error } = useRealtimeRunsWithStreams(tag, {
|
||||
accessToken: publicAccessToken,
|
||||
experimental_throttleInMs: 1000, // Throttle updates to once per second
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## SWR Hooks
|
||||
|
||||
### useRun
|
||||
|
||||
The `useRun` hook allows you to fetch a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun(runId);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The `run` object returned is the same as the [run object](/management/runs/retrieve) returned by the Trigger.dev API. To correctly type the run's payload and output, you can provide the type of your task to the `useRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun<typeof myTask>(runId, {
|
||||
refreshInterval: 0, // Disable polling
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
You can pass the following options to the all SWR hooks:
|
||||
|
||||
<ParamField path="revalidateOnFocus" type="boolean">
|
||||
Revalidate the data when the window regains focus.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="revalidateOnReconnect" type="boolean">
|
||||
Revalidate the data when the browser regains a network connection.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="refreshInterval" type="number">
|
||||
Poll for updates at the specified interval (in milliseconds). Polling is not recommended for most
|
||||
use-cases. Use the Realtime hooks instead.
|
||||
</ParamField>
|
||||
|
||||
### Common return values
|
||||
|
||||
<ResponseField name="error" type="Error">
|
||||
An error object if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isLoading" type="boolean">
|
||||
A boolean indicating if the data is currently being fetched.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isValidating" type="boolean">
|
||||
A boolean indicating if the data is currently being revalidated.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isError" type="boolean">
|
||||
A boolean indicating if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
## Trigger Hooks
|
||||
|
||||
We provide a set of hooks that can be used to trigger tasks from your frontend application. You'll need to generate a Public Access Token with `write` permissions to use these hooks. See our [frontend guide](/frontend/overview#write-scopes) for more information.
|
||||
|
||||
### useTaskTrigger
|
||||
|
||||
The `useTaskTrigger` hook allows you to trigger a task from your frontend application.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
return <div>Run ID: {handle.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeTaskTrigger
|
||||
|
||||
The `useRealtimeTaskTrigger` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime:
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, error, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
// This is the realtime run object, which will automatically update when the run changes
|
||||
if (run) {
|
||||
return <div>Run ID: {run.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeTaskTriggerWithStreams
|
||||
|
||||
The `useRealtimeTaskTriggerWithStreams` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime, and also receive any streams that are emitted by the task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTriggerWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, streams, error, isLoading } = useRealtimeTaskTriggerWithStreams<
|
||||
typeof myTask,
|
||||
STREAMS
|
||||
>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (streams && run) {
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run ID: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: Overview
|
||||
sidebarTitle: Overview
|
||||
description: Using the Trigger.dev v3 API from your React application.
|
||||
---
|
||||
|
||||
Our react hooks package provides a set of hooks that make it easy to interact with the Trigger.dev API from your React application, using our [frontend API](/frontend/overview). You can use these hooks to fetch runs, and subscribe to real-time updates, and trigger tasks from your frontend application.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `@trigger.dev/react-hooks` package in your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn install @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Authentication
|
||||
|
||||
All hooks accept an optional last argument `options` that accepts an `accessToken` param, which should be a valid Public Access Token. Learn more about [generating tokens in the frontend guide](/frontend/overview).
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken, // This is required
|
||||
baseURL: "https://your-trigger-dev-instance.com", // optional, only needed if you are self-hosting Trigger.dev
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can use our `TriggerAuthContext` provider
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: publicAccessToken }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Now children components can use the hooks to interact with the Trigger.dev API. If you are self-hosting Trigger.dev, you can provide the `baseURL` to the `TriggerAuthContext` provider.
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken: publicAccessToken,
|
||||
baseURL: "https://your-trigger-dev-instance.com",
|
||||
}}
|
||||
>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Next.js and client components
|
||||
|
||||
If you are using Next.js with the App Router, you have to make sure the component that uses the `TriggerAuthContext` is a client component. So for example, the following code will not work:
|
||||
|
||||
```tsx app/page.tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: "your-access-token" }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
That's because `Page` is a server component and the `TriggerAuthContext.Provider` uses client-only react code. To fix this, wrap the `TriggerAuthContext.Provider` in a client component:
|
||||
|
||||
```ts components/TriggerProvider.tsx
|
||||
"use client";
|
||||
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function TriggerProvider({
|
||||
accessToken,
|
||||
children,
|
||||
}: {
|
||||
accessToken: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing the token to the frontend
|
||||
|
||||
Techniques for passing the token to the frontend vary depending on your setup. Here are a few ways to do it for different setups:
|
||||
|
||||
#### Next.js App Router
|
||||
|
||||
If you are using Next.js with the App Router and you are triggering a task from a server action, you can use cookies to store and pass the token to the frontend.
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
"use server";
|
||||
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
// Set the auto-generated publicAccessToken in a cookie
|
||||
cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run
|
||||
|
||||
redirect(`/runs/${handle.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Then in the `/runs/[id].tsx` page, you can read the token from the cookie and pass it to the `TriggerProvider`.
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({ params }: { params: { id: string } }) {
|
||||
const publicAccessToken = cookies().get("publicAccessToken");
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Instead of a cookie, you could also use a query parameter to pass the token to the frontend:
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`);
|
||||
}
|
||||
```
|
||||
|
||||
And then in the `/runs/[id].tsx` page:
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams: { publicAccessToken: string };
|
||||
}) {
|
||||
return (
|
||||
<TriggerProvider accessToken={searchParams.publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Another alternative would be to use a server-side rendered page to fetch the token and pass it to the frontend:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
import { generatePublicAccessToken } from "@/trigger/auth";
|
||||
|
||||
export default async function RunPage({ params }: { params: { id: string } }) {
|
||||
// This will be executed on the server only
|
||||
const publicAccessToken = await generatePublicAccessToken(params.id);
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx trigger/auth.ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export async function generatePublicAccessToken(runId: string) {
|
||||
return auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: [runId],
|
||||
},
|
||||
},
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## SWR vs Realtime hooks
|
||||
|
||||
We offer two "styles" of hooks: SWR and Realtime. The SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. The Realtime hooks use [Trigger.dev realtime](/realtime) to subscribe to updates in real-time.
|
||||
|
||||
<Note>
|
||||
It can be a little confusing which one to use because [swr](https://swr.vercel.app/) can also be
|
||||
configured to poll for updates. But because of rate-limits and the way the Trigger.dev API works,
|
||||
we recommend using the Realtime hooks for most use-cases.
|
||||
</Note>
|
||||
|
||||
## SWR Hooks
|
||||
|
||||
### useRun
|
||||
|
||||
The `useRun` hook allows you to fetch a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun(runId);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The `run` object returned is the same as the [run object](/management/runs/retrieve) returned by the Trigger.dev API. To correctly type the run's payload and output, you can provide the type of your task to the `useRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun<typeof myTask>(runId, {
|
||||
refreshInterval: 0, // Disable polling
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
You can pass the following options to the all SWR hooks:
|
||||
|
||||
<ParamField path="revalidateOnFocus" type="boolean">
|
||||
Revalidate the data when the window regains focus.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="revalidateOnReconnect" type="boolean">
|
||||
Revalidate the data when the browser regains a network connection.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="refreshInterval" type="number">
|
||||
Poll for updates at the specified interval (in milliseconds). Polling is not recommended for most
|
||||
use-cases. Use the Realtime hooks instead.
|
||||
</ParamField>
|
||||
|
||||
### Common return values
|
||||
|
||||
<ResponseField name="error" type="Error">
|
||||
An error object if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isLoading" type="boolean">
|
||||
A boolean indicating if the data is currently being fetched.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isValidating" type="boolean">
|
||||
A boolean indicating if the data is currently being revalidated.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isError" type="boolean">
|
||||
A boolean indicating if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
## Realtime hooks
|
||||
|
||||
See our [Realtime hooks documentation](/frontend/react-hooks/realtime) for more information.
|
||||
|
||||
## Trigger Hooks
|
||||
|
||||
See our [Trigger hooks documentation](/frontend/react-hooks/triggering) for more information.
|
||||
@@ -0,0 +1,416 @@
|
||||
---
|
||||
title: Realtime hooks
|
||||
sidebarTitle: Realtime
|
||||
description: Get live updates from the Trigger.dev API in your frontend application.
|
||||
---
|
||||
|
||||
These hooks allow you to subscribe to runs, batches, and streams using [Trigger.dev realtime](/realtime). Before reading this guide:
|
||||
|
||||
- Read our [Realtime documentation](/realtime) to understand how the Trigger.dev realtime API works.
|
||||
- Read how to [setup and authenticate](/frontend/overview) using the `@trigger.dev/react-hooks` package.
|
||||
|
||||
## Hooks
|
||||
|
||||
### useRealtimeRun
|
||||
|
||||
The `useRealtimeRun` hook allows you to subscribe to a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the run's payload and output, you can provide the type of your task to the `useRealtimeRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun<typeof myTask>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
You can supply an `onComplete` callback to the `useRealtimeRun` hook to be called when the run is completed or errored. This is useful if you want to perform some action when the run is completed, like navigating to a different page or showing a notification.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
onComplete: (run, error) => {
|
||||
console.log("Run completed", run);
|
||||
},
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information about the type of the run object and more.
|
||||
|
||||
### useRealtimeRunsWithTag
|
||||
|
||||
The `useRealtimeRunsWithTag` hook allows you to subscribe to multiple runs with a specific tag.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the runs payload and output, you can provide the type of your task to the `useRealtimeRunsWithTag` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now runs[i].payload and runs[i].output are correctly typed
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If `useRealtimeRunsWithTag` could return multiple different types of tasks, you can pass a union of all the task types to the hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask1, myTask2 } from "@/trigger/myTasks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask1 | typeof myTask2>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// You can narrow down the type of the run based on the taskIdentifier
|
||||
for (const run of runs) {
|
||||
if (run.taskIdentifier === "my-task-1") {
|
||||
// run is correctly typed as myTask1
|
||||
} else if (run.taskIdentifier === "my-task-2") {
|
||||
// run is correctly typed as myTask2
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeBatch
|
||||
|
||||
The `useRealtimeBatch` hook allows you to subscribe to a batch of runs by its the batch ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeBatch } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ batchId }: { batchId: string }) {
|
||||
const { runs, error } = useRealtimeBatch(batchId);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information.
|
||||
|
||||
### useRealtimeRunWithStreams
|
||||
|
||||
The `useRealtimeRunWithStreams` hook allows you to subscribe to a run by its ID and also receive any streams that are emitted by the task. See our [Realtime documentation](/realtime#streams) for more information about emitting streams from a task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>
|
||||
{Object.keys(streams).map((stream) => (
|
||||
<div key={stream}>Stream: {stream}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can provide the type of the streams to the `useRealtimeRunWithStreams` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
As you can see above, each stream is an array of the type you provided, keyed by the stream name. If instead of a pure text stream you have a stream of objects, you can provide the type of the object:
|
||||
|
||||
```tsx
|
||||
import type { TextStreamPart } from "ai";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = { openai: TextStreamPart<{}> };
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai
|
||||
?.filter((stream) => stream.type === "text-delta")
|
||||
?.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common options
|
||||
|
||||
### accessToken & baseURL
|
||||
|
||||
You can pass the `accessToken` option to the Realtime hooks to authenticate the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
baseURL: "https://my-self-hosted-trigger.com", // Optional if you are using a self-hosted Trigger.dev instance
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### enabled
|
||||
|
||||
You can pass the `enabled` option to the Realtime hooks to enable or disable the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to conditionally disable using the hook based on some state.
|
||||
|
||||
### id
|
||||
|
||||
You can pass the `id` option to the Realtime hooks to change the ID of the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
id,
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
id,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to change the ID of the subscription based on some state. Passing in a different ID will unsubscribe from the current subscription and subscribe to the new one (and remove any cached data).
|
||||
|
||||
### experimental_throttleInMs
|
||||
|
||||
The `*withStreams` variants of the Realtime hooks accept an `experimental_throttleInMs` option to throttle the updates from the server. This can be useful if you are getting too many updates and want to reduce the number of updates.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { runs, error } = useRealtimeRunsWithStreams(tag, {
|
||||
accessToken: publicAccessToken,
|
||||
experimental_throttleInMs: 1000, // Throttle updates to once per second
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: Trigger hooks
|
||||
sidebarTitle: Triggering
|
||||
description: Triggering tasks from your frontend application.
|
||||
---
|
||||
|
||||
We provide a set of hooks that can be used to trigger tasks from your frontend application.
|
||||
|
||||
## Demo
|
||||
|
||||
We've created a [Demo application](https://github.com/triggerdotdev/realtime-llm-battle) that demonstrates how to use our React hooks to trigger tasks in a Next.js application. The application uses the `@trigger.dev/react-hooks` package to trigger a task and subscribe to the run in real-time.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `@trigger.dev/react-hooks` package in your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn install @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate a trigger hook, you must provide a special one-time use "trigger" token. These tokens are very similar to [Public Access Tokens](/frontend/overview#authentication), but they can only be used once to trigger a task. You can generate a trigger token using the `auth.createTriggerPublicToken` function in your backend code:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task");
|
||||
```
|
||||
|
||||
These tokens also expire, with the default expiration time being 15 minutes. You can specify a custom expiration time by passing a `expirationTime` parameter:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task", {
|
||||
expirationTime: "24hr",
|
||||
});
|
||||
```
|
||||
|
||||
You can also pass multiple tasks to the `createTriggerPublicToken` function to create a token that can trigger multiple tasks:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken(["my-task-1", "my-task-2"]);
|
||||
```
|
||||
|
||||
You can also pass the `multipleUse` parameter to create a token that can be used multiple times:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task", {
|
||||
multipleUse: true, // ❌ Use this with caution!
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
After generating the trigger token in your backend, you must pass it to your frontend application.
|
||||
We have a guide on how to do this in the [React hooks
|
||||
overview](/frontend/react-hooks/overview#passing-the-token-to-the-frontend).
|
||||
</Note>
|
||||
|
||||
## Hooks
|
||||
|
||||
### useTaskTrigger
|
||||
|
||||
The `useTaskTrigger` hook allows you to trigger a task from your frontend application.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
// 👆 This is the type of your task
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
// pass the type of your task here 👇
|
||||
const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken, // 👈 this is the "trigger" token
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
return <div>Run ID: {handle.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useTaskTrigger` returns an object with the following properties:
|
||||
|
||||
- `submit`: A function that triggers the task. It takes the payload of the task as an argument.
|
||||
- `handle`: The run handle object. This object contains the ID of the run that was triggered, along with a Public Access Token that can be used to access the run.
|
||||
- `isLoading`: A boolean that indicates whether the task is currently being triggered.
|
||||
- `error`: An error object that contains any errors that occurred while triggering the task.
|
||||
|
||||
The `submit` function triggers the task with the specified payload. You can additionally pass an optional [options](/triggering#options) argument to the `submit` function:
|
||||
|
||||
```tsx
|
||||
submit({ foo: "bar" }, { tags: ["tag1", "tag2"] });
|
||||
```
|
||||
|
||||
#### Using the handle object
|
||||
|
||||
You can use the `handle` object to initiate a subsequent [realtime hook](/frontend/react-hooks/realtime#userealtimerun) to subscribe to the run.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useTaskTrigger, useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
// 👆 This is the type of your task
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
// pass the type of your task here 👇
|
||||
const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken, // 👈 this is the "trigger" token
|
||||
});
|
||||
|
||||
// use the handle object to preserve type-safety 👇
|
||||
const { run, error: realtimeError } = useRealtimeRun(handle, {
|
||||
accessToken: handle?.publicAccessToken,
|
||||
enabled: !!handle, // Only subscribe to the run if the handle is available
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
return <div>Run ID: {handle.id}</div>;
|
||||
}
|
||||
|
||||
if (realtimeError) {
|
||||
return <div>Error: {realtimeError.message}</div>;
|
||||
}
|
||||
|
||||
if (run) {
|
||||
return <div>Run ID: {run.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
We've also created some additional hooks that allow you to trigger tasks and subscribe to the run in one step:
|
||||
|
||||
### useRealtimeTaskTrigger
|
||||
|
||||
The `useRealtimeTaskTrigger` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime:
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, error, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
// This is the realtime run object, which will automatically update when the run changes
|
||||
if (run) {
|
||||
return <div>Run ID: {run.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeTaskTriggerWithStreams
|
||||
|
||||
The `useRealtimeTaskTriggerWithStreams` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime, and also receive any streams that are emitted by the task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTriggerWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, streams, error, isLoading } = useRealtimeTaskTriggerWithStreams<
|
||||
typeof myTask,
|
||||
STREAMS
|
||||
>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (streams && run) {
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run ID: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
title: "Generate an image from a prompt using Fal.ai and Trigger.dev Realtime"
|
||||
sidebarTitle: "Realtime image gen with Fal.ai"
|
||||
description: "This example project generates an image from a prompt using Fal.ai and shows the progress of the task on the frontend using Trigger.dev Realtime."
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This full stack Next.js project showcases the following:
|
||||
|
||||
- A Trigger.dev task which [generates an image from a prompt using Fal.ai](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/trigger/realtime-generate-image.ts)
|
||||
- When a [form is submitted](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/app/page.tsx) in the UI, triggering the task using a [server action](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/app/actions/process-image.ts)
|
||||
- Showing the [progress of the task](https://github.com/triggerdotdev/examples/blob/main/realtime-fal-ai-image-generation/src/app/processing/%5Bid%5D/ProcessingContent.tsx) on the frontend using Trigger.dev Realtime. This also includes error handling and a fallback UI
|
||||
- Once the task is completed, showing the generated image on the frontend next to the original image
|
||||
|
||||
## GitHub repo
|
||||
|
||||
<Card
|
||||
title="View the project on GitHub"
|
||||
icon="GitHub"
|
||||
href="https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation"
|
||||
>
|
||||
Click here to view the full code for this project in our examples repository on GitHub. You can
|
||||
fork it and use it as a starting point for your own project.
|
||||
</Card>
|
||||
|
||||
## Walkthrough video
|
||||
|
||||
This video walks through the process of creating this task in a Next.js project.
|
||||
|
||||
<div className="w-full h-full aspect-video mb-3">
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.youtube.com/embed/BWZqYfUaigg?si=XpqVUEIf1j4bsYZ4"
|
||||
title="Trigger.dev walkthrough"
|
||||
frameborder="0"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
@@ -9,7 +9,16 @@ description: "This example task generates an image from a prompt using Fal.ai an
|
||||
This video walks through the process of creating this task in a Next.js project.
|
||||
|
||||
<div className="w-full h-full aspect-video mb-3">
|
||||
<iframe width="100%" height="100%" src="https://www.youtube.com/embed/BWZqYfUaigg?si=XpqVUEIf1j4bsYZ4" title="Trigger.dev walkthrough" frameborder="0" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen/>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src="https://www.youtube.com/embed/BWZqYfUaigg?si=XpqVUEIf1j4bsYZ4"
|
||||
title="Trigger.dev walkthrough"
|
||||
frameborder="0"
|
||||
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
/>
|
||||
</div>
|
||||
|
||||
## Prerequisites
|
||||
@@ -20,7 +29,7 @@ This video walks through the process of creating this task in a Next.js project.
|
||||
|
||||
## Task code
|
||||
|
||||
This task converts an image to a cartoon using fal AI, and uploads the result to Cloudflare R2.
|
||||
This task generates an image from a prompt using Fal.ai.
|
||||
|
||||
```ts trigger/fal-ai-image-from-prompt-realtime.ts
|
||||
import * as fal from "@fal-ai/serverless-client";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Scrape the top 3 articles from Hacker News and email yourself a summary every weekday"
|
||||
sidebarTitle: "Scrape Hacker News"
|
||||
sidebarTitle: "Browserbase & Puppeteer"
|
||||
description: "This example demonstrates how to scrape the top 3 articles from Hacker News using BrowserBase and Puppeteer, summarize them with ChatGPT and send a nicely formatted email summary to yourself every weekday using Resend."
|
||||
---
|
||||
|
||||
|
||||
@@ -33,9 +33,17 @@ Get set up fast using our detailed walk-through guides.
|
||||
| [Using webhooks in Remix](/guides/frameworks/remix-webhooks) | Trigger tasks from a webhook in Remix |
|
||||
| [Stripe webhooks](/guides/examples/stripe-webhook) | Trigger tasks from incoming Stripe webhook events |
|
||||
|
||||
## Example projects
|
||||
|
||||
Example projects are full projects with example repos you can fork and use. These are a great way of learning how to encorporate Trigger.dev into your project.
|
||||
|
||||
| Example project | Description | Framework | GitHub Repo |
|
||||
| :--------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------- | :-------- | :---------------------------------------------------------------------------------------------------- |
|
||||
| [Realtime Fal.ai image generation](/guides/example-projects/realtime-fal-ai) | Generate an image from a prompt using Fal.ai and show the progress of the task on the frontend using Realtime. | Next.js | [View the repo](https://github.com/triggerdotdev/examples/tree/main/realtime-fal-ai-image-generation) |
|
||||
|
||||
## Example tasks
|
||||
|
||||
Tasks you can copy and paste to get started with Trigger.dev. They can all be extended and customized to fit your needs.
|
||||
Task code you can copy and paste to use in your project. They can all be extended and customized to fit your needs.
|
||||
|
||||
| Example task | Description |
|
||||
| :---------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ If you're creating schedules for your user you will definitely need to request m
|
||||
| Limit | Details |
|
||||
| :--------------------- | :-------------------------------------------- |
|
||||
| Single trigger payload | Must not exceed 3MB |
|
||||
| Batch trigger payload | The total of all payloads must not exceed 1MB |
|
||||
| Batch trigger payload | The total of all payloads must not exceed 5MB |
|
||||
| Task outputs | Must not exceed 10MB |
|
||||
|
||||
Payloads and outputs that exceed 512KB will be offloaded to object storage and a presigned URL will be provided to download the data when calling `runs.retrieve`. You don't need to do anything to handle this in your tasks however, as we will transparently upload/download these during operation.
|
||||
|
||||
+29
-50
@@ -1,10 +1,7 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/schema.json",
|
||||
"name": "Trigger.dev",
|
||||
"openapi": [
|
||||
"/openapi.yml",
|
||||
"/v3-openapi.yaml"
|
||||
],
|
||||
"openapi": ["/openapi.yml", "/v3-openapi.yaml"],
|
||||
"api": {
|
||||
"playground": {
|
||||
"mode": "simple"
|
||||
@@ -110,6 +107,10 @@
|
||||
{
|
||||
"source": "/runs-and-attempts",
|
||||
"destination": "/runs"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/react-hooks",
|
||||
"destination": "/frontend/react-hooks/overview"
|
||||
}
|
||||
],
|
||||
"anchors": [
|
||||
@@ -136,30 +137,20 @@
|
||||
"pages": [
|
||||
{
|
||||
"group": "Tasks",
|
||||
"pages": [
|
||||
"tasks/overview",
|
||||
"tasks/schemaTask",
|
||||
"tasks/scheduled"
|
||||
]
|
||||
"pages": ["tasks/overview", "tasks/schemaTask", "tasks/scheduled"]
|
||||
},
|
||||
"triggering",
|
||||
"runs",
|
||||
"apikeys",
|
||||
{
|
||||
"group": "Configuration",
|
||||
"pages": [
|
||||
"config/config-file",
|
||||
"config/extensions/overview"
|
||||
]
|
||||
"pages": ["config/config-file", "config/extensions/overview"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Development",
|
||||
"pages": [
|
||||
"cli-dev",
|
||||
"run-tests"
|
||||
]
|
||||
"pages": ["cli-dev", "run-tests"]
|
||||
},
|
||||
{
|
||||
"group": "Deployment",
|
||||
@@ -169,9 +160,7 @@
|
||||
"github-actions",
|
||||
{
|
||||
"group": "Deployment integrations",
|
||||
"pages": [
|
||||
"vercel-integration"
|
||||
]
|
||||
"pages": ["vercel-integration"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -183,13 +172,7 @@
|
||||
"errors-retrying",
|
||||
{
|
||||
"group": "Wait",
|
||||
"pages": [
|
||||
"wait",
|
||||
"wait-for",
|
||||
"wait-until",
|
||||
"wait-for-event",
|
||||
"wait-for-request"
|
||||
]
|
||||
"pages": ["wait", "wait-for", "wait-until", "wait-for-event", "wait-for-request"]
|
||||
},
|
||||
"queue-concurrency",
|
||||
"versioning",
|
||||
@@ -207,7 +190,14 @@
|
||||
"group": "Frontend usage",
|
||||
"pages": [
|
||||
"frontend/overview",
|
||||
"frontend/react-hooks"
|
||||
{
|
||||
"group": "React hooks",
|
||||
"pages": [
|
||||
"frontend/react-hooks/overview",
|
||||
"frontend/react-hooks/realtime",
|
||||
"frontend/react-hooks/triggering"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -227,10 +217,7 @@
|
||||
"management/overview",
|
||||
{
|
||||
"group": "Tasks API",
|
||||
"pages": [
|
||||
"management/tasks/trigger",
|
||||
"management/tasks/batch-trigger"
|
||||
]
|
||||
"pages": ["management/tasks/trigger", "management/tasks/batch-trigger"]
|
||||
},
|
||||
{
|
||||
"group": "Runs API",
|
||||
@@ -269,9 +256,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Projects API",
|
||||
"pages": [
|
||||
"management/projects/runs"
|
||||
]
|
||||
"pages": ["management/projects/runs"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -317,17 +302,11 @@
|
||||
},
|
||||
{
|
||||
"group": "Help",
|
||||
"pages": [
|
||||
"community",
|
||||
"help-slack",
|
||||
"help-email"
|
||||
]
|
||||
"pages": ["community", "help-slack", "help-email"]
|
||||
},
|
||||
{
|
||||
"group": "",
|
||||
"pages": [
|
||||
"guides/introduction"
|
||||
]
|
||||
"pages": ["guides/introduction"]
|
||||
},
|
||||
{
|
||||
"group": "Frameworks",
|
||||
@@ -367,6 +346,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Example projects",
|
||||
"pages": ["guides/example-projects/realtime-fal-ai"]
|
||||
},
|
||||
{
|
||||
"group": "Example tasks",
|
||||
"pages": [
|
||||
@@ -393,15 +376,11 @@
|
||||
},
|
||||
{
|
||||
"group": "Dashboard",
|
||||
"pages": [
|
||||
"guides/dashboard/creating-a-project"
|
||||
]
|
||||
"pages": ["guides/dashboard/creating-a-project"]
|
||||
},
|
||||
{
|
||||
"group": "Migrations",
|
||||
"pages": [
|
||||
"guides/use-cases/upgrading-from-v2"
|
||||
]
|
||||
"pages": ["guides/use-cases/upgrading-from-v2"]
|
||||
}
|
||||
],
|
||||
"footerSocials": {
|
||||
@@ -409,4 +388,4 @@
|
||||
"github": "https://github.com/triggerdotdev",
|
||||
"linkedin": "https://www.linkedin.com/company/triggerdotdev"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,45 @@ TRIGGER_IMAGE_TAG=v3.0.4
|
||||
|
||||
### Auth options
|
||||
|
||||
By default, magic link auth is the only login option. If the `RESEND_API_KEY` env var is not set, the magic links will be logged by the webapp container and not sent via email.
|
||||
By default, magic link auth is the only login option. If the `EMAIL_TRANSPORT` env var is not set, the magic links will be logged by the webapp container and not sent via email.
|
||||
|
||||
Depending on your choice of mail provider/transport, you will want to configure a set of variables like one of the following:
|
||||
|
||||
##### Resend:
|
||||
```bash
|
||||
EMAIL_TRANSPORT=resend
|
||||
FROM_EMAIL=
|
||||
REPLY_TO_EMAIL=
|
||||
RESEND_API_KEY=<your_resend_api_key>
|
||||
```
|
||||
|
||||
##### SMTP
|
||||
|
||||
Note that setting `SMTP_SECURE=false` does _not_ mean the email is sent insecurely.
|
||||
This simply means that the connection is secured using the modern STARTTLS protocol command instead of implicit TLS.
|
||||
You should only set this to true when the SMTP server host directs you to do so (generally when using port 465)
|
||||
|
||||
```bash
|
||||
EMAIL_TRANSPORT=smtp
|
||||
FROM_EMAIL=
|
||||
REPLY_TO_EMAIL=
|
||||
SMTP_HOST=<your_smtp_server>
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=<your_smtp_username>
|
||||
SMTP_PASSWORD=<your_smtp_password>
|
||||
```
|
||||
|
||||
##### AWS Simple Email Service
|
||||
|
||||
Credentials are to be supplied as with any other program using the AWS SDK.
|
||||
In this scenario, you would likely either supply the additional environment variables `AWS_REGION`, `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` or, when running on AWS, use credentials supplied by the EC2 IMDS.
|
||||
|
||||
```bash
|
||||
EMAIL_TRANSPORT=aws-ses
|
||||
FROM_EMAIL=
|
||||
REPLY_TO_EMAIL=
|
||||
```
|
||||
|
||||
All email addresses can sign up and log in this way. If you would like to restrict this, you can use the `WHITELISTED_EMAILS` env var. For example:
|
||||
|
||||
|
||||
+2
-2
@@ -42,8 +42,8 @@ currently running.
|
||||
<Icon icon="arrows-rotate" iconType="solid" color="#3B82F6" size={17} /> **Reattempting**: The task has
|
||||
failed and is being retried.
|
||||
|
||||
<Icon icon="snowflake" iconType="solid" color="#68BAF2" size={17} /> **Frozen**: Task has been frozen
|
||||
and is waiting to be resumed.
|
||||
<Icon icon="hourglass" iconType="solid" color="#878C99" size={17} /> **Waiting**: You have used a
|
||||
[triggerAndWait()](/triggering#yourtask-triggerandwait), [batchTriggerAndWait()](/triggering#yourtask-batchtriggerandwait) or a [wait function](/wait). When the wait is complete, the task will resume execution.
|
||||
|
||||
### Final States
|
||||
|
||||
|
||||
+1
-1
@@ -699,7 +699,7 @@ export const childTask2 = task({
|
||||
## Triggering from your frontend
|
||||
|
||||
If you want to trigger a task directly from a frontend application, you can use our [React
|
||||
hooks](/frontend/react-hooks#trigger-hooks).
|
||||
hooks](/frontend/react-hooks/triggering).
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RealtimeStreamChunk" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"runId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RealtimeStreamChunk_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Add index on (runID, createdAt) for efficient queries
|
||||
CREATE INDEX "RealtimeStreamChunk_runId" ON "RealtimeStreamChunk" ("runId");
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RealtimeStreamChunk_createdAt_idx" ON "RealtimeStreamChunk"("createdAt");
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "RealtimeStreamChunk_runId" RENAME TO "RealtimeStreamChunk_runId_idx";
|
||||
@@ -2667,3 +2667,19 @@ enum BulkActionItemStatus {
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model RealtimeStreamChunk {
|
||||
id String @id @default(cuid())
|
||||
|
||||
key String
|
||||
value String
|
||||
|
||||
sequence Int
|
||||
|
||||
runId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([runId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const AlertAttemptEmailSchema = z.object({
|
||||
stackTrace: z.string().optional(),
|
||||
}),
|
||||
attemptLink: z.string().url(),
|
||||
organization: z.string(),
|
||||
});
|
||||
|
||||
const previewDefaults = {
|
||||
@@ -44,7 +45,16 @@ const previewDefaults = {
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertAttemptEmailSchema>) {
|
||||
const { taskIdentifier, fileName, exportName, version, environment, error, attemptLink } = {
|
||||
const {
|
||||
taskIdentifier,
|
||||
fileName,
|
||||
exportName,
|
||||
version,
|
||||
environment,
|
||||
error,
|
||||
attemptLink,
|
||||
organization,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
@@ -52,7 +62,7 @@ export default function Email(props: z.infer<typeof AlertAttemptEmailSchema>) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`[${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
|
||||
<Preview>{`${organization}: [${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>There's been an error on `{taskIdentifier}`</Text>
|
||||
@@ -61,6 +71,7 @@ export default function Email(props: z.infer<typeof AlertAttemptEmailSchema>) {
|
||||
<Text style={paragraphTight}>Function: {exportName}()</Text>
|
||||
<Text style={paragraphTight}>Version: {version}</Text>
|
||||
<Text style={paragraphTight}>Environment: {environment}</Text>
|
||||
<Text style={paragraphTight}>Organization: {organization}</Text>
|
||||
|
||||
<Text style={paragraphLight}>{error.message}</Text>
|
||||
{error.stackTrace && (
|
||||
|
||||
@@ -30,6 +30,7 @@ export const AlertRunEmailSchema = z.object({
|
||||
stackTrace: z.string().optional(),
|
||||
}),
|
||||
runLink: z.string().url(),
|
||||
organization: z.string(),
|
||||
});
|
||||
|
||||
type AlertRunEmailProps = z.infer<typeof AlertRunEmailSchema>;
|
||||
@@ -49,6 +50,7 @@ const previewDefaults: AlertRunEmailProps = {
|
||||
stackTrace: "Error stack trace",
|
||||
},
|
||||
runLink: "https://trigger.dev",
|
||||
organization: "my-organization",
|
||||
};
|
||||
|
||||
export default function Email(props: AlertRunEmailProps) {
|
||||
@@ -62,6 +64,7 @@ export default function Email(props: AlertRunEmailProps) {
|
||||
environment,
|
||||
error,
|
||||
runLink,
|
||||
organization,
|
||||
} = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
@@ -70,10 +73,11 @@ export default function Email(props: AlertRunEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`[${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
|
||||
<Preview>{`${organization}: [${version}.${environment} ${taskIdentifier}] ${error.message}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Run `{runId}` failed</Text>
|
||||
<Text style={paragraphTight}>Organization: {organization}</Text>
|
||||
<Text style={paragraphTight}>Project: {project}</Text>
|
||||
<Text style={paragraphTight}>Task ID: {taskIdentifier}</Text>
|
||||
<Text style={paragraphTight}>Filename: {fileName}</Text>
|
||||
|
||||
@@ -18,6 +18,7 @@ export const AlertDeploymentFailureEmailSchema = z.object({
|
||||
email: z.literal("alert-deployment-failure"),
|
||||
version: z.string(),
|
||||
environment: z.string(),
|
||||
organization: z.string(),
|
||||
shortCode: z.string(),
|
||||
failedAt: z.date(),
|
||||
error: z.object({
|
||||
@@ -31,6 +32,7 @@ export const AlertDeploymentFailureEmailSchema = z.object({
|
||||
const previewDefaults = {
|
||||
version: "v1",
|
||||
environment: "production",
|
||||
organization: "My Organization",
|
||||
shortCode: "abc123",
|
||||
failedAt: new Date().toISOString(),
|
||||
error: {
|
||||
@@ -41,7 +43,7 @@ const previewDefaults = {
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailSchema>) {
|
||||
const { version, environment, shortCode, failedAt, error, deploymentLink } = {
|
||||
const { version, environment, organization, shortCode, failedAt, error, deploymentLink } = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
@@ -49,10 +51,12 @@ export default function Email(props: z.infer<typeof AlertDeploymentFailureEmailS
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`Deployment ${version} [${environment}] failed: ${error.name}`}</Preview>
|
||||
<Preview>{`[${organization}] Deployment ${version} [${environment}] failed: ${error.name}`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>{`An error occurred deploying ${version} in ${environment}`}</Text>
|
||||
<Text
|
||||
style={h1}
|
||||
>{`An error occurred deploying ${version} in ${environment} in your ${organization} organization`}</Text>
|
||||
<Text style={paragraphLight}>
|
||||
{error.name} {error.message}
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const AlertDeploymentSuccessEmailSchema = z.object({
|
||||
email: z.literal("alert-deployment-success"),
|
||||
version: z.string(),
|
||||
environment: z.string(),
|
||||
organization: z.string(),
|
||||
shortCode: z.string(),
|
||||
deployedAt: z.date(),
|
||||
taskCount: z.number(),
|
||||
@@ -17,6 +18,7 @@ export const AlertDeploymentSuccessEmailSchema = z.object({
|
||||
const previewDefaults = {
|
||||
version: "v1",
|
||||
environment: "production",
|
||||
organization: "My Organization",
|
||||
shortCode: "abc123",
|
||||
deployedAt: new Date().toISOString(),
|
||||
taskCount: 3,
|
||||
@@ -24,7 +26,7 @@ const previewDefaults = {
|
||||
};
|
||||
|
||||
export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailSchema>) {
|
||||
const { version, environment, shortCode, deployedAt, taskCount, deploymentLink } = {
|
||||
const { version, environment, organization, shortCode, deployedAt, taskCount, deploymentLink } = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
@@ -32,12 +34,12 @@ export default function Email(props: z.infer<typeof AlertDeploymentSuccessEmailS
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{`Deployment ${version} [${environment}] succeeded`}</Preview>
|
||||
<Preview>{`[${organization}] Deployment ${version} [${environment}] succeeded`}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text
|
||||
style={h1}
|
||||
>{`Version ${version} successfully deployed ${taskCount} tasks in ${environment}`}</Text>
|
||||
>{`Version ${version} successfully deployed ${taskCount} tasks in ${environment} in your ${organization} organization`}</Text>
|
||||
|
||||
<Link
|
||||
href={deploymentLink}
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
"dev": "PORT=3080 email dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-ses": "^3.716.0",
|
||||
"@react-email/components": "0.0.16",
|
||||
"@react-email/render": "^0.0.12",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react": "^18.2.0",
|
||||
"react-email": "^2.1.1",
|
||||
"resend": "^3.2.0",
|
||||
@@ -19,10 +21,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "18.2.69",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { ReactElement } from "react";
|
||||
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
|
||||
|
||||
import { z } from "zod";
|
||||
import AlertAttemptFailureEmail, { AlertAttemptEmailSchema } from "../emails/alert-attempt-failure";
|
||||
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
|
||||
import { setGlobalBasePath } from "../emails/components/BasePath";
|
||||
import AlertDeploymentFailureEmail, {
|
||||
AlertDeploymentFailureEmailSchema,
|
||||
@@ -12,9 +13,9 @@ import AlertDeploymentSuccessEmail, {
|
||||
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
|
||||
import MagicLinkEmail from "../emails/magic-link";
|
||||
import WelcomeEmail from "../emails/welcome";
|
||||
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
|
||||
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
export { type MailTransportOptions }
|
||||
|
||||
export const DeliverEmailSchema = z
|
||||
.discriminatedUnion("email", [
|
||||
@@ -39,14 +40,20 @@ export type DeliverEmail = z.infer<typeof DeliverEmailSchema>;
|
||||
export type SendPlainTextOptions = { to: string; subject: string; text: string };
|
||||
|
||||
export class EmailClient {
|
||||
#client?: Resend;
|
||||
#transport: MailTransport;
|
||||
|
||||
#imagesBaseUrl: string;
|
||||
#from: string;
|
||||
#replyTo: string;
|
||||
|
||||
constructor(config: { apikey?: string; imagesBaseUrl: string; from: string; replyTo: string }) {
|
||||
this.#client =
|
||||
config.apikey && config.apikey.startsWith("re_") ? new Resend(config.apikey) : undefined;
|
||||
constructor(config: {
|
||||
transport?: MailTransportOptions;
|
||||
imagesBaseUrl: string;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
}) {
|
||||
this.#transport = constructMailTransport(config.transport ?? { type: undefined });
|
||||
|
||||
this.#imagesBaseUrl = config.imagesBaseUrl;
|
||||
this.#from = config.from;
|
||||
this.#replyTo = config.replyTo;
|
||||
@@ -57,25 +64,21 @@ export class EmailClient {
|
||||
|
||||
setGlobalBasePath(this.#imagesBaseUrl);
|
||||
|
||||
return this.#sendEmail({
|
||||
return await this.#transport.send({
|
||||
to: data.to,
|
||||
subject,
|
||||
react: component,
|
||||
from: this.#from,
|
||||
replyTo: this.#replyTo,
|
||||
});
|
||||
}
|
||||
|
||||
async sendPlainText(options: SendPlainTextOptions) {
|
||||
if (this.#client) {
|
||||
await this.#client.emails.send({
|
||||
from: this.#from,
|
||||
to: options.to,
|
||||
reply_to: this.#replyTo,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
await this.#transport.sendPlainText({
|
||||
...options,
|
||||
from: this.#from,
|
||||
replyTo: this.#replyTo,
|
||||
});
|
||||
}
|
||||
|
||||
#getTemplate(data: DeliverEmail): {
|
||||
@@ -100,50 +103,28 @@ export class EmailClient {
|
||||
};
|
||||
case "alert-attempt": {
|
||||
return {
|
||||
subject: `Error on ${data.taskIdentifier} [${data.version}.${data.environment}] ${data.error.message}`,
|
||||
subject: `[${data.organization}] Error on ${data.taskIdentifier} [${data.version}.${data.environment}] ${data.error.message}`,
|
||||
component: <AlertAttemptFailureEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-run": {
|
||||
return {
|
||||
subject: `Run ${data.runId} failed for ${data.taskIdentifier} [${data.version}.${data.environment}] ${data.error.message}`,
|
||||
subject: `[${data.organization}] Run ${data.runId} failed for ${data.taskIdentifier} [${data.version}.${data.environment}] ${data.error.message}`,
|
||||
component: <AlertRunFailureEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-deployment-failure": {
|
||||
return {
|
||||
subject: `Deployment ${data.version} [${data.environment}] failed: ${data.error.name}`,
|
||||
subject: `[${data.organization}] Deployment ${data.version} [${data.environment}] failed: ${data.error.name}`,
|
||||
component: <AlertDeploymentFailureEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "alert-deployment-success": {
|
||||
return {
|
||||
subject: `Deployment ${data.version} [${data.environment}] succeeded`,
|
||||
subject: `[${data.organization}] Deployment ${data.version} [${data.environment}] succeeded`,
|
||||
component: <AlertDeploymentSuccessEmail {...data} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #sendEmail({ to, subject, react }: { to: string; subject: string; react: ReactElement }) {
|
||||
if (this.#client) {
|
||||
await this.#client.emails.send({
|
||||
from: this.#from,
|
||||
to,
|
||||
reply_to: this.#replyTo,
|
||||
subject,
|
||||
react,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${render(react, {
|
||||
plainText: true,
|
||||
})}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import nodemailer from "nodemailer"
|
||||
import * as awsSes from "@aws-sdk/client-ses"
|
||||
|
||||
export type AwsSesMailTransportOptions = {
|
||||
type: 'aws-ses',
|
||||
}
|
||||
|
||||
export class AwsSesMailTransport implements MailTransport {
|
||||
#client: nodemailer.Transporter;
|
||||
|
||||
constructor(options: AwsSesMailTransportOptions) {
|
||||
const ses = new awsSes.SESClient()
|
||||
|
||||
this.#client = nodemailer.createTransport({
|
||||
SES: {
|
||||
aws: awsSes,
|
||||
ses
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
html: render(react),
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
text: text,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ReactElement } from "react";
|
||||
import { AwsSesMailTransport, AwsSesMailTransportOptions } from "./aws-ses";
|
||||
import { NullMailTransport, NullMailTransportOptions } from "./null";
|
||||
import { ResendMailTransport, ResendMailTransportOptions } from "./resend";
|
||||
import { SmtpMailTransport, SmtpMailTransportOptions } from "./smtp";
|
||||
|
||||
export type MailMessage = {
|
||||
to: string;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
subject: string;
|
||||
react: ReactElement;
|
||||
};
|
||||
|
||||
export type PlainTextMailMessage = {
|
||||
to: string;
|
||||
from: string;
|
||||
replyTo: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MailTransport {
|
||||
send(message: MailMessage): Promise<void>;
|
||||
sendPlainText(message: PlainTextMailMessage): Promise<void>;
|
||||
}
|
||||
|
||||
export class EmailError extends Error {
|
||||
constructor({ name, message }: { name: string; message: string }) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
export type MailTransportOptions =
|
||||
AwsSesMailTransportOptions |
|
||||
ResendMailTransportOptions |
|
||||
NullMailTransportOptions |
|
||||
SmtpMailTransportOptions
|
||||
|
||||
export function constructMailTransport(options: MailTransportOptions): MailTransport {
|
||||
switch(options.type) {
|
||||
case "aws-ses":
|
||||
return new AwsSesMailTransport(options);
|
||||
case "resend":
|
||||
return new ResendMailTransport(options);
|
||||
case "smtp":
|
||||
return new SmtpMailTransport(options);
|
||||
case undefined:
|
||||
return new NullMailTransport(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
|
||||
export type NullMailTransportOptions = {
|
||||
type: undefined,
|
||||
}
|
||||
|
||||
export class NullMailTransport implements MailTransport {
|
||||
constructor(options: NullMailTransportOptions) {
|
||||
}
|
||||
|
||||
async send({to, subject, react}: MailMessage): Promise<void> {
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${render(react, {
|
||||
plainText: true,
|
||||
})}
|
||||
`);
|
||||
}
|
||||
|
||||
async sendPlainText({to, subject, text}: PlainTextMailMessage): Promise<void> {
|
||||
console.log(`
|
||||
##### sendEmail to ${to}, subject: ${subject}
|
||||
|
||||
${text}
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
import { Resend } from "resend";
|
||||
|
||||
export type ResendMailTransportOptions = {
|
||||
type: 'resend',
|
||||
config: {
|
||||
apiKey?: string
|
||||
}
|
||||
}
|
||||
|
||||
export class ResendMailTransport implements MailTransport {
|
||||
#client: Resend;
|
||||
|
||||
constructor(options: ResendMailTransportOptions) {
|
||||
this.#client = new Resend(options.config.apiKey)
|
||||
}
|
||||
|
||||
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
|
||||
const result = await this.#client.emails.send({
|
||||
from: from,
|
||||
to,
|
||||
reply_to: replyTo,
|
||||
subject,
|
||||
react,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
|
||||
);
|
||||
throw new EmailError(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
|
||||
const result = await this.#client.emails.send({
|
||||
from: from,
|
||||
to,
|
||||
reply_to: replyTo,
|
||||
subject,
|
||||
text,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
|
||||
);
|
||||
throw new EmailError(result.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render } from "@react-email/render";
|
||||
import nodemailer from "nodemailer";
|
||||
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
|
||||
|
||||
export type SmtpMailTransportOptions = {
|
||||
type: "smtp";
|
||||
config: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
secure?: boolean;
|
||||
auth?: {
|
||||
user?: string;
|
||||
pass?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class SmtpMailTransport implements MailTransport {
|
||||
#client: nodemailer.Transporter;
|
||||
|
||||
constructor(options: SmtpMailTransportOptions) {
|
||||
this.#client = nodemailer.createTransport(options.config);
|
||||
}
|
||||
|
||||
async send({ to, from, replyTo, subject, react }: MailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
html: render(react),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendPlainText({ to, from, replyTo, subject, text }: PlainTextMailMessage): Promise<void> {
|
||||
try {
|
||||
await this.#client.sendMail({
|
||||
from: from,
|
||||
to,
|
||||
replyTo: replyTo,
|
||||
subject,
|
||||
text: text,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
|
||||
);
|
||||
throw new EmailError(error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import { StartedRedisContainer } from "@testcontainers/redis";
|
||||
import { Redis } from "ioredis";
|
||||
import { test } from "vitest";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { createPostgresContainer, createRedisContainer, createElectricContainer } from "./utils";
|
||||
import { Network, type StartedNetwork, type StartedTestContainer } from "testcontainers";
|
||||
import { Redis } from "ioredis";
|
||||
import { Network, type StartedNetwork } from "testcontainers";
|
||||
import { test } from "vitest";
|
||||
import { createElectricContainer, createPostgresContainer, createRedisContainer } from "./utils";
|
||||
|
||||
type NetworkContext = { network: StartedNetwork };
|
||||
|
||||
@@ -20,7 +20,8 @@ type ElectricContext = {
|
||||
};
|
||||
|
||||
type ContainerContext = NetworkContext & PostgresContext & RedisContext;
|
||||
type ContainerWithElectricContext = ContainerContext & ElectricContext;
|
||||
type ContainerWithElectricAndRedisContext = ContainerContext & ElectricContext;
|
||||
type ContainerWithElectricContext = NetworkContext & PostgresContext & ElectricContext;
|
||||
|
||||
type Use<T> = (value: T) => Promise<void>;
|
||||
|
||||
@@ -97,6 +98,13 @@ export const containerTest = test.extend<ContainerContext>({
|
||||
});
|
||||
|
||||
export const containerWithElectricTest = test.extend<ContainerWithElectricContext>({
|
||||
network,
|
||||
postgresContainer,
|
||||
prisma,
|
||||
electricOrigin,
|
||||
});
|
||||
|
||||
export const containerWithElectricAndRedisTest = test.extend<ContainerWithElectricAndRedisContext>({
|
||||
network,
|
||||
postgresContainer,
|
||||
prisma,
|
||||
|
||||
@@ -55,7 +55,9 @@ export async function createElectricContainer(
|
||||
network.getName()
|
||||
)}:5432/${postgresContainer.getDatabase()}?sslmode=disable`;
|
||||
|
||||
const container = await new GenericContainer("electricsql/electric:0.8.1")
|
||||
const container = await new GenericContainer(
|
||||
"electricsql/electric:1.0.0-beta.1@sha256:2262f6f09caf5fa45f233731af97b84999128170a9529e5f9b9b53642308493f"
|
||||
)
|
||||
.withExposedPorts(3000)
|
||||
.withNetwork(network)
|
||||
.withEnvironment({
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# @trigger.dev/build
|
||||
|
||||
## 3.3.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.8`
|
||||
|
||||
## 3.3.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.7`
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.6`
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.5`
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.4`
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.3`
|
||||
|
||||
## 3.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user