four-stroke 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ runs:
2
+ using: "composite"
3
+ steps:
4
+ - uses: actions/setup-node@v4
5
+ with:
6
+ node-version: "24"
7
+
8
+ - run: corepack enable
9
+ shell: bash
10
+
11
+ - run: pnpm install
12
+ shell: bash
@@ -0,0 +1,14 @@
1
+ # Autogenerated by https://github.com/change-engine/ecu/github
2
+ version: 2
3
+ updates:
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "daily"
8
+ time: "05:08"
9
+
10
+ - package-ecosystem: "npm"
11
+ directory: "/"
12
+ schedule:
13
+ interval: "daily"
14
+ time: "05:08"
@@ -0,0 +1,20 @@
1
+ name: CI
2
+
3
+ on: [push]
4
+
5
+ permissions:
6
+ contents: read
7
+
8
+ defaults:
9
+ run:
10
+ shell: bash
11
+
12
+ jobs:
13
+ ci:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+
18
+ - uses: ./.github/actions/install
19
+
20
+ - run: pnpm eslint
@@ -0,0 +1,32 @@
1
+ # Autogenerated by https://github.com/change-engine/ecu/github
2
+
3
+ name: Dependabot auto-approve
4
+ on:
5
+ pull_request_target:
6
+ types: [opened]
7
+
8
+ permissions:
9
+ pull-requests: write
10
+ contents: write
11
+
12
+ jobs:
13
+ dependabot:
14
+ runs-on: ubuntu-slim
15
+ if: ${{ github.actor == 'dependabot[bot]' }}
16
+ steps:
17
+ - name: Dependabot metadata
18
+ id: metadata
19
+ uses: dependabot/fetch-metadata@v3.1.0
20
+ with:
21
+ github-token: "${{ secrets.GITHUB_TOKEN }}"
22
+ - name: Approve a PR
23
+ if: ${{steps.metadata.outputs.update-type != 'version-update:semver-major'}}
24
+ run: gh pr review --approve "$PR_URL"
25
+ env:
26
+ PR_URL: ${{github.event.pull_request.html_url}}
27
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
28
+ - name: Enable auto-merge for Dependabot PRs
29
+ run: gh pr merge --auto --merge "$PR_URL"
30
+ env:
31
+ PR_URL: ${{github.event.pull_request.html_url}}
32
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
@@ -0,0 +1,24 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ id-token: write
10
+ contents: read
11
+
12
+ defaults:
13
+ run:
14
+ shell: bash
15
+
16
+ jobs:
17
+ ci:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+
22
+ - uses: ./.github/actions/install
23
+
24
+ - run: pnpm publish --no-git-checks
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # four-stroke
2
+
3
+ Simple Cloudflare Worker SSR SPA framework
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install four-stroke
9
+ ```
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import openapiTS from "openapi-typescript";
5
+ import prettier from "prettier";
6
+ import ts from "typescript";
7
+
8
+ const services = process.argv[2].split(",");
9
+
10
+ const UUID = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("UUID"));
11
+ const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull());
12
+
13
+ await Promise.all(
14
+ services.map(async (service) => {
15
+ const output = await openapiTS(`${service}/doc`, {
16
+ transform(schemaObject) {
17
+ if ("format" in schemaObject && schemaObject.format === "uuid") {
18
+ return schemaObject.nullable ? ts.factory.createUnionTypeNode([UUID, NULL]) : UUID;
19
+ }
20
+ },
21
+ });
22
+ const printer = ts.createPrinter({});
23
+ const resultFile = ts.createSourceFile(
24
+ `src/__definitions__/${service.replace("https://", "")}-definitions.ts`,
25
+ "",
26
+ ts.ScriptTarget.Latest,
27
+ );
28
+ const result = printer.printNode(ts.EmitHint.Unspecified, output[0], resultFile);
29
+ fs.writeFileSync(
30
+ `src/__definitions__/${service.replace("https://", "")}-definitions.ts`,
31
+ await prettier.format(
32
+ `// Autogenerated by four-stroke
33
+
34
+ ${fs.readFileSync("src/__definitions__/type-definitions.ts", "utf-8")}
35
+
36
+ ${result}`,
37
+ { parser: "typescript", printWidth: 100 },
38
+ ),
39
+ );
40
+ }),
41
+ );
package/bin/deploy.mjs ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import fs from "fs";
4
+ import { cmd } from "../src/cmd.mjs";
5
+ import { basename } from "path";
6
+
7
+ const env = process.argv[2];
8
+ const release = process.argv[3];
9
+
10
+ fs.writeFileSync("src/release.ts", `export default "${release}";`);
11
+
12
+ cmd(`wrangler secret bulk /dev/stdin --env ${env}`);
13
+
14
+ cmd(
15
+ `sentry-cli releases --org change-engine --project ${basename(process.cwd())} new ${release} --finalize`,
16
+ );
17
+
18
+ cmd(
19
+ `sentry-cli releases --org change-engine --project ${basename(process.cwd())} set-commits ${release} --ignore-missing`,
20
+ );
21
+
22
+ cmd(`vite build`);
23
+ cmd(`wrangler deploy --env ${env} --outdir dist`);
24
+
25
+ cmd(
26
+ `sentry-cli sourcemaps --org change-engine --project ${basename(process.cwd())} upload --release="${release}" dist`,
27
+ );
package/bin/dev.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ cmd("vite dev");
package/bin/format.mjs ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ cmd("eslint --cache --fix");
6
+ cmd("prettier --cache --write .");
package/bin/lint.mjs ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import fs from "fs";
4
+ import { cmd } from "../src/cmd.mjs";
5
+
6
+ cmd("react-router typegen");
7
+ if (fs.existsSync("wrangler.jsonc")) {
8
+ cmd("wrangler types --strict-vars false --check");
9
+ }
10
+ cmd("eslint --cache --max-warnings=0");
11
+ cmd("prettier --cache --check .");
package/bin/test.mjs ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import { cmd } from "../src/cmd.mjs";
4
+
5
+ cmd("vitest", [
6
+ ...(!process.argv.slice(2).includes("-w") && !process.argv.slice(2).includes("--watch")
7
+ ? ["--run"]
8
+ : []),
9
+ ...process.argv.slice(2),
10
+ ]);
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ import fs from "fs";
4
+ import { cmd } from "../src/cmd.mjs";
5
+
6
+ if (fs.existsSync("wrangler.jsonc")) {
7
+ const devVarsFiles = fs.globSync("**/.dev.vars");
8
+ const backed = [];
9
+
10
+ for (const file of devVarsFiles) {
11
+ const tmp = file + ".bak.bak";
12
+ fs.renameSync(file, tmp);
13
+ backed.push({ original: file, tmp });
14
+ }
15
+
16
+ try {
17
+ cmd("wrangler types --strict-vars false");
18
+ } finally {
19
+ for (const { original, tmp } of backed) {
20
+ fs.renameSync(tmp, original);
21
+ }
22
+ }
23
+ }
24
+ cmd("vite build");
25
+ cmd("tsc --noEmit");
@@ -0,0 +1,3 @@
1
+ import config from "eslint-config-two-stroke";
2
+
3
+ export default config;
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "bin": {
3
+ "api-types": "./bin/api-types.mjs",
4
+ "deploy": "./bin/deploy.mjs",
5
+ "dev": "./bin/dev.mjs",
6
+ "format": "./bin/format.mjs",
7
+ "lint": "./bin/lint.mjs",
8
+ "test": "./bin/test.mjs",
9
+ "type-check": "./bin/type-check.mjs"
10
+ },
11
+ "dependencies": {
12
+ "@cloudflare/vitest-pool-workers": "^0.16.13",
13
+ "@sentry/cli": "^3.5.0",
14
+ "@types/node": "^25.9.2",
15
+ "@types/react": "^19.2.17",
16
+ "@types/react-dom": "^19.2.3",
17
+ "@typescript-eslint/eslint-plugin": "^8.60.1",
18
+ "@typescript-eslint/parser": "^8.60.1",
19
+ "@vitest/runner": "^4.1.8",
20
+ "@vitest/snapshot": "^4.1.8",
21
+ "eslint-config-prettier": "^10.1.8",
22
+ "eslint-config-two-stroke": "^1.5.4",
23
+ "eslint-config-typescript": "^3.0.0",
24
+ "openapi-typescript": "^7.13.0",
25
+ "react": "^19.2.7",
26
+ "react-dom": "^19.2.7"
27
+ },
28
+ "peerDependencies": {
29
+ "@sentry/cli": ">=2",
30
+ "eslint": ">=10",
31
+ "prettier": ">=3",
32
+ "typescript": ">=5",
33
+ "vitest": ">=4",
34
+ "wrangler": ">=3"
35
+ },
36
+ "description": "Simple Cloudflare Worker SSR SPA framework",
37
+ "engines": {
38
+ "node": "^24.9.0"
39
+ },
40
+ "license": "MIT",
41
+ "main": "src/index.ts",
42
+ "name": "four-stroke",
43
+ "prettier": {
44
+ "printWidth": 100
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/change-engine/four-stroke"
49
+ },
50
+ "type": "module",
51
+ "version": "0.0.2",
52
+ "devDependencies": {
53
+ "@types/eslint": "^9.6.1",
54
+ "eslint": "^10.4.1",
55
+ "prettier": "^3.8.3",
56
+ "typescript": "^6.0.3",
57
+ "vitest": "^4.1.8"
58
+ }
59
+ }
@@ -0,0 +1,8 @@
1
+ minimumReleaseAge: 4320
2
+ minimumReleaseAgeExclude:
3
+ - "jwk-subtle"
4
+ - "pbkdf-subtle"
5
+ - "eslint-config-two-stroke"
6
+ trustPolicy: no-downgrade
7
+ ignoreScripts: true
8
+ blockExoticSubdeps: true
package/src/cmd.mjs ADDED
@@ -0,0 +1,14 @@
1
+ import { spawnSync } from "child_process";
2
+
3
+ export function cmd(program, args = []) {
4
+ const { error, status } = spawnSync(
5
+ program.split(" ")[0],
6
+ [...program.split(" ").slice(1), ...args],
7
+ {
8
+ stdio: "inherit",
9
+ },
10
+ );
11
+ // eslint-disable-next-line n/no-process-exit
12
+ if (status) process.exit(status);
13
+ if (error) throw error;
14
+ }
@@ -0,0 +1,39 @@
1
+ import type { Viewer } from "../util/auth";
2
+
3
+ type ViewerMenuProps = {
4
+ logoutUrl: string;
5
+ viewer: Viewer;
6
+ };
7
+
8
+ export default function ViewerMenu({ logoutUrl, viewer }: ViewerMenuProps): React.ReactNode {
9
+ return (
10
+ <details className="group relative">
11
+ <summary className="flex cursor-pointer list-none items-center gap-3 rounded-md px-2 py-1.5 hover:bg-stone-100 focus:outline-none focus:ring-2 focus:ring-emerald-900 focus:ring-offset-2 [&::-webkit-details-marker]:hidden">
12
+ {viewer.picture ? (
13
+ <img
14
+ alt=""
15
+ className="size-9 rounded-full object-cover"
16
+ referrerPolicy="no-referrer"
17
+ src={viewer.picture}
18
+ />
19
+ ) : (
20
+ <span
21
+ aria-hidden="true"
22
+ className="grid size-9 place-items-center rounded-full bg-emerald-950 text-sm font-semibold text-white"
23
+ >
24
+ {viewer.name.slice(0, 1).toUpperCase()}
25
+ </span>
26
+ )}
27
+ <span className="max-w-48 truncate text-sm font-medium">{viewer.name}</span>
28
+ </summary>
29
+ <nav className="absolute right-0 mt-2 w-40 rounded-md border border-stone-200 bg-white py-1 shadow-lg">
30
+ <a
31
+ className="block px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-stone-100 focus:bg-stone-100 focus:outline-none"
32
+ href={logoutUrl}
33
+ >
34
+ Logout
35
+ </a>
36
+ </nav>
37
+ </details>
38
+ );
39
+ }
package/src/fake.ts ADDED
@@ -0,0 +1 @@
1
+ export default { fetch: () => {} };
@@ -0,0 +1,55 @@
1
+ export type Viewer = {
2
+ name: string;
3
+ picture: string | null;
4
+ };
5
+
6
+ export const getCookieValue = (request: Request, name: string): string | null => {
7
+ const cookieHeader = request.headers.get("Cookie");
8
+
9
+ if (!cookieHeader) {
10
+ return null;
11
+ }
12
+
13
+ const prefix = `${name}=`;
14
+ const cookie = cookieHeader.split(";").find((part) => part.trim().startsWith(prefix));
15
+
16
+ return cookie ? decodeURIComponent(cookie.trim().slice(prefix.length)) : null;
17
+ };
18
+
19
+ const decodeBase64Url = (value: string): string => {
20
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
21
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
22
+ const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
23
+
24
+ return new TextDecoder().decode(bytes);
25
+ };
26
+
27
+ export const parseViewerFromAccessToken = (accessToken: string): Viewer | null => {
28
+ const [, payload] = accessToken.split(".");
29
+
30
+ if (!payload) {
31
+ return null;
32
+ }
33
+
34
+ try {
35
+ const claims = JSON.parse(decodeBase64Url(payload)) as {
36
+ email?: unknown;
37
+ name?: unknown;
38
+ picture?: unknown;
39
+ };
40
+ const name =
41
+ typeof claims.name === "string" && claims.name.trim().length > 0
42
+ ? claims.name
43
+ : typeof claims.email === "string" && claims.email.trim().length > 0
44
+ ? claims.email
45
+ : "Signed in";
46
+ const picture =
47
+ typeof claims.picture === "string" && claims.picture.trim().length > 0
48
+ ? claims.picture
49
+ : null;
50
+
51
+ return { name, picture };
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "erasableSyntaxOnly": true,
4
+ "esModuleInterop": true,
5
+ "isolatedModules": true,
6
+ "lib": ["ESNext"],
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
9
+ "noUncheckedIndexedAccess": true,
10
+ "resolveJsonModule": true,
11
+ "skipLibCheck": true,
12
+ "strict": true,
13
+ "target": "ESNext",
14
+ "types": [
15
+ "./worker-configuration.d.ts",
16
+ "vitest/globals",
17
+ "@cloudflare/vitest-pool-workers/types"
18
+ ],
19
+ "verbatimModuleSyntax": true,
20
+ "jsx": "react-jsx"
21
+ },
22
+ "exclude": ["../../dist"]
23
+ }