├── .env.example ├── .gitignore ├── .hooks └── pre-commit ├── .vscode ├── extensions.json └── settings.example.json ├── Dockerfile.site ├── LICENSE ├── Makefile ├── README.md ├── RELEASE_INSTRUCTIONS.md ├── biome.jsonc ├── bootstrap ├── bootstrap.go ├── tmpls │ ├── app_go_tmpl.txt │ ├── backend_router_actions_go_tmpl.txt │ ├── backend_router_core_go_tmpl.txt │ ├── backend_router_loaders_go_tmpl.txt │ ├── backend_router_tasks_registry_go_tmpl.txt │ ├── backend_server_server_go_tmpl.txt │ ├── backend_static_entry_go_html_str.txt │ ├── cmd_app_main_go_tmpl.txt │ ├── cmd_build_main_go_tmpl.txt │ ├── dist_static_keep_tmpl.txt │ ├── frontend_app_tsx_tmpl.txt │ ├── frontend_app_utils_ts_tmpl.txt │ ├── frontend_entry_tsx_preact_str.txt │ ├── frontend_entry_tsx_react_str.txt │ ├── frontend_entry_tsx_solid_str.txt │ ├── frontend_home_tsx_tmpl.txt │ ├── frontend_routes_ts_str.txt │ ├── gitignore_str.txt │ ├── main_critical_css_str.txt │ ├── main_css_str.txt │ ├── package_json_str.txt │ ├── ts_config_json_tmpl.txt │ ├── vite_config_ts_tmpl.txt │ └── wave_config_json_tmpl.txt └── utils.go ├── go.mod ├── go.sum ├── internal ├── framework │ ├── _typescript │ │ ├── client │ │ │ ├── index.ts │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ ├── head.test.ts │ │ │ │ ├── head.ts │ │ │ │ ├── history_types.ts │ │ │ │ ├── impl_helpers.ts │ │ │ │ ├── redirects.ts │ │ │ │ ├── river_ctx.ts │ │ │ │ ├── route_def_helpers.ts │ │ │ │ └── utils.ts │ │ │ ├── tests │ │ │ │ └── client.test.ts │ │ │ └── tsconfig.json │ │ ├── preact │ │ │ ├── index.tsx │ │ │ ├── src │ │ │ │ ├── helpers.ts │ │ │ │ ├── link.tsx │ │ │ │ └── preact.tsx │ │ │ └── tsconfig.json │ │ ├── react │ │ │ ├── index.tsx │ │ │ ├── src │ │ │ │ ├── helpers.ts │ │ │ │ ├── link.tsx │ │ │ │ └── react.tsx │ │ │ └── tsconfig.json │ │ └── solid │ │ │ ├── index.tsx │ │ │ ├── src │ │ │ ├── helpers.ts │ │ │ ├── link.tsx │ │ │ └── solid.tsx │ │ │ └── tsconfig.json │ ├── get_deps.go │ ├── get_root_handler.go │ ├── gmpd.go │ ├── river_build.go │ ├── river_core.go │ ├── river_gen_ts.go │ ├── river_init.go │ ├── ssr.go │ └── vite_cmd.go ├── junk-drawer │ └── playground │ │ ├── embedded │ │ └── main.go │ │ ├── matcher │ │ └── main.go │ │ ├── mux │ │ └── main.go │ │ ├── nested │ │ └── main.go │ │ ├── singletypegen │ │ └── main.go │ │ ├── tasks │ │ └── main.go │ │ ├── tstyper │ │ └── main.go │ │ └── validate │ │ └── main.go └── scripts │ ├── _typescript │ └── tsconfig.json │ ├── buildts │ ├── build-solid.mjs │ └── main.go │ ├── bumper │ └── main.go │ └── npm_bumper │ └── main.go ├── kit ├── _typescript │ ├── converters │ │ ├── converters.test.ts │ │ └── converters.ts │ ├── cookies │ │ └── cookies.ts │ ├── debounce │ │ ├── debounce.test.ts │ │ └── debounce.ts │ ├── fmt │ │ └── fmt.ts │ ├── json │ │ ├── deep_equals.test.ts │ │ ├── deep_equals.ts │ │ ├── json.ts │ │ ├── search_param_serializer.ts │ │ ├── search_params_serializer.test.ts │ │ ├── stringify_stable.test.ts │ │ └── stringify_stable.ts │ ├── listeners │ │ └── listeners.ts │ ├── theme │ │ └── theme.ts │ ├── tsconfig.json │ └── url │ │ ├── url.test.ts │ │ └── url.ts ├── bytesutil │ ├── bytesutil.go │ └── bytesutil_test.go ├── cliutil │ └── cliutil.go ├── colorlog │ ├── colorlog.go │ └── colorlog_test.go ├── contextutil │ ├── contextutil.go │ └── contextutil_test.go ├── cryptoutil │ ├── cryptoutil.go │ └── cryptoutil_test.go ├── dedupe │ └── dedupe.go ├── dirs │ ├── dirs.go │ └── dirs_test.go ├── envutil │ ├── envutil.go │ └── envutil_test.go ├── errutil │ └── errutil.go ├── esbuildutil │ └── esbuildutil.go ├── executil │ ├── executil.go │ └── executil_test.go ├── fsutil │ ├── fsutil.go │ └── fsutil_test.go ├── genericsutil │ ├── genericsutil.go │ └── genericsutil_test.go ├── grace │ ├── grace.go │ └── grace_test.go ├── headels │ ├── headblocks.go │ ├── headblocks_new_test.go │ └── headblocks_test.go ├── htmltestutil │ └── htmltestutil.go ├── htmlutil │ ├── htmlutil.go │ └── htmlutil_test.go ├── id │ ├── id.go │ └── id_test.go ├── ioutil │ ├── ioutil.go │ └── ioutil_test.go ├── jsonschema │ └── jsonschema.go ├── jsonutil │ ├── jsonutil.go │ └── jsonutil_test.go ├── lazyget │ ├── lazyget.go │ └── lazyget_test.go ├── lru │ ├── lru.go │ └── lru_test.go ├── matcher │ ├── bench.txt │ ├── find_best_match.go │ ├── find_best_match_test.go │ ├── find_nested_matches.go │ ├── find_nested_matches_test.go │ ├── matcher.go │ ├── parse_segments.go │ ├── parse_segments_test.go │ └── register.go ├── middleware │ ├── csrftoken │ │ ├── csrftoken.go │ │ └── csrftoken_test.go │ ├── etag │ │ ├── etag.go │ │ └── etag_test.go │ ├── healthcheck │ │ └── healthcheck.go │ ├── middleware.go │ ├── robotstxt │ │ └── robotstxt.go │ └── secureheaders │ │ ├── secureheaders.go │ │ └── secureheaders_test.go ├── mux │ ├── bench.txt │ ├── mux.go │ ├── nested_mux.go │ └── router_test.go ├── opt │ └── opt.go ├── parseutil │ └── parseutil.go ├── port │ └── port.go ├── reflectutil │ └── reflectutil.go ├── response │ ├── proxy.go │ ├── response.go │ └── response_test.go ├── rpc │ ├── rpc.go │ └── rpc_test.go ├── safecache │ ├── safecache.go │ └── safecache_test.go ├── scripts │ └── bumper │ │ └── bumper.go ├── securestring │ ├── securestring.go │ └── securestring_test.go ├── set │ └── set.go ├── signedcookie │ ├── signedcookie.go │ └── signedcookie_test.go ├── sqlutil │ └── sqlutil.go ├── stringsutil │ ├── collect_lines.go │ └── stringsutil.go ├── tasks │ ├── tasks.go │ └── tasks_test.go ├── theme │ └── theme.go ├── timer │ └── timer.go ├── tsgen │ ├── generate_ts_content.go │ ├── generate_ts_content_test.go │ ├── statements.go │ ├── to_file.go │ └── tsgencore │ │ ├── collector.go │ │ └── core.go ├── typed │ ├── syncmap.go │ └── syncmap_test.go ├── validate │ ├── error_acc_test.go │ ├── error_collector.go │ ├── error_collector_test.go │ ├── more_error_collector_test.go │ ├── rules.go │ ├── rules_test.go │ ├── search_params.go │ ├── search_params_test.go │ ├── validate.go │ └── validate_test.go ├── viteutil │ ├── cmd.go │ └── viteutil.go ├── walkutil │ └── walkutil.go └── xyz │ ├── fsmarkdown │ └── fsmarkdown.go │ └── xyz.go ├── package.json ├── pnpm-lock.yaml ├── river.go ├── site ├── .gitignore ├── Makefile ├── __cmd │ ├── app │ │ └── main.go │ └── build │ │ └── main.go ├── __dist │ └── static │ │ └── .keep ├── app.go ├── backend │ ├── __static │ │ ├── entry.go.html │ │ └── markdown │ │ │ ├── faq.md │ │ │ └── start.md │ ├── markdown │ │ └── markdown.go │ ├── router │ │ ├── actions.go │ │ ├── core.go │ │ └── loaders.go │ └── server │ │ └── server.go ├── frontend │ ├── __static │ │ ├── desktop.svg │ │ ├── favicon.svg │ │ ├── full-logo.svg │ │ ├── logo.svg │ │ ├── moon.svg │ │ ├── river-banner.webp │ │ └── sun.svg │ ├── components │ │ ├── app.tsx │ │ ├── app_link.tsx │ │ ├── app_utils.ts │ │ ├── global_loader.ts │ │ ├── highlight.ts │ │ ├── rendered-markdown.tsx │ │ └── routes │ │ │ ├── dyn.tsx │ │ │ ├── home.tsx │ │ │ └── md.tsx │ ├── css │ │ ├── hljs.css │ │ ├── main.critical.css │ │ ├── main.css │ │ ├── nprogress.css │ │ └── tailwind-input.css │ ├── entry.tsx │ ├── river.gen.ts │ └── routes.ts ├── go.mod ├── go.sum ├── package.json ├── pnpm-lock.yaml ├── tsconfig.json ├── vite.config.ts └── wave.config.json ├── tsconfig.base.json ├── vitest.config.ts └── wave ├── internal └── ki │ ├── build.go │ ├── buildhelper.go │ ├── config.go │ ├── configschema │ └── main.go │ ├── css.go │ ├── css_test.go │ ├── debouncer.go │ ├── dev_core.go │ ├── dev_events.go │ ├── dev_vite.go │ ├── dist.go │ ├── dumb_little_helpers.go │ ├── env.go │ ├── env_test.go │ ├── evt_utils.go │ ├── file_hash.go │ ├── filemap.go │ ├── ik_test.go │ ├── main_init.go │ ├── middleware.go │ ├── on_change.go │ ├── pattern_utils.go │ ├── port.go │ ├── public_asset_keys.go │ ├── readiness.go │ ├── refresh.go │ ├── setup.go │ ├── static_assets.go │ ├── universal_fs.go │ └── universal_fs_test.go └── wave.go /.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_TOKEN = ghp_123 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .DS_Store 4 | 5 | *.local 6 | *.local.* 7 | 8 | .env 9 | .env.* 10 | !.env.example 11 | 12 | .vscode/settings.json 13 | 14 | npm_dist/ 15 | -------------------------------------------------------------------------------- /.hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ##################################################################### 4 | # 5 | # You must initialize this pre-commit hook by running the following 6 | # commands: 7 | # 8 | # ```sh 9 | # git config core.hooksPath .hooks 10 | # chmod +x .hooks/pre-commit 11 | # ``` 12 | # 13 | # After running the above, this pre-commit hook will run before 14 | # every commit, and if it returns a non-zero exit code, the commit 15 | # will be aborted. You can test the script's behavior by executing 16 | # it directly: `.hooks/pre-commit`. 17 | # 18 | ##################################################################### 19 | 20 | set -e 21 | 22 | run_test() { 23 | echo "[pre-commit] INFO Running cmd '$1'." 24 | $1 25 | if [ $? -ne 0 ]; then 26 | echo "[pre-commit] ERROR Cmd '$1' failed. Commit aborted." 27 | exit 1 28 | fi 29 | echo "[pre-commit] OK Cmd '$1' passed." 30 | } 31 | 32 | run_test "make gotest" 33 | run_test "make tstest" 34 | run_test "make tslint" 35 | run_test "make tscheck" 36 | 37 | echo "[pre-commit] OK Done." 38 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome", "golang.go", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports.biome": "explicit", 4 | "source.fixAll.biome": "explicit" 5 | }, 6 | 7 | "search.exclude": { 8 | "**/node_modules": true, 9 | "**/river.gen.ts": true 10 | }, 11 | 12 | "javascript.preferences.importModuleSpecifierEnding": "js", 13 | "typescript.preferences.importModuleSpecifierEnding": "js", 14 | 15 | "[go]": { 16 | "editor.defaultFormatter": "golang.go" 17 | }, 18 | 19 | "[html][css][json][jsonc][javascript][typescript][javascriptreact][typescriptreact]": { 20 | "editor.defaultFormatter": "biomejs.biome" 21 | }, 22 | 23 | "[markdown]": { 24 | "editor.defaultFormatter": "esbenp.prettier-vscode", 25 | "prettier.proseWrap": "always" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Dockerfile.site: -------------------------------------------------------------------------------- 1 | ### builder-base 2 | FROM golang:1.24 as builder-base 3 | WORKDIR /monorepo 4 | RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - 5 | RUN apt-get update 6 | RUN apt-get install -y --no-install-recommends nodejs make 7 | RUN npm i -g pnpm 8 | RUN apt-get clean 9 | RUN rm -rf /var/lib/apt/lists/* 10 | 11 | ### river-npm-deps 12 | FROM builder-base as river-npm-deps 13 | WORKDIR /monorepo 14 | COPY package.json pnpm-lock.yaml ./ 15 | RUN pnpm i --frozen-lockfile 16 | COPY . . 17 | RUN make npmbuild 18 | 19 | ### frontend-deps 20 | FROM node:22-alpine as frontend-deps 21 | WORKDIR /monorepo 22 | RUN npm i -g pnpm 23 | COPY --from=river-npm-deps /monorepo/npm_dist ./npm_dist 24 | COPY site/package.json site/pnpm-lock.yaml ./site/ 25 | WORKDIR /monorepo/site 26 | RUN pnpm i --frozen-lockfile 27 | COPY site/ ./ 28 | 29 | ### backend-builder 30 | FROM builder-base as backend-builder 31 | WORKDIR /monorepo 32 | COPY . . 33 | COPY --from=river-npm-deps /monorepo/npm_dist ./npm_dist 34 | COPY --from=frontend-deps /monorepo/site ./site 35 | WORKDIR /monorepo/site 36 | RUN go mod download 37 | RUN npx @tailwindcss/cli \ 38 | -i ./frontend/css/tailwind-input.css \ 39 | -o ./frontend/css/tailwind-output.css 40 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ 41 | -o /tmp/build_tool \ 42 | ./__cmd/build 43 | RUN /tmp/build_tool --no-binary 44 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -v \ 45 | -o /monorepo/__main \ 46 | ./__cmd/app 47 | 48 | ### server 49 | FROM alpine:latest 50 | WORKDIR /app 51 | RUN apk --no-cache add ca-certificates 52 | COPY --from=backend-builder /monorepo/__main . 53 | RUN adduser -D serveruser 54 | USER serveruser 55 | ENTRYPOINT ["/app/__main"] 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright 2023 Samuel J. Cook 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ##################################################################### 2 | ####### GO 3 | ##################################################################### 4 | 5 | gotest: 6 | @go test -race ./... 7 | 8 | gotestloud: 9 | @go test -race -v ./... 10 | 11 | gobump: gotest 12 | @go run ./internal/scripts/bumper 13 | 14 | # call with `make gobench pkg=./kit/mux` (or whatever) 15 | gobench: 16 | @go test -bench=. $(pkg) 17 | 18 | ##################################################################### 19 | ####### TS 20 | ##################################################################### 21 | 22 | tstest: 23 | @pnpm vitest run 24 | 25 | tstestwatch: 26 | @pnpm vitest 27 | 28 | tsreset: 29 | @rm -rf node_modules 2>/dev/null || true 30 | @find . -path "*/node_modules" -type d -exec rm -rf {} \; 2>/dev/null || true 31 | @pnpm i 32 | 33 | tslint: 34 | @pnpm biome check . 35 | 36 | tscheck: tscheck-kit tscheck-fw-client tscheck-fw-react tscheck-fw-solid 37 | 38 | tscheck-kit: 39 | @pnpm tsc --noEmit --project ./kit/_typescript 40 | 41 | tscheck-fw-client: 42 | @pnpm tsc --noEmit --project ./internal/framework/_typescript/client 43 | 44 | tscheck-fw-react: 45 | @pnpm tsc --noEmit --project ./internal/framework/_typescript/react 46 | 47 | tscheck-fw-solid: 48 | @pnpm tsc --noEmit --project ./internal/framework/_typescript/solid 49 | 50 | tscheck-fw-preact: 51 | @pnpm tsc --noEmit --project ./internal/framework/_typescript/preact 52 | 53 | tsprepforpub: tsreset tstest tslint tscheck 54 | 55 | tspublishpre: tsprepforpub 56 | @npm publish --access public --tag pre 57 | 58 | tspublishnonpre: tsprepforpub 59 | @npm publish --access public 60 | 61 | npmbuild: 62 | @go run ./internal/scripts/buildts 63 | 64 | npmbump: 65 | @go run ./internal/scripts/npm_bumper 66 | 67 | docker-site: 68 | @docker build -t river-site -f Dockerfile.site . 69 | 70 | docker-run-site: 71 | docker run -d -p $(PORT):$(PORT) -e PORT=$(PORT) river-site 72 | -------------------------------------------------------------------------------- /RELEASE_INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | 1. bump package.json / run build / publish to npm 2 | 3 | ```sh 4 | make npmbump 5 | ``` 6 | 7 | 2. push to github 8 | 9 | ```sh 10 | git add . 11 | git commit -m 'v0.0.0-pre.0' 12 | git push 13 | ``` 14 | 15 | 3. publish to go proxy / push version tag 16 | 17 | ```sh 18 | make gobump 19 | ``` 20 | 21 | 4. profit 22 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.0-beta.5/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "includes": [ 11 | "**", 12 | "!**/node_modules", 13 | "!npm_dist/**/*", 14 | "!**/__dist", 15 | "!**/frontend/river.gen.ts", 16 | "!**/frontend/css/tailwind-output.css" 17 | ] 18 | }, 19 | "formatter": { 20 | "enabled": true, 21 | "indentStyle": "tab", 22 | "lineWidth": 87, 23 | "includes": ["**"] 24 | }, 25 | "assist": { 26 | "actions": { 27 | "source": { 28 | "organizeImports": "on" 29 | } 30 | } 31 | }, 32 | "linter": { 33 | "enabled": true, 34 | "includes": ["**"], 35 | "rules": { 36 | "nursery": { 37 | "noImportCycles": "error" 38 | }, 39 | "style": { 40 | "useTemplate": "off", // permit string concatenation 41 | "useLiteralEnumMembers": "error", 42 | "noCommaOperator": "error", 43 | "useNodejsImportProtocol": "error", 44 | "useAsConstAssertion": "error", 45 | "useEnumInitializers": "error", 46 | "useSelfClosingElements": "error", 47 | "useConst": "error", 48 | "useSingleVarDeclarator": "error", 49 | "noUnusedTemplateLiteral": "error", 50 | "useNumberNamespace": "error", 51 | "noInferrableTypes": "error", 52 | "useExponentiationOperator": "error", 53 | "noParameterAssign": "error", 54 | "noNonNullAssertion": "error", 55 | "useDefaultParameterLast": "error", 56 | "noArguments": "error", 57 | "useImportType": "error", 58 | "useExportType": "error", 59 | "noUselessElse": "error", 60 | "useShorthandFunctionType": "error", 61 | "useConsistentArrayType": { 62 | "level": "error", 63 | "options": { 64 | "syntax": "generic" 65 | } 66 | }, 67 | "useBlockStatements": "error" 68 | }, 69 | "correctness": { 70 | "useImportExtensions": "error" // require ".ts", etc. in import statements 71 | }, 72 | "suspicious": { 73 | "noExplicitAny": "off" // permit explicit any 74 | }, 75 | "a11y": { 76 | "noStaticElementInteractions": "off" 77 | } 78 | } 79 | }, 80 | "javascript": { 81 | "formatter": { 82 | "quoteStyle": "double" 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /bootstrap/tmpls/app_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | 7 | "github.com/river-now/river" 8 | "github.com/river-now/river/kit/colorlog" 9 | "github.com/river-now/river/kit/headels" 10 | "github.com/river-now/river/kit/htmlutil" 11 | "github.com/river-now/river/wave" 12 | ) 13 | 14 | var River = &river.River{ 15 | Wave: Wave, 16 | GetHeadElUniqueRules: func() *headels.HeadEls { 17 | e := river.NewHeadEls(2) 18 | 19 | e.Meta(e.Property("og:title")) 20 | e.Meta(e.Property("og:description")) 21 | 22 | return e 23 | }, 24 | GetDefaultHeadEls: func(r *http.Request) ([]*htmlutil.Element, error) { 25 | e := river.NewHeadEls() 26 | 27 | e.Title("River Example") 28 | e.Description("This is a River example.") 29 | 30 | return e.Collect(), nil 31 | }, 32 | GetRootTemplateData: func(r *http.Request) (map[string]any, error) { 33 | // This gets fed into backend/__static/entry.go.html 34 | return map[string]any{}, nil 35 | }, 36 | } 37 | 38 | //go:embed wave.config.json 39 | var configBytes []byte 40 | 41 | //go:embed all:__dist/static 42 | var staticFS embed.FS 43 | 44 | var Wave = wave.New(&wave.Config{ 45 | ConfigBytes: configBytes, 46 | StaticFS: staticFS, 47 | StaticFSEmbedDirective: "all:__dist/static", 48 | }) 49 | 50 | var Log = colorlog.New("{{.GoImportBase}}") 51 | -------------------------------------------------------------------------------- /bootstrap/tmpls/backend_router_actions_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/river-now/river/kit/mux" 8 | "github.com/river-now/river/kit/validate" 9 | ) 10 | 11 | var ActionsRouter = mux.NewRouter(&mux.Options{ 12 | TasksRegistry: SharedTasksRegistry, 13 | MountRoot: "/api/", 14 | MarshalInput: func(r *http.Request, iPtr any) error { 15 | if r.Method == http.MethodGet { 16 | return validate.URLSearchParamsInto(r, iPtr) 17 | } 18 | if r.Method == http.MethodPost { 19 | return validate.JSONBodyInto(r, iPtr) 20 | } 21 | return errors.New("unsupported method") 22 | }, 23 | }) 24 | 25 | type ActionCtx[I any] struct { 26 | *mux.ReqData[I] 27 | // Anything else you want available on the ActionCtx 28 | } 29 | 30 | func NewAction[I any, O any](method, pattern string, f func(c *ActionCtx[I]) (O, error)) *mux.TaskHandler[I, O] { 31 | wrappedF := func(c *mux.ReqData[I]) (O, error) { 32 | return f(&ActionCtx[I]{ 33 | ReqData: c, 34 | // Anything else you want available on the ActionCtx 35 | }) 36 | } 37 | actionTask := mux.TaskHandlerFromFunc(ActionsRouter.TasksRegistry(), wrappedF) 38 | mux.RegisterTaskHandler(ActionsRouter, method, pattern, actionTask) 39 | return actionTask 40 | } 41 | -------------------------------------------------------------------------------- /bootstrap/tmpls/backend_router_core_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | app "{{.GoImportBase}}" 5 | "net/http" 6 | "strings" 7 | 8 | chimw "github.com/go-chi/chi/v5/middleware" 9 | "github.com/river-now/river/kit/middleware/etag" 10 | "github.com/river-now/river/kit/middleware/healthcheck" 11 | "github.com/river-now/river/kit/middleware/robotstxt" 12 | "github.com/river-now/river/kit/middleware/secureheaders" 13 | "github.com/river-now/river/kit/mux" 14 | ) 15 | 16 | func Core() *mux.Router { 17 | r := mux.NewRouter(nil) 18 | 19 | mux.SetGlobalHTTPMiddleware(r, chimw.Logger) 20 | mux.SetGlobalHTTPMiddleware(r, chimw.Recoverer) 21 | mux.SetGlobalHTTPMiddleware(r, etag.Auto(&etag.Config{ 22 | SkipFunc: func(r *http.Request) bool { 23 | return strings.HasPrefix(r.URL.Path, app.Wave.GetPublicPathPrefix()) 24 | }, 25 | })) 26 | mux.SetGlobalHTTPMiddleware(r, secureheaders.Middleware) 27 | mux.SetGlobalHTTPMiddleware(r, healthcheck.Healthz) 28 | mux.SetGlobalHTTPMiddleware(r, robotstxt.Allow) 29 | mux.SetGlobalHTTPMiddleware(r, app.Wave.FaviconRedirect()) 30 | 31 | // static public assets 32 | mux.RegisterHandler(r, "GET", app.Wave.GetPublicPathPrefix()+"*", app.Wave.MustGetServeStaticHandler(true)) 33 | 34 | // river UI routes 35 | mux.RegisterHandler(r, "GET", "/*", app.River.GetUIHandler(LoadersRouter)) 36 | 37 | // river API routes 38 | actionsHandler := app.River.GetActionsHandler(ActionsRouter) 39 | mux.RegisterHandler(r, "GET", ActionsRouter.MountRoot("*"), actionsHandler) 40 | mux.RegisterHandler(r, "POST", ActionsRouter.MountRoot("*"), actionsHandler) 41 | 42 | return r 43 | } 44 | -------------------------------------------------------------------------------- /bootstrap/tmpls/backend_router_loaders_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/river-now/river/kit/mux" 4 | 5 | var LoadersRouter = mux.NewNestedRouter(&mux.NestedOptions{ 6 | TasksRegistry: SharedTasksRegistry, 7 | ExplicitIndexSegment: "_index", // Optional, but recommended 8 | }) 9 | 10 | type LoaderCtx struct { 11 | *mux.NestedReqData 12 | // Anything else you want available on the LoaderCtx 13 | } 14 | 15 | func NewLoader[O any](pattern string, f func(c *LoaderCtx) (O, error)) *mux.TaskHandler[mux.None, O] { 16 | wrappedF := func(c *mux.NestedReqData) (O, error) { 17 | return f(&LoaderCtx{ 18 | NestedReqData: c, 19 | // Anything else you want available on the LoaderCtx 20 | }) 21 | } 22 | loaderTask := mux.TaskHandlerFromFunc(LoadersRouter.TasksRegistry(), wrappedF) 23 | mux.RegisterNestedTaskHandler(LoadersRouter, pattern, loaderTask) 24 | return loaderTask 25 | } 26 | 27 | type RootData struct { 28 | Message string 29 | } 30 | 31 | // Example loader 32 | var _ = NewLoader("/", func(c *LoaderCtx) (*RootData, error) { 33 | return &RootData{Message: "Hello from a River loader!"}, nil 34 | }) 35 | -------------------------------------------------------------------------------- /bootstrap/tmpls/backend_router_tasks_registry_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import "github.com/river-now/river/kit/tasks" 4 | 5 | var SharedTasksRegistry = tasks.NewRegistry("{{.GoImportBase}}") 6 | -------------------------------------------------------------------------------- /bootstrap/tmpls/backend_server_server_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | app "{{.GoImportBase}}" 5 | "{{.GoImportBase}}/backend/router" 6 | "context" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/river-now/river/kit/grace" 14 | "github.com/river-now/river/wave" 15 | ) 16 | 17 | func Serve() { 18 | app.River.Init(wave.GetIsDev()) 19 | 20 | addr := fmt.Sprintf(":%d", wave.MustGetPort()) 21 | 22 | server := &http.Server{ 23 | Addr: addr, 24 | Handler: http.TimeoutHandler(router.Core(), 60*time.Second, "Request timed out"), 25 | ReadTimeout: 15 * time.Second, 26 | WriteTimeout: 30 * time.Second, 27 | IdleTimeout: 60 * time.Second, 28 | ReadHeaderTimeout: 10 * time.Second, 29 | MaxHeaderBytes: 1 << 20, // 1 MB 30 | DisableGeneralOptionsHandler: true, 31 | ErrorLog: log.New(os.Stderr, "HTTP: ", log.Ldate|log.Ltime|log.Lshortfile), 32 | } 33 | 34 | url := "http://localhost" + addr 35 | 36 | grace.Orchestrate(grace.OrchestrateOptions{ 37 | StartupCallback: func() error { 38 | app.Log.Info("Starting server", "url", url) 39 | 40 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 41 | log.Fatalf("Server listen and serve error: %v\n", err) 42 | } 43 | 44 | return nil 45 | }, 46 | 47 | ShutdownCallback: func(shutdownCtx context.Context) error { 48 | app.Log.Info("Shutting down server", "url", url) 49 | 50 | if err := server.Shutdown(shutdownCtx); err != nil { 51 | log.Fatalf("Server shutdown error: %v\n", err) 52 | } 53 | 54 | return nil 55 | }, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /bootstrap/tmpls/backend_static_entry_go_html_str.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{.RiverHeadEls}} 7 | {{.RiverSSRScript}} 8 | 9 | 10 |
11 | {{.RiverBodyScripts}} 12 | 13 | 14 | -------------------------------------------------------------------------------- /bootstrap/tmpls/cmd_app_main_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "{{.GoImportBase}}/backend/server" 4 | 5 | func main() { 6 | server.Serve() 7 | } 8 | -------------------------------------------------------------------------------- /bootstrap/tmpls/cmd_build_main_go_tmpl.txt: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | app "{{.GoImportBase}}" 5 | "{{.GoImportBase}}/backend/router" 6 | 7 | "github.com/river-now/river" 8 | "github.com/river-now/river/kit/tsgen" 9 | ) 10 | 11 | func main() { 12 | a := tsgen.Statements{} 13 | 14 | a.Serialize("export const ACTIONS_ROUTER_MOUNT_ROOT", router.ActionsRouter.MountRoot()) 15 | 16 | app.Wave.Builder(func(isDev bool) error { 17 | return app.River.Build(&river.BuildOptions{ 18 | IsDev: isDev, 19 | LoadersRouter: router.LoadersRouter, 20 | ActionsRouter: router.ActionsRouter, 21 | AdHocTypes: []*river.AdHocType{}, 22 | ExtraTSCode: a.BuildString(), 23 | }) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /bootstrap/tmpls/dist_static_keep_tmpl.txt: -------------------------------------------------------------------------------- 1 | //go:embed directives require at least one file to compile 2 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_app_tsx_tmpl.txt: -------------------------------------------------------------------------------- 1 | import { RiverRootOutlet } from "river.now/{{.UIVariant}}"; 2 | 3 | export function App() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_app_utils_ts_tmpl.txt: -------------------------------------------------------------------------------- 1 | import { 2 | makeTypedAddClientLoader, 3 | makeTypedUseLoaderData, 4 | makeTypedUsePatternLoaderData, 5 | makeTypedUseRouterData, 6 | type RiverRouteProps, 7 | } from "river.now/{{.UIVariant}}"; 8 | import type { RiverLoader, RiverLoaderPattern, RiverRootData } from "./river.gen.ts"; 9 | 10 | export type RouteProps

= RiverRouteProps; 11 | 12 | export const useRouterData = makeTypedUseRouterData(); 13 | export const useLoaderData = makeTypedUseLoaderData(); 14 | export const addClientLoader = makeTypedAddClientLoader(); 15 | export const usePatternLoaderData = makeTypedUsePatternLoaderData(); 16 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_entry_tsx_preact_str.txt: -------------------------------------------------------------------------------- 1 | import { render } from "preact"; 2 | import { getRootEl, initClient } from "river.now/client"; 3 | import { App } from "./app.tsx"; 4 | 5 | await initClient(() => { 6 | render(, getRootEl()); 7 | }); 8 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_entry_tsx_react_str.txt: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import { getRootEl, initClient } from "river.now/client"; 3 | import { RiverProvider } from "river.now/react"; 4 | import { App } from "./app.tsx"; 5 | 6 | await initClient(() => { 7 | createRoot(getRootEl()).render( 8 | 9 | 10 | , 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_entry_tsx_solid_str.txt: -------------------------------------------------------------------------------- 1 | import { getRootEl, initClient } from "river.now/client"; 2 | import { render } from "solid-js/web"; 3 | import { App } from "./app.tsx"; 4 | 5 | await initClient(() => { 6 | render(() => , getRootEl()); 7 | }); 8 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_home_tsx_tmpl.txt: -------------------------------------------------------------------------------- 1 | import { useLoaderData, type RouteProps } from "./app_utils.ts"; 2 | 3 | export function Home(props: RouteProps<"/">) { 4 | const loaderData = useLoaderData(props); 5 | 6 | return ( 7 | <> 8 |

Welcome to River!

9 |

{loaderData{{.Accessor}}.Message}

10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /bootstrap/tmpls/frontend_routes_ts_str.txt: -------------------------------------------------------------------------------- 1 | import type { RiverRoutes } from "river.now/client"; 2 | 3 | declare const routes: RiverRoutes; 4 | export default routes; 5 | 6 | routes.Add("/", import("./home.tsx"), "Home"); 7 | -------------------------------------------------------------------------------- /bootstrap/tmpls/gitignore_str.txt: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | 4 | # Wave 5 | __dist/static/* 6 | !__dist/static/.keep 7 | __dist/main* 8 | -------------------------------------------------------------------------------- /bootstrap/tmpls/main_critical_css_str.txt: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #333; 3 | color: #efefef; 4 | } 5 | -------------------------------------------------------------------------------- /bootstrap/tmpls/main_css_str.txt: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: monospace; 3 | } 4 | -------------------------------------------------------------------------------- /bootstrap/tmpls/package_json_str.txt: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "private": true, 4 | "scripts": { 5 | "dev": "go run ./__cmd/build --dev", 6 | "build": "go run ./__cmd/build" 7 | }, 8 | "devDependencies": {} 9 | } 10 | -------------------------------------------------------------------------------- /bootstrap/tmpls/ts_config_json_tmpl.txt: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "noUncheckedIndexedAccess": true, 12 | "verbatimModuleSyntax": true, 13 | "allowImportingTsExtensions": true, 14 | "jsx": "{{.TSConfigJSXVal}}", 15 | "jsxImportSource": "{{.TSConfigJSXImportSourceVal}}" 16 | }, 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /bootstrap/tmpls/vite_config_ts_tmpl.txt: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import {{.UIVariant}} from "{{.UIVitePlugin}}"; 3 | import { riverVitePlugin } from "./frontend/river.gen.ts"; 4 | 5 | export default defineConfig({ 6 | plugins: [{{.UIVariant}}(), riverVitePlugin()], 7 | }); 8 | -------------------------------------------------------------------------------- /bootstrap/tmpls/wave_config_json_tmpl.txt: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "__dist/static/internal/schema.json", 3 | "Core": { 4 | "ConfigLocation": "wave.config.json", 5 | "DevBuildHook": "go run ./__cmd/build --dev --hook", 6 | "ProdBuildHook": "go run ./__cmd/build --hook", 7 | "MainAppEntry": "__cmd/app", 8 | "DistDir": "__dist", 9 | "StaticAssetDirs": { 10 | "Private": "backend/__static", 11 | "Public": "frontend/__static" 12 | }, 13 | "CSSEntryFiles": { 14 | "Critical": "frontend/css/main.critical.css", 15 | "NonCritical": "frontend/css/main.css" 16 | }, 17 | "PublicPathPrefix": "/public/" 18 | }, 19 | "River": { 20 | "UIVariant": "{{.UIVariant}}", 21 | "HTMLTemplateLocation": "entry.go.html", 22 | "ClientEntry": "frontend/entry.tsx", 23 | "ClientRouteDefsFile": "frontend/routes.ts", 24 | "TSGenOutPath": "frontend/river.gen.ts", 25 | "BuildtimePublicURLFuncName": "getPublicURLBuildtime" 26 | }, 27 | "Vite": { 28 | "JSPackageManagerBaseCmd": "{{.JSPackageManagerBaseCmd}}" 29 | }, 30 | "Watch": { 31 | "HealthcheckEndpoint": "/healthz", 32 | "Include": [] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /bootstrap/utils.go: -------------------------------------------------------------------------------- 1 | package bootstrap 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "text/template" 7 | ) 8 | 9 | func (d *derivedOptions) tmplWriteMust(target, tmplStr string) { 10 | tmpl := template.Must(template.New(target).Parse(tmplStr)) 11 | var sb strings.Builder 12 | if err := tmpl.Execute(&sb, d); err != nil { 13 | panic(err) 14 | } 15 | b := []byte(sb.String()) 16 | if err := os.WriteFile(target, b, 0644); err != nil { 17 | panic(err) 18 | } 19 | } 20 | 21 | func strWriteMust(target string, content string) { 22 | if err := os.WriteFile(target, []byte(content), 0644); err != nil { 23 | panic(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/river-now/river 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/bmatcuk/doublestar/v4 v4.8.1 7 | github.com/evanw/esbuild v0.25.3 8 | github.com/fsnotify/fsnotify v1.9.0 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/joho/godotenv v1.5.1 11 | golang.org/x/crypto v0.37.0 12 | golang.org/x/net v0.39.0 13 | golang.org/x/sync v0.13.0 14 | golang.org/x/term v0.31.0 15 | ) 16 | 17 | require golang.org/x/sys v0.32.0 // indirect 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= 2 | github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 3 | github.com/evanw/esbuild v0.25.3 h1:4JKyUsm/nHDhpxis4IyWXAi8GiyTwG1WdEp6OhGVE8U= 4 | github.com/evanw/esbuild v0.25.3/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= 5 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 6 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 7 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 8 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 9 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 10 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 11 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 12 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 13 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 14 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 15 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 16 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 17 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 18 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 19 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 20 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 21 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 22 | -------------------------------------------------------------------------------- /internal/framework/_typescript/client/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | addBuildIDListener, 3 | addLocationListener, 4 | addRouteChangeListener, 5 | addStatusListener, 6 | applyScrollState, 7 | getBuildID, 8 | getHistoryInstance, 9 | getLocation, 10 | getPrefetchHandlers, 11 | getRootEl, 12 | getStatus, 13 | hmrRunClientLoaders, 14 | initClient, 15 | makeLinkOnClickFn, 16 | navigate, 17 | type RouteChangeEvent, 18 | revalidate, 19 | type StatusEvent, 20 | submit, 21 | } from "./src/client.ts"; 22 | export { 23 | makeFinalLinkProps, 24 | type RiverLinkPropsBase, 25 | type RiverRouteGeneric, 26 | type RiverRoutePropsGeneric, 27 | type RiverUntypedLoader, 28 | type UseRouterDataFunction, 29 | } from "./src/impl_helpers.ts"; 30 | export { getRouterData, internal_RiverClientGlobal } from "./src/river_ctx.ts"; 31 | export type { RiverRoutes } from "./src/route_def_helpers.ts"; 32 | -------------------------------------------------------------------------------- /internal/framework/_typescript/client/src/redirects.ts: -------------------------------------------------------------------------------- 1 | import { getHrefDetails, getIsGETRequest, type HrefDetails } from "river.now/kit/url"; 2 | import { LogInfo } from "./utils.ts"; 3 | 4 | export type RedirectData = { href: string; hrefDetails: HrefDetails } & ( 5 | | { 6 | status: "did"; 7 | } 8 | | { 9 | status: "should"; 10 | shouldRedirectStrategy: "hard" | "soft"; 11 | latestBuildID: string; 12 | } 13 | ); 14 | 15 | export function getBuildIDFromResponse(response: Response | undefined) { 16 | return response?.headers.get("X-River-Build-Id") || ""; 17 | } 18 | 19 | export function parseFetchResponseForRedirectData( 20 | reqInit: RequestInit, 21 | res: Response, 22 | ): RedirectData | null { 23 | const latestBuildID = getBuildIDFromResponse(res); 24 | 25 | const riverReloadTarget = res.headers.get("X-River-Reload"); 26 | if (riverReloadTarget) { 27 | const newURL = new URL(riverReloadTarget, window.location.href); 28 | const hrefDetails = getHrefDetails(newURL.href); 29 | if (!hrefDetails.isHTTP) { 30 | return null; 31 | } 32 | 33 | return { 34 | hrefDetails, 35 | status: "should", 36 | href: riverReloadTarget, 37 | shouldRedirectStrategy: "hard", 38 | latestBuildID, 39 | }; 40 | } 41 | 42 | if (res.redirected) { 43 | const newURL = new URL(res.url, window.location.href); 44 | const hrefDetails = getHrefDetails(newURL.href); 45 | if (!hrefDetails.isHTTP) { 46 | return null; 47 | } 48 | 49 | const isCurrent = newURL.href === window.location.href; 50 | if (isCurrent) { 51 | return { hrefDetails, status: "did", href: newURL.href }; 52 | } 53 | 54 | const wasGETRequest = getIsGETRequest(reqInit); 55 | if (!wasGETRequest) { 56 | LogInfo("Not a GET request. No way to handle."); 57 | return null; 58 | } 59 | 60 | return { 61 | hrefDetails, 62 | status: "should", 63 | href: newURL.href, 64 | shouldRedirectStrategy: hrefDetails.isInternal ? "soft" : "hard", 65 | latestBuildID, 66 | }; 67 | } 68 | 69 | const clientRedirectHeader = res.headers.get("X-Client-Redirect"); 70 | 71 | if (!clientRedirectHeader) { 72 | return null; 73 | } 74 | 75 | const newURL = new URL(clientRedirectHeader, window.location.href); 76 | const hrefDetails = getHrefDetails(newURL.href); 77 | if (!hrefDetails.isHTTP) { 78 | return null; 79 | } 80 | 81 | return { 82 | hrefDetails, 83 | status: "should", 84 | href: hrefDetails.absoluteURL, 85 | shouldRedirectStrategy: hrefDetails.isInternal ? "soft" : "hard", 86 | latestBuildID, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /internal/framework/_typescript/client/src/river_ctx.ts: -------------------------------------------------------------------------------- 1 | export type HeadEl = { 2 | tag?: string; 3 | attributesKnownSafe?: Record; 4 | booleanAttributes?: Array; 5 | dangerousInnerHTML?: string; 6 | }; 7 | 8 | type Meta = { 9 | title: HeadEl | null | undefined; 10 | metaHeadEls: Array | null | undefined; 11 | restHeadEls: Array | null | undefined; 12 | }; 13 | 14 | type shared = { 15 | outermostError?: string; 16 | outermostErrorIdx?: number; 17 | errorExportKey?: string; 18 | 19 | matchedPatterns: Array; 20 | loadersData: Array; 21 | importURLs: Array; 22 | exportKeys: Array; 23 | hasRootData: boolean; 24 | 25 | params: Record; 26 | splatValues: Array; 27 | 28 | buildID: string; 29 | 30 | activeComponents: Array | null; 31 | activeErrorBoundary?: any; 32 | }; 33 | 34 | export type GetRouteDataOutput = Omit & 35 | Meta & { 36 | deps: Array; 37 | cssBundles: Array; 38 | }; 39 | 40 | export const RIVER_SYMBOL = Symbol.for("__river_internal__"); 41 | 42 | export type RouteErrorComponent = (props: { error: string }) => any; 43 | 44 | export type RiverClientGlobal = shared & { 45 | isDev: boolean; 46 | viteDevURL: string; 47 | publicPathPrefix: string; 48 | isTouchDevice: boolean; 49 | patternToWaitFnMap: Record< 50 | string, 51 | (props: ReturnType & { loaderData: any }) => Promise 52 | >; 53 | clientLoadersData: Array; 54 | defaultErrorBoundary: RouteErrorComponent; 55 | useViewTransitions: boolean; 56 | }; 57 | 58 | export function __getRiverClientGlobal() { 59 | const dangerousGlobalThis = globalThis as any; 60 | function get(key: K) { 61 | return dangerousGlobalThis[RIVER_SYMBOL][key] as RiverClientGlobal[K]; 62 | } 63 | function set( 64 | key: K, 65 | value: V, 66 | ) { 67 | dangerousGlobalThis[RIVER_SYMBOL][key] = value; 68 | } 69 | return { get, set }; 70 | } 71 | 72 | export const internal_RiverClientGlobal = __getRiverClientGlobal(); 73 | 74 | // to debug ctx in browser, paste this: 75 | // const river_ctx = window[Symbol.for("__river_internal__")]; 76 | 77 | export function getRouterData< 78 | T = any, 79 | P extends Record = Record, 80 | >() { 81 | const rootData: T = internal_RiverClientGlobal.get("hasRootData") 82 | ? internal_RiverClientGlobal.get("loadersData")[0] 83 | : null; 84 | return { 85 | buildID: internal_RiverClientGlobal.get("buildID") || "", 86 | matchedPatterns: internal_RiverClientGlobal.get("matchedPatterns") || [], 87 | splatValues: internal_RiverClientGlobal.get("splatValues") || [], 88 | params: (internal_RiverClientGlobal.get("params") || {}) as P, 89 | rootData, 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /internal/framework/_typescript/client/src/route_def_helpers.ts: -------------------------------------------------------------------------------- 1 | type ImportPromise = Promise>; 2 | type Key = keyof Awaited; 3 | 4 | export type RiverRoutes = { 5 | Add: ( 6 | pattern: string, 7 | importPromise: IP, 8 | componentKey: Key, 9 | errorBoundaryKey?: Key, 10 | ) => void; 11 | }; 12 | -------------------------------------------------------------------------------- /internal/framework/_typescript/client/src/utils.ts: -------------------------------------------------------------------------------- 1 | ///////////////////////////////////////////////////////////////////// 2 | // GENERAL UTILS 3 | ///////////////////////////////////////////////////////////////////// 4 | 5 | export function isAbortError(error: unknown) { 6 | return error instanceof Error && error.name === "AbortError"; 7 | } 8 | 9 | export function LogInfo(message?: any, ...optionalParams: Array) { 10 | console.log("River:", message, ...optionalParams); 11 | } 12 | 13 | export function LogError(message?: any, ...optionalParams: Array) { 14 | console.error("River:", message, ...optionalParams); 15 | } 16 | 17 | export function Panic(msg?: string): never { 18 | LogError("Panic"); 19 | throw new Error(msg ?? "panic"); 20 | } 21 | -------------------------------------------------------------------------------- /internal/framework/_typescript/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /internal/framework/_typescript/preact/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | makeTypedAddClientLoader, 3 | makeTypedUseLoaderData, 4 | makeTypedUsePatternLoaderData, 5 | makeTypedUseRouterData, 6 | type RiverRoute, 7 | type RiverRouteProps, 8 | usePatternClientLoaderData, 9 | } from "./src/helpers.ts"; 10 | export { RiverLink } from "./src/link.tsx"; 11 | export { location, RiverRootOutlet } from "./src/preact.tsx"; 12 | -------------------------------------------------------------------------------- /internal/framework/_typescript/preact/src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "preact/hooks"; 2 | import type { JSX } from "preact/jsx-runtime"; 3 | import { 4 | type getRouterData, 5 | internal_RiverClientGlobal, 6 | type RiverRouteGeneric, 7 | type RiverRoutePropsGeneric, 8 | type RiverUntypedLoader, 9 | type UseRouterDataFunction, 10 | } from "river.now/client"; 11 | import { clientLoadersData, loadersData, routerData } from "./preact.tsx"; 12 | 13 | export type RiverRouteProps< 14 | Loader extends RiverUntypedLoader = RiverUntypedLoader, 15 | Pattern extends Loader["pattern"] = string, 16 | > = RiverRoutePropsGeneric; 17 | 18 | export type RiverRoute< 19 | Loader extends RiverUntypedLoader = RiverUntypedLoader, 20 | Pattern extends Loader["pattern"] = string, 21 | > = RiverRouteGeneric; 22 | 23 | export function makeTypedUseRouterData< 24 | OuterLoader extends RiverUntypedLoader, 25 | RootData, 26 | >() { 27 | return (() => { 28 | return routerData.value; 29 | }) as UseRouterDataFunction; 30 | } 31 | 32 | export function makeTypedUseLoaderData() { 33 | return function useLoaderData< 34 | Props extends RiverRouteProps, 35 | LoaderData = Extract< 36 | Loader, 37 | { pattern: Props["__phantom_pattern"] } 38 | >["phantomOutputType"], 39 | >(props: Props): LoaderData { 40 | return loadersData.value[props.idx]; 41 | }; 42 | } 43 | 44 | export function makeTypedUsePatternLoaderData() { 45 | return function usePatternData( 46 | pattern: Pattern, 47 | ): Extract["phantomOutputType"] | undefined { 48 | const idx = useMemo(() => { 49 | return routerData.value.matchedPatterns.findIndex((p) => p === pattern); 50 | }, [pattern]); 51 | 52 | if (idx === -1) { 53 | return undefined; 54 | } 55 | return loadersData.value[idx]; 56 | }; 57 | } 58 | 59 | export function usePatternClientLoaderData( 60 | pattern: string, 61 | ): ClientLoaderData | undefined { 62 | const idx = useMemo(() => { 63 | return routerData.value.matchedPatterns.findIndex((p) => p === pattern); 64 | }, [pattern]); 65 | 66 | if (idx === -1) { 67 | return undefined; 68 | } 69 | return clientLoadersData.value[idx]; 70 | } 71 | 72 | export function makeTypedAddClientLoader< 73 | OuterLoader extends RiverUntypedLoader, 74 | RootData, 75 | >() { 76 | const m = internal_RiverClientGlobal.get("patternToWaitFnMap"); 77 | 78 | return function addClientLoader< 79 | Pattern extends OuterLoader["pattern"], 80 | Loader extends Extract, 81 | RouterData = ReturnType>, 82 | LoaderData = Loader["phantomOutputType"], 83 | T = any, 84 | >(p: Pattern, fn: (props: RouterData & { loaderData: LoaderData }) => Promise) { 85 | (m as any)[p] = fn; 86 | 87 | return function useClientLoaderData( 88 | props: RiverRouteProps, 89 | ): Awaited> { 90 | return clientLoadersData.value[props.idx]; 91 | }; 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /internal/framework/_typescript/preact/src/link.tsx: -------------------------------------------------------------------------------- 1 | import { h, type JSX } from "preact"; 2 | import { useMemo } from "preact/hooks"; 3 | import { makeFinalLinkProps, type RiverLinkPropsBase } from "river.now/client"; 4 | 5 | export function RiverLink( 6 | props: JSX.HTMLAttributes & 7 | RiverLinkPropsBase< 8 | (e: JSX.TargetedMouseEvent) => void | Promise 9 | >, 10 | ) { 11 | const finalLinkProps = useMemo(() => makeFinalLinkProps(props), [props]); 12 | 13 | return h( 14 | "a", 15 | { 16 | "data-external": finalLinkProps.dataExternal, 17 | ...(props as any), 18 | onPointerEnter: finalLinkProps.onPointerEnter, 19 | onFocus: finalLinkProps.onFocus, 20 | onPointerLeave: finalLinkProps.onPointerLeave, 21 | onBlur: finalLinkProps.onBlur, 22 | onTouchCancel: finalLinkProps.onTouchCancel, 23 | onClick: finalLinkProps.onClick, 24 | }, 25 | props.children, 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /internal/framework/_typescript/preact/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "jsxImportSource": "preact" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /internal/framework/_typescript/react/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | makeTypedAddClientLoader, 3 | makeTypedUseLoaderData, 4 | makeTypedUsePatternLoaderData, 5 | makeTypedUseRouterData, 6 | type RiverRoute, 7 | type RiverRouteProps, 8 | usePatternClientLoaderData, 9 | } from "./src/helpers.ts"; 10 | export { RiverLink } from "./src/link.tsx"; 11 | export { RiverProvider, RiverRootOutlet, useLocation } from "./src/react.tsx"; 12 | -------------------------------------------------------------------------------- /internal/framework/_typescript/react/src/link.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps, useMemo } from "react"; 2 | import { makeFinalLinkProps, type RiverLinkPropsBase } from "river.now/client"; 3 | 4 | export function RiverLink( 5 | props: ComponentProps<"a"> & 6 | RiverLinkPropsBase< 7 | (e: React.MouseEvent) => void | Promise 8 | >, 9 | ) { 10 | const finalLinkProps = useMemo(() => makeFinalLinkProps(props), [props]); 11 | 12 | return ( 13 | 24 | {props.children} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /internal/framework/_typescript/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /internal/framework/_typescript/solid/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | makeTypedAddClientLoader, 3 | makeTypedUseLoaderData, 4 | makeTypedUsePatternLoaderData, 5 | makeTypedUseRouterData, 6 | type RiverRoute, 7 | type RiverRouteProps, 8 | usePatternClientLoaderData, 9 | } from "./src/helpers.ts"; 10 | export { RiverLink } from "./src/link.tsx"; 11 | export { location, RiverRootOutlet } from "./src/solid.tsx"; 12 | -------------------------------------------------------------------------------- /internal/framework/_typescript/solid/src/link.tsx: -------------------------------------------------------------------------------- 1 | import { makeFinalLinkProps, type RiverLinkPropsBase } from "river.now/client"; 2 | import { createMemo, type JSX } from "solid-js"; 3 | 4 | export function RiverLink( 5 | props: JSX.AnchorHTMLAttributes & 6 | RiverLinkPropsBase["onClick"]>, 7 | ) { 8 | const finalLinkProps = createMemo(() => makeFinalLinkProps(props)); 9 | 10 | return ( 11 | 22 | {props.children} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /internal/framework/_typescript/solid/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /internal/framework/get_deps.go: -------------------------------------------------------------------------------- 1 | package framework 2 | 3 | import "github.com/river-now/river/kit/matcher" 4 | 5 | func (h *River) getDeps(_matches []*matcher.Match) []string { 6 | var deps []string 7 | seen := make(map[string]struct{}, len(_matches)) 8 | handleDeps := func(src []string) { 9 | for _, d := range src { 10 | if _, ok := seen[d]; !ok { 11 | deps = append(deps, d) 12 | seen[d] = struct{}{} 13 | } 14 | } 15 | } 16 | if h._clientEntryDeps != nil { 17 | handleDeps(h._clientEntryDeps) 18 | } 19 | for _, match := range _matches { 20 | path := h._paths[match.OriginalPattern()] 21 | if path == nil { 22 | continue 23 | } 24 | handleDeps(path.Deps) 25 | } 26 | return deps 27 | } 28 | 29 | // order matters 30 | func (h *River) getCSSBundles(deps []string) []string { 31 | cssBundles := make([]string, 0, len(deps)) 32 | // first, client entry CSS 33 | if x, exists := h._depToCSSBundleMap[h._clientEntryOut]; exists { 34 | cssBundles = append(cssBundles, x) 35 | } 36 | // then all downstream deps 37 | for _, dep := range deps { 38 | if x, exists := h._depToCSSBundleMap[dep]; exists { 39 | cssBundles = append(cssBundles, x) 40 | } 41 | } 42 | return cssBundles 43 | } 44 | -------------------------------------------------------------------------------- /internal/framework/river_core.go: -------------------------------------------------------------------------------- 1 | package framework 2 | 3 | import ( 4 | "html/template" 5 | "io/fs" 6 | "net/http" 7 | "sync" 8 | 9 | "github.com/river-now/river/kit/colorlog" 10 | "github.com/river-now/river/kit/headels" 11 | "github.com/river-now/river/kit/htmlutil" 12 | "github.com/river-now/river/kit/mux" 13 | "github.com/river-now/river/wave" 14 | ) 15 | 16 | const ( 17 | RiverSymbolStr = "__river_internal__" 18 | ) 19 | 20 | var Log = colorlog.New("river") 21 | 22 | type RouteType = string 23 | 24 | var RouteTypes = struct { 25 | Loader RouteType 26 | Query RouteType 27 | Mutation RouteType 28 | NotFound RouteType 29 | }{ 30 | Loader: "loader", 31 | Query: "query", 32 | Mutation: "mutation", 33 | NotFound: "not-found", 34 | } 35 | 36 | type Path struct { 37 | NestedRoute mux.AnyNestedRoute `json:"-"` 38 | 39 | // both stages one and two 40 | OriginalPattern string `json:"originalPattern"` 41 | SrcPath string `json:"srcPath"` 42 | ExportKey string `json:"exportKey"` 43 | ErrorExportKey string `json:"errorExportKey,omitempty"` 44 | 45 | // stage two only 46 | OutPath string `json:"outPath,omitempty"` 47 | Deps []string `json:"deps,omitempty"` 48 | } 49 | 50 | type UIVariant string 51 | 52 | var UIVariants = struct { 53 | React UIVariant 54 | Preact UIVariant 55 | Solid UIVariant 56 | }{ 57 | React: "react", 58 | Preact: "preact", 59 | Solid: "solid", 60 | } 61 | 62 | type River struct { 63 | Wave *wave.Wave 64 | GetDefaultHeadEls func(r *http.Request) ([]*htmlutil.Element, error) 65 | GetHeadElUniqueRules func() *headels.HeadEls 66 | GetRootTemplateData func(r *http.Request) (map[string]any, error) 67 | 68 | mu sync.RWMutex 69 | _isDev bool 70 | _paths map[string]*Path 71 | _clientEntrySrc string 72 | _clientEntryOut string 73 | _clientEntryDeps []string 74 | _buildID string 75 | _depToCSSBundleMap map[string]string 76 | _rootTemplate *template.Template 77 | _privateFS fs.FS 78 | } 79 | -------------------------------------------------------------------------------- /internal/framework/vite_cmd.go: -------------------------------------------------------------------------------- 1 | package framework 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func (h *River) PostViteProdBuild() error { 11 | // Must come after Vite -- only needed in prod (the stage "one" version is fine in dev) 12 | pf, err := h.toPathsFile_StageTwo() 13 | if err != nil { 14 | Log.Error(fmt.Sprintf("error converting paths to paths file: %s", err)) 15 | return err 16 | } 17 | 18 | pathsAsJSON, err := json.MarshalIndent(pf, "", "\t") 19 | 20 | if err != nil { 21 | Log.Error(fmt.Sprintf("error marshalling paths to JSON: %s", err)) 22 | return err 23 | } 24 | 25 | pathsJSONOut_StageTwo := filepath.Join( 26 | h.Wave.GetStaticPrivateOutDir(), 27 | "river_out", 28 | RiverPathsStageTwoJSONFileName, 29 | ) 30 | err = os.WriteFile(pathsJSONOut_StageTwo, pathsAsJSON, os.ModePerm) 31 | if err != nil { 32 | Log.Error(fmt.Sprintf("error writing paths to disk: %s", err)) 33 | return err 34 | } 35 | 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /internal/junk-drawer/playground/embedded/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // If embedded direct with no json tag: just as though it were top-level 9 | // If embedded pointer with no json tag: just as though top-level, but fields optional 10 | // If embedded direct with json tag: tag becomes a required root field under which its fields are nested 11 | // If embedded pointer with json tag: tag becomes an optional root field under which its fields are nested 12 | 13 | type Base struct{ Name string } 14 | type BasePtr struct{ NamePtr string } 15 | type Wrapper struct { 16 | BuiltIn string 17 | Base 18 | *BasePtr 19 | } 20 | type WrapperWithJSONTags struct { 21 | BuiltIn string 22 | Base `json:"base"` 23 | *BasePtr `json:"basePtr"` 24 | } 25 | 26 | func main() { 27 | w := Wrapper{} 28 | wJsonTags := WrapperWithJSONTags{} 29 | 30 | jsonBytes_w, _ := json.Marshal(w) 31 | fmt.Println(string(jsonBytes_w)) 32 | 33 | jsonBytes_wJsonTags, _ := json.Marshal(wJsonTags) 34 | fmt.Println(string(jsonBytes_wJsonTags)) 35 | } 36 | -------------------------------------------------------------------------------- /internal/junk-drawer/playground/matcher/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/river-now/river/kit/matcher" 7 | ) 8 | 9 | var m = matcher.New(&matcher.Options{ 10 | ExplicitIndexSegment: "_index", 11 | DynamicParamPrefixRune: '$', 12 | }) 13 | 14 | var rps = registerPatterns([]string{"/", "/$user", "/$user/*", "/posts"}) 15 | 16 | func main() { 17 | fmt.Println("REGISTERED PATTERNS") 18 | for _, rp := range rps { 19 | fmt.Printf("Clean: '%s', Original: '%s'\n", rp.NormalizedPattern(), rp.OriginalPattern()) 20 | for _, seg := range rp.NormalizedSegments() { 21 | fmt.Println(seg) 22 | } 23 | } 24 | 25 | fmt.Println() 26 | 27 | var matches []*matcher.BestMatch 28 | var ok bool 29 | // matches, ok = m.FindNestedMatches("/") 30 | match, ok := m.FindBestMatch("/bob") 31 | if ok { 32 | matches = append(matches, match) 33 | } 34 | 35 | if !ok { 36 | fmt.Println("No match found") 37 | return 38 | } else { 39 | fmt.Println("MATCHES:", len(matches)) 40 | } 41 | 42 | for i, match := range matches { 43 | fmt.Println() 44 | fmt.Println("Match", i+1) 45 | fmt.Println() 46 | if len(match.Params) > 0 { 47 | fmt.Println("Params:", match.Params) 48 | } 49 | 50 | fmt.Println("SplatValues:", match.SplatValues) 51 | fmt.Printf("Clean: '%s', Original: '%s'\n", match.NormalizedPattern(), match.RegisteredPattern.OriginalPattern()) 52 | fmt.Println() 53 | } 54 | } 55 | 56 | ///////////////////////////////////////////////////////////////////// 57 | /////// UTILS 58 | ///////////////////////////////////////////////////////////////////// 59 | 60 | func registerPatterns(ps []string) []*matcher.RegisteredPattern { 61 | var rps []*matcher.RegisteredPattern 62 | for _, p := range ps { 63 | rps = append(rps, m.RegisterPattern(p)) 64 | } 65 | return rps 66 | } 67 | -------------------------------------------------------------------------------- /internal/junk-drawer/playground/nested/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/river-now/river/kit/mux" 10 | "github.com/river-now/river/kit/tasks" 11 | ) 12 | 13 | var tasksRegistry = tasks.NewRegistry("jd") 14 | 15 | var r = mux.NewNestedRouter(&mux.NestedOptions{ 16 | TasksRegistry: tasksRegistry, 17 | ExplicitIndexSegment: "_index", 18 | }) 19 | 20 | func newLoaderTask[O any](f func(*mux.NestedReqData) (O, error)) *mux.TaskHandler[mux.None, O] { 21 | return mux.TaskHandlerFromFunc(tasksRegistry, f) 22 | } 23 | 24 | var AuthTask = newLoaderTask(func(rd *mux.NestedReqData) (int, error) { 25 | fmt.Println("running auth ...", rd.Request().URL, time.Now().UnixMilli()) 26 | time.Sleep(1 * time.Second) 27 | fmt.Println("finishing auth ...", rd.Request().URL, time.Now().UnixMilli()) 28 | return 123, nil 29 | }) 30 | 31 | var AuthLarryTask = newLoaderTask(func(rd *mux.NestedReqData) (int, error) { 32 | fmt.Println("running auth larry ...", rd.Request().URL, time.Now().UnixMilli()) 33 | time.Sleep(1 * time.Second) 34 | fmt.Println("finishing auth larry ...", rd.Request().URL, time.Now().UnixMilli()) 35 | // return 24892498, nil 36 | return 0, errors.New("auth larry error") 37 | }) 38 | 39 | var AuthLarryIDTask = newLoaderTask(func(rd *mux.NestedReqData) (string, error) { 40 | fmt.Println("running auth larry :id ...", rd.Request().URL, time.Now().UnixMilli()) 41 | time.Sleep(1 * time.Second) 42 | fmt.Println("finishing auth larry :id ...", rd.Params()["id"], time.Now().UnixMilli()) 43 | return "*** Larry has an ID of " + rd.Params()["id"], nil 44 | }) 45 | 46 | func registerLoader[O any](pattern string, taskHandler *mux.TaskHandler[mux.None, O]) { 47 | mux.RegisterNestedTaskHandler(r, pattern, taskHandler) 48 | } 49 | 50 | func initRoutes() { 51 | registerLoader("/auth", AuthTask) 52 | registerLoader("/auth/larry", AuthLarryTask) 53 | registerLoader("/auth/larry/:id", AuthLarryIDTask) 54 | } 55 | 56 | func main() { 57 | initRoutes() 58 | 59 | req, _ := http.NewRequest("GET", "/auth/larry/12879", nil) 60 | 61 | tasksCtx := tasksRegistry.NewCtxFromRequest(req) 62 | 63 | results, _ := mux.FindNestedMatchesAndRunTasks(r, tasksCtx, req) 64 | 65 | fmt.Println() 66 | 67 | fmt.Println("results.Params", results.Params) 68 | fmt.Println("results.SplatValues", results.SplatValues) 69 | 70 | for _, v := range results.Slice { 71 | fmt.Println() 72 | 73 | fmt.Println("result: ", v.Pattern()) 74 | 75 | if v.OK() { 76 | fmt.Println("Data: ", v.Data()) 77 | } else { 78 | fmt.Println("Err : ", v.Err()) 79 | } 80 | } 81 | 82 | fmt.Println() 83 | } 84 | -------------------------------------------------------------------------------- /internal/junk-drawer/playground/tasks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/river-now/river/kit/tasks" 10 | ) 11 | 12 | var tasksRegistry = tasks.NewRegistry("jd") 13 | 14 | var ( 15 | Auth = tasks.Register(tasksRegistry, auth) 16 | User = tasks.Register(tasksRegistry, user) 17 | User2 = tasks.Register(tasksRegistry, user2) 18 | Profile = tasks.Register(tasksRegistry, profile) 19 | ) 20 | 21 | func main() { 22 | go func() { 23 | // every 1 second, print a new line 24 | for { 25 | fmt.Println() 26 | time.Sleep(1 * time.Second) 27 | } 28 | }() 29 | 30 | req, _ := http.NewRequest("GET", "http://localhost:8080", nil) 31 | c := tasksRegistry.NewCtxFromRequest(req) 32 | 33 | data, err := Profile.Prep(c, "32isdoghj").Get() 34 | 35 | fmt.Println("from main -- profile data:", data) 36 | fmt.Println("from main -- profile err:", err) 37 | } 38 | 39 | func auth(c *tasks.ArgNoInput) (int, error) { 40 | fmt.Println("running auth ...", time.Now().UnixMilli()) 41 | // return 0, errors.New("auth error") 42 | 43 | time.Sleep(2 * time.Second) 44 | 45 | fmt.Println("auth done", time.Now().UnixMilli()) 46 | return 123, nil 47 | } 48 | 49 | func user(c *tasks.Arg[string]) (string, error) { 50 | user_id := c.Input 51 | fmt.Println("running user ...", user_id, time.Now().UnixMilli()) 52 | // time.Sleep(500 * time.Millisecond) 53 | // c.Cancel() 54 | token, _ := Auth.PrepNoInput(c.TasksCtx).Get() 55 | fmt.Println("user retrieved token", token) 56 | 57 | time.Sleep(2 * time.Second) 58 | 59 | fmt.Println("user done", time.Now().UnixMilli()) 60 | return fmt.Sprintf("user-%d", token), nil 61 | } 62 | 63 | func user2(c *tasks.Arg[string]) (string, error) { 64 | fmt.Println("running user2 ...", time.Now().UnixMilli()) 65 | token, _ := Auth.PrepNoInput(c.TasksCtx).Get() 66 | fmt.Println("user2 retrieved token", token) 67 | 68 | time.Sleep(2 * time.Second) 69 | 70 | fmt.Println("user2 done", time.Now().UnixMilli()) 71 | return fmt.Sprintf("user2-%d", token), nil 72 | } 73 | 74 | func profile(c *tasks.Arg[string]) (string, error) { 75 | user_id := c.Input 76 | fmt.Println("running profile...", time.Now().UnixMilli()) 77 | user := User.Prep(c.TasksCtx, user_id) 78 | user2 := User2.Prep(c.TasksCtx, user_id) 79 | 80 | fmt.Println("profile running user, user2 in parallel") 81 | if ok := c.ParallelPreload(user, user2); !ok { 82 | return "", errors.New("user error") 83 | } 84 | fmt.Println("profile running user, user2 in parallel done") 85 | userData, _ := user.Get() 86 | user2Data, _ := user2.Get() 87 | fmt.Println("profile user, user2 data", userData, user2Data) 88 | 89 | time.Sleep(2 * time.Second) 90 | fmt.Println("profile done", time.Now().UnixMilli(), userData, user2Data) 91 | return "profile", nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/junk-drawer/playground/tstyper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | ) 7 | 8 | type Base struct { 9 | X string `json:"x"` 10 | } 11 | 12 | func (b Base) TSType() map[string]string { return map[string]string{"X": "asdf"} } 13 | 14 | type BaseWithPtrMethod struct { 15 | X string `json:"x"` 16 | } 17 | 18 | func (b *BaseWithPtrMethod) TSType() map[string]string { return map[string]string{"X": "asdf"} } 19 | 20 | type Base_Wrapped struct{ Base } 21 | type Base_WrappedPtr struct{ *Base } 22 | type BaseWithPtrMethod_Wrapped struct{ BaseWithPtrMethod } 23 | type BaseWithPtrMethod_WrappedPtr struct{ *BaseWithPtrMethod } 24 | 25 | type TSTyper interface { 26 | TSType() map[string]string 27 | } 28 | 29 | func implementsTSTyper(t reflect.Type) bool { 30 | if t.Implements(reflect.TypeOf((*TSTyper)(nil)).Elem()) { 31 | return true 32 | } 33 | if t.Kind() != reflect.Ptr && reflect.PointerTo(t).Implements(reflect.TypeOf((*TSTyper)(nil)).Elem()) { 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | func main() { 40 | run(Base{}) 41 | run(&Base{}) 42 | fmt.Println() 43 | 44 | run(BaseWithPtrMethod{}) 45 | run(&BaseWithPtrMethod{}) 46 | fmt.Println() 47 | 48 | run(Base_Wrapped{}) 49 | run(&Base_Wrapped{}) 50 | fmt.Println() 51 | 52 | run(Base_WrappedPtr{}) 53 | run(&Base_WrappedPtr{}) 54 | fmt.Println() 55 | 56 | run(BaseWithPtrMethod_Wrapped{}) 57 | run(&BaseWithPtrMethod_Wrapped{}) 58 | fmt.Println() 59 | 60 | run(BaseWithPtrMethod_WrappedPtr{}) 61 | run(&BaseWithPtrMethod_WrappedPtr{}) 62 | } 63 | 64 | func run(x any) { 65 | reflectType := reflect.TypeOf(x) 66 | name := reflectType.Name() 67 | if reflectType.Kind() == reflect.Ptr { 68 | name = "*" + reflectType.Elem().Name() 69 | } 70 | if implementsTSTyper(reflectType) { 71 | fmt.Println(name, " -- yes", getTSTypeMap(reflectType)) 72 | } else { 73 | fmt.Println(name, " -- no") 74 | } 75 | } 76 | 77 | func getTSTypeMap(t reflect.Type) map[string]string { 78 | var instance reflect.Value 79 | if t.Kind() == reflect.Ptr { 80 | instance = reflect.New(t.Elem()) 81 | } else { 82 | instance = reflect.New(t) 83 | } 84 | initializeEmbeddedPointers(instance) 85 | if t.Kind() == reflect.Ptr { 86 | return instance.Interface().(TSTyper).TSType() 87 | } else { 88 | return instance.Interface().(TSTyper).TSType() 89 | } 90 | } 91 | 92 | func initializeEmbeddedPointers(v reflect.Value) { 93 | if v.Kind() == reflect.Ptr && v.Elem().Kind() == reflect.Struct { 94 | elem := v.Elem() 95 | typ := elem.Type() 96 | for i := range elem.NumField() { 97 | field := elem.Field(i) 98 | fieldType := typ.Field(i) 99 | if fieldType.Anonymous && field.Kind() == reflect.Ptr && field.IsNil() { 100 | newValue := reflect.New(field.Type().Elem()) 101 | field.Set(newValue) 102 | initializeEmbeddedPointers(newValue) 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /internal/junk-drawer/playground/validate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/river-now/river/kit/validate" 8 | ) 9 | 10 | type FirstName string 11 | type LastName string 12 | 13 | func (ln LastName) Validate() error { 14 | return errors.New("asdf") 15 | } 16 | 17 | type Job struct { 18 | Employer string 19 | Title string 20 | } 21 | 22 | type Email struct { 23 | EmailAddress *string 24 | } 25 | 26 | type Person struct { 27 | FirstName 28 | LastName 29 | *Job 30 | Email 31 | } 32 | 33 | func (p *Person) Validate() error { 34 | v := validate.Object(p) 35 | v.Required("FirstName") 36 | v.Required("LastName") 37 | v.Required("Employer") 38 | v.Required("Title") 39 | v.Required("EmailAddress") 40 | return v.Error() 41 | } 42 | 43 | func main() { 44 | // lastName := LastName("Cook") 45 | // // emailAddress := "bob@bob.com 46 | 47 | // p := &Person{ 48 | // FirstName: "Samuel", 49 | // LastName: lastName, 50 | // Job: &Job{ 51 | // Employer: "Jim", 52 | // Title: "", 53 | // }, 54 | // Email: Email{ 55 | // // EmailAddress: &emailAddress, 56 | // }, 57 | // } 58 | 59 | // err := p.Validate() 60 | 61 | // if err != nil { 62 | // fmt.Println(err) 63 | // } else { 64 | // fmt.Println("OK") 65 | // } 66 | 67 | // ////////////// 68 | 69 | // var x LastName = "Cook" 70 | // err = validate.Any("x", x).Required().Error() 71 | // if err != nil { 72 | // fmt.Println("2", err) 73 | // } else { 74 | // fmt.Println("2 OK") 75 | // } 76 | 77 | // err := validate.Any("int", 0).Required().Error() 78 | // if err != nil { 79 | // fmt.Println("3", err) 80 | // } else { 81 | // fmt.Println("3 OK") 82 | // } 83 | 84 | var m map[string]any 85 | fmt.Println("m", m, m == nil) 86 | err := validate.Object(m).Error() 87 | fmt.Println(err) 88 | } 89 | 90 | // type Person map[string]string 91 | 92 | // func (p *Person) Validate() error { 93 | // v := validate.Object(p) 94 | // v.Required("FirstName").Min(20) 95 | // v.Required("LastName") 96 | // return v.Error() 97 | // } 98 | 99 | // func main() { 100 | // p := &Person{ 101 | // "FirstName": "Samuel", 102 | // "LastName": "Cook", 103 | // } 104 | 105 | // err := p.Validate() 106 | 107 | // if err != nil { 108 | // fmt.Println(err) 109 | // } else { 110 | // fmt.Println("OK") 111 | // } 112 | // } 113 | -------------------------------------------------------------------------------- /internal/scripts/_typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /internal/scripts/buildts/build-solid.mjs: -------------------------------------------------------------------------------- 1 | import { build } from "esbuild"; 2 | import { solidPlugin } from "esbuild-plugin-solid"; 3 | 4 | await build({ 5 | plugins: [solidPlugin()], 6 | sourcemap: "linked", 7 | target: "esnext", 8 | format: "esm", 9 | treeShaking: true, 10 | splitting: true, 11 | write: true, 12 | bundle: true, 13 | entryPoints: ["./internal/framework/_typescript/solid/index.tsx"], 14 | external: ["river.now", "solid-js"], 15 | outdir: "./npm_dist/internal/framework/_typescript/solid", 16 | tsconfig: "./internal/framework/_typescript/solid/tsconfig.json", 17 | }); 18 | -------------------------------------------------------------------------------- /internal/scripts/bumper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/river-now/river/kit/scripts/bumper" 5 | ) 6 | 7 | func main() { 8 | bumper.Run() 9 | } 10 | -------------------------------------------------------------------------------- /internal/scripts/npm_bumper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | t "github.com/river-now/river/kit/cliutil" 8 | "github.com/river-now/river/kit/parseutil" 9 | ) 10 | 11 | func main() { 12 | lines, versionLine, currentVersion := parseutil.PackageJSONFromFile("./package.json") 13 | 14 | // Show current tag 15 | t.Plain("current version: ") 16 | t.Green(currentVersion) 17 | t.NewLine() 18 | 19 | // Ask for new version 20 | t.Blue("what is the new version? ") 21 | version, err := t.NewReader().ReadString('\n') 22 | if err != nil { 23 | t.Exit("failed to read version", err) 24 | } 25 | 26 | trimmedVersion := strings.TrimSpace(version) 27 | if trimmedVersion == "" { 28 | t.Exit("version is empty", nil) 29 | } 30 | 31 | // Show new tag 32 | t.Plain("Result: ") 33 | t.Red(currentVersion) 34 | t.Plain(" --> ") 35 | t.Green(trimmedVersion) 36 | t.NewLine() 37 | 38 | // Ask for confirmation 39 | t.Blue("is this correct? ") 40 | t.RequireYes("aborted") 41 | 42 | lines[versionLine] = strings.Replace(lines[versionLine], currentVersion, trimmedVersion, 1) 43 | 44 | // Ask for write confirmation 45 | t.Blue("write new version ") 46 | t.Green(trimmedVersion) 47 | t.Blue(" to package.json? ") 48 | t.RequireYes("aborted") 49 | 50 | // Write the new version to the file 51 | if err = os.WriteFile("./package.json", []byte(strings.Join(lines, "\n")+"\n"), 0644); err != nil { 52 | t.Exit("failed to write file", err) 53 | } 54 | 55 | // Sanity check 56 | _, _, newCurrentVersion := parseutil.PackageJSONFromFile("./package.json") 57 | if newCurrentVersion != trimmedVersion { 58 | t.Exit("failed to update version", nil) 59 | } 60 | 61 | isPre := strings.Contains(newCurrentVersion, "pre") 62 | 63 | if isPre { 64 | t.Plain("pre-release version detected") 65 | t.NewLine() 66 | } 67 | 68 | // Ask whether to initiate a new build? 69 | t.Blue("emit a new build to ./npm_dist? ") 70 | t.RequireYes("aborted") 71 | 72 | cmd := t.Cmd("make", "npmbuild") 73 | t.MustRun(cmd, "npm dist build failed") 74 | 75 | // Ask for publish confirmation 76 | t.Blue("do you want to publish ") 77 | if isPre { 78 | t.Red("PRE release ") 79 | } else { 80 | t.Red("FINAL release ") 81 | } 82 | t.Green(trimmedVersion) 83 | t.Blue(" npm? ") 84 | t.RequireYes("aborted") 85 | 86 | cmd = t.Cmd("make", "tspublishpre") 87 | if !isPre { 88 | cmd = t.Cmd("make", "tspublishnonpre") 89 | } 90 | 91 | t.MustRun(cmd, "npm publish failed") 92 | 93 | t.Plain("npm publish done") 94 | t.NewLine() 95 | } 96 | -------------------------------------------------------------------------------- /kit/_typescript/cookies/cookies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks the client cookie for a specific name. Returns the value if 3 | * found, otherwise undefined. Does not do any encoding or decoding. 4 | */ 5 | export function getClientCookie(name: string) { 6 | const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); 7 | return match ? match[2] : undefined; 8 | } 9 | 10 | /** 11 | * Sets a client cookie with the specified name and value. The cookie 12 | * is set to expire in one year and is accessible to all paths on the 13 | * domain. The SameSite attribute is set to Lax. Does not do any 14 | * encoding or decoding. 15 | */ 16 | export function setClientCookie(name: string, value: string) { 17 | document.cookie = `${name}=${value}; path=/; max-age=31536000; SameSite=Lax`; 18 | } 19 | -------------------------------------------------------------------------------- /kit/_typescript/debounce/debounce.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 2 | import { debounce } from "./debounce.ts"; 3 | 4 | describe("debounce", () => { 5 | beforeEach(() => { 6 | vi.useFakeTimers(); 7 | }); 8 | 9 | afterEach(() => { 10 | vi.useRealTimers(); 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it("delays the call by the specified time and resolves with return value", async () => { 15 | const spy = vi.fn((a: number, b: number) => a + b); 16 | const debounced = debounce(spy, 100); 17 | 18 | const resultPromise = debounced(1, 2); 19 | // not called immediately 20 | expect(spy).not.toHaveBeenCalled(); 21 | 22 | // midway through delay 23 | vi.advanceTimersByTime(50); 24 | expect(spy).not.toHaveBeenCalled(); 25 | 26 | // after full delay 27 | vi.advanceTimersByTime(50); 28 | await expect(resultPromise).resolves.toBe(3); 29 | 30 | expect(spy).toHaveBeenCalledTimes(1); 31 | expect(spy).toHaveBeenCalledWith(1, 2); 32 | }); 33 | 34 | it("only the last call within the delay window executes", async () => { 35 | const spy = vi.fn((x: number) => x * 2); 36 | const debounced = debounce(spy, 100); 37 | 38 | // first call 39 | const p1 = debounced(5); 40 | // before it fires, call again 41 | vi.advanceTimersByTime(50); 42 | const p2 = debounced(6); 43 | 44 | // advance past delay 45 | vi.advanceTimersByTime(100); 46 | 47 | // only the second promise should resolve 48 | await expect(p2).resolves.toBe(12); 49 | 50 | // ensure original call was cancelled 51 | expect(spy).toHaveBeenCalledTimes(1); 52 | expect(spy).toHaveBeenCalledWith(6); 53 | }); 54 | 55 | it("maintains correct `this` if wrapped in a method", async () => { 56 | const obj = { 57 | value: 10, 58 | getValue(add: number) { 59 | return this.value + add; 60 | }, 61 | }; 62 | // wrap as a method 63 | const debounced = debounce(obj.getValue.bind(obj), 30); 64 | 65 | const p = debounced(5); 66 | vi.advanceTimersByTime(30); 67 | await expect(p).resolves.toBe(15); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /kit/_typescript/debounce/debounce.ts: -------------------------------------------------------------------------------- 1 | type Fn = (...args: Array) => any; 2 | 3 | export function debounce( 4 | fn: T, 5 | delayInMs: number, 6 | ): (...args: Parameters) => Promise>> { 7 | let timeoutID: number; 8 | 9 | return (...args: Parameters) => { 10 | return new Promise>>((resolve) => { 11 | clearTimeout(timeoutID); 12 | timeoutID = window.setTimeout(() => { 13 | resolve(fn(...args)); 14 | }, delayInMs); 15 | }); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /kit/_typescript/fmt/fmt.ts: -------------------------------------------------------------------------------- 1 | export function prettyJSON(obj: any): string { 2 | return JSON.stringify(obj, null, 2); 3 | } 4 | -------------------------------------------------------------------------------- /kit/_typescript/json/deep_equals.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Performs a deep equality comparison of two JSON-compatible values. 3 | * Handles null, undefined, primitives, arrays, and plain objects, recursively. 4 | * Does not support Maps, Sets, Functions, or other non-JSON types. 5 | */ 6 | export function jsonDeepEquals(a: unknown, b: unknown): boolean { 7 | // Tautology 8 | if (a === b) { 9 | return true; 10 | } 11 | 12 | // If both were null or both were undefined, we would have early returned above. 13 | // So if either at this point is loosely null, we know they're not equal. 14 | if (a == null || b == null) { 15 | return false; 16 | } 17 | 18 | // If types are different, we know they're not equal. 19 | if (typeof a !== typeof b) { 20 | return false; 21 | } 22 | 23 | const aIsArray = Array.isArray(a); 24 | const bIsArray = Array.isArray(b); 25 | 26 | // If one is an array and the other is not, we know they're not equal. 27 | if (aIsArray !== bIsArray) { 28 | return false; 29 | } 30 | 31 | // Handle arrays 32 | if (aIsArray && bIsArray) { 33 | if (a.length !== b.length) { 34 | return false; 35 | } 36 | return a.every((item, index) => jsonDeepEquals(item, b[index])); 37 | } 38 | 39 | // Handle objects 40 | if (typeof a === "object" && typeof b === "object") { 41 | const aKeys = Object.keys(a as object); 42 | const bKeys = Object.keys(b as object); 43 | 44 | if (aKeys.length !== bKeys.length) { 45 | return false; 46 | } 47 | 48 | return aKeys.every((key) => { 49 | return Object.hasOwn(b, key) && jsonDeepEquals((a as any)[key], (b as any)[key]); 50 | }); 51 | } 52 | 53 | return false; 54 | } 55 | -------------------------------------------------------------------------------- /kit/_typescript/json/json.ts: -------------------------------------------------------------------------------- 1 | export { jsonDeepEquals } from "./deep_equals.ts"; 2 | export { serializeToSearchParams } from "./search_param_serializer.ts"; 3 | export { jsonStringifyStable } from "./stringify_stable.ts"; 4 | -------------------------------------------------------------------------------- /kit/_typescript/json/search_param_serializer.ts: -------------------------------------------------------------------------------- 1 | export function serializeToSearchParams(obj: Record): URLSearchParams { 2 | const params = new URLSearchParams(); 3 | 4 | function appendValue(key: string, value: any) { 5 | if (value === null || value === undefined) { 6 | params.append(key, ""); 7 | return; 8 | } 9 | 10 | if (Array.isArray(value)) { 11 | if (value.length === 0) { 12 | params.append(key, ""); 13 | } else { 14 | for (const item of value) { 15 | appendValue(key, item); 16 | } 17 | } 18 | return; 19 | } 20 | 21 | if (typeof value === "object") { 22 | const entries = Object.entries(value); 23 | if (entries.length === 0) { 24 | params.append(key, ""); 25 | } else { 26 | // Sort nested keys alphabetically 27 | entries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); 28 | for (const [subKey, subValue] of entries) { 29 | const newKey = key ? `${key}.${subKey}` : subKey; 30 | appendValue(newKey, subValue); 31 | } 32 | } 33 | return; 34 | } 35 | 36 | params.append(key, String(value)); 37 | } 38 | 39 | if (typeof obj === "object" && obj !== null) { 40 | // Sort top-level keys alphabetically 41 | const entries = Object.entries(obj); 42 | entries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB)); 43 | for (const [key, value] of entries) { 44 | appendValue(key, value); 45 | } 46 | } 47 | 48 | return params; 49 | } 50 | -------------------------------------------------------------------------------- /kit/_typescript/json/stringify_stable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deterministically serializes a JSON-compatible value to a stable string. 3 | * Throws if it detects a circular reference. Does not support Maps, Sets, 4 | * Functions, or other non-JSON types. 5 | */ 6 | export function jsonStringifyStable(input: unknown): string { 7 | // First stabilize the structure, then JSON.stringify 8 | const stabilized = stabilizeStructure(input, new WeakSet()); 9 | 10 | return JSON.stringify(stabilized); 11 | } 12 | 13 | function stabilizeStructure(value: unknown, visited: WeakSet): unknown { 14 | // Handle primitives and null 15 | if (value === null || typeof value !== "object") { 16 | return value; 17 | } 18 | 19 | // Prevent circular references 20 | if (visited.has(value)) { 21 | throw new Error("Circular reference detected during stable JSON stringification"); 22 | } 23 | visited.add(value); 24 | 25 | // Handle arrays - recursively stabilize each element 26 | if (Array.isArray(value)) { 27 | const result = value.map((item) => stabilizeStructure(item, visited)); 28 | visited.delete(value); // Clean up after processing 29 | return result; 30 | } 31 | 32 | // Handle objects - sort keys and recursively stabilize values 33 | const keys = Object.keys(value).sort(); 34 | const stable: Record = {}; 35 | 36 | // Add sorted keys with stabilized values 37 | for (const key of keys) { 38 | stable[key] = stabilizeStructure((value as Record)[key], visited); 39 | } 40 | 41 | visited.delete(value); // Clean up after processing 42 | return stable; 43 | } 44 | -------------------------------------------------------------------------------- /kit/_typescript/listeners/listeners.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from "river.now/kit/debounce"; 2 | 3 | export function addOnWindowFocusListener(callback: () => void): void { 4 | const debouncedCallback = debounce(callback, 10); 5 | window.addEventListener("focus", debouncedCallback); 6 | window.addEventListener("visibilitychange", () => { 7 | if (document.visibilityState === "visible") { 8 | debouncedCallback(); 9 | } 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /kit/_typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /kit/bytesutil/bytesutil.go: -------------------------------------------------------------------------------- 1 | // Package bytesutil provides utility functions for byte slice operations. 2 | package bytesutil 3 | 4 | import ( 5 | "bytes" 6 | "crypto/rand" 7 | "encoding/base64" 8 | "encoding/gob" 9 | "fmt" 10 | "reflect" 11 | ) 12 | 13 | // Random returns a slice of cryptographically random bytes of length byteLen. 14 | func Random(byteLen int) ([]byte, error) { 15 | r := make([]byte, byteLen) 16 | if _, err := rand.Read(r); err != nil { 17 | return nil, err 18 | } 19 | return r, nil 20 | } 21 | 22 | // FromBase64 decodes a base64-encoded string into a byte slice. 23 | func FromBase64(base64Str string) ([]byte, error) { 24 | return base64.StdEncoding.DecodeString(base64Str) 25 | } 26 | 27 | // FromBase64Multi decodes multiple base64-encoded strings into byte slices. 28 | func FromBase64Multi(base64Strs ...string) ([][]byte, error) { 29 | var bytes = make([][]byte, 0, len(base64Strs)) 30 | for _, str := range base64Strs { 31 | b, err := FromBase64(str) 32 | if err != nil { 33 | return nil, err 34 | } 35 | bytes = append(bytes, b) 36 | } 37 | return bytes, nil 38 | } 39 | 40 | // ToBase64 encodes a byte slice into a base64-encoded string. 41 | func ToBase64(bytes []byte) string { 42 | return base64.StdEncoding.EncodeToString(bytes) 43 | } 44 | 45 | // ToBase64Multi encodes multiple byte slices into base64-encoded strings. 46 | func ToBase64Multi(bytes ...[]byte) []string { 47 | var strs = make([]string, 0, len(bytes)) 48 | for _, b := range bytes { 49 | strs = append(strs, ToBase64(b)) 50 | } 51 | return strs 52 | } 53 | 54 | // ToGob encodes an arbitrary value into a gob-encoded byte slice. 55 | func ToGob(src any) ([]byte, error) { 56 | rv := reflect.ValueOf(src) 57 | if rv.Kind() == reflect.Ptr && rv.IsNil() { 58 | return nil, fmt.Errorf("bytesutil.ToGob: cannot encode nil pointer value") 59 | } 60 | var a bytes.Buffer 61 | enc := gob.NewEncoder(&a) 62 | err := enc.Encode(src) 63 | if err != nil { 64 | return nil, fmt.Errorf("bytesutil.ToGob: failed to encode src to bytes: %w", err) 65 | } 66 | return a.Bytes(), nil 67 | } 68 | 69 | // FromGobInto decodes a gob-encoded byte slice into a destination. 70 | // The destination must be a pointer to the destination type. 71 | func FromGobInto(gobBytes []byte, destPtr any) error { 72 | if gobBytes == nil { 73 | return fmt.Errorf("bytesutil.FromGobInto: cannot decode nil bytes") 74 | } 75 | if destPtr == nil { 76 | return fmt.Errorf("bytesutil.FromGobInto: cannot decode into nil destination") 77 | } 78 | dec := gob.NewDecoder(bytes.NewReader(gobBytes)) 79 | err := dec.Decode(destPtr) 80 | if err != nil { 81 | return fmt.Errorf("bytesutil.FromGobInto: failed to decode bytes into dest: %w", err) 82 | } 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /kit/cliutil/cliutil.go: -------------------------------------------------------------------------------- 1 | package cliutil 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | "golang.org/x/term" 10 | ) 11 | 12 | const ( 13 | ColorRed = "\033[0;31m" 14 | ColorGreen = "\033[0;32m" 15 | ColorBlue = "\033[0;34m" 16 | ColorPlain = "\033[0m" 17 | ) 18 | 19 | func NewReader() *bufio.Reader { 20 | return bufio.NewReader(os.Stdin) 21 | } 22 | 23 | func RequireYes(failMsg string) { 24 | Plain("(y/n) ") 25 | 26 | fd := int(os.Stdin.Fd()) 27 | oldState, err := term.MakeRaw(fd) 28 | if err != nil { 29 | Exit("failed to set terminal raw mode", err) 30 | } 31 | 32 | buf := make([]byte, 1) 33 | _, err = os.Stdin.Read(buf) 34 | 35 | term.Restore(fd, oldState) 36 | 37 | if err != nil { 38 | Exit("failed to read input", err) 39 | } 40 | 41 | fmt.Printf("%c", buf[0]) 42 | NewLine() 43 | 44 | if buf[0] != 'y' && buf[0] != 'Y' { 45 | Exit(failMsg, err) 46 | } 47 | } 48 | 49 | func MustRun(cmd *exec.Cmd, failMsg string) { 50 | cmd.Stdout = os.Stdout 51 | cmd.Stderr = os.Stderr 52 | if err := cmd.Run(); err != nil { 53 | Exit(failMsg, err) 54 | } 55 | } 56 | 57 | func Cmd(cmd string, args ...string) *exec.Cmd { 58 | return exec.Command(cmd, args...) 59 | } 60 | 61 | func Exit(msg string, err error) { 62 | if err != nil { 63 | Plain("ERROR: " + msg + ": ") 64 | Red(fmt.Sprintf("%v", err)) 65 | } else { 66 | Plain(msg) 67 | } 68 | NewLine() 69 | 70 | code := 0 71 | if err != nil { 72 | code = 1 73 | } 74 | 75 | os.Exit(code) 76 | } 77 | 78 | func Red(s string) { 79 | Wrap(s, startRed) 80 | } 81 | 82 | func Green(s string) { 83 | Wrap(s, startGreen) 84 | } 85 | 86 | func Blue(s string) { 87 | Wrap(s, startBlue) 88 | } 89 | 90 | func Plain(s string) { 91 | Wrap(s, startPlain) 92 | } 93 | 94 | func Wrap(s string, f func()) { 95 | f() 96 | fmt.Print(s) 97 | startPlain() 98 | } 99 | 100 | func NewLine() { 101 | fmt.Println() 102 | } 103 | 104 | func startRed() { 105 | fmt.Print(ColorRed) 106 | } 107 | 108 | func startGreen() { 109 | fmt.Print(ColorGreen) 110 | } 111 | 112 | func startBlue() { 113 | fmt.Print(ColorBlue) 114 | } 115 | 116 | func startPlain() { 117 | fmt.Print(ColorPlain) 118 | } 119 | -------------------------------------------------------------------------------- /kit/contextutil/contextutil.go: -------------------------------------------------------------------------------- 1 | package contextutil 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/river-now/river/kit/genericsutil" 8 | ) 9 | 10 | type Store[T any] struct { 11 | key keyWrapper 12 | } 13 | 14 | type keyWrapper struct { 15 | name string 16 | } 17 | 18 | func NewStore[T any](key string) *Store[T] { 19 | return &Store[T]{key: keyWrapper{name: key}} 20 | } 21 | 22 | func (s *Store[T]) GetContextWithValue(c context.Context, val T) context.Context { 23 | return context.WithValue(c, s.key, val) 24 | } 25 | 26 | func (s *Store[T]) GetValueFromContext(c context.Context) T { 27 | return genericsutil.AssertOrZero[T](c.Value(s.key)) 28 | } 29 | 30 | func (s *Store[T]) GetRequestWithContext(r *http.Request, val T) *http.Request { 31 | return r.WithContext(s.GetContextWithValue(r.Context(), val)) 32 | } 33 | -------------------------------------------------------------------------------- /kit/contextutil/contextutil_test.go: -------------------------------------------------------------------------------- 1 | package contextutil 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func genericTest[T comparable](t *testing.T, val T) { 10 | store := NewStore[T]("key") 11 | 12 | ctx := store.GetContextWithValue(context.Background(), val) 13 | 14 | if store.GetValueFromContext(ctx) != val { 15 | t.Error("expected 'hello', got", store.GetValueFromContext(ctx)) 16 | } 17 | 18 | r := store.GetRequestWithContext(&http.Request{}, val) 19 | 20 | if store.GetValueFromContext(r.Context()) != val { 21 | t.Error("expected 'world', got", store.GetValueFromContext(r.Context())) 22 | } 23 | } 24 | 25 | func Test(t *testing.T) { 26 | genericTest(t, "hello") 27 | genericTest(t, "world") 28 | genericTest(t, 42) 29 | genericTest(t, 3.14) 30 | genericTest(t, true) 31 | genericTest(t, false) 32 | genericTest(t, struct{}{}) 33 | genericTest(t, struct{ Name string }{Name: "Bob"}) 34 | } 35 | -------------------------------------------------------------------------------- /kit/dedupe/dedupe.go: -------------------------------------------------------------------------------- 1 | package dedupe 2 | 3 | type Seen[K comparable] map[K]struct{} 4 | 5 | func NewSeen[K comparable]() Seen[K] { 6 | return make(Seen[K]) 7 | } 8 | 9 | // OK registers a key as seen, and returns true if 10 | // the key was already seen before this call. 11 | func (s Seen[K]) OK(key K) bool { 12 | if _, ok := s[key]; ok { 13 | return true 14 | } 15 | s[key] = struct{}{} 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /kit/dirs/dirs.go: -------------------------------------------------------------------------------- 1 | package dirs 2 | 3 | import ( 4 | "path/filepath" 5 | "reflect" 6 | ) 7 | 8 | // NOTE! You must use exported (public, uppercased) fields 9 | // or else this won't work. The build step for a directory 10 | // structure uses reflection, so do it once when you start 11 | // your app, and then cache it, so you don't have to do it 12 | // over and over again. 13 | 14 | type ( 15 | empty = struct{} 16 | File struct { 17 | name string 18 | path string 19 | } 20 | Dir[T any] struct { 21 | slash T 22 | name string 23 | path string 24 | } 25 | DirEmpty = Dir[empty] 26 | builder interface { 27 | build(parent string) 28 | } 29 | ) 30 | 31 | func (f *File) LastSegment() string { return f.name } 32 | func (f *File) FullPath() string { return f.path } 33 | 34 | func (d *Dir[T]) LastSegment() string { return d.name } 35 | func (d *Dir[T]) FullPath() string { return d.path } 36 | func (d *Dir[T]) S() T { return d.slash } 37 | 38 | func ToFile(name string) *File { 39 | return &File{name: name} 40 | } 41 | func ToRoot[T any](children T) *Dir[T] { 42 | return &Dir[T]{slash: children} 43 | } 44 | func ToDir[T any](name string, children T) *Dir[T] { 45 | return &Dir[T]{name: name, slash: children} 46 | } 47 | func ToDirEmpty(name string) *Dir[empty] { 48 | return &Dir[empty]{name: name} 49 | } 50 | 51 | func Build[T any](basePath string, root *Dir[T]) *Dir[T] { 52 | root.path = filepath.Join(basePath, root.name) 53 | reflectBuild(root.slash, root.path) 54 | return root 55 | } 56 | 57 | func (f *File) build(parent string) { 58 | f.path = filepath.Join(parent, f.name) 59 | } 60 | 61 | func (d *Dir[T]) build(parent string) { 62 | d.path = filepath.Join(parent, d.name) 63 | reflectBuild(d.slash, d.path) 64 | } 65 | 66 | func reflectBuild(data any, parentPath string) { 67 | if data == nil { 68 | return 69 | } 70 | val := reflect.ValueOf(data) 71 | 72 | if val.Kind() == reflect.Ptr && !val.IsNil() { 73 | val = val.Elem() 74 | } 75 | 76 | if b, ok := asBuilder(data); ok { 77 | b.build(parentPath) 78 | return 79 | } 80 | 81 | if val.Kind() == reflect.Struct { 82 | for i := range val.NumField() { 83 | f := val.Field(i) 84 | if f.CanInterface() { 85 | reflectBuild(f.Interface(), parentPath) 86 | } 87 | } 88 | } 89 | } 90 | 91 | func asBuilder(data any) (builder, bool) { 92 | b, ok := data.(builder) 93 | return b, ok 94 | } 95 | -------------------------------------------------------------------------------- /kit/envutil/envutil.go: -------------------------------------------------------------------------------- 1 | package envutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/joho/godotenv" 9 | ) 10 | 11 | func GetStr(key string, defaultValue string) string { 12 | if value, ok := os.LookupEnv(key); ok { 13 | return value 14 | } 15 | return defaultValue 16 | } 17 | 18 | func GetInt(key string, defaultValue int) int { 19 | strValue := GetStr(key, strconv.Itoa(defaultValue)) 20 | value, err := strconv.Atoi(strValue) 21 | if err == nil { 22 | return value 23 | } 24 | return defaultValue 25 | } 26 | 27 | func GetBool(key string, defaultValue bool) bool { 28 | strValue := GetStr(key, strconv.FormatBool(defaultValue)) 29 | value, err := strconv.ParseBool(strValue) 30 | if err == nil { 31 | return value 32 | } 33 | return defaultValue 34 | } 35 | 36 | type Env interface { 37 | GetIsDev() bool 38 | GetPort() int 39 | } 40 | 41 | const ( 42 | ModeKey = "MODE" 43 | ModeValueProd = "production" 44 | ModeValueDev = "development" 45 | PortKey = "PORT" 46 | ) 47 | 48 | type Base struct { 49 | Mode string 50 | IsDev bool 51 | IsProd bool 52 | Port int 53 | } 54 | 55 | func (e *Base) GetIsDev() bool { return e.IsDev } 56 | func (e *Base) GetPort() int { return e.Port } 57 | 58 | type InitOptions struct { 59 | FallbackGetPortFunc func() int 60 | GetIsDevFunc func() bool 61 | } 62 | 63 | func InitBase(options InitOptions) (Base, error) { 64 | base := Base{} 65 | 66 | err := godotenv.Load() 67 | if err != nil { 68 | err = fmt.Errorf("envutil: failed to load .env file: %w", err) 69 | } 70 | 71 | if options.GetIsDevFunc == nil { 72 | base.Mode = GetStr(ModeKey, ModeValueProd) 73 | base.IsDev = base.Mode == ModeValueDev 74 | base.IsProd = base.Mode == ModeValueProd 75 | } else { 76 | base.IsDev = options.GetIsDevFunc() 77 | base.IsProd = !base.IsDev 78 | base.Mode = ModeValueProd 79 | if base.IsDev { 80 | base.Mode = ModeValueDev 81 | } 82 | } 83 | 84 | if options.FallbackGetPortFunc == nil { 85 | base.Port = GetInt(PortKey, 8080) 86 | } else { 87 | base.Port = GetInt(PortKey, options.FallbackGetPortFunc()) 88 | } 89 | 90 | return base, err 91 | } 92 | 93 | // SetDevMode sets the MODE environment variable to "development". 94 | func SetDevMode() error { 95 | return os.Setenv(ModeKey, ModeValueDev) 96 | } 97 | -------------------------------------------------------------------------------- /kit/errutil/errutil.go: -------------------------------------------------------------------------------- 1 | package errutil 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type Args struct { 9 | OuterErr error 10 | InnerErr error 11 | ContextualMsg string 12 | } 13 | 14 | func New(in Args) error { 15 | // If wrapped and wrapper are the same, de-dupe 16 | if in.InnerErr == in.OuterErr { 17 | in.InnerErr = nil 18 | } 19 | 20 | if in.ContextualMsg == "" { 21 | if in.InnerErr == nil { 22 | return in.OuterErr 23 | } 24 | return fmt.Errorf("%w: %w", in.OuterErr, in.InnerErr) 25 | } 26 | 27 | if in.InnerErr == nil { 28 | return fmt.Errorf("%w: %s", in.OuterErr, in.ContextualMsg) 29 | } 30 | return fmt.Errorf("%w: %s: %w", in.OuterErr, in.ContextualMsg, in.InnerErr) 31 | } 32 | 33 | func Maybe(msg string, err error) error { 34 | if err == nil { 35 | return nil 36 | } 37 | return fmt.Errorf("%s: %w", msg, err) 38 | } 39 | 40 | func ToIsErrFunc(targetErr error) func(error) bool { 41 | return func(err error) bool { return errors.Is(err, targetErr) } 42 | } 43 | -------------------------------------------------------------------------------- /kit/esbuildutil/esbuildutil.go: -------------------------------------------------------------------------------- 1 | package esbuildutil 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "path/filepath" 8 | "slices" 9 | "strings" 10 | 11 | esbuild "github.com/evanw/esbuild/pkg/api" 12 | ) 13 | 14 | func CollectErrors(result esbuild.BuildResult) error { 15 | var errors []string 16 | for _, msg := range result.Errors { 17 | errors = append(errors, msg.Text) 18 | } 19 | if len(errors) > 0 { 20 | return fmt.Errorf("esbuild errors: %v", strings.Join(errors, "\n")) 21 | } 22 | return nil 23 | } 24 | 25 | type ESBuildMetafileSubset struct { 26 | Inputs map[string]struct { 27 | Imports []struct { 28 | Path string `json:"path"` 29 | Kind string `json:"kind"` 30 | } `json:"imports"` 31 | } `json:"inputs"` 32 | Outputs map[string]struct { 33 | Imports []struct { 34 | Path string `json:"path"` 35 | Kind string `json:"kind"` 36 | } `json:"imports"` 37 | EntryPoint string `json:"entryPoint"` 38 | CSSBundle string `json:"cssBundle"` 39 | } `json:"outputs"` 40 | } 41 | 42 | const ( 43 | KindDymanicImport = "dynamic-import" 44 | ) 45 | 46 | func UnmarshalOutput(result esbuild.BuildResult) (*ESBuildMetafileSubset, error) { 47 | m := &ESBuildMetafileSubset{} 48 | err := json.Unmarshal([]byte(result.Metafile), m) 49 | return m, err 50 | } 51 | 52 | // FindAllDependencies recursively finds all of an es module's dependencies 53 | // according to the provided metafile, which is a compatible, marshalable 54 | // subset of esbuild's standard json metafile output. The importPath arg 55 | // should be a key in the metafile's Outputs map. 56 | func FindAllDependencies(metafile *ESBuildMetafileSubset, importPath string) []string { 57 | seen := make(map[string]bool) 58 | var result []string 59 | 60 | var recurse func(ip string) 61 | recurse = func(ip string) { 62 | if seen[ip] { 63 | return 64 | } 65 | seen[ip] = true 66 | result = append(result, ip) 67 | 68 | if output, exists := metafile.Outputs[ip]; exists { 69 | for _, imp := range output.Imports { 70 | if imp.Kind == KindDymanicImport { 71 | continue 72 | } 73 | recurse(imp.Path) 74 | } 75 | } 76 | } 77 | 78 | recurse(importPath) 79 | 80 | cleanResults := make([]string, 0, len(result)+1) 81 | for _, res := range result { 82 | cleanResults = append(cleanResults, filepath.Base(res)) 83 | } 84 | if !slices.Contains(cleanResults, filepath.Base(importPath)) { 85 | cleanResults = append(cleanResults, filepath.Base(importPath)) 86 | } 87 | return cleanResults 88 | } 89 | 90 | func FindRelativeEntrypointPath(metafile *ESBuildMetafileSubset, entrypointToFind string) (string, error) { 91 | for key, output := range metafile.Outputs { 92 | if output.EntryPoint == entrypointToFind { 93 | return key, nil 94 | } 95 | } 96 | return "", errors.New("entrypoint not found") 97 | } 98 | -------------------------------------------------------------------------------- /kit/executil/executil.go: -------------------------------------------------------------------------------- 1 | package executil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | ) 9 | 10 | func MakeCmdRunner(commands ...string) func() error { 11 | return func() error { 12 | if len(commands) == 0 { 13 | return fmt.Errorf("no commands provided") 14 | } 15 | cmd := exec.Command(commands[0], commands[1:]...) 16 | cmd.Stdout = os.Stdout 17 | cmd.Stderr = os.Stderr 18 | return cmd.Run() 19 | } 20 | } 21 | 22 | func GetExecutableDir() (string, error) { 23 | execPath, err := os.Executable() 24 | if err != nil { 25 | return "", fmt.Errorf("error getting executable path: %w", err) 26 | } 27 | return filepath.Dir(execPath), nil 28 | } 29 | 30 | func RunCmd(commands ...string) error { 31 | return MakeCmdRunner(commands...)() 32 | } 33 | -------------------------------------------------------------------------------- /kit/executil/executil_test.go: -------------------------------------------------------------------------------- 1 | package executil 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestMakeCmdRunner(t *testing.T) { 9 | // Test running a simple command (e.g., echo) 10 | runner := MakeCmdRunner("echo", "hello, world") 11 | if err := runner(); err != nil { 12 | t.Fatalf("expected no error, got %v", err) 13 | } 14 | 15 | // Test running a command that fails 16 | runner = MakeCmdRunner("false") // "false" is a command that always exits with status 1 17 | if err := runner(); err == nil { 18 | t.Fatalf("expected error, got nil") 19 | } 20 | } 21 | 22 | func TestGetExecutableDir(t *testing.T) { 23 | // Get the current executable's directory 24 | execDir, err := GetExecutableDir() 25 | if err != nil { 26 | t.Fatalf("expected no error, got %v", err) 27 | } 28 | 29 | // Verify the returned directory is not empty 30 | if execDir == "" { 31 | t.Fatalf("expected non-empty executable directory") 32 | } 33 | 34 | // Verify that the returned path is a directory 35 | info, err := os.Stat(execDir) 36 | if err != nil { 37 | t.Fatalf("failed to stat the directory: %v", err) 38 | } 39 | if !info.IsDir() { 40 | t.Fatalf("expected a directory, got a non-directory") 41 | } 42 | } 43 | 44 | func TestEdgeCases(t *testing.T) { 45 | // Test MakeCmdRunner with a non-existent command 46 | runner := MakeCmdRunner("non_existent_command") 47 | if err := runner(); err == nil { 48 | t.Fatalf("expected error for non-existent command, got nil") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /kit/htmltestutil/htmltestutil.go: -------------------------------------------------------------------------------- 1 | package htmltestutil 2 | 3 | import ( 4 | "strings" 5 | 6 | "golang.org/x/net/html" 7 | ) 8 | 9 | // ParseHTML parses an HTML string into a node. 10 | func ParseHTML(input string) (*html.Node, error) { 11 | return html.Parse(strings.NewReader(input)) 12 | } 13 | 14 | // CompareNodes checks if two nodes are structurally equivalent (ignoring attribute order). 15 | func CompareNodes(n1, n2 *html.Node) bool { 16 | // Compare node types and tag names. 17 | if n1.Type != n2.Type || n1.Data != n2.Data { 18 | return false 19 | } 20 | 21 | // Compare attributes, ignoring order. 22 | if len(n1.Attr) != len(n2.Attr) { 23 | return false 24 | } 25 | attrMap1 := make(map[string]string) 26 | for _, a := range n1.Attr { 27 | attrMap1[a.Key] = a.Val 28 | } 29 | for _, a := range n2.Attr { 30 | if attrMap1[a.Key] != a.Val { 31 | return false 32 | } 33 | } 34 | 35 | // Compare children recursively. 36 | n1Child, n2Child := n1.FirstChild, n2.FirstChild 37 | for n1Child != nil && n2Child != nil { 38 | if !CompareNodes(n1Child, n2Child) { 39 | return false 40 | } 41 | n1Child = n1Child.NextSibling 42 | n2Child = n2Child.NextSibling 43 | } 44 | return n1Child == nil && n2Child == nil 45 | } 46 | -------------------------------------------------------------------------------- /kit/id/id.go: -------------------------------------------------------------------------------- 1 | package id 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | ) 7 | 8 | const defaultCharset = "0123456789" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" 9 | 10 | // New generates a cryptographically random string ID of the specified length. 11 | // By default, IDs consist of mixed-case alphanumeric characters (0-9, A-Z, a-z). 12 | // A single optional custom charset can be provided if you want to use different characters. 13 | // The idLen parameter must be between 0 and 255 inclusive. 14 | // If provided, the custom charset length must be between 1 and 255 inclusive. 15 | func New(idLen uint8, optionalCharset ...string) (string, error) { 16 | if idLen == 0 { 17 | return "", nil 18 | } 19 | charset := defaultCharset 20 | if len(optionalCharset) > 0 { 21 | charset = optionalCharset[0] 22 | } 23 | charsetLen := len(charset) 24 | if charsetLen == 0 || charsetLen > 255 { 25 | return "", fmt.Errorf( 26 | "charset length must be between 1 and 255 inclusive, got %d", charsetLen, 27 | ) 28 | } 29 | effectiveTotalValues := (256 / charsetLen) * charsetLen 30 | idOutputBytes := make([]byte, idLen) 31 | randomByteHolder := make([]byte, 1) 32 | for i := uint8(0); i < idLen; i++ { 33 | for { 34 | _, err := rand.Read(randomByteHolder) 35 | if err != nil { 36 | return "", fmt.Errorf("failed to read random bytes: %w", err) 37 | } 38 | randomVal := randomByteHolder[0] 39 | if int(randomVal) < effectiveTotalValues { 40 | idOutputBytes[i] = charset[randomVal%byte(charsetLen)] 41 | break 42 | } 43 | } 44 | } 45 | return string(idOutputBytes), nil 46 | } 47 | 48 | // NewMulti generates multiple cryptographically random string IDs of the specified length and quantity. 49 | // By default, IDs consist of mixed-case alphanumeric characters (0-9, A-Z, a-z). 50 | // A single optional custom charset can be provided if you want to use different characters. 51 | // The idLen parameter must be between 0 and 255 inclusive. 52 | func NewMulti(idLen uint8, quantity uint8, optionalCharset ...string) ([]string, error) { 53 | ids := make([]string, quantity) 54 | useOptionalCharset := len(optionalCharset) > 0 55 | for i := uint8(0); i < quantity; i++ { 56 | var id string 57 | var err error 58 | if useOptionalCharset { 59 | id, err = New(idLen, optionalCharset[0]) 60 | } else { 61 | id, err = New(idLen) 62 | } 63 | if err != nil { 64 | return nil, err 65 | } 66 | ids[i] = id 67 | } 68 | return ids, nil 69 | } 70 | -------------------------------------------------------------------------------- /kit/ioutil/ioutil.go: -------------------------------------------------------------------------------- 1 | package ioutil 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | ) 7 | 8 | const ( 9 | OneKB uint64 = 1024 10 | OneMB = 1024 * OneKB 11 | OneGB = 1024 * OneMB 12 | ) 13 | 14 | var ErrReadLimitExceeded = errors.New("read limit exceeded") 15 | 16 | // ReadLimited reads data from the provided reader up to the given limit. 17 | // It returns ErrReadLimitExceeded if the data exceeds the specified limit. 18 | // Under the hood, it reads a single extra byte to check if the limit is 19 | // exceeded. If that is a concern for your use case, just use io.LimitReader 20 | // directly. 21 | func ReadLimited(r io.Reader, limit uint64) ([]byte, error) { 22 | // Read one extra byte to allow checking if the limit is exceeded 23 | limitReader := io.LimitReader(r, int64(limit+1)) 24 | 25 | data, err := io.ReadAll(limitReader) 26 | if err != nil { 27 | return data, err 28 | } 29 | 30 | // Check if the limit was exceeded 31 | if uint64(len(data)) > limit { 32 | return data[:limit], ErrReadLimitExceeded 33 | } 34 | 35 | return data, nil 36 | } 37 | -------------------------------------------------------------------------------- /kit/jsonutil/jsonutil.go: -------------------------------------------------------------------------------- 1 | package jsonutil 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | func ToString(v any) (string, error) { 9 | b, err := json.Marshal(v) 10 | if err != nil { 11 | return "", fmt.Errorf("error encoding JSON: %w", err) 12 | } 13 | return string(b), nil 14 | } 15 | -------------------------------------------------------------------------------- /kit/jsonutil/jsonutil_test.go: -------------------------------------------------------------------------------- 1 | package jsonutil 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestToString(t *testing.T) { 8 | type testStruct struct { 9 | Name string `json:"name"` 10 | Value int `json:"value"` 11 | } 12 | 13 | // Test case: simple struct 14 | input := testStruct{Name: "Test", Value: 42} 15 | expected := `{"name":"Test","value":42}` 16 | result, err := ToString(input) 17 | if err != nil { 18 | t.Fatalf("expected no error, got %v", err) 19 | } 20 | if result != expected { 21 | t.Fatalf("expected %s, got %s", expected, result) 22 | } 23 | 24 | // Test case: empty struct 25 | inputEmpty := testStruct{} 26 | expectedEmpty := `{"name":"","value":0}` 27 | resultEmpty, err := ToString(inputEmpty) 28 | if err != nil { 29 | t.Fatalf("expected no error, got %v", err) 30 | } 31 | if resultEmpty != expectedEmpty { 32 | t.Fatalf("expected %s, got %s", expectedEmpty, resultEmpty) 33 | } 34 | 35 | // Test case: nil input 36 | var inputNil any 37 | expectedNil := "null" 38 | resultNil, err := ToString(inputNil) 39 | if err != nil { 40 | t.Fatalf("expected no error, got %v", err) 41 | } 42 | if resultNil != expectedNil { 43 | t.Fatalf("expected %s, got %s", expectedNil, resultNil) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /kit/lazyget/lazyget.go: -------------------------------------------------------------------------------- 1 | // Package lazyget provides a simple way to create thread-safe, 2 | // type-safe getter functions to lazily initialize, cache, 3 | // and return a value of a given type. It uses a sync.Once 4 | // to ensure that the initialization function is only 5 | // called once, even in concurrent environments. All 6 | // subsequent calls will return the cached value. 7 | // 8 | // Usage: 9 | // 10 | // var GetResource = lazyget.New(func() *Resource { 11 | // // do some expensive initialization here 12 | // return &Resource{} 13 | // }) 14 | // 15 | // r := GetResource() // r is of type *Resource 16 | package lazyget 17 | 18 | import "sync" 19 | 20 | // New takes an initialization function and returns 21 | // a getter function that will lazily initialize, 22 | // cache, and return a value of type T. 23 | func New[T any](initFunc func() T) func() T { 24 | c := &cache[T]{initFunc: initFunc} 25 | return c.get 26 | } 27 | 28 | type cache[T any] struct { 29 | val T 30 | init sync.Once 31 | initFunc func() T 32 | } 33 | 34 | func (loc *cache[T]) get() T { 35 | loc.init.Do(func() { loc.val = loc.initFunc() }) 36 | return loc.val 37 | } 38 | -------------------------------------------------------------------------------- /kit/matcher/bench.txt: -------------------------------------------------------------------------------- 1 | cpu: Apple M3 Max 2 | BenchmarkFindBestMatchSimple/StaticPattern-14 56212635 21.96 ns/op 48 B/op 1 allocs/op 3 | BenchmarkFindBestMatchSimple/DynamicPattern-14 4872408 237.1 ns/op 480 B/op 4 allocs/op 4 | BenchmarkFindBestMatchSimple/SplatPattern-14 12015344 102.3 ns/op 128 B/op 2 allocs/op 5 | BenchmarkFindBestMatchAtScale/Scale_small-14 13821547 85.69 ns/op 214 B/op 2 allocs/op 6 | BenchmarkFindBestMatchAtScale/Scale_medium-14 10015520 117.5 ns/op 236 B/op 2 allocs/op 7 | BenchmarkFindBestMatchAtScale/Scale_large-14 10214275 115.9 ns/op 238 B/op 2 allocs/op 8 | BenchmarkFindBestMatchAtScale/WorstCase_DeepNested-14 5872576 204.2 ns/op 480 B/op 4 allocs/op 9 | BenchmarkFindNestedMatches/StaticPatterns-14 7936275 146.1 ns/op 88 B/op 3 allocs/op 10 | BenchmarkFindNestedMatches/DynamicPatterns-14 1778655 669.2 ns/op 810 B/op 13 allocs/op 11 | BenchmarkFindNestedMatches/DeepNestedPatterns-14 1600353 744.1 ns/op 1090 B/op 14 allocs/op 12 | BenchmarkFindNestedMatches/SplatPatterns-14 2029634 592.6 ns/op 736 B/op 11 allocs/op 13 | BenchmarkFindNestedMatches/MixedPatterns-14 2182398 547.5 ns/op 658 B/op 10 allocs/op 14 | BenchmarkParseSegments/ParseSegments-14 30997472 36.20 ns/op 64 B/op 1 allocs/op 15 | -------------------------------------------------------------------------------- /kit/matcher/find_best_match.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | 3 | func (m *Matcher) FindBestMatch(realPath string) (*BestMatch, bool) { 4 | if rr, ok := m.staticPatterns[realPath]; ok { 5 | return &BestMatch{RegisteredPattern: rr}, true 6 | } 7 | 8 | segments := ParseSegments(realPath) 9 | hasTrailingSlash := len(realPath) > 0 && realPath[len(realPath)-1] == '/' 10 | 11 | if hasTrailingSlash { 12 | pathWithoutTrailingSlash := realPath[:len(realPath)-1] 13 | if rr, ok := m.staticPatterns[pathWithoutTrailingSlash]; ok { 14 | return &BestMatch{RegisteredPattern: rr}, true 15 | } 16 | } 17 | 18 | best := new(BestMatch) 19 | var bestScore uint16 20 | foundMatch := false 21 | 22 | m.dfsBest(m.rootNode, segments, 0, 0, best, &bestScore, &foundMatch, hasTrailingSlash) 23 | 24 | if !foundMatch { 25 | return nil, false 26 | } 27 | 28 | if best.numberOfDynamicParamSegs > 0 { 29 | params := make(Params, best.numberOfDynamicParamSegs) 30 | for i, seg := range best.normalizedSegments { 31 | if seg.segType == segTypes.dynamic { 32 | params[seg.normalizedVal[1:]] = segments[i] 33 | } 34 | } 35 | best.Params = params 36 | } 37 | 38 | if best.normalizedPattern == "/*" || best.lastSegIsNonRootSplat { 39 | best.SplatValues = segments[len(best.normalizedSegments)-1:] 40 | } 41 | 42 | return best, true 43 | } 44 | 45 | func (m *Matcher) dfsBest( 46 | node *segmentNode, 47 | segments []string, 48 | depth int, 49 | score uint16, 50 | best *BestMatch, 51 | bestScore *uint16, 52 | foundMatch *bool, 53 | checkTrailingSlash bool, 54 | ) { 55 | atNormalEnd := checkTrailingSlash && depth == len(segments)-1 56 | 57 | if len(node.pattern) > 0 { 58 | if rp, ok := m.dynamicPatterns[node.pattern]; ok { 59 | if depth == len(segments) || node.nodeType == nodeSplat || atNormalEnd { 60 | if !*foundMatch || score > *bestScore { 61 | best.RegisteredPattern = rp 62 | best.score = score 63 | *bestScore = score 64 | *foundMatch = true 65 | } 66 | } 67 | } 68 | } 69 | 70 | if depth >= len(segments) { 71 | return 72 | } 73 | 74 | if node.children != nil { 75 | if child, ok := node.children[segments[depth]]; ok { 76 | m.dfsBest(child, segments, depth+1, score+scoreStaticMatch, best, bestScore, foundMatch, checkTrailingSlash) 77 | 78 | if *foundMatch && depth+1 == len(segments) && child.pattern != "" { 79 | return 80 | } 81 | } 82 | } 83 | 84 | for _, child := range node.dynChildren { 85 | switch child.nodeType { 86 | case nodeDynamic: 87 | // Don't match empty segments to dynamic parameters 88 | if segments[depth] != "" { 89 | m.dfsBest(child, segments, depth+1, score+scoreDynamic, best, bestScore, foundMatch, checkTrailingSlash) 90 | } 91 | 92 | case nodeSplat: 93 | if len(child.pattern) > 0 { 94 | if rp := m.dynamicPatterns[child.pattern]; rp != nil { 95 | if !*foundMatch { 96 | best.RegisteredPattern = rp 97 | *foundMatch = true 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /kit/matcher/matcher.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/river-now/river/kit/opt" 7 | ) 8 | 9 | type ( 10 | Params = map[string]string 11 | 12 | pattern = string 13 | segType = string 14 | patternsMap = map[pattern]*RegisteredPattern 15 | matchesMap = map[pattern]*Match 16 | ) 17 | 18 | type Matcher struct { 19 | staticPatterns patternsMap 20 | dynamicPatterns patternsMap 21 | rootNode *segmentNode 22 | 23 | explicitIndexSegment string 24 | dynamicParamPrefixRune rune 25 | splatSegmentRune rune 26 | 27 | slashIndexSegment string 28 | usingExplicitIndexSegment bool 29 | 30 | quiet bool 31 | } 32 | 33 | func (m *Matcher) GetExplicitIndexSegment() string { 34 | return m.explicitIndexSegment 35 | } 36 | func (m *Matcher) GetDynamicParamPrefixRune() rune { 37 | return m.dynamicParamPrefixRune 38 | } 39 | func (m *Matcher) GetSplatSegmentRune() rune { 40 | return m.splatSegmentRune 41 | } 42 | 43 | type Match struct { 44 | *RegisteredPattern 45 | params Params 46 | splatValues []string 47 | } 48 | 49 | type BestMatch struct { 50 | *RegisteredPattern 51 | Params Params 52 | SplatValues []string 53 | 54 | score uint16 55 | } 56 | 57 | type Options struct { 58 | DynamicParamPrefixRune rune // Optional. Defaults to ':'. 59 | SplatSegmentRune rune // Optional. Defaults to '*'. 60 | 61 | // Optional. Defaults to empty string (effectively a trailing slash in the pattern). 62 | // Could also be something like "_index" if preferred by the user. 63 | ExplicitIndexSegment string 64 | 65 | Quiet bool // Optional. Defaults to false. Set to true if you want to quash warnings. 66 | } 67 | 68 | func New(opts *Options) *Matcher { 69 | var instance = new(Matcher) 70 | 71 | instance.staticPatterns = make(patternsMap) 72 | instance.dynamicPatterns = make(patternsMap) 73 | instance.rootNode = new(segmentNode) 74 | 75 | mungedOpts := mungeOptsToDefaults(opts) 76 | 77 | instance.explicitIndexSegment = mungedOpts.ExplicitIndexSegment 78 | instance.dynamicParamPrefixRune = mungedOpts.DynamicParamPrefixRune 79 | instance.splatSegmentRune = mungedOpts.SplatSegmentRune 80 | instance.quiet = mungedOpts.Quiet 81 | 82 | instance.slashIndexSegment = "/" + instance.explicitIndexSegment 83 | instance.usingExplicitIndexSegment = instance.explicitIndexSegment != "" 84 | 85 | return instance 86 | } 87 | 88 | func mungeOptsToDefaults(opts *Options) Options { 89 | if opts == nil { 90 | opts = new(Options) 91 | } 92 | 93 | copy := *opts 94 | 95 | if strings.Contains(copy.ExplicitIndexSegment, "/") { 96 | panic("explicit index segment cannot contain a slash") 97 | } 98 | 99 | copy.DynamicParamPrefixRune = opt.Resolve(copy, copy.DynamicParamPrefixRune, ':') 100 | copy.SplatSegmentRune = opt.Resolve(copy, copy.SplatSegmentRune, '*') 101 | copy.ExplicitIndexSegment = opt.Resolve(copy, copy.ExplicitIndexSegment, "") 102 | copy.Quiet = opt.Resolve(copy, copy.Quiet, false) 103 | 104 | return copy 105 | } 106 | -------------------------------------------------------------------------------- /kit/matcher/parse_segments.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | 3 | func ParseSegments(path string) []string { 4 | // Fast path for common cases 5 | if path == "" { 6 | return []string{} 7 | } 8 | if path == "/" { 9 | return []string{""} 10 | } 11 | 12 | // Skip leading slash 13 | startIdx := 0 14 | if path[0] == '/' { 15 | startIdx = 1 16 | } 17 | 18 | // Maximum potential segments 19 | var maxSegments int 20 | for i := startIdx; i < len(path); i++ { 21 | if path[i] == '/' { 22 | maxSegments++ 23 | } 24 | } 25 | 26 | // Add one more for the final segment 27 | if len(path) > 0 { 28 | maxSegments++ 29 | } 30 | 31 | if maxSegments == 0 { 32 | return nil 33 | } 34 | 35 | segs := make([]string, 0, maxSegments) 36 | 37 | var start = startIdx 38 | 39 | for i := startIdx; i < len(path); i++ { 40 | if path[i] == '/' { 41 | if i > start { 42 | segs = append(segs, path[start:i]) 43 | } 44 | start = i + 1 45 | } 46 | } 47 | 48 | // Add final segment 49 | if start < len(path) { 50 | segs = append(segs, path[start:]) 51 | } 52 | 53 | if len(path) > 0 && path[len(path)-1] == '/' { 54 | // Add empty string for trailing slash 55 | segs = append(segs, "") 56 | } 57 | 58 | return segs 59 | } 60 | -------------------------------------------------------------------------------- /kit/matcher/parse_segments_test.go: -------------------------------------------------------------------------------- 1 | package matcher 2 | 3 | import ( 4 | "reflect" 5 | "runtime" 6 | "testing" 7 | ) 8 | 9 | func TestParseSegments(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | path string 13 | expected []string 14 | }{ 15 | {"empty path", "", []string{}}, 16 | {"root path", "/", []string{""}}, 17 | {"simple path", "/users", []string{"users"}}, 18 | {"multi-segment path", "/api/v1/users", []string{"api", "v1", "users"}}, 19 | {"trailing slash", "/users/", []string{"users", ""}}, 20 | {"path with parameters", "/users/:id/posts", []string{"users", ":id", "posts"}}, 21 | {"path with parameters, implicit index segment", "/users/:id/posts/", []string{"users", ":id", "posts", ""}}, 22 | {"path with parameters, explicit index segment", "/users/:id/posts/_index", []string{"users", ":id", "posts", "_index"}}, 23 | {"path with splat", "/files/*", []string{"files", "*"}}, 24 | {"multiple slashes", "//api///users", []string{"api", "users"}}, 25 | {"complex path", "/api/v1/users/:user_id/posts/:post_id/comments", []string{"api", "v1", "users", ":user_id", "posts", ":post_id", "comments"}}, 26 | {"unicode path", "/café/über/resumé", []string{"café", "über", "resumé"}}, 27 | } 28 | 29 | for _, tt := range tests { 30 | t.Run(tt.name, func(t *testing.T) { 31 | result := ParseSegments(tt.path) 32 | if !reflect.DeepEqual(result, tt.expected) { 33 | t.Errorf("ParseSegments(%q) = %v, want %v", tt.path, result, tt.expected) 34 | } 35 | }) 36 | } 37 | } 38 | 39 | func BenchmarkParseSegments(b *testing.B) { 40 | paths := []string{ 41 | "/", 42 | "/api/v1/users", 43 | "/api/v1/users/123/posts/456/comments", 44 | "/files/documents/reports/quarterly/q3-2023.pdf", 45 | } 46 | 47 | b.Run("ParseSegments", func(b *testing.B) { 48 | b.ReportAllocs() 49 | for i := 0; i < b.N; i++ { 50 | path := paths[i%len(paths)] 51 | segments := ParseSegments(path) 52 | runtime.KeepAlive(segments) 53 | } 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /kit/middleware/csrftoken/csrftoken.go: -------------------------------------------------------------------------------- 1 | package csrftoken 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/river-now/river/kit/response" 10 | ) 11 | 12 | type ( 13 | GetExpectedCSRFToken = func(r *http.Request) string 14 | GetSubmittedCSRFToken = func(r *http.Request) string 15 | ) 16 | 17 | type Opts struct { 18 | GetExpectedCSRFToken GetExpectedCSRFToken 19 | GetSubmittedCSRFToken GetSubmittedCSRFToken 20 | GetIsExempt func(r *http.Request) bool // Exempts from CSRF token check (not host check) 21 | PermittedHosts []string // If len == 0, all hosts are permitted 22 | } 23 | 24 | func NewMiddleware(opts Opts) func(http.Handler) http.Handler { 25 | return func(next http.Handler) http.Handler { 26 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 | res := response.New(w) 28 | 29 | switch r.Method { 30 | case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: 31 | next.ServeHTTP(w, r) 32 | return 33 | } 34 | 35 | lowercaseHost, err := getLowercaseHost(r) 36 | if err != nil { 37 | res.InternalServerError("") 38 | return 39 | } 40 | if lowercaseHost == "" { 41 | res.BadRequest("Origin not provided") 42 | return 43 | } 44 | if len(opts.PermittedHosts) > 0 { 45 | permitted := false 46 | for _, permittedHost := range opts.PermittedHosts { 47 | if lowercaseHost == strings.ToLower(permittedHost) { 48 | permitted = true 49 | break 50 | } 51 | } 52 | if !permitted { 53 | res.Forbidden("Origin not permitted") 54 | return 55 | } 56 | } 57 | 58 | if opts.GetIsExempt != nil && opts.GetIsExempt(r) { 59 | next.ServeHTTP(w, r) 60 | return 61 | } 62 | 63 | expectedToken := opts.GetExpectedCSRFToken(r) 64 | if expectedToken == "" { 65 | res.InternalServerError("") 66 | return 67 | } 68 | 69 | submittedToken := opts.GetSubmittedCSRFToken(r) 70 | if submittedToken == "" { 71 | res.BadRequest("CSRF token missing") 72 | return 73 | } 74 | 75 | if submittedToken != expectedToken { 76 | res.Forbidden("CSRF token mismatch") 77 | return 78 | } 79 | 80 | next.ServeHTTP(w, r) 81 | }) 82 | } 83 | } 84 | 85 | func getLowercaseHost(r *http.Request) (string, error) { 86 | origin := r.Header.Get("Origin") 87 | if origin == "" { 88 | origin = r.Header.Get("Referer") 89 | } 90 | if origin == "" { 91 | return "", nil 92 | } 93 | originURL, err := url.Parse(origin) 94 | if err != nil { 95 | return "", err 96 | } 97 | if originURL.Scheme == "" || originURL.Host == "" { 98 | return "", errors.New("invalid URL: missing scheme or host") 99 | } 100 | return strings.ToLower(originURL.Host), nil 101 | } 102 | -------------------------------------------------------------------------------- /kit/middleware/healthcheck/healthcheck.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/river-now/river/kit/middleware" 7 | "github.com/river-now/river/kit/response" 8 | ) 9 | 10 | // Healthz is a middleware that responds with an HTTP 200 OK status code and the 11 | // string "OK" in the response body for GET and HEAD requests to the "/healthz" endpoint. 12 | var Healthz = OK("/healthz") 13 | 14 | // OK returns a middleware that responds with an HTTP 200 OK status code and the 15 | // string "OK" in the response body for GET and HEAD requests to the given endpoint. 16 | func OK(endpoint string) middleware.Middleware { 17 | methods := []string{http.MethodGet, http.MethodHead} 18 | 19 | handlerFunc := func(w http.ResponseWriter, r *http.Request) { 20 | res := response.New(w) 21 | res.OKText() 22 | } 23 | 24 | return middleware.ToHandlerMiddleware(endpoint, methods, handlerFunc) 25 | } 26 | -------------------------------------------------------------------------------- /kit/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "slices" 6 | "strings" 7 | ) 8 | 9 | type Middleware func(http.Handler) http.Handler 10 | 11 | func ToHandlerMiddleware(endpoint string, methods []string, handlerFunc http.HandlerFunc) Middleware { 12 | return func(next http.Handler) http.Handler { 13 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 14 | if slices.Contains(methods, r.Method) && strings.EqualFold(r.URL.Path, endpoint) { 15 | handlerFunc(w, r) 16 | return 17 | } 18 | next.ServeHTTP(w, r) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /kit/middleware/robotstxt/robotstxt.go: -------------------------------------------------------------------------------- 1 | package robotstxt 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/river-now/river/kit/middleware" 7 | "github.com/river-now/river/kit/response" 8 | ) 9 | 10 | var ( 11 | // Allow is a middleware that responds with a barebones robots.txt file that 12 | // allows all user agents to access any path. 13 | Allow = Content("User-agent: *\nAllow: /") 14 | 15 | // Disallow is a middleware that responds with a barebones robots.txt file that 16 | // disallows all user agents from accessing any path. 17 | Disallow = Content("User-agent: *\nDisallow: /") 18 | ) 19 | 20 | // Content returns a middleware that responds with a robots.txt file containing the 21 | // given content. 22 | func Content(content string) middleware.Middleware { 23 | endpoint := "/robots.txt" 24 | 25 | methods := []string{http.MethodGet, http.MethodHead} 26 | 27 | handlerFunc := func(w http.ResponseWriter, r *http.Request) { 28 | res := response.New(w) 29 | res.Text(content) 30 | } 31 | 32 | return middleware.ToHandlerMiddleware(endpoint, methods, handlerFunc) 33 | } 34 | -------------------------------------------------------------------------------- /kit/middleware/secureheaders/secureheaders.go: -------------------------------------------------------------------------------- 1 | package secureheaders 2 | 3 | import "net/http" 4 | 5 | // see https://owasp.org/www-project-secure-headers/ci/headers_add.json 6 | var securityHeadersMap = map[string]string{ 7 | "Cross-Origin-Embedder-Policy": "require-corp", 8 | "Cross-Origin-Opener-Policy": "same-origin", 9 | "Cross-Origin-Resource-Policy": "same-origin", 10 | "Permissions-Policy": "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()", 11 | "Referrer-Policy": "no-referrer", 12 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains", 13 | "X-Content-Type-Options": "nosniff", 14 | "X-Frame-Options": "deny", 15 | "X-Permitted-Cross-Domain-Policies": "none", 16 | } 17 | 18 | // Sets various security-related headers to responses. 19 | func Middleware(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | for header, value := range securityHeadersMap { 22 | w.Header().Set(header, value) 23 | } 24 | w.Header().Del("Server") 25 | w.Header().Del("X-Powered-By") 26 | next.ServeHTTP(w, r) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /kit/middleware/secureheaders/secureheaders_test.go: -------------------------------------------------------------------------------- 1 | package secureheaders 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | ) 8 | 9 | func TestMiddleware_SetsSecurityHeaders(t *testing.T) { 10 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) 11 | middleware := Middleware(nextHandler) 12 | 13 | // Create a new HTTP request to pass through the middleware 14 | req := httptest.NewRequest(http.MethodGet, "/", nil) 15 | 16 | // Create a response recorder to capture the response 17 | rr := httptest.NewRecorder() 18 | rr.Header().Set("X-Powered-By", "GoTestServer") 19 | 20 | // Serve the request through the middleware 21 | middleware.ServeHTTP(rr, req) 22 | 23 | // Check that all expected security headers are set 24 | for header, expectedValue := range securityHeadersMap { 25 | if value := rr.Header().Get(header); value != expectedValue { 26 | t.Errorf("expected header %s to be %s, but got %s", header, expectedValue, value) 27 | } 28 | } 29 | 30 | // Check that the X-Powered-By header is removed 31 | if value := rr.Header().Get("X-Powered-By"); value != "" { 32 | t.Errorf("expected header X-Powered-By to be removed, but got %s", value) 33 | } 34 | } 35 | 36 | func TestMiddleware_PassesToNextHandler(t *testing.T) { 37 | // Flag to ensure next handler was called 38 | nextHandlerCalled := false 39 | 40 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | nextHandlerCalled = true 42 | }) 43 | 44 | middleware := Middleware(nextHandler) 45 | 46 | // Create a new HTTP request to pass through the middleware 47 | req := httptest.NewRequest(http.MethodGet, "/", nil) 48 | // Create a response recorder to capture the response 49 | rr := httptest.NewRecorder() 50 | 51 | // Serve the request through the middleware 52 | middleware.ServeHTTP(rr, req) 53 | 54 | // Check that the next handler was called 55 | if !nextHandlerCalled { 56 | t.Error("expected next handler to be called, but it was not") 57 | } 58 | } 59 | 60 | func TestMiddleware_DoesNotOverrideExistingHeaders(t *testing.T) { 61 | nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 62 | // Set a custom header that should not be overridden 63 | w.Header().Set("X-Custom-Header", "custom-value") 64 | }) 65 | 66 | middleware := Middleware(nextHandler) 67 | 68 | // Create a new HTTP request to pass through the middleware 69 | req := httptest.NewRequest(http.MethodGet, "/", nil) 70 | // Create a response recorder to capture the response 71 | rr := httptest.NewRecorder() 72 | 73 | // Serve the request through the middleware 74 | middleware.ServeHTTP(rr, req) 75 | 76 | // Check that the custom header is still present 77 | if value := rr.Header().Get("X-Custom-Header"); value != "custom-value" { 78 | t.Errorf("expected header X-Custom-Header to be custom-value, but got %s", value) 79 | } 80 | 81 | // Ensure that the security headers are still set correctly 82 | for header, expectedValue := range securityHeadersMap { 83 | if value := rr.Header().Get(header); value != expectedValue { 84 | t.Errorf("expected header %s to be %s, but got %s", header, expectedValue, value) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /kit/mux/bench.txt: -------------------------------------------------------------------------------- 1 | cpu: Apple M3 Max 2 | BenchmarkRouter/SimpleStaticRoute-14 12368881 81.66 ns/op 368 B/op 2 allocs/op 3 | BenchmarkRouter/DynamicRoute-14 6190022 190.9 ns/op 736 B/op 5 allocs/op 4 | BenchmarkRouter/WithMiddleware-14 11278684 104.5 ns/op 392 B/op 3 allocs/op 5 | BenchmarkRouter/RESTfulAPI-14 4418270 260.1 ns/op 423 B/op 5 allocs/op 6 | BenchmarkRouter/LargeRouterMatch-14 5015697 241.9 ns/op 197 B/op 5 allocs/op 7 | BenchmarkRouter/WorstCaseMatch-14 5758496 204.2 ns/op 158 B/op 5 allocs/op 8 | BenchmarkRouter/NestedDynamicRoute-14 3932948 296.6 ns/op 832 B/op 5 allocs/op 9 | -------------------------------------------------------------------------------- /kit/opt/opt.go: -------------------------------------------------------------------------------- 1 | package opt 2 | 3 | func Resolve[F comparable](nilableOptionsObj any, field F, defaultVal F) F { 4 | var zeroField F 5 | if nilableOptionsObj == nil || field == zeroField { 6 | return defaultVal 7 | } 8 | return field 9 | } 10 | -------------------------------------------------------------------------------- /kit/parseutil/parseutil.go: -------------------------------------------------------------------------------- 1 | /* 2 | NOTE: 3 | 4 | This package primarily exists primarily in service of internal utils. 5 | 6 | Buyer beware. 7 | */ 8 | package parseutil 9 | 10 | import ( 11 | "encoding/json" 12 | "os" 13 | "strings" 14 | 15 | "github.com/river-now/river/kit/stringsutil" 16 | ) 17 | 18 | // Returns: linesSlice, versionLineIdx, currentVersionStr 19 | func PackageJSONFromString(content string) ([]string, int, string) { 20 | lines, err := stringsutil.CollectLines(content) 21 | if err != nil { 22 | panic(err) 23 | } 24 | versionLine := -1 25 | for i, line := range lines { 26 | if strings.HasPrefix(strings.TrimSpace(line), `"version":`) { 27 | versionLine = i 28 | break 29 | } 30 | } 31 | if versionLine == -1 { 32 | panic("version line not found") 33 | } 34 | versionMap := make(map[string]any) 35 | if err = json.Unmarshal([]byte(content), &versionMap); err != nil { 36 | panic(err) 37 | } 38 | currentVersion := versionMap["version"].(string) 39 | if currentVersion == "" { 40 | panic("version not found") 41 | } 42 | return lines, versionLine, currentVersion 43 | } 44 | 45 | // Returns: linesSlice, versionLineIdx, currentVersionStr 46 | func PackageJSONFromFile(targetFile string) ([]string, int, string) { 47 | file, err := os.ReadFile(targetFile) 48 | if err != nil { 49 | panic(err) 50 | } 51 | return PackageJSONFromString(string(file)) 52 | } 53 | -------------------------------------------------------------------------------- /kit/port/port.go: -------------------------------------------------------------------------------- 1 | package port 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | // GetFreePort returns a free port number. If the default port 9 | // is not available, it will try to find a free port, checking 10 | // at most the next 1024 ports. If no free port is found, it 11 | // will get a random free port. If that fails, it will return 12 | // the default port. If the default port is set to 0, 8080 will 13 | // be used instead. 14 | func GetFreePort(defaultPort int) (int, error) { 15 | if defaultPort == 0 { 16 | defaultPort = 8080 17 | } 18 | 19 | if CheckAvailability(defaultPort) { 20 | return defaultPort, nil 21 | } 22 | 23 | for i := range 1024 { 24 | port := defaultPort + i 25 | if port >= 0 && port <= 65535 { 26 | if CheckAvailability(port) { 27 | return port, nil 28 | } 29 | } else { 30 | break 31 | } 32 | } 33 | 34 | port, err := GetRandomFreePort() 35 | if err != nil { 36 | return defaultPort, err 37 | } 38 | 39 | return port, nil 40 | } 41 | 42 | func CheckAvailability(port int) bool { 43 | addr := fmt.Sprintf(":%d", port) 44 | 45 | addrsToCheck := []string{addr, "localhost" + addr} 46 | networksToCheck := []string{"tcp", "tcp4", "tcp6"} 47 | 48 | for _, network := range networksToCheck { 49 | for _, addr := range addrsToCheck { 50 | ln, err := net.Listen(network, addr) 51 | if err != nil { 52 | return false 53 | } 54 | ln.Close() 55 | } 56 | } 57 | 58 | return true 59 | } 60 | 61 | func GetRandomFreePort() (port int, err error) { 62 | // Asks the kernel for a free open port that is ready to use. 63 | // Credit: https://gist.github.com/sevkin/96bdae9274465b2d09191384f86ef39d 64 | var a *net.TCPAddr 65 | if a, err = net.ResolveTCPAddr("tcp", "localhost:0"); err == nil { 66 | var l *net.TCPListener 67 | if l, err = net.ListenTCP("tcp", a); err == nil { 68 | defer l.Close() 69 | return l.Addr().(*net.TCPAddr).Port, nil 70 | } 71 | } 72 | return 73 | } 74 | -------------------------------------------------------------------------------- /kit/reflectutil/reflectutil.go: -------------------------------------------------------------------------------- 1 | package reflectutil 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/river-now/river/kit/genericsutil" 7 | ) 8 | 9 | func ImplementsInterface(t reflect.Type, iface reflect.Type) bool { 10 | if t == nil { 11 | return false 12 | } 13 | if iface == nil { 14 | return false 15 | } 16 | if iface.Kind() != reflect.Interface { 17 | panic("tsgencore error: expected interface type") 18 | } 19 | if t.Implements(iface) { 20 | return true 21 | } 22 | if t.Kind() != reflect.Ptr { 23 | if reflect.PointerTo(t).Implements(iface) { 24 | return true 25 | } 26 | } 27 | return false 28 | } 29 | 30 | func ToInterfaceReflectType[T any]() reflect.Type { 31 | return reflect.TypeOf((*T)(nil)).Elem() 32 | } 33 | 34 | func ExcludingNoneGetIsNilOrUltimatelyPointsToNil(v any) bool { 35 | return excludingNoneGetIsNilOrUltimatelyPointsToNil_inner(v, false) 36 | } 37 | 38 | func excludingNoneGetIsNilOrUltimatelyPointsToNil_inner(v any, skipIsNoneCheck bool) bool { 39 | if !skipIsNoneCheck && genericsutil.IsNone(v) { 40 | return false 41 | } 42 | 43 | if v == nil { 44 | return true 45 | } 46 | 47 | reflectVal := reflect.ValueOf(v) 48 | 49 | switch reflectVal.Kind() { 50 | case reflect.Ptr, reflect.Interface: 51 | if reflectVal.IsNil() { 52 | return true 53 | } 54 | return excludingNoneGetIsNilOrUltimatelyPointsToNil_inner(reflectVal.Elem().Interface(), true) 55 | 56 | case reflect.Map, reflect.Slice: 57 | return reflectVal.IsNil() 58 | 59 | default: 60 | return false 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /kit/scripts/bumper/bumper.go: -------------------------------------------------------------------------------- 1 | package bumper 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | t "github.com/river-now/river/kit/cliutil" 8 | ) 9 | 10 | func Run() { 11 | // Confirm user has pushed code 12 | t.Blue("have you pushed your code? ") 13 | t.RequireYes("aborted. go commit and push your changes, then come back") 14 | 15 | // Get current tag 16 | cmd := t.Cmd("git", "describe", "--tags", "--abbrev=0") 17 | output, err := cmd.Output() 18 | if err != nil { 19 | t.Plain("No existing tags found. Get started by running:") 20 | t.NewLine() 21 | 22 | t.Plain("```sh") 23 | t.NewLine() 24 | 25 | t.Blue("git tag v0.0.1") 26 | t.NewLine() 27 | 28 | t.Blue("git push origin v0.0.1") 29 | t.NewLine() 30 | 31 | t.Blue("GOPROXY=proxy.golang.org go list -m all") 32 | t.NewLine() 33 | 34 | t.Plain("```") 35 | t.NewLine() 36 | 37 | t.Exit("Aborted", nil) 38 | } 39 | 40 | // Clean current tag 41 | currentTagStr := strings.TrimSpace(string(output)) 42 | if currentTagStr == "" { 43 | t.Exit("current tag is empty", nil) 44 | } 45 | 46 | // Show current tag 47 | t.Plain("current tag: ") 48 | t.Green(currentTagStr) 49 | t.NewLine() 50 | 51 | // Ask for new version 52 | t.Blue("what is the new version? ") 53 | t.Plain("v") 54 | version, err := t.NewReader().ReadString('\n') 55 | if err != nil { 56 | t.Exit("failed to read version", err) 57 | } 58 | 59 | trimmedVersion := strings.TrimSpace(version) 60 | if trimmedVersion == "" { 61 | t.Exit("version is empty", nil) 62 | } 63 | 64 | bumpedVersion := "v" + trimmedVersion 65 | 66 | // Show new tag 67 | t.Plain("Result: ") 68 | t.Red(currentTagStr) 69 | t.Plain(" --> ") 70 | t.Green(bumpedVersion) 71 | t.NewLine() 72 | 73 | // Ask for confirmation 74 | t.Blue("is this correct? ") 75 | t.RequireYes("aborted") 76 | 77 | // Ask for git push confirmation 78 | t.Blue("apply tag ") 79 | t.Green(bumpedVersion) 80 | t.Blue(" and push to git? ") 81 | t.RequireYes("aborted") 82 | 83 | // Create new tag 84 | t.Plain("creating new tag") 85 | t.NewLine() 86 | cmd = t.Cmd("git", "tag", bumpedVersion) 87 | t.MustRun(cmd, "tag creation failed") 88 | 89 | // Push new tag 90 | t.Plain("pushing new tag") 91 | t.NewLine() 92 | cmd = t.Cmd("git", "push", "origin", bumpedVersion) 93 | t.MustRun(cmd, "tag push failed") 94 | 95 | // Update go proxy 96 | t.Plain("updating go proxy") 97 | t.NewLine() 98 | cmd = t.Cmd("go", "list", "-m", "all") 99 | cmd.Env = append(os.Environ(), "GOPROXY=proxy.golang.org") 100 | t.MustRun(cmd, "go proxy update failed") 101 | 102 | // Done 103 | t.Plain("done") 104 | t.NewLine() 105 | } 106 | -------------------------------------------------------------------------------- /kit/set/set.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | type Set[T comparable] map[T]struct{} 4 | 5 | func New[T comparable]() Set[T] { 6 | return make(Set[T]) 7 | } 8 | 9 | func (s Set[T]) Add(val T) Set[T] { 10 | s[val] = struct{}{} 11 | return s 12 | } 13 | 14 | func (s Set[T]) Contains(val T) bool { 15 | _, ok := s[val] 16 | return ok 17 | } 18 | -------------------------------------------------------------------------------- /kit/sqlutil/sqlutil.go: -------------------------------------------------------------------------------- 1 | package sqlutil 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | // Transaction runs a function within a transaction. 10 | func Transaction(db *sql.DB, f func(tx *sql.Tx) error) error { 11 | return TransactionContext(db, context.Background(), nil, f) 12 | } 13 | 14 | // TransactionContext runs a function within a transaction using the provided context and options. 15 | func TransactionContext(db *sql.DB, ctx context.Context, opts *sql.TxOptions, f func(tx *sql.Tx) error) error { 16 | tx, err := db.BeginTx(ctx, opts) 17 | if err != nil { 18 | return fmt.Errorf("failed to begin transaction: %w", err) 19 | } 20 | defer func() { 21 | if p := recover(); p != nil { 22 | _ = tx.Rollback() 23 | panic(p) // re-throw panic after Rollback 24 | } else if err != nil { 25 | _ = tx.Rollback() // err is non-nil; don't change it 26 | } else { 27 | err = tx.Commit() // err is nil; if Commit returns error update err 28 | } 29 | }() 30 | err = f(tx) 31 | return err 32 | } 33 | -------------------------------------------------------------------------------- /kit/stringsutil/collect_lines.go: -------------------------------------------------------------------------------- 1 | package stringsutil 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func CollectLines(s string) ([]string, error) { 10 | if len(s) == 0 { 11 | return nil, nil 12 | } 13 | scanner := bufio.NewScanner(strings.NewReader(s)) 14 | lines := make([]string, 0) 15 | for scanner.Scan() { 16 | lines = append(lines, scanner.Text()) 17 | } 18 | if err := scanner.Err(); err != nil { 19 | return nil, fmt.Errorf("error reading lines: %w", err) 20 | } 21 | return lines, nil 22 | } 23 | -------------------------------------------------------------------------------- /kit/stringsutil/stringsutil.go: -------------------------------------------------------------------------------- 1 | package stringsutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Builder struct { 9 | sb strings.Builder 10 | } 11 | 12 | func (b *Builder) Write(str string) *Builder { 13 | b.sb.WriteString(str) 14 | return b 15 | } 16 | 17 | func (b *Builder) Return() *Builder { 18 | return b.Write("\n") 19 | } 20 | 21 | func (b *Builder) Tab() *Builder { 22 | return b.Write("\t") 23 | } 24 | 25 | func (b *Builder) Writef(format string, a ...any) *Builder { 26 | return b.Write(fmt.Sprintf(format, a...)) 27 | } 28 | 29 | func (b *Builder) Line(line string) *Builder { 30 | return b.Write(line).Return() 31 | } 32 | 33 | func (b *Builder) Linef(format string, a ...any) *Builder { 34 | return b.Writef(format, a...).Return() 35 | } 36 | 37 | func (b *Builder) Space() *Builder { 38 | return b.Write(" ") 39 | } 40 | 41 | func (b *Builder) String() string { 42 | return b.sb.String() 43 | } 44 | -------------------------------------------------------------------------------- /kit/theme/theme.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/river-now/river/kit/htmlutil" 9 | ) 10 | 11 | const ( 12 | SystemValue = "system" 13 | LightValue = "light" 14 | DarkValue = "dark" 15 | themeCookieName = "kit_theme" 16 | resolvedThemeCookieName = "kit_resolved_theme" 17 | ) 18 | 19 | type ThemeData struct { 20 | Theme string 21 | ResolvedTheme string 22 | ResolvedThemeOpposite string 23 | HTMLClass string 24 | } 25 | 26 | func GetThemeData(r *http.Request) ThemeData { 27 | c, err := r.Cookie(themeCookieName) 28 | if err != nil { 29 | return ThemeData{ 30 | Theme: SystemValue, 31 | ResolvedTheme: LightValue, 32 | ResolvedThemeOpposite: DarkValue, 33 | HTMLClass: "system light", 34 | } 35 | } 36 | 37 | rawTheme := c.Value 38 | resolvedTheme := rawTheme 39 | 40 | htmlClass := strings.Builder{} 41 | htmlClass.WriteString(rawTheme) 42 | 43 | if rawTheme == SystemValue { 44 | resolvedTheme = getResolved(r) 45 | htmlClass.WriteString(" ") 46 | htmlClass.WriteString(resolvedTheme) 47 | } 48 | 49 | return ThemeData{ 50 | Theme: rawTheme, 51 | ResolvedTheme: resolvedTheme, 52 | ResolvedThemeOpposite: getResolvedOpposite(resolvedTheme), 53 | HTMLClass: htmlClass.String(), 54 | } 55 | } 56 | 57 | func getResolved(r *http.Request) string { 58 | c, err := r.Cookie(resolvedThemeCookieName) 59 | if err != nil { 60 | return LightValue 61 | } 62 | return c.Value 63 | } 64 | 65 | func getResolvedOpposite(theme string) string { 66 | if theme == LightValue { 67 | return DarkValue 68 | } 69 | return LightValue 70 | } 71 | 72 | var SystemThemeScript, SystemThemeScriptSha256Hash = mustGetSystemThemeScript() 73 | 74 | func mustGetSystemThemeScript() (template.HTML, string) { 75 | el := &htmlutil.Element{Tag: "script", DangerousInnerHTML: string(systemThemeScriptInnerHTML)} 76 | sha256Hash, _ := htmlutil.AddSha256HashInline(el) 77 | renderedEl, _ := htmlutil.RenderElement(el) 78 | return renderedEl, sha256Hash 79 | } 80 | 81 | const systemThemeScriptInnerHTML = template.HTML(` 82 | if (window.document.documentElement.classList.contains("system")) { 83 | const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; 84 | window.document.documentElement.classList.add(isDark ? "dark" : "light"); 85 | window.document.documentElement.classList.remove(isDark ? "light" : "dark"); 86 | } 87 | `) 88 | -------------------------------------------------------------------------------- /kit/timer/timer.go: -------------------------------------------------------------------------------- 1 | // Package timer provides a simple timer for measuring the duration of code execution. 2 | package timer 3 | 4 | import ( 5 | "log" 6 | "time" 7 | ) 8 | 9 | // Timer is a simple timer for measuring the duration of code execution. 10 | type Timer struct { 11 | start time.Time 12 | on bool 13 | } 14 | 15 | // New returns a new timer. 16 | func New() *Timer { 17 | return &Timer{start: time.Now(), on: true} 18 | } 19 | 20 | // Conditional returns a new timer that is only active if the condition is true. 21 | func Conditional(condition bool) *Timer { 22 | return &Timer{start: time.Now(), on: condition} 23 | } 24 | 25 | // Checkpoint prints the duration since the last checkpoint and resets the timer. 26 | func (t *Timer) Checkpoint(label string) { 27 | if !t.on { 28 | return 29 | } 30 | log.Println("duration for", label, ":", time.Since(t.start)) 31 | t.Reset() 32 | } 33 | 34 | // Reset resets the timer. 35 | func (t *Timer) Reset() { 36 | if !t.on { 37 | return 38 | } 39 | t.start = time.Now() 40 | } 41 | 42 | // Example usage: `defer timer.Duration("some label", time.Now())` 43 | func Duration(label string, t time.Time) { 44 | log.Printf(` %s completed in %s`, label, time.Since(t)) 45 | } 46 | -------------------------------------------------------------------------------- /kit/tsgen/statements.go: -------------------------------------------------------------------------------- 1 | package tsgen 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | type Statements [][2]string 11 | 12 | func (m *Statements) Raw(prefix string, value string) *Statements { 13 | *m = append(*m, [2]string{prefix, value}) 14 | return m 15 | } 16 | 17 | func (m *Statements) Serialize(prefix string, value any) *Statements { 18 | *m = append(*m, [2]string{prefix, serialize(value)}) 19 | return m 20 | } 21 | 22 | func (m *Statements) Enum(constName, typeName string, enumStruct any) *Statements { 23 | m.Serialize(fmt.Sprintf("export const %s", constName), enumStruct) 24 | m.Raw(fmt.Sprintf("export type %s", typeName), fmt.Sprintf("(typeof %s)[keyof typeof %s]", constName, constName)) 25 | return m 26 | } 27 | 28 | func (m *Statements) BuildString() string { 29 | var code strings.Builder 30 | 31 | for _, def := range *m { 32 | code.WriteString(def[0]) 33 | code.WriteString(" = ") 34 | code.WriteString(def[1]) 35 | code.WriteString(";\n") 36 | } 37 | 38 | return code.String() 39 | } 40 | 41 | func serialize(v any) string { 42 | json, err := json.MarshalIndent(v, "", "\t") 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | code := string(json) 48 | 49 | kind := reflect.TypeOf(v).Kind() 50 | if kind != reflect.String && kind != reflect.Int && kind != reflect.Bool { 51 | code += " as const" 52 | } 53 | 54 | return code 55 | } 56 | 57 | func StringUnion(strs []string) string { 58 | if len(strs) == 0 { 59 | return "" 60 | } 61 | quoted := make([]string, len(strs)) 62 | for i, s := range strs { 63 | quoted[i] = fmt.Sprintf("'%s'", s) 64 | } 65 | return strings.Join(quoted, " | ") 66 | } 67 | 68 | func TypeUnion(typeVars []string) string { 69 | if len(typeVars) == 0 { 70 | return "" 71 | } 72 | return strings.Join(typeVars, " | ") 73 | } 74 | -------------------------------------------------------------------------------- /kit/tsgen/to_file.go: -------------------------------------------------------------------------------- 1 | package tsgen 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/river-now/river/kit/fsutil" 9 | ) 10 | 11 | // GenerateTSToFile generates a TypeScript file from the provided Opts. 12 | func GenerateTSToFile(opts Opts) error { 13 | if opts.OutPath == "" { 14 | return errors.New("outpath is required") 15 | } 16 | 17 | tsContent, err := GenerateTSContent(opts) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | err = fsutil.EnsureDir(filepath.Dir(opts.OutPath)) 23 | if err != nil { 24 | return errors.New("failed to ensure out dest dir: " + err.Error()) 25 | } 26 | 27 | err = os.WriteFile(opts.OutPath, []byte(tsContent), os.ModePerm) 28 | if err != nil { 29 | return errors.New("failed to write ts file: " + err.Error()) 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /kit/typed/syncmap.go: -------------------------------------------------------------------------------- 1 | package typed 2 | 3 | import "sync" 4 | 5 | type SyncMap[K comparable, V any] struct { 6 | m sync.Map 7 | } 8 | 9 | func (sm *SyncMap[K, V]) Load(key K) (value V, ok bool) { 10 | v, ok := sm.m.Load(key) 11 | if !ok { 12 | return value, false 13 | } 14 | return v.(V), true 15 | } 16 | 17 | func (sm *SyncMap[K, V]) Store(key K, value V) { 18 | sm.m.Store(key, value) 19 | } 20 | 21 | func (sm *SyncMap[K, V]) Delete(key K) { 22 | sm.m.Delete(key) 23 | } 24 | 25 | func (sm *SyncMap[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { 26 | v, loaded := sm.m.LoadOrStore(key, value) 27 | return v.(V), loaded 28 | } 29 | 30 | func (sm *SyncMap[K, V]) LoadAndDelete(key K) (value V, loaded bool) { 31 | v, loaded := sm.m.LoadAndDelete(key) 32 | if !loaded { 33 | return value, false 34 | } 35 | return v.(V), loaded 36 | } 37 | 38 | func (sm *SyncMap[K, V]) Range(f func(key K, value V) bool) { 39 | sm.m.Range(func(key, value any) bool { 40 | return f(key.(K), value.(V)) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /kit/validate/validate.go: -------------------------------------------------------------------------------- 1 | // Package validate provides a simple way to validate and parse data from HTTP requests. 2 | package validate 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | // JSONBodyInto decodes an HTTP request body into a struct and validates it. 11 | func JSONBodyInto(r *http.Request, destStructPtr any) error { 12 | if err := json.NewDecoder(r.Body).Decode(destStructPtr); err != nil { 13 | return &ValidationError{Err: fmt.Errorf("error decoding JSON: %w", err)} 14 | } 15 | if err := attemptValidation("validate.JSONBodyInto", destStructPtr); err != nil { 16 | return err 17 | } 18 | return nil 19 | } 20 | 21 | // JSONBytesInto decodes a byte slice containing JSON data into a struct and validates it. 22 | func JSONBytesInto(data []byte, destStructPtr any) error { 23 | if err := json.Unmarshal(data, destStructPtr); err != nil { 24 | return &ValidationError{Err: fmt.Errorf("error decoding JSON: %w", err)} 25 | } 26 | if err := attemptValidation("validate.JSONBytesInto", destStructPtr); err != nil { 27 | return err 28 | } 29 | return nil 30 | } 31 | 32 | // JSONStrInto decodes a string containing JSON data into a struct and validates it. 33 | func JSONStrInto(data string, destStructPtr any) error { 34 | if err := json.Unmarshal([]byte(data), destStructPtr); err != nil { 35 | return &ValidationError{Err: fmt.Errorf("error decoding JSON: %w", err)} 36 | } 37 | if err := attemptValidation("validate.JSONStrInto", destStructPtr); err != nil { 38 | return err 39 | } 40 | return nil 41 | } 42 | 43 | // URLSearchParamsInto parses the URL parameters of an HTTP request into a struct and validates it. 44 | func URLSearchParamsInto(r *http.Request, destStructPtr any) error { 45 | if err := parseURLValues(r.URL.Query(), destStructPtr); err != nil { 46 | return &ValidationError{Err: fmt.Errorf("error parsing URL parameters: %w", err)} 47 | } 48 | if err := attemptValidation("validate.URLSearchParamsInto", destStructPtr); err != nil { 49 | return err 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /kit/walkutil/walkutil.go: -------------------------------------------------------------------------------- 1 | package walkutil 2 | 3 | import ( 4 | "io/fs" 5 | "path/filepath" 6 | "sync" 7 | ) 8 | 9 | const defaultMaxConcurrency = 10 10 | 11 | type WalkDirParallelOptions struct { 12 | RootDir string 13 | // If set to 0 or negative, it will default to 10 14 | MaxConcurrency int 15 | // Return true to skip the directory, false to process it 16 | SkipDirFunc func(path string, d fs.DirEntry) bool 17 | 18 | // ErrorHandler allows custom handling of errors during processing 19 | // If nil, errors will be collected and returned 20 | ErrorHandler func(path string, err error) 21 | 22 | // FileProcessor is the function to process each file 23 | FileProcessor func(path string) error 24 | } 25 | 26 | // WalkDirParallel walks a directory tree in parallel, processing files concurrently 27 | // but directories sequentially to ensure proper traversal. 28 | func WalkDirParallel(opts *WalkDirParallelOptions) []error { 29 | if opts == nil { 30 | panic("opts must not be nil") 31 | } 32 | 33 | if opts.RootDir == "" { 34 | panic("Root must not be empty") 35 | } 36 | 37 | if opts.FileProcessor == nil { 38 | panic("FileProcessor must not be nil") 39 | } 40 | 41 | var wg sync.WaitGroup 42 | var errorsMutex sync.Mutex 43 | var errors []error 44 | 45 | // Apply defaults for unset options 46 | if opts.MaxConcurrency <= 0 { 47 | opts.MaxConcurrency = defaultMaxConcurrency 48 | } 49 | 50 | // Create semaphore channel to limit concurrency 51 | semaphore := make(chan struct{}, opts.MaxConcurrency) 52 | 53 | // Walk the directory 54 | walkErr := filepath.WalkDir(opts.RootDir, func(path string, d fs.DirEntry, err error) error { 55 | if err != nil { 56 | if opts.ErrorHandler != nil { 57 | opts.ErrorHandler(path, err) 58 | } else { 59 | errorsMutex.Lock() 60 | errors = append(errors, err) 61 | errorsMutex.Unlock() 62 | } 63 | return nil // Continue walking despite errors 64 | } 65 | 66 | // Skip directories based on the SkipDirFunc 67 | if d.IsDir() { 68 | if opts.SkipDirFunc != nil && opts.SkipDirFunc(path, d) { 69 | return filepath.SkipDir 70 | } 71 | return nil 72 | } 73 | 74 | // For each file, launch a goroutine 75 | wg.Add(1) 76 | go func() { 77 | defer wg.Done() 78 | 79 | // Acquire semaphore slot 80 | semaphore <- struct{}{} 81 | defer func() { <-semaphore }() // Release when done 82 | 83 | // Process the file 84 | if err := opts.FileProcessor(path); err != nil { 85 | if opts.ErrorHandler != nil { 86 | opts.ErrorHandler(path, err) 87 | } else { 88 | errorsMutex.Lock() 89 | errors = append(errors, err) 90 | errorsMutex.Unlock() 91 | } 92 | } 93 | }() 94 | 95 | return nil 96 | }) 97 | 98 | // Wait for all file processing to complete 99 | wg.Wait() 100 | 101 | // Add the walk error if it occurred 102 | if walkErr != nil { 103 | if opts.ErrorHandler != nil { 104 | opts.ErrorHandler(opts.RootDir, walkErr) 105 | } else { 106 | errors = append(errors, walkErr) 107 | } 108 | } 109 | 110 | return errors 111 | } 112 | -------------------------------------------------------------------------------- /kit/xyz/xyz.go: -------------------------------------------------------------------------------- 1 | // Experimental or miscellaneous things. Buyer beware. 2 | package xyz 3 | 4 | import ( 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | func MakeEmojiDataURL(emojiStr string) string { 11 | sb := strings.Builder{} 12 | sb.WriteString("data:image/svg+xml,") 13 | sb.WriteString("") 14 | sb.WriteString("") 15 | sb.WriteString(emojiStr) 16 | sb.WriteString("") 17 | sb.WriteString("") 18 | return sb.String() 19 | } 20 | 21 | func GetRootURL(r *http.Request) string { 22 | return fmt.Sprintf("https://%s", r.Host) 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "river.now", 3 | "version": "0.34.0", 4 | "type": "module", 5 | "license": "BSD-3-Clause", 6 | "repository": { 7 | "url": "https://github.com/river-now/river", 8 | "type": "git" 9 | }, 10 | "exports": { 11 | "./client": { 12 | "import": "./npm_dist/internal/framework/_typescript/client/index.js", 13 | "types": "./npm_dist/internal/framework/_typescript/client/index.d.ts" 14 | }, 15 | "./react": { 16 | "import": "./npm_dist/internal/framework/_typescript/react/index.js", 17 | "types": "./npm_dist/internal/framework/_typescript/react/index.d.ts" 18 | }, 19 | "./solid": { 20 | "import": "./npm_dist/internal/framework/_typescript/solid/index.js", 21 | "types": "./npm_dist/internal/framework/_typescript/solid/index.d.ts" 22 | }, 23 | "./preact": { 24 | "import": "./npm_dist/internal/framework/_typescript/preact/index.js", 25 | "types": "./npm_dist/internal/framework/_typescript/preact/index.d.ts" 26 | }, 27 | "./kit/converters": { 28 | "import": "./npm_dist/kit/_typescript/converters/converters.js", 29 | "types": "./npm_dist/kit/_typescript/converters/converters.d.ts" 30 | }, 31 | "./kit/cookies": { 32 | "import": "./npm_dist/kit/_typescript/cookies/cookies.js", 33 | "types": "./npm_dist/kit/_typescript/cookies/cookies.d.ts" 34 | }, 35 | "./kit/debounce": { 36 | "import": "./npm_dist/kit/_typescript/debounce/debounce.js", 37 | "types": "./npm_dist/kit/_typescript/debounce/debounce.d.ts" 38 | }, 39 | "./kit/fmt": { 40 | "import": "./npm_dist/kit/_typescript/fmt/fmt.js", 41 | "types": "./npm_dist/kit/_typescript/fmt/fmt.d.ts" 42 | }, 43 | "./kit/json": { 44 | "import": "./npm_dist/kit/_typescript/json/json.js", 45 | "types": "./npm_dist/kit/_typescript/json/json.d.ts" 46 | }, 47 | "./kit/listeners": { 48 | "import": "./npm_dist/kit/_typescript/listeners/listeners.js", 49 | "types": "./npm_dist/kit/_typescript/listeners/listeners.d.ts" 50 | }, 51 | "./kit/theme": { 52 | "import": "./npm_dist/kit/_typescript/theme/theme.js", 53 | "types": "./npm_dist/kit/_typescript/theme/theme.d.ts" 54 | }, 55 | "./kit/url": { 56 | "import": "./npm_dist/kit/_typescript/url/url.js", 57 | "types": "./npm_dist/kit/_typescript/url/url.d.ts" 58 | } 59 | }, 60 | "files": [ 61 | "npm_dist/", 62 | "LICENSE", 63 | "README.md", 64 | "site/frontend/__static/river-banner.webp", 65 | "tsconfig.base.json", 66 | "internal/framework/_typescript/", 67 | "kit/_typescript/" 68 | ], 69 | "devDependencies": { 70 | "@biomejs/biome": "2.0.0-beta.5", 71 | "@preact/signals": "^2.0.4", 72 | "@types/jsdom": "^21.1.7", 73 | "@types/node": "^22.15.3", 74 | "@types/react": "^19.1.2", 75 | "@types/react-dom": "^19.1.3", 76 | "esbuild": "^0.25.3", 77 | "esbuild-plugin-solid": "^0.6.0", 78 | "history": "5.3.0", 79 | "jotai": "^2.12.3", 80 | "jsdom": "^26.1.0", 81 | "preact": "^10.26.5", 82 | "prettier": "^3.5.3", 83 | "react": "^19.1.0", 84 | "react-dom": "^19.1.0", 85 | "solid-js": "^1.9.6", 86 | "typescript": "^5.8.3", 87 | "vite": "npm:rolldown-vite@latest", 88 | "vitest": "^3.1.2" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /river.go: -------------------------------------------------------------------------------- 1 | package river 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "github.com/river-now/river/internal/framework" 7 | "github.com/river-now/river/kit/headels" 8 | "github.com/river-now/river/kit/htmlutil" 9 | "github.com/river-now/river/kit/parseutil" 10 | ) 11 | 12 | ///////////////////////////////////////////////////////////////////// 13 | /////// PUBLIC API 14 | ///////////////////////////////////////////////////////////////////// 15 | 16 | type ( 17 | River = framework.River 18 | HeadEl = htmlutil.Element 19 | AdHocType = framework.AdHocType 20 | BuildOptions = framework.BuildOptions 21 | ) 22 | 23 | var ( 24 | IsJSONRequest = framework.IsJSONRequest 25 | NewHeadEls = headels.New 26 | ) 27 | 28 | //go:embed package.json 29 | var packageJSON string 30 | 31 | // This utility exists primarily in service of the River.now 32 | // website. There is no guarantee that this utility will always 33 | // be available or kept up to date. 34 | func Internal__GetCurrentNPMVersion() string { 35 | _, _, currentVersion := parseutil.PackageJSONFromString(packageJSON) 36 | return currentVersion 37 | } 38 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | # Wave 2 | __dist/static/* 3 | !__dist/static/.keep 4 | __dist/main* 5 | 6 | # Tailwind 7 | /frontend/css/tailwind-output.css 8 | -------------------------------------------------------------------------------- /site/Makefile: -------------------------------------------------------------------------------- 1 | pre: 2 | @pnpm i && go mod tidy 3 | 4 | tailwind-prod: 5 | @pnpx @tailwindcss/cli -i ./frontend/css/tailwind-input.css -o ./frontend/css/tailwind-output.css 6 | 7 | tailwind-dev: 8 | @pnpx @tailwindcss/cli -i ./frontend/css/tailwind-input.css -o ./frontend/css/tailwind-output.css --watch 9 | 10 | serve-dev: 11 | @go run ./__cmd/build --dev 12 | 13 | dev-inner: tailwind-dev serve-dev 14 | 15 | dev: pre 16 | @make dev-inner -j 17 | 18 | build-prod: pre tailwind-prod 19 | @go run ./__cmd/build 20 | 21 | # call with `make run-prod port=whatever` 22 | run-prod: 23 | @PORT=$(PORT) ./__dist/main 24 | -------------------------------------------------------------------------------- /site/__cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "app/backend/server" 4 | 5 | func main() { 6 | server.Serve() 7 | } 8 | -------------------------------------------------------------------------------- /site/__cmd/build/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "app" 5 | "app/backend/router" 6 | 7 | "github.com/river-now/river" 8 | "github.com/river-now/river/kit/tsgen" 9 | ) 10 | 11 | func main() { 12 | a := tsgen.Statements{} 13 | 14 | a.Serialize("export const ACTIONS_ROUTER_MOUNT_ROOT", router.ActionsRouter.MountRoot()) 15 | 16 | app.Wave.Builder(func(isDev bool) error { 17 | return app.River.Build(&river.BuildOptions{ 18 | IsDev: isDev, 19 | LoadersRouter: router.LoadersRouter, 20 | ActionsRouter: router.ActionsRouter, 21 | AdHocTypes: []*river.AdHocType{}, 22 | ExtraTSCode: a.BuildString(), 23 | }) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /site/__dist/static/.keep: -------------------------------------------------------------------------------- 1 | //go:embed directives require at least one file to compile 2 | -------------------------------------------------------------------------------- /site/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | 7 | "github.com/river-now/river" 8 | "github.com/river-now/river/kit/colorlog" 9 | "github.com/river-now/river/kit/headels" 10 | "github.com/river-now/river/kit/htmlutil" 11 | "github.com/river-now/river/kit/theme" 12 | "github.com/river-now/river/kit/xyz" 13 | "github.com/river-now/river/wave" 14 | ) 15 | 16 | const ( 17 | Domain = "river.now" 18 | Origin = "https://" + Domain 19 | SiteTitle = "river.now" 20 | SiteDescription = "River is a Go / TypeScript meta-framework with first-class support for React, Solid, and Preact – built on Vite." 21 | ) 22 | 23 | var River = &river.River{ 24 | Wave: Wave, 25 | 26 | GetHeadElUniqueRules: func() *headels.HeadEls { 27 | e := river.NewHeadEls(7) 28 | 29 | e.Meta(e.Property("og:title")) 30 | e.Meta(e.Property("og:description")) 31 | e.Meta(e.Property("og:type")) 32 | e.Meta(e.Property("og:image")) 33 | e.Meta(e.Property("og:url")) 34 | e.Meta(e.Name("twitter:card")) 35 | e.Meta(e.Name("twitter:site")) 36 | 37 | return e 38 | }, 39 | 40 | GetDefaultHeadEls: func(r *http.Request) ([]*htmlutil.Element, error) { 41 | root := xyz.GetRootURL(r) 42 | imgURL := root + Wave.GetPublicURL("river-banner.webp") 43 | currentURL := root 44 | if r.URL.Path != "/" { 45 | currentURL += r.URL.Path 46 | } 47 | 48 | e := river.NewHeadEls() 49 | 50 | e.Title(SiteTitle) 51 | e.Description(SiteDescription) 52 | 53 | e.Meta(e.Property("og:title"), e.Content(SiteTitle)) 54 | e.Meta(e.Property("og:description"), e.Content(SiteDescription)) 55 | e.Meta(e.Property("og:type"), e.Content("website")) 56 | e.Meta(e.Property("og:image"), e.Content(imgURL)) 57 | e.Meta(e.Property("og:url"), e.Content(currentURL)) 58 | 59 | e.Meta(e.Name("twitter:card"), e.Content("summary_large_image")) 60 | e.Meta(e.Name("twitter:site"), e.Content("@riverframework")) 61 | 62 | e.Link(e.Attr("rel", "icon"), e.Attr("href", Wave.GetPublicURL("favicon.svg"))) 63 | 64 | return e.Collect(), nil 65 | }, 66 | 67 | GetRootTemplateData: func(r *http.Request) (map[string]any, error) { 68 | return map[string]any{ 69 | "HTMLClass": theme.GetThemeData(r).HTMLClass, 70 | "SystemThemeScript": theme.SystemThemeScript, 71 | "SystemThemeScriptSha256Hash": theme.SystemThemeScriptSha256Hash, 72 | }, nil 73 | }, 74 | } 75 | 76 | //go:embed wave.config.json 77 | var configBytes []byte 78 | 79 | //go:embed all:__dist/static 80 | var staticFS embed.FS 81 | 82 | var Wave = wave.New(&wave.Config{ 83 | ConfigBytes: configBytes, 84 | StaticFS: staticFS, 85 | StaticFSEmbedDirective: "all:__dist/static", 86 | }) 87 | 88 | var Log = colorlog.New("app server") 89 | -------------------------------------------------------------------------------- /site/backend/__static/entry.go.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{.RiverHeadEls}} 7 | {{.RiverSSRScript}} 8 | {{.SystemThemeScript}} 9 | 10 | 11 |
12 | {{.RiverBodyScripts}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /site/backend/__static/markdown/start.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Get Started 3 | description: Get started with the river.now framework 4 | --- 5 | 6 | ## Prerequisites 7 | 8 | Make sure you have both `Go` (at least `v1.24`) and `Node` (at least `v22.11`) 9 | installed on your machine. 10 | 11 | ## Step 1 12 | 13 | Start in the directory where you want your new River app to live. 14 | 15 | Then, if you don't already have a Go module initiated, do so. You can name your 16 | module anything you want, and it's fine if your `go.mod` file lives in a parent 17 | directory. 18 | 19 | ```go 20 | go mod init app 21 | ``` 22 | 23 | ## Step 2 24 | 25 | Now let's create a little bootstrapping script that can automatically build out 26 | our River project. 27 | 28 | Start by running the following commands: 29 | 30 | ```sh 31 | mkdir __bootstrap 32 | touch __bootstrap/main.go 33 | go get github.com/river-now/river 34 | ``` 35 | 36 | ## Step 3 37 | 38 | Insert the following into the `__bootstrap/main.go` file you created in Step 2, 39 | and edit the options as appropriate: 40 | 41 | ```go 42 | package main 43 | 44 | import "github.com/river-now/river/bootstrap" 45 | 46 | func main() { 47 | bootstrap.Init(bootstrap.Options{ 48 | GoImportBase: "app", // e.g., "appname" or "modroot/apps/appname" 49 | UIVariant: "react", // "react", "solid", or "preact" 50 | JSPackageManager: "npm", // "npm", "pnpm", "yarn", or "bun" 51 | }) 52 | } 53 | ``` 54 | 55 | ## Step 4 56 | 57 | Now let's run the bootstrapping script. This will (i) create a new River 58 | project in your current working directory and (ii) install the required 59 | packages for the `UIVariant` you chose (using the `JSPackageManager` you chose). 60 | 61 | ```sh 62 | go run ./__bootstrap/main.go 63 | ``` 64 | 65 | ## Step 5 66 | 67 | Once you're done, you can delete the `__bootstrap` directory from your project: 68 | 69 | ```sh 70 | rm -rf ./__bootstrap 71 | ``` 72 | 73 | ## Step 6 74 | 75 | Enjoy! If you have questions about how to use River, make sure to first check 76 | out the 77 | [source code for this site](https://github.com/river-now/river/tree/main/site), 78 | which shows how to do some basic things. Then, if you still have questions, feel 79 | free to open issues in our 80 | [GitHub repo](https://github.com/river-now/river/issues) or contact us on 81 | [X / Twitter](https://x.com/riverframework). 82 | -------------------------------------------------------------------------------- /site/backend/markdown/markdown.go: -------------------------------------------------------------------------------- 1 | package markdown 2 | 3 | import ( 4 | "app" 5 | "io" 6 | 7 | "github.com/adrg/frontmatter" 8 | "github.com/river-now/river/kit/xyz/fsmarkdown" 9 | "github.com/river-now/river/wave" 10 | "github.com/russross/blackfriday/v2" 11 | ) 12 | 13 | var Markdown = fsmarkdown.New(fsmarkdown.Options{ 14 | FS: app.Wave.MustGetPrivateFS(), 15 | FrontmatterParser: func(r io.Reader, v any) ([]byte, error) { return frontmatter.Parse(r, v) }, 16 | MarkdownParser: func(b []byte) []byte { 17 | return blackfriday.Run(b, blackfriday.WithExtensions(blackfriday.AutoHeadingIDs|blackfriday.CommonExtensions)) 18 | }, 19 | IsDev: wave.GetIsDev(), 20 | }) 21 | -------------------------------------------------------------------------------- /site/backend/router/actions.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | 7 | "github.com/river-now/river/kit/mux" 8 | "github.com/river-now/river/kit/validate" 9 | ) 10 | 11 | var ActionsRouter = mux.NewRouter(&mux.Options{ 12 | TasksRegistry: sharedTasksRegistry, 13 | MountRoot: "/api/", 14 | MarshalInput: func(r *http.Request, iPtr any) error { 15 | if r.Method == http.MethodGet { 16 | return validate.URLSearchParamsInto(r, iPtr) 17 | } 18 | if r.Method == http.MethodPost { 19 | return validate.JSONBodyInto(r, iPtr) 20 | } 21 | return errors.New("unsupported method") 22 | }, 23 | }) 24 | 25 | type ActionCtx[I any] struct { 26 | *mux.ReqData[I] 27 | // Anything else you want available on the ActionCtx 28 | } 29 | 30 | func NewAction[I any, O any](method, pattern string, f func(c *ActionCtx[I]) (O, error)) *mux.TaskHandler[I, O] { 31 | wrappedF := func(c *mux.ReqData[I]) (O, error) { 32 | return f(&ActionCtx[I]{ 33 | ReqData: c, 34 | // Anything else you want available on the ActionCtx 35 | }) 36 | } 37 | actionTask := mux.TaskHandlerFromFunc(ActionsRouter.TasksRegistry(), wrappedF) 38 | mux.RegisterTaskHandler(ActionsRouter, method, pattern, actionTask) 39 | return actionTask 40 | } 41 | -------------------------------------------------------------------------------- /site/backend/router/core.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "app" 5 | "net/http" 6 | "strings" 7 | 8 | chimw "github.com/go-chi/chi/v5/middleware" 9 | "github.com/river-now/river/kit/middleware/etag" 10 | "github.com/river-now/river/kit/middleware/healthcheck" 11 | "github.com/river-now/river/kit/middleware/robotstxt" 12 | "github.com/river-now/river/kit/middleware/secureheaders" 13 | "github.com/river-now/river/kit/mux" 14 | "github.com/river-now/river/kit/tasks" 15 | ) 16 | 17 | var sharedTasksRegistry = tasks.NewRegistry("site") 18 | 19 | func Core() *mux.Router { 20 | r := mux.NewRouter(nil) 21 | 22 | mux.SetGlobalHTTPMiddleware(r, chimw.Logger) 23 | mux.SetGlobalHTTPMiddleware(r, chimw.Recoverer) 24 | mux.SetGlobalHTTPMiddleware(r, etag.Auto(&etag.Config{ 25 | SkipFunc: func(r *http.Request) bool { 26 | return strings.HasPrefix(r.URL.Path, app.Wave.GetPublicPathPrefix()) 27 | }, 28 | })) 29 | mux.SetGlobalHTTPMiddleware(r, secureheaders.Middleware) 30 | mux.SetGlobalHTTPMiddleware(r, healthcheck.Healthz) 31 | mux.SetGlobalHTTPMiddleware(r, robotstxt.Allow) 32 | mux.SetGlobalHTTPMiddleware(r, app.Wave.FaviconRedirect()) 33 | 34 | // static public assets 35 | mux.RegisterHandler(r, "GET", app.Wave.GetPublicPathPrefix()+"*", app.Wave.MustGetServeStaticHandler(true)) 36 | 37 | // river UI routes 38 | mux.RegisterHandler(r, "GET", "/*", app.River.GetUIHandler(LoadersRouter)) 39 | 40 | // river API routes 41 | actionsHandler := app.River.GetActionsHandler(ActionsRouter) 42 | mux.RegisterHandler(r, "GET", ActionsRouter.MountRoot("*"), actionsHandler) 43 | mux.RegisterHandler(r, "POST", ActionsRouter.MountRoot("*"), actionsHandler) 44 | 45 | return r 46 | } 47 | -------------------------------------------------------------------------------- /site/backend/router/loaders.go: -------------------------------------------------------------------------------- 1 | package router 2 | 3 | import ( 4 | "app" 5 | "app/backend/markdown" 6 | "fmt" 7 | 8 | "github.com/river-now/river" 9 | "github.com/river-now/river/kit/mux" 10 | "github.com/river-now/river/kit/xyz/fsmarkdown" 11 | "github.com/river-now/river/wave" 12 | ) 13 | 14 | var LoadersRouter = mux.NewNestedRouter(&mux.NestedOptions{ 15 | TasksRegistry: sharedTasksRegistry, 16 | ExplicitIndexSegment: "_index", 17 | }) 18 | 19 | func newLoader[O any](pattern string, f mux.TaskHandlerFunc[mux.None, O]) *mux.TaskHandler[mux.None, O] { 20 | loaderTask := mux.TaskHandlerFromFunc(LoadersRouter.TasksRegistry(), f) 21 | mux.RegisterNestedTaskHandler(LoadersRouter, pattern, loaderTask) 22 | return loaderTask 23 | } 24 | 25 | type RootData struct { 26 | LatestVersion string 27 | } 28 | 29 | var currentNPMVersion = "v" + river.Internal__GetCurrentNPMVersion() 30 | 31 | var _ = newLoader("/", func(c *mux.NestedReqData) (*RootData, error) { 32 | req, res := c.Request(), c.ResponseProxy() 33 | 34 | if !wave.GetIsDev() { 35 | if river.IsJSONRequest(req) { 36 | // Because this app has no user-specific data, we can cache the JSON response 37 | // pretty aggressively. 38 | // res.SetHeader("Cache-Control", "public, max-age=10, s-maxage=20, must-revalidate") 39 | } else { 40 | // Don't cache HTML, but stop short of "no-store" so it's still eligible for ETag revalidation 41 | res.SetHeader("Cache-Control", "no-cache") 42 | } 43 | } 44 | 45 | return &RootData{LatestVersion: currentNPMVersion}, nil 46 | }) 47 | 48 | var _ = newLoader("/_index", func(c *mux.NestedReqData) (string, error) { 49 | return app.SiteDescription, nil 50 | }) 51 | 52 | var _ = newLoader("/*", func(c *mux.NestedReqData) (*fsmarkdown.DetailedPage, error) { 53 | r := c.Request() 54 | 55 | p, err := markdown.Markdown.GetPageDetails(r) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to get page details: %w", err) 58 | } 59 | 60 | data := p 61 | e := river.NewHeadEls(2) 62 | 63 | if p.Title != "" { 64 | e.Title(fmt.Sprintf("%s | %s", app.SiteTitle, p.Title)) 65 | e.Meta(e.Property("og:title"), e.Content(p.Title)) 66 | } 67 | 68 | if p.Description != "" { 69 | e.Description(p.Description) 70 | e.Meta(e.Property("og:description"), e.Content(p.Description)) 71 | } 72 | 73 | c.ResponseProxy().AddHeadElements(e.Collect()...) 74 | 75 | return data, nil 76 | }) 77 | -------------------------------------------------------------------------------- /site/backend/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "app" 5 | "app/backend/router" 6 | "context" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/river-now/river/kit/grace" 14 | "github.com/river-now/river/wave" 15 | ) 16 | 17 | func Serve() { 18 | app.River.Init(wave.GetIsDev()) 19 | 20 | addr := fmt.Sprintf(":%d", wave.MustGetPort()) 21 | 22 | server := &http.Server{ 23 | Addr: addr, 24 | Handler: http.TimeoutHandler(router.Core(), 60*time.Second, "Request timed out"), 25 | ReadTimeout: 15 * time.Second, 26 | WriteTimeout: 30 * time.Second, 27 | IdleTimeout: 60 * time.Second, 28 | ReadHeaderTimeout: 10 * time.Second, 29 | MaxHeaderBytes: 1 << 20, // 1 MB 30 | DisableGeneralOptionsHandler: true, 31 | ErrorLog: log.New(os.Stderr, "HTTP: ", log.Ldate|log.Ltime|log.Lshortfile), 32 | } 33 | 34 | url := "http://localhost" + addr 35 | 36 | grace.Orchestrate(grace.OrchestrateOptions{ 37 | StartupCallback: func() error { 38 | app.Log.Info("Starting server", "url", url) 39 | 40 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 41 | log.Fatalf("Server listen and serve error: %v\n", err) 42 | } 43 | 44 | return nil 45 | }, 46 | 47 | ShutdownCallback: func(shutdownCtx context.Context) error { 48 | app.Log.Info("Shutting down server", "url", url) 49 | 50 | if err := server.Shutdown(shutdownCtx); err != nil { 51 | log.Fatalf("Server shutdown error: %v\n", err) 52 | } 53 | 54 | return nil 55 | }, 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /site/frontend/__static/desktop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/frontend/__static/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/frontend/__static/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/frontend/__static/river-banner.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/river-now/river/1d558082e1572d6f629282d70be636de257a063b/site/frontend/__static/river-banner.webp -------------------------------------------------------------------------------- /site/frontend/__static/sun.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /site/frontend/components/app_link.tsx: -------------------------------------------------------------------------------- 1 | import { RiverLink } from "river.now/solid"; 2 | 3 | export function Link(props: Parameters[0]) { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /site/frontend/components/app_utils.ts: -------------------------------------------------------------------------------- 1 | import { addThemeChangeListener, getTheme } from "river.now/kit/theme"; 2 | import { 3 | makeTypedAddClientLoader, 4 | makeTypedUseLoaderData, 5 | makeTypedUsePatternLoaderData, 6 | makeTypedUseRouterData, 7 | type RiverRouteProps, 8 | } from "river.now/solid"; 9 | import { createSignal } from "solid-js"; 10 | import type { RiverLoader, RiverLoaderPattern, RiverRootData } from "../river.gen.ts"; 11 | 12 | export type RouteProps

= RiverRouteProps; 13 | 14 | export const useRouterData = makeTypedUseRouterData(); 15 | export const useLoaderData = makeTypedUseLoaderData(); 16 | export const addClientLoader = makeTypedAddClientLoader(); 17 | export const usePatternLoaderData = makeTypedUsePatternLoaderData(); 18 | 19 | const [theme, set_theme_signal] = createSignal(getTheme()); 20 | addThemeChangeListener((e) => set_theme_signal(e.detail.theme)); 21 | export { theme }; 22 | -------------------------------------------------------------------------------- /site/frontend/components/global_loader.ts: -------------------------------------------------------------------------------- 1 | import NProgress from "nprogress"; 2 | import { addStatusListener, getStatus, type StatusEvent } from "river.now/client"; 3 | 4 | let debounceStartTimer: number | null = null; 5 | let debounceEndTimer: number | null = null; 6 | 7 | addStatusListener((e) => { 8 | if (e.detail.isNavigating || e.detail.isRevalidating || e.detail.isSubmitting) { 9 | if (debounceStartTimer) { 10 | clearTimeout(debounceStartTimer); 11 | } 12 | debounceStartTimer = window.setTimeout(startNProgress, 30); 13 | return; 14 | } 15 | if (debounceEndTimer) { 16 | clearTimeout(debounceEndTimer); 17 | } 18 | debounceEndTimer = window.setTimeout(stopNProgress, 3); 19 | }); 20 | 21 | function startNProgress() { 22 | if (!getIsWorking()) { 23 | return; 24 | } 25 | if (!NProgress.isStarted()) { 26 | NProgress.start(); 27 | } 28 | } 29 | 30 | function stopNProgress() { 31 | if (getIsWorking()) { 32 | return; 33 | } 34 | if (NProgress.isStarted()) { 35 | NProgress.done(); 36 | } 37 | } 38 | 39 | function getIsWorking(statusEvent?: StatusEvent) { 40 | const statusEventToUse = statusEvent?.detail || getStatus(); 41 | return ( 42 | statusEventToUse.isNavigating || 43 | statusEventToUse.isRevalidating || 44 | statusEventToUse.isSubmitting 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /site/frontend/components/highlight.ts: -------------------------------------------------------------------------------- 1 | const languages = ["bash", "go", "json", "typescript", "css"]; 2 | 3 | const [highlight, ...promises] = await Promise.all([ 4 | import("highlight.js/lib/core").then((x) => x.default), 5 | import("highlight.js/lib/languages/bash").then((x) => x.default), 6 | import("highlight.js/lib/languages/go").then((x) => x.default), 7 | import("highlight.js/lib/languages/json").then((x) => x.default), 8 | import("highlight.js/lib/languages/typescript").then((x) => x.default), 9 | import("highlight.js/lib/languages/css").then((x) => x.default), 10 | ]); 11 | 12 | for (const lang of languages) { 13 | const first = promises.shift(); 14 | if (!first) { 15 | break; 16 | } 17 | highlight.registerLanguage(lang, first); 18 | } 19 | 20 | export { highlight }; 21 | -------------------------------------------------------------------------------- /site/frontend/components/rendered-markdown.tsx: -------------------------------------------------------------------------------- 1 | import { getHrefDetails } from "river.now/kit/url"; 2 | import { createEffect, onCleanup } from "solid-js"; 3 | import { render } from "solid-js/web"; 4 | import { waveRuntimeURL } from "../river.gen.ts"; 5 | import { Link } from "./app_link.tsx"; 6 | import { highlight } from "./highlight.ts"; 7 | 8 | export function RenderedMarkdown(props: { markdown: string }) { 9 | let containerRef: HTMLDivElement | null = null; 10 | const disposers: Array<() => void> = []; 11 | 12 | // Cleanup function to remove any previously rendered components 13 | const cleanupPreviousRender = () => { 14 | disposers.forEach((dispose) => dispose()); 15 | disposers.length = 0; 16 | }; 17 | 18 | // Process the markdown content 19 | const processContent = () => { 20 | if (!containerRef) { 21 | return; 22 | } 23 | 24 | cleanupPreviousRender(); 25 | 26 | containerRef.innerHTML = props.markdown; // Set the HTML content 27 | 28 | // Process code blocks 29 | const codeBlocks = containerRef.querySelectorAll("pre code"); 30 | for (const codeBlock of codeBlocks) { 31 | highlight.highlightElement(codeBlock as HTMLElement); 32 | } 33 | 34 | // Process links 35 | for (const link of containerRef.querySelectorAll("a")) { 36 | const hrefDetails = getHrefDetails(link.href); 37 | 38 | if (hrefDetails.isHTTP && hrefDetails.isExternal) { 39 | link.dataset.external = "true"; 40 | link.target = "_blank"; 41 | } else { 42 | const href = link.href; 43 | const label = link.innerText; 44 | const placeholder = document.createElement("span"); 45 | link.parentNode?.replaceChild(placeholder, link); 46 | 47 | const dispose = render( 48 | () => ( 49 | 50 | {label} 51 | 52 | ), 53 | placeholder, 54 | ); 55 | disposers.push(dispose); 56 | } 57 | } 58 | 59 | // Process images 60 | for (const img of containerRef.querySelectorAll("img")) { 61 | // if data-src is set, grab value 62 | const src = img.getAttribute("data-src"); 63 | if (src) { 64 | img.src = waveRuntimeURL(src as any); 65 | img.removeAttribute("data-src"); 66 | } 67 | 68 | const width = img.getAttribute("data-width"); 69 | const height = img.getAttribute("data-height"); 70 | if (width && height) { 71 | img.style.aspectRatio = `${width}/${height}`; 72 | } 73 | } 74 | }; 75 | 76 | // Set up ref callback to store the container element 77 | const ref = (el: HTMLDivElement | null) => { 78 | containerRef = el; 79 | if (el) { 80 | processContent(); 81 | } 82 | }; 83 | 84 | // Create effect to run processContent when markdown changes 85 | createEffect(() => { 86 | props.markdown; // Access props.markdown to track changes 87 | if (containerRef) { 88 | processContent(); 89 | } 90 | }); 91 | 92 | onCleanup(cleanupPreviousRender); // Clean up all disposers when component unmounts 93 | 94 | return

; 95 | } 96 | -------------------------------------------------------------------------------- /site/frontend/components/routes/dyn.tsx: -------------------------------------------------------------------------------- 1 | import { type RouteProps, useRouterData } from "../app_utils.ts"; 2 | 3 | export function Dyn(props: RouteProps<"/__/:dyn">) { 4 | const routerData = useRouterData(props); 5 | 6 | return
{routerData().params.dyn}
; 7 | } 8 | -------------------------------------------------------------------------------- /site/frontend/components/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import { usePatternClientLoaderData } from "river.now/solid"; 2 | import { Link } from "../app_link.tsx"; 3 | import { 4 | addClientLoader, 5 | type RouteProps, 6 | usePatternLoaderData, 7 | } from "../app_utils.ts"; 8 | 9 | const useClientLoaderData = addClientLoader("/", async (props) => { 10 | // This is pointless -- just an example of how to use a client loader 11 | // await new Promise((r) => setTimeout(r, 1_000)); 12 | console.log("Client loader running"); 13 | return props.loaderData.LatestVersion; 14 | }); 15 | 16 | type RootCLD = ReturnType; 17 | 18 | export function Home(props: RouteProps<"/_index">) { 19 | const x = usePatternLoaderData(""); 20 | const y = usePatternClientLoaderData(""); 21 | // console.log("x", x()); 22 | // console.log("y", y()); 23 | 24 | return ( 25 |
26 |

27 | River is a Go / TypeScript{" "} 28 | meta-framework with first-class support for React,{" "} 29 | Solid, and Preact – built on{" "} 30 | Vite. 31 |

32 | 33 | 38 | Get Started 39 | 40 |
41 | ); 42 | } 43 | 44 | function FancySpan(props: { children: string }) { 45 | return ( 46 | 47 | {props.children} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /site/frontend/components/routes/md.tsx: -------------------------------------------------------------------------------- 1 | import { hmrRunClientLoaders } from "river.now/client"; 2 | import { Show } from "solid-js"; 3 | import { Link } from "../app_link.tsx"; 4 | import { addClientLoader, type RouteProps, useLoaderData } from "../app_utils.ts"; 5 | import { RenderedMarkdown } from "../rendered-markdown.tsx"; 6 | 7 | // Use this if you want your client loaders to re-run when you save this file 8 | hmrRunClientLoaders(import.meta); 9 | 10 | const useClientLoaderData = addClientLoader("/*", async (props) => { 11 | // This is pointless -- just an example of how to use a client loader 12 | // await new Promise((r) => setTimeout(r, 1_000)); 13 | console.log("Client loader running"); 14 | return props.loaderData.Title; 15 | }); 16 | 17 | export type CatchAllCLD = ReturnType; 18 | 19 | export function MD(props: RouteProps<"/*">) { 20 | const loaderData = useLoaderData(props); 21 | const clientLoaderData = useClientLoaderData(props); 22 | 23 | return ( 24 |
25 | {(n) =>

{n()}

}
26 | {(n) => {n()}} 27 | 28 | {(n) => } 29 | 30 | 31 | {(n) => ( 32 |
33 |
    34 | {n().map((x) => { 35 | return ( 36 |
  • 37 | 38 | {x.title} 39 | 40 |
  • 41 | ); 42 | })} 43 |
44 |
45 | )} 46 |
47 |
48 | ); 49 | } 50 | 51 | export function ErrorBoundary(props: { error: string }) { 52 | return
Error: {props.error}
; 53 | } 54 | -------------------------------------------------------------------------------- /site/frontend/css/hljs.css: -------------------------------------------------------------------------------- 1 | /*! 2 | MODIFIED FROM: 3 | 4 | Theme: An Old Hope – Star Wars Syntax 5 | Author: (c) Gustavo Costa 6 | Maintainer: @gusbemacbe 7 | 8 | Original theme - Ocean Dark Theme – by https://github.com/gavsiu 9 | Based on Jesse Leite's Atom syntax theme 'An Old Hope' 10 | https://github.com/JesseLeite/an-old-hope-syntax-atom 11 | */ 12 | 13 | /* Millenium Falcon */ 14 | .hljs { 15 | background: light-dark(#c0c5ce, #1c1d21); 16 | color: light-dark(#1c1d21, #c0c5ce); 17 | } 18 | 19 | /* Death Star Comment */ 20 | .hljs-comment, 21 | .hljs-quote { 22 | color: light-dark(#747159, #b6b18b); 23 | } 24 | 25 | /* Darth Vader */ 26 | .hljs-variable, 27 | .hljs-template-variable, 28 | .hljs-tag, 29 | .hljs-name, 30 | .hljs-selector-id, 31 | .hljs-selector-class, 32 | .hljs-regexp, 33 | .hljs-deletion { 34 | color: light-dark(#872331, #eb3c54); 35 | } 36 | 37 | /* Threepio */ 38 | .hljs-number, 39 | .hljs-built_in, 40 | .hljs-literal, 41 | .hljs-type, 42 | .hljs-params, 43 | .hljs-meta, 44 | .hljs-link { 45 | color: light-dark(#857730, #e7ce56); 46 | } 47 | 48 | /* Luke Skywalker */ 49 | .hljs-attribute { 50 | color: light-dark(#743d15, #ee7c2b); 51 | } 52 | 53 | /* Obi Wan Kenobi */ 54 | .hljs-string, 55 | .hljs-symbol, 56 | .hljs-bullet, 57 | .hljs-addition { 58 | color: light-dark(#2c6679, #4fb4d7); 59 | } 60 | 61 | /* Yoda */ 62 | .hljs-title, 63 | .hljs-section { 64 | color: light-dark(#3d6033, #78bb65); 65 | } 66 | 67 | /* Mace Windu */ 68 | .hljs-keyword, 69 | .hljs-selector-tag { 70 | color: light-dark(#6f3a65, #b45ea4); 71 | } 72 | 73 | .hljs-emphasis { 74 | font-style: italic; 75 | } 76 | 77 | .hljs-strong { 78 | font-weight: bold; 79 | } 80 | -------------------------------------------------------------------------------- /site/frontend/css/main.critical.css: -------------------------------------------------------------------------------- 1 | :root { 2 | color-scheme: light dark; 3 | --dark-green: #064929; 4 | --light-green: #4bba5b; 5 | --resolved-green: light-dark(var(--dark-green), var(--light-green)); 6 | } 7 | html.light { 8 | color-scheme: light; 9 | --bg: white; 10 | } 11 | html.dark { 12 | color-scheme: dark; 13 | --bg: #111; 14 | } 15 | html { 16 | font-family: monospace; 17 | font-size: 16px; 18 | } 19 | html, 20 | body { 21 | height: 100dvh; 22 | background: var(--bg); 23 | } 24 | html, 25 | body, 26 | #river-root, 27 | main { 28 | display: flex; 29 | flex-direction: column; 30 | flex-grow: 1; 31 | } 32 | main { 33 | margin: 1rem; 34 | } 35 | .content { 36 | width: 720px; 37 | max-width: 100%; 38 | margin: 2rem auto; 39 | p, 40 | ul, 41 | ol, 42 | pre { 43 | font-size: 17px; 44 | line-height: 1.8; 45 | margin-bottom: 1.5rem; 46 | } 47 | pre { 48 | font-size: 15.5px; 49 | line-height: 1.7; 50 | } 51 | li { 52 | margin-bottom: 0.5rem; 53 | } 54 | } 55 | @media screen and (max-width: 640px) { 56 | .content { 57 | margin: 1rem auto; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /site/frontend/css/main.css: -------------------------------------------------------------------------------- 1 | @import url("./hljs.css"); 2 | @import url("./nprogress.css"); 3 | 4 | a, 5 | button, 6 | input[type="button"], 7 | input[type="submit"] { 8 | touch-action: manipulation; 9 | } 10 | 11 | .content { 12 | p { 13 | margin-bottom: 1.5rem; 14 | } 15 | a { 16 | text-decoration: underline; 17 | text-underline-offset: 3px; 18 | &:hover { 19 | color: var(--resolved-green); 20 | } 21 | } 22 | h1, 23 | h2 { 24 | text-transform: uppercase; 25 | letter-spacing: 0.05em; 26 | } 27 | h1, 28 | h2, 29 | h3, 30 | h4, 31 | h5, 32 | h6 { 33 | margin-bottom: 1.5rem; 34 | color: var(--resolved-green); 35 | scroll-margin-top: 7rem; 36 | } 37 | h1 { 38 | font-size: 3rem; 39 | text-align: center; 40 | font-weight: bold; 41 | } 42 | h2 { 43 | font-size: 2rem; 44 | text-align: center; 45 | } 46 | h3 { 47 | font-size: 1.5rem; 48 | font-weight: bold; 49 | font-style: italic; 50 | } 51 | h4 { 52 | font-size: 1.3rem; 53 | } 54 | h5 { 55 | font-size: 1.15rem; 56 | font-weight: bold; 57 | } 58 | h6 { 59 | font-size: 1rem; 60 | font-style: italic; 61 | } 62 | 63 | pre { 64 | overflow-x: auto; 65 | background-color: light-dark(#fcf4e7, black); 66 | padding: 0.75rem 1rem; 67 | max-width: 100%; 68 | border: 1px solid #7777; 69 | } 70 | code { 71 | background-color: #7773; 72 | padding: 0.15rem 0.25rem; 73 | tab-size: 1.75rem; 74 | line-height: 1.5; 75 | } 76 | pre code { 77 | background-color: transparent !important; 78 | padding: 0; 79 | } 80 | p code { 81 | word-wrap: break-word; 82 | } 83 | 84 | ul { 85 | margin-left: 1.5rem; 86 | list-style: disc; 87 | } 88 | ol { 89 | margin-left: 3rem; 90 | list-style: decimal; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /site/frontend/css/nprogress.css: -------------------------------------------------------------------------------- 1 | #nprogress { 2 | pointer-events: none; 3 | } 4 | 5 | #nprogress .bar { 6 | background: var(--light-green); 7 | position: fixed; 8 | z-index: 9999; 9 | top: 0; 10 | left: 0; 11 | width: 100%; 12 | height: 4px; 13 | } 14 | 15 | #nprogress .peg { 16 | display: block; 17 | position: absolute; 18 | right: 0px; 19 | width: 100px; 20 | height: 100%; 21 | box-shadow: 22 | 0 0 10px var(--light-green), 23 | 0 0 5px var(--light-green); 24 | opacity: 1; 25 | transform: rotate(3deg) translate(0px, -4px); 26 | } 27 | 28 | .nprogress-custom-parent { 29 | overflow: hidden; 30 | position: relative; 31 | } 32 | 33 | .nprogress-custom-parent #nprogress .bar { 34 | position: absolute; 35 | } 36 | -------------------------------------------------------------------------------- /site/frontend/css/tailwind-input.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @custom-variant dark (&:where(.dark, .dark *)); 4 | -------------------------------------------------------------------------------- /site/frontend/entry.tsx: -------------------------------------------------------------------------------- 1 | import { getRootEl, initClient } from "river.now/client"; 2 | import { render } from "solid-js/web"; 3 | import { App } from "./components/app.tsx"; 4 | 5 | await initClient( 6 | () => { 7 | render(() => , getRootEl()); 8 | }, 9 | { useViewTransitions: false, defaultErrorBoundary: undefined }, 10 | ); 11 | 12 | import("./components/highlight.ts"); // warm up highlighter 13 | -------------------------------------------------------------------------------- /site/frontend/routes.ts: -------------------------------------------------------------------------------- 1 | import type { RiverRoutes } from "river.now/client"; 2 | 3 | declare const routes: RiverRoutes; 4 | export default routes; 5 | 6 | routes.Add("/_index", import("./components/routes/home.tsx"), "Home"); 7 | routes.Add("/*", import("./components/routes/md.tsx"), "MD", "ErrorBoundary"); 8 | routes.Add("/__/:dyn", import("./components/routes/dyn.tsx"), "Dyn"); 9 | -------------------------------------------------------------------------------- /site/go.mod: -------------------------------------------------------------------------------- 1 | module app 2 | 3 | go 1.24.0 4 | 5 | replace github.com/river-now/river => ../ 6 | 7 | require ( 8 | github.com/adrg/frontmatter v0.2.0 9 | github.com/go-chi/chi/v5 v5.2.1 10 | github.com/river-now/river v0.17.0 11 | github.com/russross/blackfriday/v2 v2.1.0 12 | ) 13 | 14 | require ( 15 | github.com/BurntSushi/toml v0.3.1 // indirect 16 | github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect 17 | github.com/evanw/esbuild v0.25.3 // indirect 18 | github.com/fsnotify/fsnotify v1.9.0 // indirect 19 | github.com/gorilla/websocket v1.5.3 // indirect 20 | golang.org/x/crypto v0.37.0 // indirect 21 | golang.org/x/sync v0.13.0 // indirect 22 | golang.org/x/sys v0.32.0 // indirect 23 | gopkg.in/yaml.v2 v2.3.0 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /site/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= 4 | github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= 5 | github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= 6 | github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 7 | github.com/evanw/esbuild v0.25.3 h1:4JKyUsm/nHDhpxis4IyWXAi8GiyTwG1WdEp6OhGVE8U= 8 | github.com/evanw/esbuild v0.25.3/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= 9 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 10 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 11 | github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 12 | github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 13 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 14 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 16 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 17 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 18 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 19 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 20 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 21 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 22 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 23 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 25 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 29 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 30 | -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "devDependencies": { 4 | "@tailwindcss/cli": "^4.1.5", 5 | "@types/nprogress": "^0.2.3", 6 | "highlight.js": "^11.11.1", 7 | "nprogress": "^0.2.0", 8 | "river.now": "link:../", 9 | "solid-js": "^1.9.6", 10 | "tailwindcss": "^4.1.5", 11 | "typescript": "^5.8.3", 12 | "vite": "npm:rolldown-vite@latest", 13 | "vite-plugin-solid": "^2.11.6" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "noUncheckedIndexedAccess": true, 12 | "verbatimModuleSyntax": true, 13 | "allowImportingTsExtensions": true, 14 | "jsx": "preserve", 15 | "jsxImportSource": "solid-js" 16 | }, 17 | "exclude": ["node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /site/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import solid from "vite-plugin-solid"; 3 | import { riverVitePlugin } from "./frontend/river.gen.ts"; 4 | 5 | export default defineConfig({ 6 | plugins: [solid(), riverVitePlugin()], 7 | }); 8 | -------------------------------------------------------------------------------- /site/wave.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "__dist/static/internal/schema.json", 3 | "Core": { 4 | "ConfigLocation": "wave.config.json", 5 | "DevBuildHook": "go run ./__cmd/build --dev --hook", 6 | "ProdBuildHook": "go run ./__cmd/build --hook", 7 | "MainAppEntry": "__cmd/app", 8 | "DistDir": "__dist", 9 | "StaticAssetDirs": { 10 | "Private": "backend/__static", 11 | "Public": "frontend/__static" 12 | }, 13 | "CSSEntryFiles": { 14 | "Critical": "frontend/css/main.critical.css", 15 | "NonCritical": "frontend/css/main.css" 16 | }, 17 | "PublicPathPrefix": "/public/" 18 | }, 19 | "River": { 20 | "UIVariant": "solid", 21 | "HTMLTemplateLocation": "entry.go.html", 22 | "ClientEntry": "frontend/entry.tsx", 23 | "ClientRouteDefsFile": "frontend/routes.ts", 24 | "TSGenOutPath": "frontend/river.gen.ts", 25 | "BuildtimePublicURLFuncName": "hashedURL" 26 | }, 27 | "Vite": { 28 | "JSPackageManagerBaseCmd": "pnpm" 29 | }, 30 | "Watch": { 31 | "HealthcheckEndpoint": "/healthz", 32 | "Include": [ 33 | { 34 | "Pattern": "backend/__static/markdown/**/*.md", 35 | "OnlyRunClientDefinedRevalidateFunc": true 36 | } 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "noUncheckedIndexedAccess": true, 13 | "verbatimModuleSyntax": true, 14 | "allowImportingTsExtensions": true 15 | }, 16 | "exclude": ["**/node_modules", "npm_dist"] 17 | } 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ test: { environment: "jsdom" } }); 4 | -------------------------------------------------------------------------------- /wave/internal/ki/buildhelper.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "flag" 5 | ) 6 | 7 | func (c *Config) Builder(hook func(isDev bool) error) { 8 | devModeFlag := flag.Bool("dev", false, "set dev mode") 9 | hookModeFlag := flag.Bool("hook", false, "set hook mode") 10 | noBinaryFlag := flag.Bool("no-binary", false, "skip go binary compilation") 11 | 12 | flag.Parse() 13 | 14 | isDev := *devModeFlag 15 | isHook := *hookModeFlag 16 | noBinary := *noBinaryFlag 17 | 18 | if isHook { 19 | if err := hook(isDev); err != nil { 20 | panic(err) 21 | } 22 | return 23 | } 24 | 25 | if isDev { 26 | c.MustStartDev() 27 | return 28 | } 29 | 30 | if err := c.Build(BuildOptions{RecompileGoBinary: !noBinary}); err != nil { 31 | panic(err) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /wave/internal/ki/debouncer.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | ) 9 | 10 | // Debouncer to handle event batching 11 | type debouncer struct { 12 | mu sync.Mutex 13 | timer *time.Timer 14 | events []fsnotify.Event 15 | duration time.Duration 16 | callback func(events []fsnotify.Event) 17 | } 18 | 19 | func new_debouncer(duration time.Duration, callback func(events []fsnotify.Event)) *debouncer { 20 | return &debouncer{duration: duration, callback: callback} 21 | } 22 | 23 | func (d *debouncer) add_evt(event fsnotify.Event) { 24 | d.mu.Lock() 25 | defer d.mu.Unlock() 26 | 27 | d.events = append(d.events, event) 28 | 29 | if d.timer != nil { 30 | d.timer.Stop() 31 | } 32 | 33 | d.timer = time.AfterFunc(d.duration, func() { 34 | d.mu.Lock() 35 | defer d.mu.Unlock() 36 | 37 | if len(d.events) > 0 { 38 | d.callback(d.events) 39 | d.events = nil 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /wave/internal/ki/dev_core.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | "github.com/river-now/river/kit/port" 9 | ) 10 | 11 | type must_start_dev_opts struct { 12 | is_rebuild bool 13 | recompile_go bool 14 | } 15 | 16 | func (c *Config) MustStartDev(_opts ...must_start_dev_opts) { 17 | var opts must_start_dev_opts 18 | if len(_opts) > 0 { 19 | opts = _opts[0] 20 | } 21 | 22 | if opts.is_rebuild { 23 | c.send_rebuilding_signal() 24 | c.kill_running_go_binary() 25 | } 26 | 27 | c.MainInit(MainInitOptions{IsDev: true, IsRebuild: opts.is_rebuild}, "MustStartDev") 28 | 29 | MustGetAppPort() // Warm port right away, in case default is unavailable. Also, env needs to be set in this scope. 30 | 31 | refresh_server_port, err := port.GetFreePort(default_refresh_server_port) 32 | if err != nil { 33 | c.panic("failed to get free port", err) 34 | } 35 | set_refresh_server_port(refresh_server_port) 36 | 37 | // build without binary 38 | err = c.Build(BuildOptions{ 39 | IsDev: true, 40 | RecompileGoBinary: false, 41 | is_dev_rebuild: opts.is_rebuild, 42 | }) 43 | if err != nil { 44 | c.panic("failed to build app", err) 45 | } 46 | 47 | if c.isUsingVite() { 48 | if c._vite_dev_ctx != nil { 49 | c._vite_dev_ctx.Cleanup() 50 | } 51 | c._vite_dev_ctx, err = c.viteDevBuild() 52 | if err != nil { 53 | c.panic("failed to start vite dev server", err) 54 | } 55 | go c._vite_dev_ctx.Wait() 56 | } 57 | 58 | if !opts.is_rebuild || opts.recompile_go { 59 | // compile go binary now because we didn't above 60 | if err := c.compile_go_binary(); err != nil { 61 | c.panic("failed to compile go binary", err) 62 | } 63 | } 64 | 65 | go c.run_go_binary() 66 | go c.setup_browser_refresh_mux() 67 | 68 | if opts.is_rebuild { 69 | c.must_reload_broadcast( 70 | refreshFilePayload{ChangeType: changeTypeOther}, 71 | must_reload_broadcast_opts{ 72 | wait_for_app: true, 73 | wait_for_vite: c.isUsingVite(), 74 | message: "Hard reloading browser", 75 | }, 76 | ) 77 | 78 | return 79 | } 80 | 81 | defer c.kill_running_go_binary() 82 | 83 | debouncer := new_debouncer(30*time.Millisecond, func(events []fsnotify.Event) { 84 | c.process_batched_events(events) 85 | }) 86 | 87 | for { 88 | select { 89 | case evt := <-c.watcher.Events: 90 | debouncer.add_evt(evt) 91 | case err := <-c.watcher.Errors: 92 | c.Logger.Error(fmt.Sprintf("watcher error: %v", err)) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /wave/internal/ki/dev_vite.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/river-now/river/kit/viteutil" 7 | ) 8 | 9 | func (c *Config) isUsingVite() bool { 10 | return c._uc.Vite != nil 11 | } 12 | 13 | func (c *Config) GetViteManifestLocation() string { 14 | return filepath.Join(c.GetStaticPrivateOutDir(), "river_out", "river_vite_manifest.json") 15 | } 16 | 17 | func (c *Config) GetViteOutDir() string { 18 | return c._dist.S().Static.S().Assets.S().Public.FullPath() 19 | } 20 | 21 | func (c *Config) toViteCtx() *viteutil.BuildCtx { 22 | return viteutil.NewBuildCtx(&viteutil.BuildCtxOptions{ 23 | JSPackageManagerBaseCmd: c._uc.Vite.JSPackageManagerBaseCmd, 24 | JSPackageManagerCmdDir: c._uc.Vite.JSPackageManagerCmdDir, 25 | OutDir: c.GetViteOutDir(), 26 | ManifestOut: c.GetViteManifestLocation(), 27 | ViteConfigFile: c._uc.Vite.ViteConfigFile, 28 | }) 29 | } 30 | 31 | func (c *Config) viteDevBuild() (*viteutil.BuildCtx, error) { 32 | if !c.isUsingVite() { 33 | return nil, nil 34 | } 35 | ctx := c.toViteCtx() 36 | err := ctx.DevBuild() 37 | return ctx, err 38 | } 39 | 40 | func (c *Config) ViteProdBuild() error { 41 | if !c.isUsingVite() { 42 | return nil 43 | } 44 | ctx := c.toViteCtx() 45 | return ctx.ProdBuild() 46 | } 47 | -------------------------------------------------------------------------------- /wave/internal/ki/dist.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/river-now/river/kit/dirs" 7 | ) 8 | 9 | const ( 10 | PUBLIC = "public" 11 | PRIVATE = "private" 12 | ) 13 | 14 | type Dist struct { 15 | Binary *dirs.File 16 | Static *dirs.Dir[DistStatic] 17 | } 18 | 19 | type DistStatic struct { 20 | Assets *dirs.Dir[DistStaticAssets] 21 | Internal *dirs.Dir[DistWaveInternal] 22 | Keep *dirs.File 23 | } 24 | 25 | type DistStaticAssets struct { 26 | Public *dirs.Dir[DistStaticAssetsPublic] 27 | Private *dirs.DirEmpty 28 | } 29 | 30 | type DistStaticAssetsPublic struct { 31 | PublicInternal *dirs.DirEmpty 32 | } 33 | 34 | type DistWaveInternal struct { 35 | CriticalDotCSS *dirs.File 36 | NormalCSSFileRefDotTXT *dirs.File 37 | PublicFileMapFileRefDotTXT *dirs.File 38 | } 39 | 40 | func toDistLayout(cleanDistDir string) *dirs.Dir[Dist] { 41 | mainOut := "main" 42 | if runtime.GOOS == "windows" { 43 | mainOut += ".exe" 44 | } 45 | x := dirs.Build(cleanDistDir, dirs.ToRoot(Dist{ 46 | Binary: dirs.ToFile(mainOut), 47 | Static: dirs.ToDir("static", DistStatic{ 48 | Assets: dirs.ToDir("assets", DistStaticAssets{ 49 | Public: dirs.ToDir(PUBLIC, DistStaticAssetsPublic{ 50 | PublicInternal: dirs.ToDirEmpty("internal"), 51 | }), 52 | Private: dirs.ToDirEmpty(PRIVATE), 53 | }), 54 | Internal: dirs.ToDir("internal", DistWaveInternal{ 55 | CriticalDotCSS: dirs.ToFile("critical.css"), 56 | NormalCSSFileRefDotTXT: dirs.ToFile("normal_css_file_ref.txt"), 57 | PublicFileMapFileRefDotTXT: dirs.ToFile("public_file_map_file_ref.txt"), 58 | }), 59 | Keep: dirs.ToFile(".keep"), 60 | }), 61 | })) 62 | 63 | return x 64 | } 65 | -------------------------------------------------------------------------------- /wave/internal/ki/env.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | const ( 10 | modeKey = "WAVE_MODE" 11 | devModeVal = "development" 12 | portKey = "PORT" 13 | portHasBeenSetKey = "WAVE_PORT_HAS_BEEN_SET" 14 | refreshServerPortKey = "WAVE_REFRESH_SERVER_PORT" 15 | trueStr = "true" 16 | ) 17 | 18 | func GetIsDev() bool { 19 | return os.Getenv(modeKey) == devModeVal 20 | } 21 | 22 | func setPort(port int) { 23 | os.Setenv(portKey, fmt.Sprintf("%d", port)) 24 | } 25 | 26 | func getPort() int { 27 | port, err := strconv.Atoi(os.Getenv(portKey)) 28 | if err != nil { 29 | return 0 30 | } 31 | return port 32 | } 33 | 34 | func setPortHasBeenSet() { 35 | os.Setenv(portHasBeenSetKey, trueStr) 36 | } 37 | 38 | func getPortHasBeenSet() bool { 39 | return os.Getenv(portHasBeenSetKey) == trueStr 40 | } 41 | 42 | func getRefreshServerPort() int { 43 | port, err := strconv.Atoi(os.Getenv(refreshServerPortKey)) 44 | if err != nil { 45 | return 0 46 | } 47 | return port 48 | } 49 | 50 | func SetModeToDev() { 51 | os.Setenv(modeKey, devModeVal) 52 | } 53 | 54 | func set_refresh_server_port(port int) { 55 | os.Setenv(refreshServerPortKey, fmt.Sprintf("%d", port)) 56 | } 57 | -------------------------------------------------------------------------------- /wave/internal/ki/env_test.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestGetIsDev(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | envValue string 12 | want bool 13 | }{ 14 | {"DevMode", devModeVal, true}, 15 | {"ProdMode", "production", false}, 16 | {"EmptyMode", "", false}, 17 | } 18 | 19 | for _, tt := range tests { 20 | t.Run(tt.name, func(t *testing.T) { 21 | resetEnv() 22 | if tt.envValue != "" { 23 | os.Setenv(modeKey, tt.envValue) 24 | } 25 | if got := GetIsDev(); got != tt.want { 26 | t.Errorf("GetIsDev() = %v, want %v", got, tt.want) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | func TestPortFunctions(t *testing.T) { 33 | resetEnv() 34 | 35 | // Test setPort and getPort 36 | setPort(8080) 37 | if got := getPort(); got != 8080 { 38 | t.Errorf("getPort() = %v, want %v", got, 8080) 39 | } 40 | 41 | // Test setPortHasBeenSet and getPortHasBeenSet 42 | if getPortHasBeenSet() { 43 | t.Errorf("getPortHasBeenSet() = true, want false before setting") 44 | } 45 | setPortHasBeenSet() 46 | if !getPortHasBeenSet() { 47 | t.Errorf("getPortHasBeenSet() = false, want true after setting") 48 | } 49 | } 50 | 51 | func TestRefreshServerPort(t *testing.T) { 52 | resetEnv() 53 | 54 | set_refresh_server_port(3000) 55 | if got := getRefreshServerPort(); got != 3000 { 56 | t.Errorf("getRefreshServerPort() = %v, want %v", got, 3000) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /wave/internal/ki/file_hash.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "crypto/sha256" 5 | "fmt" 6 | "hash" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | func getHashedFilenameFromPath(filePath string, originalFileName string) (string, error) { 14 | file, err := os.Open(filePath) 15 | if err != nil { 16 | return "", err 17 | } 18 | defer file.Close() 19 | 20 | hash := sha256.New() 21 | buf := make([]byte, 32*1024) 22 | for { 23 | n, err := file.Read(buf) 24 | if n > 0 { 25 | hash.Write(buf[:n]) 26 | } 27 | if err == io.EOF { 28 | break 29 | } 30 | if err != nil { 31 | return "", err 32 | } 33 | } 34 | 35 | return toOutputFileName(hash, originalFileName), nil 36 | } 37 | 38 | func getHashedFilenameFromBytes(content []byte, originalFileName string) string { 39 | hash := sha256.New() 40 | hash.Write(content) 41 | return toOutputFileName(hash, originalFileName) 42 | } 43 | 44 | func toOutputFileName(hash hash.Hash, originalFileName string) string { 45 | hashedSuffix := fmt.Sprintf("%x", hash.Sum(nil))[:12] 46 | ext := filepath.Ext(originalFileName) 47 | return fmt.Sprintf("%s_%s%s", strings.TrimSuffix(originalFileName, ext), hashedSuffix, ext) 48 | } 49 | -------------------------------------------------------------------------------- /wave/internal/ki/middleware.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/river-now/river/kit/middleware" 7 | "github.com/river-now/river/kit/response" 8 | ) 9 | 10 | func (c *Config) FaviconRedirect() middleware.Middleware { 11 | methods := []string{http.MethodGet, http.MethodHead} 12 | 13 | handlerFunc := func(w http.ResponseWriter, r *http.Request) { 14 | faviconDotIcoURL := c.GetPublicURL("favicon.ico") 15 | if faviconDotIcoURL == c.GetPublicPathPrefix()+"favicon.ico" { 16 | res := response.New(w) 17 | res.NotFound() 18 | return 19 | } 20 | http.Redirect(w, r, faviconDotIcoURL, http.StatusFound) 21 | } 22 | 23 | return middleware.ToHandlerMiddleware("/favicon.ico", methods, handlerFunc) 24 | } 25 | -------------------------------------------------------------------------------- /wave/internal/ki/pattern_utils.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | 7 | "github.com/bmatcuk/doublestar/v4" 8 | ) 9 | 10 | type potentialMatch struct { 11 | pattern string 12 | path string 13 | } 14 | 15 | func (c *Config) match_results_key_maker(k potentialMatch) string { 16 | return k.pattern + k.path 17 | } 18 | 19 | func (c *Config) get_initial_match_results(k potentialMatch) (bool, error) { 20 | normalizedPath := filepath.ToSlash(k.path) 21 | 22 | matches, err := doublestar.Match(k.pattern, normalizedPath) 23 | if err != nil { 24 | c.Logger.Error(fmt.Sprintf("error: failed to match file: %v", err)) 25 | return false, err 26 | } 27 | 28 | return matches, nil 29 | } 30 | 31 | func (c *Config) get_is_match(k potentialMatch) bool { 32 | isMatch, _ := c.matchResults.Get(k) 33 | return isMatch 34 | } 35 | 36 | func (c *Config) get_is_ignored(path string, ignoredPatterns []string) bool { 37 | for _, pattern := range ignoredPatterns { 38 | if c.get_is_match(potentialMatch{pattern: pattern, path: path}) { 39 | return true 40 | } 41 | } 42 | return false 43 | } 44 | -------------------------------------------------------------------------------- /wave/internal/ki/port.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/river-now/river/kit/port" 7 | ) 8 | 9 | const ( 10 | default_refresh_server_port = 10_000 11 | ) 12 | 13 | func MustGetAppPort() int { 14 | isDev := GetIsDev() 15 | portHasBeenSet := getPortHasBeenSet() 16 | defaultPort := getPort() 17 | 18 | if !isDev || portHasBeenSet { 19 | return defaultPort 20 | } 21 | 22 | port, err := port.GetFreePort(defaultPort) 23 | if err != nil { 24 | log.Panicf("error: failed to get free port: %v", err) 25 | } 26 | 27 | setPort(port) 28 | setPortHasBeenSet() 29 | 30 | return port 31 | } 32 | -------------------------------------------------------------------------------- /wave/internal/ki/public_asset_keys.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "github.com/river-now/river/kit/tsgen" 5 | ) 6 | 7 | // If you pass nil to this function, it will return a pointer to a new Statements 8 | // object. If you pass a pointer to an existing Statements object, it will mutate 9 | // that object and return it. 10 | func (c *Config) AddPublicAssetKeys(statements *tsgen.Statements) *tsgen.Statements { 11 | a := statements 12 | if a == nil { 13 | a = &tsgen.Statements{} 14 | } 15 | 16 | keys, err := c.GetPublicFileMapKeysBuildtime() 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | a.Serialize("const WAVE_PUBLIC_ASSETS", keys) 22 | a.Raw("export type WavePublicAsset", "`${\"/\" | \"\"}${(typeof WAVE_PUBLIC_ASSETS)[number]}`") 23 | 24 | return a 25 | } 26 | -------------------------------------------------------------------------------- /wave/internal/ki/readiness.go: -------------------------------------------------------------------------------- 1 | package ki 2 | -------------------------------------------------------------------------------- /wave/internal/ki/setup.go: -------------------------------------------------------------------------------- 1 | package ki 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | func (c *Config) SetupDistDir() error { 9 | // make a dist/static/internal directory 10 | path := c._dist.S().Static.S().Internal.FullPath() 11 | if err := os.MkdirAll(path, 0755); err != nil { 12 | return fmt.Errorf("error making internal directory: %w", err) 13 | } 14 | 15 | // add an empty file so that go:embed doesn't complain 16 | path = c._dist.S().Static.S().Keep.FullPath() 17 | if err := os.WriteFile(path, []byte("//go:embed directives require at least one file to compile\n"), 0644); err != nil { 18 | return fmt.Errorf("error making x file: %w", err) 19 | } 20 | 21 | // make an empty dist/static/assets/public/internal directory 22 | path = c._dist.S().Static.S().Assets.S().Public.S().PublicInternal.FullPath() 23 | if err := os.MkdirAll(path, 0755); err != nil { 24 | return fmt.Errorf("error making public directory: %w", err) 25 | } 26 | 27 | // make an empty dist/static/assets/private directory 28 | path = c._dist.S().Static.S().Assets.S().Private.FullPath() 29 | if err := os.MkdirAll(path, 0755); err != nil { 30 | return fmt.Errorf("error making private directory: %w", err) 31 | } 32 | 33 | return nil 34 | } 35 | --------------------------------------------------------------------------------