Add the example app: every seam wired, everyone signed in as dev
Its own module with a replace back to the addon, so `go test ./...` at the root does not reach it and the Makefile visits it explicitly. It serves the two embedded JS files and its own worker as text/javascript with no-cache, mounts the three handlers, awaits the worker's ready before reconciling, and gates its notify route with csrf.SameOrigin the way any state-changing POST in a rastrillo app is gated. The test proves each seam answers and a subscribe lands as "dev".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
9 files changed,
+513
−0
Makefile+4 −0example/go.mod+31 −0example/go.sum+141 −0example/index.html+28 −0example/main.go+121 −0example/main_test.go+110 −0example/static/app.js+46 −0example/static/manifest.webmanifest+11 −0example/static/sw.js+21 −0
diff --git a/Makefile b/Makefile| index 7edbc81..9276d6f 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -10,8 +10,11 @@ |
| # it cannot run. |
| ci: vet fmt-check test race |
| +# The example is its own module with a replace back here, so ./... |
| +# does not reach it; each target visits it explicitly. |
| vet: |
| go vet ./... |
| + cd example && go vet ./... |
| fmt-check: |
| @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi |
| @@ -21,6 +24,7 @@ fmt-check: |
| # as though it were every scheduling. |
| test: |
| go test ./... -count=1 |
| + cd example && go test ./... -count=1 |
| # -race needs cgo; everything else runs with the default toolchain, so |
| # this target sets CGO_ENABLED for its own command only. |
diff --git a/example/go.mod b/example/go.mod| new file mode 100644 |
| index 0000000..5f9ebec |
| --- /dev/null |
| +++ b/example/go.mod |
| @@ -0,0 +1,31 @@ |
| +module example |
| + |
| +go 1.25.0 |
| + |
| +require ( |
| + amadan.net/rastrillo/aviso v0.0.0 |
| + amadan.net/rastrillo/rastrillo v0.26.0 |
| +) |
| + |
| +require ( |
| + github.com/SherClockHolmes/webpush-go v1.4.0 // indirect |
| + github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect |
| + github.com/google/uuid v1.6.0 // indirect |
| + github.com/jinzhu/inflection v1.0.0 // indirect |
| + github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/mattn/go-isatty v0.0.20 // indirect |
| + github.com/ncruces/go-strftime v1.0.0 // indirect |
| + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/crypto v0.31.0 // indirect |
| + golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/text v0.21.0 // indirect |
| + gorm.io/gorm v1.31.2 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| + modernc.org/libc v1.74.1 // indirect |
| + modernc.org/mathutil v1.7.1 // indirect |
| + modernc.org/memory v1.11.0 // indirect |
| + modernc.org/sqlite v1.55.0 // indirect |
| +) |
| + |
| +replace amadan.net/rastrillo/aviso => ../ |
diff --git a/example/go.sum b/example/go.sum| new file mode 100644 |
| index 0000000..7c585c7 |
| --- /dev/null |
| +++ b/example/go.sum |
| @@ -0,0 +1,141 @@ |
| +amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| +amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= |
| +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= |
| +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= |
| +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
| +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
| +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= |
| +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= |
| +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= |
| +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= |
| +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= |
| +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= |
| +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= |
| +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
| +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
| +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
| +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
| +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
| +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= |
| +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= |
| +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= |
| +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= |
| +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= |
| +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= |
| +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= |
| +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= |
| +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= |
| +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= |
| +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= |
| +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
| +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
| +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= |
| +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
| +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
| +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= |
| +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= |
| +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= |
| +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= |
| +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= |
| +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= |
| +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |
| +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
| +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= |
| +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= |
| +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= |
| +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= |
| +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= |
| +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= |
| +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= |
| +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= |
| +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
| +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
| +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= |
| +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= |
| +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= |
| +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= |
| +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
| +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= |
| +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= |
| +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= |
| +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
| +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
| +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= |
| +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= |
| +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= |
| +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= |
| +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
| +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
| +modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
| +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
| +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
| +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
| +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
diff --git a/example/index.html b/example/index.html| new file mode 100644 |
| index 0000000..e8ae234 |
| --- /dev/null |
| +++ b/example/index.html |
| @@ -0,0 +1,28 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| + <meta charset="utf-8"> |
| + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| + <title>aviso example</title> |
| + <link rel="manifest" href="/static/manifest.webmanifest"> |
| + <meta name="theme-color" content="#5b6cff"> |
| + <style> |
| + body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 40rem; padding: 0 1rem; } |
| + button { font: inherit; padding: .5rem 1rem; margin-right: .5rem; } |
| + pre { background: #f4f4f6; padding: 1rem; overflow-x: auto; } |
| + #coach { display: none; border-left: 3px solid #5b6cff; padding-left: 1rem; } |
| + </style> |
| +</head> |
| +<body> |
| + <h1>aviso example</h1> |
| + <p>Everyone here is signed in as <code>dev</code>. Enable push, then notify yourself.</p> |
| + <p id="coach">On a phone, add this app to your Home Screen and open it from there first — push is only delivered to an installed app.</p> |
| + <p> |
| + <button id="enable">Enable notifications</button> |
| + <button id="disable">Disable</button> |
| + <button id="notify">Notify me</button> |
| + </p> |
| + <pre id="log"></pre> |
| + <script type="module" src="/static/app.js"></script> |
| +</body> |
| +</html> |
diff --git a/example/main.go b/example/main.go| new file mode 100644 |
| index 0000000..8d72e2c |
| --- /dev/null |
| +++ b/example/main.go |
| @@ -0,0 +1,121 @@ |
| +// Command example is the smallest app that wires every aviso seam: |
| +// the schema, the three handlers, the two JS halves, a page with an |
| +// enable button, and a route that sends. It signs everyone in as |
| +// "dev" — an example, not a pattern — so the enrol/send loop can be |
| +// driven from one browser. |
| +// |
| +// EXAMPLE_VAPID_PRIVATE_KEY="$(go run amadan.net/rastrillo/aviso/cmd/aviso-key)" go run . |
| +// |
| +// Push needs a secure context: http://localhost is one, so the default |
| +// origin works locally without TLS. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "embed" |
| + "encoding/json" |
| + "io/fs" |
| + "log" |
| + "net/http" |
| + "os" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + |
| + "amadan.net/rastrillo/aviso" |
| +) |
| + |
| +//go:embed index.html static/* |
| +var site embed.FS |
| + |
| +func serveBytes(contentType string, b []byte) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", contentType) |
| + w.Header().Set("Cache-Control", "no-cache") |
| + _, _ = w.Write(b) |
| + } |
| +} |
| + |
| +func handler(svc *aviso.Service, origin string) http.Handler { |
| + mux := http.NewServeMux() |
| + static, _ := fs.Sub(site, "static") |
| + index, _ := site.ReadFile("index.html") |
| + sw, _ := site.ReadFile("static/sw.js") |
| + |
| + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) |
| + mux.HandleFunc("GET /static/aviso/push.mjs", serveBytes("text/javascript", aviso.JS())) |
| + mux.HandleFunc("GET /static/aviso/aviso-sw.js", serveBytes("text/javascript", aviso.WorkerJS())) |
| + // The worker at root scope, no-cache so a new one is noticed on |
| + // the next load rather than after a cache expiry nobody chose. |
| + mux.HandleFunc("GET /sw.js", serveBytes("text/javascript", sw)) |
| + mux.HandleFunc("GET /{$}", serveBytes("text/html; charset=utf-8", index)) |
| + |
| + mux.HandleFunc("GET /aviso/public-key", svc.PublicKey) |
| + mux.HandleFunc("POST /aviso/subscribe", svc.Subscribe) |
| + mux.HandleFunc("POST /aviso/unsubscribe", svc.Unsubscribe) |
| + |
| + // The app's own policy: who gets what. Here, the caller, now. Gated |
| + // like any state-changing POST in a rastrillo app. |
| + mux.HandleFunc("POST /notify", func(w http.ResponseWriter, r *http.Request) { |
| + if !csrf.SameOrigin(r, origin) { |
| + http.Error(w, "cross-origin request refused", http.StatusForbidden) |
| + return |
| + } |
| + sess, _ := sessions.Current(r) |
| + payload, _ := json.Marshal(map[string]string{ |
| + "title": "Hello from aviso", |
| + "body": "Sent at " + time.Now().Format(time.Kitchen), |
| + "url": "/", |
| + "tag": "example", |
| + }) |
| + res, err := svc.SendTo(r.Context(), sess.Subject, payload, aviso.Options{TTL: 60 * time.Second}) |
| + if err != nil { |
| + http.Error(w, err.Error(), http.StatusInternalServerError) |
| + return |
| + } |
| + out := make([]map[string]any, 0, len(res)) |
| + for _, x := range res { |
| + m := map[string]any{"id": x.ID, "status": x.Status} |
| + if x.Err != nil { |
| + m["error"] = x.Err.Error() |
| + } |
| + out = append(out, m) |
| + } |
| + w.Header().Set("Content-Type", "application/json") |
| + _ = json.NewEncoder(w).Encode(out) |
| + }) |
| + |
| + // Example-only: everyone is "dev". A real app runs |
| + // sessions.Middleware (or auth.RequireSession) here instead. |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "dev"})) |
| + }) |
| +} |
| + |
| +func main() { |
| + origin := os.Getenv("ORIGIN") |
| + if origin == "" { |
| + origin = "http://localhost:8080" |
| + } |
| + d, err := db.Open("example.db", nil) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil { |
| + log.Fatal(err) |
| + } |
| + svc, err := aviso.New(aviso.Config{ |
| + DB: d.Writer(), |
| + PrivateKey: os.Getenv("EXAMPLE_VAPID_PRIVATE_KEY"), |
| + Contact: "mailto:ops@example.test", |
| + Origin: origin, |
| + }) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + log.Println("listening on :8080 as", origin) |
| + log.Fatal(http.ListenAndServe(":8080", handler(svc, origin))) |
| +} |
diff --git a/example/main_test.go b/example/main_test.go| new file mode 100644 |
| index 0000000..95e8522 |
| --- /dev/null |
| +++ b/example/main_test.go |
| @@ -0,0 +1,110 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| + |
| + "amadan.net/rastrillo/aviso" |
| +) |
| + |
| +func newApp(t *testing.T) (http.Handler, *aviso.Service) { |
| + t.Helper() |
| + d, err := db.Open(filepath.Join(t.TempDir(), "example.db"), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil { |
| + t.Fatal(err) |
| + } |
| + key, _ := aviso.GenerateKey() |
| + svc, err := aviso.New(aviso.Config{DB: d.Writer(), PrivateKey: key, Contact: "mailto:ops@example.test", Origin: "http://localhost:8080"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return handler(svc, "http://localhost:8080"), svc |
| +} |
| + |
| +func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) |
| + return w |
| +} |
| + |
| +// Every seam answers: the page, the two embedded JS files, the |
| +// worker at root scope, the key, and a subscribe that lands as "dev". |
| +func TestEverySeamIsWired(t *testing.T) { |
| + h, svc := newApp(t) |
| + for path, want := range map[string]string{ |
| + "/": "<button id=\"enable\"", |
| + "/static/app.js": "import { enable", |
| + "/static/aviso/push.mjs": "export async function enable(", |
| + "/static/aviso/aviso-sw.js": "AvisoSW", |
| + "/sw.js": "importScripts(\"/static/aviso/aviso-sw.js\")", |
| + "/aviso/public-key": "publicKey", |
| + "/static/manifest.webmanifest": "\"display\": \"standalone\"", |
| + } { |
| + w := get(t, h, path) |
| + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), want) { |
| + t.Errorf("%s: %d, body lacks %q", path, w.Code, want) |
| + } |
| + } |
| + if cc := get(t, h, "/sw.js").Header().Get("Cache-Control"); cc != "no-cache" { |
| + t.Errorf("sw.js Cache-Control = %q", cc) |
| + } |
| + // A module or worker script served as text/plain is refused by the |
| + // browser; the example must model the right content type. |
| + for _, path := range []string{"/static/aviso/push.mjs", "/static/aviso/aviso-sw.js", "/sw.js", "/static/app.js"} { |
| + if ct := get(t, h, path).Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { |
| + t.Errorf("%s Content-Type = %q", path, ct) |
| + } |
| + } |
| + |
| + k, _ := ecdh.P256().GenerateKey(rand.Reader) |
| + auth := make([]byte, 16) |
| + _, _ = rand.Read(auth) |
| + body, _ := json.Marshal(map[string]any{ |
| + "subscription": map[string]any{ |
| + "endpoint": "https://push.example/dev-phone", |
| + "keys": map[string]string{ |
| + "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), |
| + "auth": base64.RawURLEncoding.EncodeToString(auth), |
| + }, |
| + }, |
| + "publicKey": svc.PublicKeyString(), |
| + }) |
| + r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(string(body))) |
| + r.Header.Set("Content-Type", "application/json") |
| + r.Header.Set("Sec-Fetch-Site", "same-origin") |
| + w := httptest.NewRecorder() |
| + h.ServeHTTP(w, r) |
| + if w.Code != http.StatusNoContent { |
| + t.Fatalf("subscribe: %d %s", w.Code, w.Body) |
| + } |
| + rows, err := svc.List(context.Background(), "dev") |
| + if err != nil || len(rows) != 1 { |
| + t.Fatalf("rows for dev: %v %+v", err, rows) |
| + } |
| + |
| + // notify is gated like any state-changing POST. |
| + r = httptest.NewRequest(http.MethodPost, "/notify", nil) |
| + r.Header.Set("Sec-Fetch-Site", "cross-site") |
| + w = httptest.NewRecorder() |
| + h.ServeHTTP(w, r) |
| + if w.Code != http.StatusForbidden { |
| + t.Fatalf("cross-site notify: %d", w.Code) |
| + } |
| +} |
diff --git a/example/static/app.js b/example/static/app.js| new file mode 100644 |
| index 0000000..bb02443 |
| --- /dev/null |
| +++ b/example/static/app.js |
| @@ -0,0 +1,46 @@ |
| +// The page half, as SKILL.md shows it. |
| +import { enable, reconcile, disable, capabilities, status } from "/static/aviso/push.mjs"; |
| + |
| +const log = (line) => { document.getElementById("log").textContent += line + "\n"; }; |
| +const post = (path) => (body) => fetch(path, { |
| + method: "POST", credentials: "same-origin", |
| + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), |
| +}); |
| +const save = post("/aviso/subscribe"); |
| +const remove = post("/aviso/unsubscribe"); |
| + |
| +const caps = capabilities(); |
| +log("capabilities: " + JSON.stringify(caps)); |
| +if (!caps.standalone && /iPhone|iPad/.test(navigator.userAgent)) { |
| + document.getElementById("coach").style.display = "block"; |
| +} |
| +if (!caps.serviceWorker || !caps.push) { |
| + log("this browser cannot do push"); |
| +} else { |
| + await navigator.serviceWorker.register("/sw.js"); |
| + // Active, not merely registered: subscribe() on an installing |
| + // worker rejects with InvalidStateError. |
| + const registration = await navigator.serviceWorker.ready; |
| + const { publicKey } = await (await fetch("/aviso/public-key")).json(); |
| + |
| + // Every load: repair without prompting, and keep the server's |
| + // confirmation moving. |
| + const repaired = await reconcile({ registration, publicKey, save }).catch((e) => { log("reconcile: " + e.message); return null; }); |
| + log("subscribed: " + !!repaired); |
| + |
| + // From the click, synchronously: enable prompts before its first await. |
| + document.getElementById("enable").onclick = () => { |
| + enable({ registration, publicKey, save }) |
| + .then((sub) => log(sub ? "enabled: " + sub.endpoint.slice(0, 40) + "…" : "permission denied")) |
| + .catch((e) => log("enable: " + e.message)); |
| + }; |
| + document.getElementById("disable").onclick = async () => { |
| + try { await disable({ registration, remove }); log("disabled"); } catch (e) { log("disable: " + e.message); } |
| + }; |
| + document.getElementById("notify").onclick = async () => { |
| + const r = await fetch("/notify", { method: "POST", credentials: "same-origin" }); |
| + log("notify: " + r.status + " " + await r.text()); |
| + }; |
| + const s = await status(registration); |
| + log("permission: " + s.permission); |
| +} |
diff --git a/example/static/manifest.webmanifest b/example/static/manifest.webmanifest| new file mode 100644 |
| index 0000000..bdbb5de |
| --- /dev/null |
| +++ b/example/static/manifest.webmanifest |
| @@ -0,0 +1,11 @@ |
| +{ |
| + "id": "/", |
| + "name": "aviso example", |
| + "short_name": "aviso", |
| + "start_url": "/", |
| + "scope": "/", |
| + "display": "standalone", |
| + "theme_color": "#5b6cff", |
| + "background_color": "#f6f7fb", |
| + "icons": [] |
| +} |
diff --git a/example/static/sw.js b/example/static/sw.js| new file mode 100644 |
| index 0000000..8902a9a |
| --- /dev/null |
| +++ b/example/static/sw.js |
| @@ -0,0 +1,21 @@ |
| +// The app's worker, as SKILL.md shows it. Lifecycle is the app's: |
| +// nothing here calls skipWaiting or clients.claim either, because this |
| +// example has nothing to claim. |
| +importScripts("/static/aviso/aviso-sw.js"); |
| + |
| +const post = (body) => fetch("/aviso/subscribe", { |
| + method: "POST", credentials: "same-origin", mode: "same-origin", redirect: "error", |
| + headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), |
| +}); |
| + |
| +self.addEventListener("push", (e) => e.waitUntil(AvisoSW.handlePush(e, { |
| + fallback: () => ({ title: "aviso example", options: { body: "Something happened.", data: { url: "/" } } }), |
| +}))); |
| + |
| +self.addEventListener("notificationclick", (e) => e.waitUntil(AvisoSW.handleClick(e, { fallbackURL: "/" }))); |
| + |
| +self.addEventListener("pushsubscriptionchange", (e) => e.waitUntil(AvisoSW.handleSubscriptionChange(e, { |
| + // Fetched on demand: a terminated worker forgets its variables. |
| + publicKey: () => fetch("/aviso/public-key").then((r) => r.json()).then((j) => j.publicKey), |
| + save: post, |
| +}))); |